diff --git a/ProcessMaker/Http/Middleware/Etag/HandleEtag.php b/ProcessMaker/Http/Middleware/Etag/HandleEtag.php index 7238c1cb81..fe51c6d35e 100644 --- a/ProcessMaker/Http/Middleware/Etag/HandleEtag.php +++ b/ProcessMaker/Http/Middleware/Etag/HandleEtag.php @@ -14,37 +14,94 @@ class HandleEtag /** * Handle an incoming request. */ - public function handle(Request $request, Closure $next, ?string $scope = null): Response + public function handle(Request $request, Closure $next): Response { - // Save original method and support HEAD requests. - $originalMethod = $request->getMethod(); - if ($request->isMethod('HEAD')) { - $request->setMethod('GET'); + // Process only GET and HEAD methods. + if (!$request->isMethod('GET') && !$request->isMethod('HEAD')) { + return $next($request); } - // Handle response. - $response = $next($request); + // Check if specific tables are defined for the route and calculate ETag. + $etag = $this->generateEtagFromTablesIfNeeded($request); - // Determine if the ETag should include user-specific data. - $includeUser = $scope === 'user'; + // If the client has a matching ETag, return a 304 response. + // Otherwise, continue with the controller execution. + $response = $etag && $this->etagMatchesRequest($etag, $request) + ? $this->buildNotModifiedResponse($etag) + : $next($request); - // Generate ETag for the response. - $etag = EtagManager::getEtag($request, $response, $includeUser); + // Add the pre-calculated ETag to the response if available. if ($etag) { - // Set the ETag header. - $response->setEtag($etag); + $response = $this->setEtagOnResponse($response, $etag); + } - // Get and strip weak ETags from request headers. - $noneMatch = array_map([$this, 'stripWeakTags'], $request->getETags()); + // If no ETag was calculated from tables, generate it based on the response. + if (!$etag && $this->isCacheableResponse($response)) { + $etag = EtagManager::getEtag($request, $response); + if ($etag) { + $response = $this->setEtagOnResponse($response, $etag); - // Compare ETags and set response as not modified if applicable. - if (in_array($etag, $noneMatch)) { - $response->setNotModified(); + // If the client has a matching ETag, set the response to 304. + if ($this->etagMatchesRequest($etag, $request)) { + $response = $this->buildNotModifiedResponse($etag); + } } } - // Restore original method and return the response. - $request->setMethod($originalMethod); + return $response; + } + + /** + * Determine if a response is cacheable. + */ + private function isCacheableResponse(Response $response): bool + { + $cacheableStatusCodes = [200, 203, 204, 206, 304]; + $cacheControl = $response->headers->get('Cache-Control', ''); + + // Verify if the status code is cacheable and does not contain "no-store". + return in_array($response->getStatusCode(), $cacheableStatusCodes) + && !str_contains($cacheControl, 'no-store'); + } + + /** + * Generate an ETag based on the tables defined in the route, if applicable. + */ + private function generateEtagFromTablesIfNeeded(Request $request): ?string + { + $tables = $request->route()->defaults['etag_tables'] ?? null; + + return $tables ? EtagManager::generateEtagFromTables(explode(',', $tables)) : null; + } + + /** + * Check if the ETag matches the request. + */ + private function etagMatchesRequest(string $etag, Request $request): bool + { + $noneMatch = array_map([$this, 'stripWeakTags'], $request->getETags()); + + return in_array($etag, $noneMatch); + } + + /** + * Build a 304 Not Modified response with the given ETag. + */ + private function buildNotModifiedResponse(string $etag): Response + { + $response = new Response(); + $response->setNotModified(); + $response->setEtag($etag); + + return $response; + } + + /** + * Set the ETag on a given response. + */ + private function setEtagOnResponse(Response $response, string $etag): Response + { + $response->setEtag($etag); return $response; } diff --git a/ProcessMaker/Http/Resources/Caching/EtagManager.php b/ProcessMaker/Http/Resources/Caching/EtagManager.php index e1f335e1e8..edd6a822b6 100644 --- a/ProcessMaker/Http/Resources/Caching/EtagManager.php +++ b/ProcessMaker/Http/Resources/Caching/EtagManager.php @@ -4,6 +4,8 @@ use Closure; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; @@ -23,26 +25,47 @@ public static function etagGenerateUsing(?Closure $callback): void } /** - * Get ETag value for this request and response. + * Get the ETag value for this request and response. */ - public static function getEtag(Request $request, Response $response, bool $includeUser = false): string + public static function getEtag(Request $request, Response $response): string { $etag = static::$etagGenerateCallback - ? call_user_func(static::$etagGenerateCallback, $request, $response, $includeUser) - : static::defaultGetEtag($response, $includeUser); + ? call_user_func(static::$etagGenerateCallback, $request, $response) + : static::defaultGetEtag($response); return (string) Str::of($etag)->start('"')->finish('"'); } /** - * Generate an ETag, optionally including user-specific data. + * Generate a default ETag, including user-specific data by default. */ - private static function defaultGetEtag(Response $response, bool $includeUser = false): string + private static function defaultGetEtag(Response $response): string { - if ($includeUser) { - return md5(auth()->id() . $response->getContent()); - } + return md5(auth()->id() . $response->getContent()); + } + + /** + * Generate an ETag based on the latest update timestamps from multiple tables. + */ + public static function generateEtagFromTables(array $tables, string $source = 'updated_at'): string + { + // Fetch the latest update timestamp from each table. + // If the source is 'etag_version', use a cached version key as the source of truth. + $lastUpdated = collect($tables)->map(function ($table) use ($source) { + if ($source === 'etag_version') { + // This is not currently implemented but serves as a placeholder for future flexibility. + // The idea is to use a cached version key (e.g., "etag_version_table_name") as the source of truth. + // This would allow us to version the ETag dynamically and invalidate it using model observers or other mechanisms. + // If implemented, observers can increment this version key whenever the corresponding table is updated. + return Cache::get("etag_version_{$table}", 0); + } + + // Default to the updated_at column in the database. + return DB::table($table)->max('updated_at'); + })->max(); - return md5($response->getContent()); + $etag = md5(auth()->id() . $lastUpdated); + + return (string) Str::of($etag)->start('"')->finish('"'); } } diff --git a/ProcessMaker/Http/Resources/V1_1/TaskResource.php b/ProcessMaker/Http/Resources/V1_1/TaskResource.php index c577942c8f..cec32ced7e 100644 --- a/ProcessMaker/Http/Resources/V1_1/TaskResource.php +++ b/ProcessMaker/Http/Resources/V1_1/TaskResource.php @@ -82,7 +82,6 @@ class TaskResource extends ApiResource 'is_administrator', 'expires_at', 'loggedin_at', - 'active_at', 'created_at', 'updated_at', 'delegation_user_id', diff --git a/ProcessMaker/Providers/ProcessMakerServiceProvider.php b/ProcessMaker/Providers/ProcessMakerServiceProvider.php index ce11b8e437..f8994be5a4 100644 --- a/ProcessMaker/Providers/ProcessMakerServiceProvider.php +++ b/ProcessMaker/Providers/ProcessMakerServiceProvider.php @@ -10,6 +10,7 @@ use Illuminate\Notifications\Events\BroadcastNotificationCreated; use Illuminate\Notifications\Events\NotificationSent; use Illuminate\Support\Facades; +use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\URL; use Laravel\Dusk\DuskServiceProvider; use Laravel\Horizon\Horizon; @@ -19,6 +20,7 @@ use ProcessMaker\Events\ActivityAssigned; use ProcessMaker\Events\ScreenBuilderStarting; use ProcessMaker\Helpers\PmHash; +use ProcessMaker\Http\Middleware\Etag\HandleEtag; use ProcessMaker\ImportExport\Extension; use ProcessMaker\ImportExport\SignalHelper; use ProcessMaker\Jobs\SmartInbox; @@ -52,6 +54,8 @@ public function boot(): void $this->setupFactories(); parent::boot(); + + Route::pushMiddlewareToGroup('api', HandleEtag::class); } public function register(): void diff --git a/ProcessMaker/Providers/RouteServiceProvider.php b/ProcessMaker/Providers/RouteServiceProvider.php index 1315214a1d..e12823a721 100644 --- a/ProcessMaker/Providers/RouteServiceProvider.php +++ b/ProcessMaker/Providers/RouteServiceProvider.php @@ -89,7 +89,7 @@ protected function mapApiRoutes() { Route::middleware('api') ->group(base_path('routes/api.php')); - Route::middleware('auth:api') + Route::middleware(['auth:api', 'etag']) ->group(base_path('routes/v1_1/api.php')); } diff --git a/ProcessMaker/Traits/TaskResourceIncludes.php b/ProcessMaker/Traits/TaskResourceIncludes.php index ec12530a4c..f2597ae68c 100644 --- a/ProcessMaker/Traits/TaskResourceIncludes.php +++ b/ProcessMaker/Traits/TaskResourceIncludes.php @@ -41,7 +41,12 @@ private function includeUser() private function includeRequestor() { - return ['requestor' => new Users($this->processRequest->user)]; + $user = $this->processRequest->user; + + // Exclude 'active_at' to prevent ETag inconsistencies. + $user->makeHidden(['active_at']); + + return ['requestor' => new Users($user)]; } private function includeProcessRequest() diff --git a/routes/engine.php b/routes/engine.php index 92f67def38..6ca0557b18 100644 --- a/routes/engine.php +++ b/routes/engine.php @@ -8,7 +8,10 @@ // Engine Route::prefix('api/1.0')->name('api.')->group(function () { // List of Processes that the user can start - Route::get('start_processes', [ProcessController::class, 'startProcesses'])->name('processes.start'); // Filtered in controller + Route::get('start_processes', [ProcessController::class, 'startProcesses']) + ->middleware('etag') + ->defaults('etag_tables', 'processes') + ->name('processes.start'); // Filtered in controller // Start a process Route::post('process_events/{process}', [ProcessController::class, 'triggerStartEvent'])->name('process_events.trigger')->middleware('can:start,process'); diff --git a/routes/v1_1/api.php b/routes/v1_1/api.php index ead0037612..70ed572f5c 100644 --- a/routes/v1_1/api.php +++ b/routes/v1_1/api.php @@ -22,7 +22,7 @@ // Route to show the screen of a task Route::get('/{taskId}/screen', [TaskController::class, 'showScreen']) - ->middleware('etag') + ->defaults('etag_tables', 'screens,screen_versions') ->name('show.screen'); // Route to show the interstitial screen of a task diff --git a/tests/Feature/Etag/HandleEtagTest.php b/tests/Feature/Etag/HandleEtagTest.php index 08ee467e9b..e933260f04 100644 --- a/tests/Feature/Etag/HandleEtagTest.php +++ b/tests/Feature/Etag/HandleEtagTest.php @@ -75,7 +75,7 @@ public function testDefaultGetEtagGeneratesCorrectEtagWithUser() $user = User::factory()->create(); $this->actingAs($user); - Route::middleware('etag:user')->any(self::TEST_ROUTE, function () { + Route::middleware('etag')->any(self::TEST_ROUTE, function () { return response($this->response, 200); });