diff --git a/ProcessMaker/Http/Kernel.php b/ProcessMaker/Http/Kernel.php index 5fb8432f48..522bfba009 100644 --- a/ProcessMaker/Http/Kernel.php +++ b/ProcessMaker/Http/Kernel.php @@ -41,7 +41,7 @@ class Kernel extends HttpKernel \Illuminate\Routing\Middleware\SubstituteBindings::class, Middleware\GenerateMenus::class, \Laravel\Passport\Http\Middleware\CreateFreshApiToken::class, - \ProcessMaker\Http\Middleware\IgnoreMapFiles::class, + Middleware\IgnoreMapFiles::class, ], 'api' => [ // API Middleware is defined with routeMiddleware below. @@ -83,6 +83,7 @@ class Kernel extends HttpKernel 'session_kill' => Middleware\SessionControlKill::class, 'no-cache' => Middleware\NoCache::class, 'admin' => Middleware\IsAdmin::class, + 'etag' => Middleware\Etag\HandleEtag::class, ]; /** diff --git a/ProcessMaker/Http/Middleware/Etag/HandleEtag.php b/ProcessMaker/Http/Middleware/Etag/HandleEtag.php new file mode 100644 index 0000000000..7238c1cb81 --- /dev/null +++ b/ProcessMaker/Http/Middleware/Etag/HandleEtag.php @@ -0,0 +1,59 @@ +getMethod(); + if ($request->isMethod('HEAD')) { + $request->setMethod('GET'); + } + + // Handle response. + $response = $next($request); + + // Determine if the ETag should include user-specific data. + $includeUser = $scope === 'user'; + + // Generate ETag for the response. + $etag = EtagManager::getEtag($request, $response, $includeUser); + if ($etag) { + // Set the ETag header. + $response->setEtag($etag); + + // Get and strip weak ETags from request headers. + $noneMatch = array_map([$this, 'stripWeakTags'], $request->getETags()); + + // Compare ETags and set response as not modified if applicable. + if (in_array($etag, $noneMatch)) { + $response->setNotModified(); + } + } + + // Restore original method and return the response. + $request->setMethod($originalMethod); + + return $response; + } + + /** + * Remove the weak indicator (W/) from an ETag. + */ + private function stripWeakTags(string $etag): string + { + return str_replace('W/', '', $etag); + } +} diff --git a/ProcessMaker/Http/Resources/Caching/EtagManager.php b/ProcessMaker/Http/Resources/Caching/EtagManager.php new file mode 100644 index 0000000000..e1f335e1e8 --- /dev/null +++ b/ProcessMaker/Http/Resources/Caching/EtagManager.php @@ -0,0 +1,48 @@ +start('"')->finish('"'); + } + + /** + * Generate an ETag, optionally including user-specific data. + */ + private static function defaultGetEtag(Response $response, bool $includeUser = false): string + { + if ($includeUser) { + return md5(auth()->id() . $response->getContent()); + } + + return md5($response->getContent()); + } +} diff --git a/routes/v1_1/api.php b/routes/v1_1/api.php index a2b2328dc9..ead0037612 100644 --- a/routes/v1_1/api.php +++ b/routes/v1_1/api.php @@ -22,6 +22,7 @@ // Route to show the screen of a task Route::get('/{taskId}/screen', [TaskController::class, 'showScreen']) + ->middleware('etag') ->name('show.screen'); // Route to show the interstitial screen of a task diff --git a/tests/Feature/Etag/EtagManagerTest.php b/tests/Feature/Etag/EtagManagerTest.php new file mode 100644 index 0000000000..010c28ebbb --- /dev/null +++ b/tests/Feature/Etag/EtagManagerTest.php @@ -0,0 +1,51 @@ +response, 200); + + $this->assertEquals('"e0aa021e21dddbd6d8cecec71e9cf564"', EtagManager::getEtag($request, $response)); + } + + public function testGetEtagWithCallbackMd5() + { + $request = Request::create('/', 'GET'); + $response = response($this->response, 200); + + EtagManager::etagGenerateUsing(function (Request $request, Response $response) { + return md5($response->getContent()); + }); + + $this->assertEquals('"e0aa021e21dddbd6d8cecec71e9cf564"', EtagManager::getEtag($request, $response)); + } + + public function testGetEtagWithCallbackSha256() + { + $request = Request::create('/', 'GET'); + $response = response($this->response, 200); + + EtagManager::etagGenerateUsing(function (Request $request, Response $response) { + return hash('sha256', $response->getContent()); + }); + + $expectedHash = hash('sha256', $this->response); + $this->assertEquals("\"$expectedHash\"", EtagManager::getEtag($request, $response)); + } +} diff --git a/tests/Feature/Etag/HandleEtagTest.php b/tests/Feature/Etag/HandleEtagTest.php new file mode 100644 index 0000000000..08ee467e9b --- /dev/null +++ b/tests/Feature/Etag/HandleEtagTest.php @@ -0,0 +1,87 @@ +any(self::TEST_ROUTE, function () { + return response($this->response, 200); + }); + } + + public function testMiddlewareSetsEtagHeader() + { + $response = $this->get(self::TEST_ROUTE); + $response->assertHeader('ETag'); + } + + public function testEtagHeaderHasCorrectValue() + { + $expectedEtag = '"' . md5($this->response) . '"'; + $response = $this->get(self::TEST_ROUTE); + $response->assertHeader('ETag', $expectedEtag); + } + + public function testRequestReturns200WhenIfNoneMatchDoesNotMatch() + { + $noneMatch = '"' . md5($this->response . 'NoneMatch') . '"'; + $response = $this + ->withHeaders(['If-None-Match' => $noneMatch]) + ->get(self::TEST_ROUTE); + + $response->assertStatus(200); + $response->assertHeader('ETag'); + } + + public function testRequestReturns304WhenIfNoneMatchMatches() + { + $matchingEtag = '"' . md5($this->response) . '"'; + $response = $this + ->withHeaders(['If-None-Match' => $matchingEtag]) + ->get(self::TEST_ROUTE); + + $response->assertStatus(304); + $response->assertHeader('ETag', $matchingEtag); + } + + public function testRequestIgnoresWeakEtagsInIfNoneMatch() + { + $weakEtag = 'W/"' . md5($this->response) . '"'; + $response = $this + ->withHeaders(['If-None-Match' => $weakEtag]) + ->get(self::TEST_ROUTE); + + $response->assertStatus(304); + $response->assertHeader('ETag', '"' . md5($this->response) . '"'); + } + + public function testDefaultGetEtagGeneratesCorrectEtagWithUser() + { + $user = User::factory()->create(); + $this->actingAs($user); + + Route::middleware('etag:user')->any(self::TEST_ROUTE, function () { + return response($this->response, 200); + }); + + $response = $this->get(self::TEST_ROUTE); + + $expectedEtag = '"' . md5($user->id . $this->response) . '"'; + $response->assertHeader('ETag', $expectedEtag); + } +}