diff --git a/ProcessMaker/Http/Middleware/BrowserCache.php b/ProcessMaker/Http/Middleware/BrowserCache.php index 4f632f86f2..39ebe90065 100644 --- a/ProcessMaker/Http/Middleware/BrowserCache.php +++ b/ProcessMaker/Http/Middleware/BrowserCache.php @@ -23,6 +23,10 @@ public function handle($request, Closure $next) return $response; } + if ($response->headers->has('ETag')) { + return $response; + } + $response->header('pragma', 'no-cache'); $response->header('Cache-Control', 'no-store'); diff --git a/ProcessMaker/Http/Middleware/Etag/TasksPageEtag.php b/ProcessMaker/Http/Middleware/Etag/TasksPageEtag.php new file mode 100644 index 0000000000..4a43e3ed4d --- /dev/null +++ b/ProcessMaker/Http/Middleware/Etag/TasksPageEtag.php @@ -0,0 +1,77 @@ +isMethod('GET') && !$request->isMethod('HEAD'))) { + return $next($request); + } + + $etag = $this->tasksPageEtag->getEtag($request); + + if ($this->buildResponseWithEtag($etag)->isNotModified($request)) { + return $this->withPrivateCacheHeaders($this->buildNotModifiedResponse($etag, $request)); + } + + $response = $next($request); + $response->setEtag($etag); + + return $this->withPrivateCacheHeaders($response); + } + + /** + * Build a framework-compatible 304 response for a matched Tasks page ETag. + */ + private function buildNotModifiedResponse(string $etag, Request $request): Response + { + $response = $this->buildResponseWithEtag($etag); + $response->isNotModified($request); + + return $response; + } + + /** + * Create a response carrying the Tasks page validator. + * + * Weak ETags are used because the HTML may be transformed by gzip while the + * rendered representation remains equivalent for browser revalidation. + */ + private function buildResponseWithEtag(string $etag): Response + { + $response = new Response(); + $response->setEtag($etag, true); + + return $response; + } + + /** + * Apply browser-cache headers that allow private conditional revalidation. + */ + private function withPrivateCacheHeaders(Response $response): Response + { + $response->headers->set('Cache-Control', 'private, must-revalidate'); + $response->headers->remove('Pragma'); + $response->headers->remove('Expires'); + + return $response; + } +} diff --git a/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php b/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php new file mode 100644 index 0000000000..298f8d0b81 --- /dev/null +++ b/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php @@ -0,0 +1,373 @@ +hashAlgorithm(), json_encode($this->payload($request))) . '"'; + } + + /** + * Build the complete content-affecting context used to validate the page. + * + * Volatile values such as CSRF tokens, session ids, and randomized asset URLs are + * intentionally excluded because they would prevent useful conditional requests. + */ + private function payload(Request $request): array + { + $user = $request->user(); + + return [ + 'route' => [ + 'name' => $request->route()?->getName(), + 'path' => $request->path(), + 'router' => $request->route('router'), + 'query' => $this->sorted($request->query()), + ], + 'user' => $this->userPayload($user), + 'tenant' => $this->tenantPayload(), + 'permissions_v' => $this->permissionsVersion($user), + 'session_content' => [ + 'alert' => session('_alert'), + 'rememberme' => session('rememberme'), + ], + 'saved_search_v' => $this->savedSearchPayload($user), + 'locale' => app()->getLocale(), + 'task_context' => [ + 'user_filter' => $user ? SaveSession::getConfigFilter('taskFilter', $user) : null, + 'user_configuration' => $this->userConfigurationPayload($user), + 'task_drafts_enabled' => TaskDraft::draftsEnabled(), + ], + 'features_v' => $this->featuresPayload(), + 'packages_v' => $this->packagesPayload(), + ]; + } + + /** + * Capture user fields emitted into the layout or used by Tasks page decisions. + */ + private function userPayload(?User $user): ?array + { + if (!$user) { + return null; + } + + return [ + 'id' => $user->id, + 'uuid' => $user->uuid, + 'updated_at' => $this->dateValue($user->updated_at), + 'is_administrator' => $user->is_administrator, + 'status' => $user->status, + 'fullname' => $user->fullname, + 'avatar' => $user->avatar, + 'datetime_format' => $user->datetime_format, + 'timezone' => $user->timezone, + 'language' => $user->language, + ]; + } + + /** + * Include tenant identity because tenant config and assets can change the page shell. + */ + private function tenantPayload(): ?array + { + $tenant = app()->bound('currentTenant') ? app('currentTenant') : null; + + if (!$tenant) { + return null; + } + + return [ + 'id' => $tenant->id ?? null, + 'updated_at' => $this->dateValue($tenant->updated_at ?? null), + ]; + } + + /** + * Version the effective permission context used by Blade and frontend props. + * + * The full permission list can be expensive to rebuild, so this uses the session + * snapshot plus lightweight assignment/version markers that are enough to + * invalidate when direct user or direct group permission assignments change. + */ + private function permissionsVersion(?User $user): ?array + { + if (!$user) { + return null; + } + + $directGroups = $this->directGroupPayload($user); + + return [ + 'is_administrator' => $user->is_administrator, + 'session_permissions' => $this->sessionPermissions(), + 'permissions_table' => $this->tableVersion('permissions'), + 'direct_user_permissions' => $this->assignablePermissionVersion(User::class, [$user->id]), + 'direct_groups' => $directGroups['ids'], + 'direct_group_memberships' => $directGroups['version'], + 'direct_group_permissions' => $this->assignablePermissionVersion( + Group::class, + $directGroups['ids'] + ), + ]; + } + + /** + * Include the current session permission snapshot used by legacy permission checks. + */ + private function sessionPermissions(): array + { + $permissions = session('permissions', []); + + if (!is_array($permissions)) { + return []; + } + + sort($permissions); + + return $permissions; + } + + /** + * Include the default Tasks saved search when the Saved Search package is installed. + */ + private function savedSearchPayload(?User $user): ?array + { + $class = 'ProcessMaker\\Package\\SavedSearch\\Models\\SavedSearch'; + if (!$user || !class_exists($class)) { + return null; + } + + $savedSearch = $class::firstSystemSearchFor($user, $class::KEY_TASKS); + if (!$savedSearch) { + return null; + } + + return [ + 'id' => $savedSearch->id, + 'updated_at' => $this->dateValue($savedSearch->updated_at), + 'columns_hash' => $this->hashValue($savedSearch->columns), + ]; + } + + /** + * Capture the user UI configuration rendered into Tasks page props. + */ + private function userConfigurationPayload(?User $user): array + { + if (!$user) { + return UserConfigurationController::DEFAULT_USER_CONFIGURATION; + } + + $configuration = UserConfiguration::select('updated_at', 'ui_configuration') + ->where('user_id', $user->id) + ->first(); + if (!$configuration) { + return [ + 'updated_at' => null, + 'ui_configuration_hash' => $this->hashValue(UserConfigurationController::DEFAULT_USER_CONFIGURATION), + ]; + } + + return [ + 'updated_at' => $this->dateValue($configuration->updated_at), + 'ui_configuration_hash' => $this->hashValue($configuration->ui_configuration), + ]; + } + + /** + * Return direct group ids and their version marker without loading group models. + */ + private function directGroupPayload(User $user): array + { + $memberships = DB::table('group_members') + ->where('member_type', User::class) + ->where('member_id', $user->id) + ->orderBy('group_id') + ->get(['group_id', 'updated_at']); + + return [ + 'ids' => $memberships->pluck('group_id')->all(), + 'version' => [ + 'count' => $memberships->count(), + 'updated_at' => $this->dateValue($memberships->max('updated_at')), + ], + ]; + } + + /** + * Hash direct permission assignment ids for an assignable type. + */ + private function assignablePermissionVersion(string $assignableType, array $assignableIds): array + { + if (empty($assignableIds)) { + return [ + 'count' => 0, + 'permission_ids_hash' => $this->hashValue([]), + ]; + } + + $permissionIds = DB::table('assignables') + ->where('assignable_type', $assignableType) + ->whereIn('assignable_id', $assignableIds) + ->orderBy('permission_id') + ->pluck('permission_id') + ->all(); + + return [ + 'count' => count($permissionIds), + 'permission_ids_hash' => $this->hashValue($permissionIds), + ]; + } + + /** + * Version a table with a compact count and updated_at marker. + */ + private function tableVersion(string $table): array + { + $version = DB::table($table) + ->selectRaw('COUNT(*) as count, MAX(updated_at) as updated_at') + ->first(); + + return [ + 'count' => (int) ($version->count ?? 0), + 'updated_at' => $this->dateValue($version->updated_at ?? null), + ]; + } + + /** + * Hash structured values before placing them in the ETag payload. + */ + private function hashValue($value): string + { + return hash($this->hashAlgorithm(), json_encode($value)); + } + + /** + * Capture selected config values and frontend asset versions used by the page shell. + */ + private function featuresPayload(): array + { + $features = []; + foreach (self::FEATURE_CONFIG_KEYS as $key) { + Arr::set($features, $key, config($key)); + } + + $features['mix_manifest'] = $this->fileVersion(public_path('mix-manifest.json')); + + return $features; + } + + /** + * Version installed package state so package-provided UI changes invalidate the page. + */ + private function packagesPayload(): array + { + $packages = app(PackageManager::class)->listPackages(); + sort($packages); + + $manifest = app(PackageManifest::class); + + return [ + 'app_version' => $this->appVersion(), + 'registered' => $packages, + 'manifest' => method_exists($manifest, 'list') ? $manifest->list() : $manifest->providers(), + 'composer_lock' => $this->fileVersion(base_path('composer.lock')), + ]; + } + + /** + * Read the ProcessMaker application version from composer metadata. + */ + private function appVersion(): ?string + { + $composer = json_decode(File::get(base_path('composer.json')), true); + + return $composer['version'] ?? null; + } + + /** + * Return a cheap version marker for files that affect rendered assets or packages. + */ + private function fileVersion(string $path): ?array + { + if (!File::exists($path)) { + return null; + } + + return [ + 'mtime' => File::lastModified($path), + 'hash' => hash_file($this->hashAlgorithm(), $path), + ]; + } + + /** + * Prefer xxh128 when available and fall back for older runtimes. + */ + private function hashAlgorithm(): string + { + return in_array('xxh128', hash_algos(), true) ? 'xxh128' : 'sha256'; + } + + /** + * Normalize nullable date values for deterministic JSON hashing. + */ + private function dateValue($value): ?string + { + return $value ? (string) $value : null; + } + + /** + * Recursively sort arrays so query-string order does not change the ETag. + */ + private function sorted(array $value): array + { + ksort($value); + + foreach ($value as $key => $item) { + if (is_array($item)) { + $value[$key] = $this->sorted($item); + } + } + + return $value; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index d4f47576d4..7f9b9519a8 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -114,6 +114,7 @@ 'admin' => ProcessMakerMiddleware\IsAdmin::class, 'manager' => ProcessMakerMiddleware\IsManager::class, 'etag' => ProcessMakerMiddleware\Etag\HandleEtag::class, + 'tasks-page-etag' => ProcessMakerMiddleware\Etag\TasksPageEtag::class, 'file_size_check' => ProcessMakerMiddleware\FileSizeCheck::class, 'auth.basic' => Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'throttle' => Illuminate\Routing\Middleware\ThrottleRequests::class, diff --git a/docs/tasks-page-etag-sequence.md b/docs/tasks-page-etag-sequence.md new file mode 100644 index 0000000000..68efe9c5cc --- /dev/null +++ b/docs/tasks-page-etag-sequence.md @@ -0,0 +1,95 @@ +# Tasks Page ETag Sequence + +This diagram captures the current request flow for the Tasks page shell (`/tasks`). The route-specific middleware computes a stable Tasks page ETag before rendering, short-circuits matching conditional requests with `304 Not Modified`, and sets private revalidation headers. The legacy inbox route (`/inbox/{router?}`) remains on the original `no-cache` middleware path and does not use this ETag flow. The global browser-cache middleware preserves ETag-enabled responses instead of applying `no-store`. + +![Tasks page ETag sequence](tasks-page-etag-sequence.svg) + +```mermaid +sequenceDiagram + autonumber + participant Browser as Browser + participant BrowserCache as BrowserCache
ProcessMaker\Http\Middleware\BrowserCache + participant Router as Laravel Router + participant TasksPageEtagMw as TasksPageEtag middleware
ProcessMaker\Http\Middleware\Etag\TasksPageEtag + participant Payload as TasksPageEtag payload
ProcessMaker\Http\Resources\Caching\TasksPageEtag + participant SymfonyResponse as Response
Symfony\Component\HttpFoundation\Response + participant Controller as TaskController@index
ProcessMaker\Http\Controllers\TaskController + + Browser->>BrowserCache: GET /tasks
Cookie + optional If-None-Match + BrowserCache->>Router: pass request through global middleware stack + Router->>TasksPageEtagMw: dispatch route with tasks-page-etag middleware + + alt ETags disabled or method is not GET/HEAD + TasksPageEtagMw->>Controller: bypass ETag logic + Controller-->>TasksPageEtagMw: normal page response + TasksPageEtagMw-->>BrowserCache: response without Tasks page ETag handling + else ETags enabled and method is GET/HEAD + TasksPageEtagMw->>Payload: getEtag(request) + Payload->>Payload: collect route name/path/router/query + Payload->>Payload: collect user and tenant version markers + Payload->>Payload: collect permission table/session/direct assignment markers + Payload->>Payload: collect saved search id/updated_at/columns hash + Payload->>Payload: collect user config hash and task drafts flag + Payload->>Payload: collect feature config, package list, manifest, asset versions + Payload->>Payload: exclude CSRF, session id, randomized favicon URL + Payload-->>TasksPageEtagMw: quoted stable hash + TasksPageEtagMw->>SymfonyResponse: create empty response + set weak ETag + TasksPageEtagMw->>SymfonyResponse: isNotModified(request) + + alt If-None-Match matches weak ETag + SymfonyResponse-->>TasksPageEtagMw: true + TasksPageEtagMw->>SymfonyResponse: build 304 response with same weak ETag + TasksPageEtagMw->>TasksPageEtagMw: Cache-Control: private, must-revalidate + TasksPageEtagMw->>TasksPageEtagMw: remove Pragma and Expires + TasksPageEtagMw-->>BrowserCache: 304 Not Modified + else Missing/stale If-None-Match + SymfonyResponse-->>TasksPageEtagMw: false + TasksPageEtagMw->>Controller: render Tasks page shell + Controller->>Controller: resolve title, router mode, mobile check + Controller->>Controller: load ScreenBuilderManager scripts + Controller->>Controller: load task filter, default columns, drafts flag + Controller->>Controller: load user configuration and default saved search + Controller-->>TasksPageEtagMw: tasks.index response + TasksPageEtagMw->>TasksPageEtagMw: attach weak ETag to 200 response + TasksPageEtagMw->>TasksPageEtagMw: Cache-Control: private, must-revalidate + TasksPageEtagMw->>TasksPageEtagMw: remove Pragma and Expires + TasksPageEtagMw-->>BrowserCache: 200 OK with weak ETag + end + end + + alt Response has ETag + BrowserCache->>BrowserCache: preserve response headers + BrowserCache->>BrowserCache: skip no-store / Pragma override + else No ETag and BROWSER_CACHE=false + BrowserCache->>BrowserCache: add Pragma: no-cache + BrowserCache->>BrowserCache: add Cache-Control: no-store + end + + BrowserCache-->>Browser: 200 OK + ETag or 304 Not Modified + Browser->>Browser: Store private validator and send If-None-Match on later reload +``` + +## ETag Context + +The payload intentionally includes content-affecting values: + +- Route path/query/router state. +- Authenticated user id, update timestamp, locale, timezone, display fields, and admin status. +- Tenant id and tenant update timestamp when present. +- Permission table version, session permission snapshot, direct user/group assignment hashes, and direct group membership version. +- Task filter cache, user configuration hash, task draft flag, and saved search defaults hash. +- Page-relevant feature config, registered package list, package manifest, app version, `composer.lock`, and `mix-manifest.json`. + +The payload intentionally excludes volatile values that do not define the rendered page, such as CSRF token, session id, and randomized favicon URLs. + +## Header Outcome + +Successful Tasks page responses should use private revalidation rather than storage blocking: + +```http +Cache-Control: private, must-revalidate +ETag: W/"..." +Vary: Accept-Encoding +``` + +They should not include `no-store` or `Pragma: no-cache`; otherwise the browser will not keep the validator and will not send `If-None-Match`. diff --git a/routes/web.php b/routes/web.php index c0cf1141bf..a8ca8fbb07 100644 --- a/routes/web.php +++ b/routes/web.php @@ -206,7 +206,7 @@ Route::get('tasks/search', [TaskController::class, 'search'])->name('tasks.search'); Route::get('tasks', [TaskController::class, 'index']) ->name('tasks.index') - ->middleware('no-cache'); + ->middleware('tasks-page-etag'); Route::get('tasks/{task}/edit', [TaskController::class, 'edit'])->name('tasks.edit'); Route::get('tasks/{task}/edit/quickfill', [TaskController::class, 'quickFillEdit'])->name('tasks.edit.quickfill'); Route::get('tasks/{task}/edit/{preview}', [TaskController::class, 'edit'])->name('tasks.preview'); diff --git a/tests/Feature/TasksTest.php b/tests/Feature/TasksTest.php index e63b2bb18d..6fb7aa3b57 100644 --- a/tests/Feature/TasksTest.php +++ b/tests/Feature/TasksTest.php @@ -7,6 +7,7 @@ use ProcessMaker\Models\ProcessRequestToken; use ProcessMaker\Models\ProcessTaskAssignment; use ProcessMaker\Models\User; +use ProcessMaker\Models\UserConfiguration; use Tests\Feature\Shared\RequestHelper; use Tests\TestCase; @@ -39,6 +40,79 @@ public function testIndex() $response->assertSee('Tasks'); } + public function testTasksPageSendsPrivateEtagHeaders() + { + $response = $this->webGet(self::TASKS_URL, []); + + $response->assertStatus(200); + $response->assertHeader('ETag'); + $this->assertCacheControlHasPrivateMustRevalidate($response); + $response->assertHeaderMissing('Pragma'); + $response->assertHeaderMissing('Expires'); + } + + public function testTasksPageReturnsNotModifiedWhenEtagMatches() + { + $response = $this->webGet(self::TASKS_URL, []); + $etag = $response->headers->get('ETag'); + + $responseWithMatchingEtag = $this->actingAs($this->user, 'web') + ->withHeaders(['If-None-Match' => $etag]) + ->get(self::TASKS_URL); + + $responseWithMatchingEtag->assertStatus(304); + $this->assertEquals( + $this->stripWeakEtagPrefix($etag), + $this->stripWeakEtagPrefix($responseWithMatchingEtag->headers->get('ETag')) + ); + $this->assertCacheControlHasPrivateMustRevalidate($responseWithMatchingEtag); + $this->assertEmpty($responseWithMatchingEtag->getContent()); + } + + public function testTasksPageEtagChangesWhenUserConfigurationChanges() + { + $response = $this->webGet(self::TASKS_URL, []); + $etag = $response->headers->get('ETag'); + + UserConfiguration::create([ + 'user_id' => $this->user->id, + 'ui_configuration' => json_encode([ + 'tasks' => [ + 'isMenuCollapse' => false, + ], + ]), + ]); + + $updatedResponse = $this->webGet(self::TASKS_URL, []); + + $this->assertNotEquals($etag, $updatedResponse->headers->get('ETag')); + } + + public function testTasksPageEtagChangesWhenFeatureConfigChanges() + { + $response = $this->webGet(self::TASKS_URL, []); + $etag = $response->headers->get('ETag'); + + config()->set('app.task_drafts_enabled', !config('app.task_drafts_enabled')); + + $updatedResponse = $this->webGet(self::TASKS_URL, []); + + $this->assertNotEquals($etag, $updatedResponse->headers->get('ETag')); + } + + private function assertCacheControlHasPrivateMustRevalidate($response): void + { + $cacheControl = $response->headers->get('Cache-Control'); + + $this->assertStringContainsString('private', $cacheControl); + $this->assertStringContainsString('must-revalidate', $cacheControl); + } + + private function stripWeakEtagPrefix(?string $etag): ?string + { + return $etag ? str_replace('W/', '', $etag) : null; + } + public function testViewTaskWithComments() { //Start a process request