From dae98bf240592497c7699166ead7bb9da5ac8e9d Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 6 May 2026 14:46:33 +1200 Subject: [PATCH] feat(notifications): track email opens by decoding pixel JWT and marking alert read Replace placeholder body with JWT decode, alertId/userId match validation, sparse update of read=true via injected Authorization::skip. Always returns 1x1 PNG regardless of decode outcome to avoid leaking JWT validity through response status. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Modules/Account/Http/Alerts/Track/Get.php | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Account/Http/Alerts/Track/Get.php b/src/Appwrite/Platform/Modules/Account/Http/Alerts/Track/Get.php index 32e1656af1..7c5a48346c 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Alerts/Track/Get.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Alerts/Track/Get.php @@ -2,15 +2,19 @@ namespace Appwrite\Platform\Modules\Account\Http\Alerts\Track; +use Ahc\Jwt\JWT; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Utopia\System\System; use Utopia\Validator\Text; class Get extends Action @@ -48,6 +52,7 @@ class Get extends Action ->param('jwt', '', new Text(2048, 0), 'Tracking token.', true) ->inject('response') ->inject('dbForPlatform') + ->inject('authorization') ->callback($this->action(...)); } @@ -56,9 +61,39 @@ class Get extends Action string $jwt, Response $response, Database $dbForPlatform, + Authorization $authorization, ): void { - // 1x1 transparent PNG. Wave 3 (ST14) will replace this body with - // JWT decode + read-flag flip; the skeleton always returns the pixel. + $secret = System::getEnv('_APP_OPENSSL_KEY_V1'); + + if ($secret !== '' && $jwt !== '') { + try { + $decoder = new JWT($secret, 'HS256', 2592000, 0); + $decoded = $decoder->decode($jwt); + + if ( + isset($decoded['alertId'], $decoded['userId']) + && $decoded['alertId'] === $alertId + ) { + $authorization->skip(function () use ($dbForPlatform, $alertId, $decoded) { + $alert = $dbForPlatform->getDocument('alerts', $alertId); + + if ( + !$alert->isEmpty() + && $alert->getAttribute('userId') === $decoded['userId'] + && $alert->getAttribute('read') !== true + ) { + $dbForPlatform->updateDocument('alerts', $alertId, new Document([ + 'read' => true, + ])); + } + }); + } + } catch (\Throwable) { + // Silent fail — never reveal JWT validity through response status + } + } + + // 1x1 transparent PNG (canonical 67-byte payload) $pixel = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='); $response