Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 77 additions & 20 deletions ProcessMaker/Http/Middleware/Etag/HandleEtag.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
43 changes: 33 additions & 10 deletions ProcessMaker/Http/Resources/Caching/EtagManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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('"');
}
}
1 change: 0 additions & 1 deletion ProcessMaker/Http/Resources/V1_1/TaskResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ class TaskResource extends ApiResource
'is_administrator',
'expires_at',
'loggedin_at',
'active_at',
'created_at',
'updated_at',
'delegation_user_id',
Expand Down
4 changes: 4 additions & 0 deletions ProcessMaker/Providers/ProcessMakerServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -52,6 +54,8 @@ public function boot(): void
$this->setupFactories();

parent::boot();

Route::pushMiddlewareToGroup('api', HandleEtag::class);
}

public function register(): void
Expand Down
2 changes: 1 addition & 1 deletion ProcessMaker/Providers/RouteServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}

Expand Down
7 changes: 6 additions & 1 deletion ProcessMaker/Traits/TaskResourceIncludes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 4 additions & 1 deletion routes/engine.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion routes/v1_1/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/Feature/Etag/HandleEtagTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down