From 85a2d623a67f5e2f1c0a1856b8db4a1009353380 Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Thu, 19 Jan 2023 16:36:17 -0800 Subject: [PATCH] Update APIs to check X-Appwrite-Timestamp header Appwrite will refer to the X-Appwrite-Timestamp header for when the request was originally sent and reject the request if the Timestamp is older than the updatedAt timestamp of the document. --- CHANGES.md | 4 +- app/config/errors.php | 5 ++ app/controllers/api/account.php | 60 +++++++++------ app/controllers/api/databases.php | 46 ++++++++++-- app/controllers/api/teams.php | 16 ++-- app/controllers/general.php | 8 +- app/controllers/shared/api.php | 2 - app/init.php | 14 ++++ composer.json | 6 +- composer.lock | 74 +++++++++---------- src/Appwrite/Extend/Exception.php | 1 + tests/e2e/General/HTTPTest.php | 2 +- .../e2e/Services/Databases/DatabasesBase.php | 46 ++++++++++++ 13 files changed, 201 insertions(+), 83 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 86f5123db8..b5059a8f4f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,11 +3,11 @@ ## Features - Password dictionary setting allows to compare user's password against command password database [4906](https://github.com/appwrite/appwrite/pull/4906) - Password history setting allows to save user's last used password so that it may not be used again. Maximum number of history saved is 20, which can be configured. Minimum is 0 which means disabled. [#4866](https://github.com/appwrite/appwrite/pull/4866) +- Update APIs to check X-Appwrite-Timestamp header [#5024](https://github.com/appwrite/appwrite/pull/5024) ## Bugs - -- Fix expire to formatTz in create account session [#4985](https://github.com/appwrite/appwrite/pull/4985) - Fix not storing function's response on response codes 5xx [#4610](https://github.com/appwrite/appwrite/pull/4610) +- Fix expire to formatTz in create account session [#4985](https://github.com/appwrite/appwrite/pull/4985) # Version 1.2.1 ## Changes diff --git a/app/config/errors.php b/app/config/errors.php index 266e017e93..a6cf0c4afd 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -408,6 +408,11 @@ return [ 'description' => 'Document with the requested ID already exists.', 'code' => 409, ], + Exception::DOCUMENT_UPDATE_CONFLICT => [ + 'name' => Exception::DOCUMENT_UPDATE_CONFLICT, + 'description' => 'Remote document is newer than local.', + 'code' => 409, + ], /** Attributes */ Exception::ATTRIBUTE_NOT_FOUND => [ diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 0cd44d7557..0160d56cbb 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1524,15 +1524,18 @@ App::patch('/v1/account/name') ->label('sdk.offline.model', '/account') ->label('sdk.offline.key', 'current') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.') + ->inject('requestTimestamp') ->inject('response') ->inject('user') ->inject('dbForProject') ->inject('events') - ->action(function (string $name, Response $response, Document $user, Database $dbForProject, Event $events) { + ->action(function (string $name, ?\DateTime $requestTimestamp, Response $response, Document $user, Database $dbForProject, Event $events) { - $user = $dbForProject->updateDocument('users', $user->getId(), $user + $user ->setAttribute('name', $name) - ->setAttribute('search', implode(' ', [$user->getId(), $name, $user->getAttribute('email', ''), $user->getAttribute('phone', '')]))); + ->setAttribute('search', implode(' ', [$user->getId(), $name, $user->getAttribute('email', ''), $user->getAttribute('phone', '')])); + + $user = $dbForProject->withRequestTimestamp($requestTimestamp, fn () => $dbForProject->updateDocument('users', $user->getId(), $user)); $events->setParam('userId', $user->getId()); @@ -1559,12 +1562,13 @@ App::patch('/v1/account/password') ->label('sdk.offline.model', '/account') ->label('sdk.offline.key', 'current') ->param('oldPassword', '', new Password(), 'Current user password. Must be at least 8 chars.', true) + ->inject('requestTimestamp') ->inject('response') ->inject('user') ->inject('project') ->inject('dbForProject') ->inject('events') - ->action(function (string $password, string $oldPassword, Response $response, Document $user, Document $project, Database $dbForProject, Event $events) { + ->action(function (string $password, string $oldPassword, ?\DateTime $requestTimestamp, Response $response, Document $user, Document $project, Database $dbForProject, Event $events) { // Check old password only if its an existing user. if (!empty($user->getAttribute('passwordUpdate')) && !Auth::passwordVerify($oldPassword, $user->getAttribute('password'), $user->getAttribute('hash'), $user->getAttribute('hashOptions'))) { // Double check user password @@ -1585,12 +1589,14 @@ App::patch('/v1/account/password') array_slice($history, (count($history) - $historyLimit), $historyLimit); } - $user = $dbForProject->updateDocument('users', $user->getId(), $user - ->setAttribute('password', $newPassword) - ->setAttribute('passwordHistory', $history) - ->setAttribute('passwordUpdate', DateTime::now())) - ->setAttribute('hash', Auth::DEFAULT_ALGO) - ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS); + $user + ->setAttribute('password', $newPassword) + ->setAttribute('passwordHistory', $history) + ->setAttribute('passwordUpdate', DateTime::now()) + ->setAttribute('hash', Auth::DEFAULT_ALGO) + ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS); + + $user = $dbForProject->withRequestTimestamp($requestTimestamp, fn () => $dbForProject->updateDocument('users', $user->getId(), $user)); $events->setParam('userId', $user->getId()); @@ -1617,11 +1623,12 @@ App::patch('/v1/account/email') ->label('sdk.offline.key', 'current') ->param('email', '', new Email(), 'User email.') ->param('password', '', new Password(), 'User password. Must be at least 8 chars.') + ->inject('requestTimestamp') ->inject('response') ->inject('user') ->inject('dbForProject') ->inject('events') - ->action(function (string $email, string $password, Response $response, Document $user, Database $dbForProject, Event $events) { + ->action(function (string $email, string $password, ?\DateTime $requestTimestamp, Response $response, Document $user, Database $dbForProject, Event $events) { $isAnonymousUser = Auth::isAnonymousUser($user); // Check if request is from an anonymous account for converting if ( @@ -1642,7 +1649,7 @@ App::patch('/v1/account/email') ->setAttribute('search', implode(' ', [$user->getId(), $user->getAttribute('name', ''), $email, $user->getAttribute('phone', '')])); try { - $user = $dbForProject->updateDocument('users', $user->getId(), $user); + $user = $dbForProject->withRequestTimestamp($requestTimestamp, fn () => $dbForProject->updateDocument('users', $user->getId(), $user)); } catch (Duplicate $th) { throw new Exception(Exception::USER_EMAIL_ALREADY_EXISTS); } @@ -1672,11 +1679,12 @@ App::patch('/v1/account/phone') ->label('sdk.offline.key', 'current') ->param('phone', '', new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.') ->param('password', '', new Password(), 'User password. Must be at least 8 chars.') + ->inject('requestTimestamp') ->inject('response') ->inject('user') ->inject('dbForProject') ->inject('events') - ->action(function (string $phone, string $password, Response $response, Document $user, Database $dbForProject, Event $events) { + ->action(function (string $phone, string $password, ?\DateTime $requestTimestamp, Response $response, Document $user, Database $dbForProject, Event $events) { $isAnonymousUser = Auth::isAnonymousUser($user); // Check if request is from an anonymous account for converting @@ -1693,7 +1701,7 @@ App::patch('/v1/account/phone') ->setAttribute('search', implode(' ', [$user->getId(), $user->getAttribute('name', ''), $user->getAttribute('email', ''), $phone])); try { - $user = $dbForProject->updateDocument('users', $user->getId(), $user); + $user = $dbForProject->withRequestTimestamp($requestTimestamp, fn () => $dbForProject->updateDocument('users', $user->getId(), $user)); } catch (Duplicate $th) { throw new Exception(Exception::USER_PHONE_ALREADY_EXISTS); } @@ -1722,13 +1730,16 @@ App::patch('/v1/account/prefs') ->label('sdk.offline.model', '/account/prefs') ->label('sdk.offline.key', 'current') ->param('prefs', [], new Assoc(), 'Prefs key-value JSON object.') + ->inject('requestTimestamp') ->inject('response') ->inject('user') ->inject('dbForProject') ->inject('events') - ->action(function (array $prefs, Response $response, Document $user, Database $dbForProject, Event $events) { + ->action(function (array $prefs, ?\DateTime $requestTimestamp, Response $response, Document $user, Database $dbForProject, Event $events) { - $user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('prefs', $prefs)); + $user->setAttribute('prefs', $prefs); + + $user = $dbForProject->withRequestTimestamp($requestTimestamp, fn () => $dbForProject->updateDocument('users', $user->getId(), $user)); $events->setParam('userId', $user->getId()); @@ -1751,14 +1762,16 @@ App::patch('/v1/account/status') ->label('sdk.response.code', Response::STATUS_CODE_OK) ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) ->label('sdk.response.model', Response::MODEL_ACCOUNT) - ->inject('request') + ->inject('requestTimestamp') ->inject('response') ->inject('user') ->inject('dbForProject') ->inject('events') - ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $events) { + ->action(function (?\DateTime $requestTimestamp, Response $response, Document $user, Database $dbForProject, Event $events) { - $user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('status', false)); + $user->setAttribute('status', false); + + $user = $dbForProject->withRequestTimestamp($requestTimestamp, fn () => $dbForProject->updateDocument('users', $user->getId(), $user)); $events ->setParam('userId', $user->getId()) @@ -1788,6 +1801,7 @@ App::delete('/v1/account/sessions/:sessionId') ->label('sdk.response.model', Response::MODEL_NONE) ->label('abuse-limit', 100) ->param('sessionId', '', new UID(), 'Session ID. Use the string \'current\' to delete the current device session.') + ->inject('requestTimestamp') ->inject('request') ->inject('response') ->inject('user') @@ -1795,7 +1809,7 @@ App::delete('/v1/account/sessions/:sessionId') ->inject('locale') ->inject('events') ->inject('project') - ->action(function (?string $sessionId, Request $request, Response $response, Document $user, Database $dbForProject, Locale $locale, Event $events, Document $project) { + ->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, Document $user, Database $dbForProject, Locale $locale, Event $events, Document $project) { $protocol = $request->getProtocol(); $authDuration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; @@ -1807,9 +1821,11 @@ App::delete('/v1/account/sessions/:sessionId') foreach ($sessions as $key => $session) {/** @var Document $session */ if ($sessionId == $session->getId()) { - unset($sessions[$key]); + $dbForProject->withRequestTimestamp($requestTimestamp, function () use ($dbForProject, $session) { + return $dbForProject->deleteDocument('sessions', $session->getId()); + }); - $dbForProject->deleteDocument('sessions', $session->getId()); + unset($sessions[$key]); $session->setAttribute('current', false); diff --git a/app/controllers/api/databases.php b/app/controllers/api/databases.php index 7b2eddfade..3f420396e7 100644 --- a/app/controllers/api/databases.php +++ b/app/controllers/api/databases.php @@ -2252,11 +2252,12 @@ App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:docum ->param('documentId', '', new UID(), 'Document ID.') ->param('data', [], new JSON(), 'Document data as JSON object. Include only attribute and value pairs to be updated.', true) ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](/docs/permissions).', true) + ->inject('requestTimestamp') ->inject('response') ->inject('dbForProject') ->inject('events') ->inject('mode') - ->action(function (string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, Response $response, Database $dbForProject, Event $events, string $mode) { + ->action(function (string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?\DateTime $requestTimestamp, Response $response, Database $dbForProject, Event $events, string $mode) { $data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array @@ -2286,6 +2287,7 @@ App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:docum } // Read permission should not be required for update + /** @var Document */ $document = Authorization::skip(fn() => $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId)); if ($document->isEmpty()) { @@ -2331,10 +2333,31 @@ App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:docum $data['$permissions'] = $permissions; try { + $privateCollectionId = 'database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(); if ($documentSecurity && !$valid) { - $document = $dbForProject->updateDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $document->getId(), new Document($data)); + $document = $dbForProject->withRequestTimestamp( + $requestTimestamp, + function () use ($dbForProject, $privateCollectionId, $document, $data) { + return $dbForProject->updateDocument( + $privateCollectionId, + $document->getId(), + new Document($data) + ); + } + ); } else { - $document = Authorization::skip(fn() => $dbForProject->updateDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $document->getId(), new Document($data))); + $document = Authorization::skip(function () use ($dbForProject, $requestTimestamp, $privateCollectionId, $document, $data) { + return $dbForProject->withRequestTimestamp( + $requestTimestamp, + function () use ($dbForProject, $privateCollectionId, $document, $data) { + return $dbForProject->updateDocument( + $privateCollectionId, + $document->getId(), + new Document($data) + ); + } + ); + }); } /** @@ -2385,12 +2408,13 @@ App::delete('/v1/databases/:databaseId/collections/:collectionId/documents/:docu ->param('databaseId', '', new UID(), 'Database ID.') ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') ->param('documentId', '', new UID(), 'Document ID.') + ->inject('requestTimestamp') ->inject('response') ->inject('dbForProject') ->inject('events') ->inject('deletes') ->inject('mode') - ->action(function (string $databaseId, string $collectionId, string $documentId, Response $response, Database $dbForProject, Event $events, Delete $deletes, string $mode) { + ->action(function (string $databaseId, string $collectionId, string $documentId, ?\DateTime $requestTimestamp, Response $response, Database $dbForProject, Event $events, Delete $deletes, string $mode) { $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -2420,17 +2444,25 @@ App::delete('/v1/databases/:databaseId/collections/:collectionId/documents/:docu throw new Exception(Exception::DOCUMENT_NOT_FOUND); } + $privateCollectionId = 'database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(); + if ($documentSecurity && !$valid) { try { - $dbForProject->deleteDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId); + $dbForProject->withRequestTimestamp($requestTimestamp, function () use ($dbForProject, $privateCollectionId, $documentId) { + return $dbForProject->deleteDocument($privateCollectionId, $documentId); + }); } catch (AuthorizationException) { throw new Exception(Exception::USER_UNAUTHORIZED); } } else { - Authorization::skip(fn() => $dbForProject->deleteDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId)); + Authorization::skip(function () use ($dbForProject, $requestTimestamp, $privateCollectionId, $documentId) { + return $dbForProject->withRequestTimestamp($requestTimestamp, function () use ($dbForProject, $privateCollectionId, $documentId) { + return $dbForProject->deleteDocument($privateCollectionId, $documentId); + }); + }); } - $dbForProject->deleteCachedDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId); + $dbForProject->deleteCachedDocument($privateCollectionId, $documentId); /** * Reset $collection attribute to remove prefix. diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index f95c779838..204f4dfc02 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -6,6 +6,7 @@ use Appwrite\Detector\Detector; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Mail; +use Appwrite\Event\Phone as EventPhone; use Appwrite\Extend\Exception; use Appwrite\Network\Validator\Email; use Utopia\Validator\Host; @@ -21,7 +22,6 @@ use Appwrite\Utopia\Response; use MaxMind\Db\Reader; use Utopia\App; use Utopia\Audit\Audit; -use Utopia\CLI\Console; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; @@ -37,10 +37,7 @@ use Utopia\Database\Validator\Key; use Utopia\Database\Validator\UID; use Utopia\Locale\Locale; use Utopia\Validator\Text; -use Utopia\Validator\Range; use Utopia\Validator\ArrayList; -use Utopia\Validator\WhiteList; -use Appwrite\Event\Phone as EventPhone; App::post('/v1/teams') ->desc('Create Team') @@ -219,10 +216,11 @@ App::put('/v1/teams/:teamId') ->label('sdk.offline.key', '{teamId}') ->param('teamId', '', new UID(), 'Team ID.') ->param('name', null, new Text(128), 'New team name. Max length: 128 chars.') + ->inject('requestTimestamp') ->inject('response') ->inject('dbForProject') ->inject('events') - ->action(function (string $teamId, string $name, Response $response, Database $dbForProject, Event $events) { + ->action(function (string $teamId, string $name, ?\DateTime $requestTimestamp, Response $response, Database $dbForProject, Event $events) { $team = $dbForProject->getDocument('teams', $teamId); @@ -230,9 +228,13 @@ App::put('/v1/teams/:teamId') throw new Exception(Exception::TEAM_NOT_FOUND); } - $team = $dbForProject->updateDocument('teams', $team->getId(), $team + $team ->setAttribute('name', $name) - ->setAttribute('search', implode(' ', [$teamId, $name]))); + ->setAttribute('search', implode(' ', [$teamId, $name])); + + $team = $dbForProject->withRequestTimestamp($requestTimestamp, function () use ($dbForProject, $team) { + return $dbForProject->updateDocument('teams', $team->getId(), $team); + }); $events->setParam('teamId', $team->getId()); diff --git a/app/controllers/general.php b/app/controllers/general.php index 99af4681b8..c1b8457d12 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -233,7 +233,7 @@ App::init() ->addHeader('Server', 'Appwrite') ->addHeader('X-Content-Type-Options', 'nosniff') ->addHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE') - ->addHeader('Access-Control-Allow-Headers', 'Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, Content-Range, Range, Cache-Control, Expires, Pragma') + ->addHeader('Access-Control-Allow-Headers', 'Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, X-Appwrite-Timestamp, Content-Range, Range, Cache-Control, Expires, Pragma') ->addHeader('Access-Control-Expose-Headers', 'X-Fallback-Cookies') ->addHeader('Access-Control-Allow-Origin', $refDomain) ->addHeader('Access-Control-Allow-Credentials', 'true') @@ -384,7 +384,7 @@ App::options() $response ->addHeader('Server', 'Appwrite') ->addHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE') - ->addHeader('Access-Control-Allow-Headers', 'Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, Content-Range, Range, Cache-Control, Expires, Pragma, X-Fallback-Cookies') + ->addHeader('Access-Control-Allow-Headers', 'Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, X-Appwrite-Timestamp, Content-Range, Range, Cache-Control, Expires, Pragma, X-Fallback-Cookies') ->addHeader('Access-Control-Expose-Headers', 'X-Fallback-Cookies') ->addHeader('Access-Control-Allow-Origin', $origin) ->addHeader('Access-Control-Allow-Credentials', 'true') @@ -485,6 +485,10 @@ App::error() $error->setType(AppwriteException::GENERAL_ROUTE_NOT_FOUND); break; } + } elseif ($error instanceof Utopia\Database\Exception\Conflict) { + $error = new AppwriteException(AppwriteException::DOCUMENT_UPDATE_CONFLICT, null, null, $error); + $code = $error->getCode(); + $message = $error->getMessage(); } /** Wrap all exceptions inside Appwrite\Extend\Exception */ diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 5655874c65..93cf81e1fa 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -5,7 +5,6 @@ use Appwrite\Event\Audit; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; use Appwrite\Event\Event; -use Appwrite\Event\Mail; use Appwrite\Extend\Exception; use Appwrite\Messaging\Adapter\Realtime; use Appwrite\Usage\Stats; @@ -16,7 +15,6 @@ use Utopia\Abuse\Abuse; use Utopia\Abuse\Adapters\TimeLimit; use Utopia\Cache\Adapter\Filesystem; use Utopia\Cache\Cache; -use Utopia\CLI\Console; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; diff --git a/app/init.php b/app/init.php index 29624dfc6e..1e6e7de94a 100644 --- a/app/init.php +++ b/app/init.php @@ -1155,3 +1155,17 @@ App::setResource('schema', function ($utopia, $dbForProject) { $params, ); }, ['utopia', 'dbForProject']); + +App::setResource('requestTimestamp', function ($request) { + // Validate x-appwrite-timestamp header + $timestampHeader = $request->getHeader('x-appwrite-timestamp'); + $requestTimestamp = null; + if (!empty($timestampHeader)) { + try { + $requestTimestamp = new \DateTime($timestampHeader); + } catch (\Throwable $e) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid X-Appwrite-Timestamp header value'); + } + } + return $requestTimestamp; +}, ['request']); diff --git a/composer.json b/composer.json index 1711407ece..4fae985fdd 100644 --- a/composer.json +++ b/composer.json @@ -43,13 +43,13 @@ "ext-sockets": "*", "appwrite/php-clamav": "1.1.*", "appwrite/php-runtimes": "0.11.*", - "utopia-php/abuse": "0.18.*", + "utopia-php/abuse": "0.19.*", "utopia-php/analytics": "0.2.*", - "utopia-php/audit": "0.20.*", + "utopia-php/audit": "0.21.*", "utopia-php/cache": "0.8.*", "utopia-php/cli": "0.13.*", "utopia-php/config": "0.2.*", - "utopia-php/database": "0.30.*", + "utopia-php/database": "0.31.*", "utopia-php/preloader": "0.2.*", "utopia-php/domains": "1.1.*", "utopia-php/framework": "0.26.*", diff --git a/composer.lock b/composer.lock index 82a9de301f..7dd2ce89aa 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ac80cafdd8c2c6deaec3dfe628084655", + "content-hash": "3e00aa37bea907b7dcca7f912402a392", "packages": [ { "name": "adhocore/jwt", @@ -1808,29 +1808,28 @@ }, { "name": "utopia-php/abuse", - "version": "0.18.0", + "version": "0.19.0", "source": { "type": "git", "url": "https://github.com/utopia-php/abuse.git", - "reference": "8496401234f73a49f8c4259d3e89ab4a7c1f9ecf" + "reference": "419b6e2e0a5dec35ea83a25758df9cd129b6c412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/abuse/zipball/8496401234f73a49f8c4259d3e89ab4a7c1f9ecf", - "reference": "8496401234f73a49f8c4259d3e89ab4a7c1f9ecf", + "url": "https://api.github.com/repos/utopia-php/abuse/zipball/419b6e2e0a5dec35ea83a25758df9cd129b6c412", + "reference": "419b6e2e0a5dec35ea83a25758df9cd129b6c412", "shasum": "" }, "require": { "ext-curl": "*", "ext-pdo": "*", "php": ">=8.0", - "utopia-php/database": "0.30.*" + "utopia-php/database": "0.31.*" }, "require-dev": { "laravel/pint": "1.2.*", - "phpstan/phpstan": "1.9.x-dev", - "phpunit/phpunit": "^9.4", - "vimeo/psalm": "4.0.1" + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^9.4" }, "type": "library", "autoload": { @@ -1852,9 +1851,9 @@ ], "support": { "issues": "https://github.com/utopia-php/abuse/issues", - "source": "https://github.com/utopia-php/abuse/tree/0.18.0" + "source": "https://github.com/utopia-php/abuse/tree/0.19.0" }, - "time": "2023-02-14T09:56:04+00:00" + "time": "2023-02-26T03:28:48+00:00" }, { "name": "utopia-php/analytics", @@ -1913,22 +1912,22 @@ }, { "name": "utopia-php/audit", - "version": "0.20.0", + "version": "0.21.0", "source": { "type": "git", "url": "https://github.com/utopia-php/audit.git", - "reference": "3fce3f4ad3ea9dfcb39b79668abd76331412a5ed" + "reference": "7f9783a14718c82570c6effb35a3cb42c30b13a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/audit/zipball/3fce3f4ad3ea9dfcb39b79668abd76331412a5ed", - "reference": "3fce3f4ad3ea9dfcb39b79668abd76331412a5ed", + "url": "https://api.github.com/repos/utopia-php/audit/zipball/7f9783a14718c82570c6effb35a3cb42c30b13a2", + "reference": "7f9783a14718c82570c6effb35a3cb42c30b13a2", "shasum": "" }, "require": { "ext-pdo": "*", "php": ">=8.0", - "utopia-php/database": "0.30.*" + "utopia-php/database": "0.31.*" }, "require-dev": { "laravel/pint": "1.2.*", @@ -1956,9 +1955,9 @@ ], "support": { "issues": "https://github.com/utopia-php/audit/issues", - "source": "https://github.com/utopia-php/audit/tree/0.20.0" + "source": "https://github.com/utopia-php/audit/tree/0.21.0" }, - "time": "2023-02-14T09:46:54+00:00" + "time": "2023-02-26T03:28:30+00:00" }, { "name": "utopia-php/cache", @@ -2115,16 +2114,16 @@ }, { "name": "utopia-php/database", - "version": "0.30.1", + "version": "0.31.0", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "1cea72c1217357bf0747ae4f28ebef57e9dc0e65" + "reference": "61f9f4743a317f1d78558a5f981adf74e7fdc931" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/1cea72c1217357bf0747ae4f28ebef57e9dc0e65", - "reference": "1cea72c1217357bf0747ae4f28ebef57e9dc0e65", + "url": "https://api.github.com/repos/utopia-php/database/zipball/61f9f4743a317f1d78558a5f981adf74e7fdc931", + "reference": "61f9f4743a317f1d78558a5f981adf74e7fdc931", "shasum": "" }, "require": { @@ -2163,9 +2162,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/0.30.1" + "source": "https://github.com/utopia-php/database/tree/0.31.0" }, - "time": "2023-02-14T06:25:03+00:00" + "time": "2023-02-23T09:49:44+00:00" }, { "name": "utopia-php/domains", @@ -3742,23 +3741,23 @@ }, { "name": "phpunit/php-code-coverage", - "version": "9.2.24", + "version": "9.2.25", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "2cf940ebc6355a9d430462811b5aaa308b174bed" + "reference": "0e2b40518197a8c0d4b08bc34dfff1c99c508954" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2cf940ebc6355a9d430462811b5aaa308b174bed", - "reference": "2cf940ebc6355a9d430462811b5aaa308b174bed", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/0e2b40518197a8c0d4b08bc34dfff1c99c508954", + "reference": "0e2b40518197a8c0d4b08bc34dfff1c99c508954", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.14", + "nikic/php-parser": "^4.15", "php": ">=7.3", "phpunit/php-file-iterator": "^3.0.3", "phpunit/php-text-template": "^2.0.2", @@ -3807,7 +3806,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.24" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.25" }, "funding": [ { @@ -3815,7 +3814,7 @@ "type": "github" } ], - "time": "2023-01-26T08:26:55+00:00" + "time": "2023-02-25T05:32:00+00:00" }, { "name": "phpunit/php-file-iterator", @@ -5127,16 +5126,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.7.1", + "version": "3.7.2", "source": { "type": "git", "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "1359e176e9307e906dc3d890bcc9603ff6d90619" + "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/1359e176e9307e906dc3d890bcc9603ff6d90619", - "reference": "1359e176e9307e906dc3d890bcc9603ff6d90619", + "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/ed8e00df0a83aa96acf703f8c2979ff33341f879", + "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879", "shasum": "" }, "require": { @@ -5172,14 +5171,15 @@ "homepage": "https://github.com/squizlabs/PHP_CodeSniffer", "keywords": [ "phpcs", - "standards" + "standards", + "static analysis" ], "support": { "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues", "source": "https://github.com/squizlabs/PHP_CodeSniffer", "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" }, - "time": "2022-06-18T07:21:10+00:00" + "time": "2023-02-22T23:07:41+00:00" }, { "name": "swoole/ide-helper", diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index d0fb86db5f..c0c4ad0925 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -135,6 +135,7 @@ class Exception extends \Exception public const DOCUMENT_INVALID_STRUCTURE = 'document_invalid_structure'; public const DOCUMENT_MISSING_PAYLOAD = 'document_missing_payload'; public const DOCUMENT_ALREADY_EXISTS = 'document_already_exists'; + public const DOCUMENT_UPDATE_CONFLICT = 'document_update_conflict'; /** Attribute */ public const ATTRIBUTE_NOT_FOUND = 'attribute_not_found'; diff --git a/tests/e2e/General/HTTPTest.php b/tests/e2e/General/HTTPTest.php index 09f744f271..f033cd6d42 100644 --- a/tests/e2e/General/HTTPTest.php +++ b/tests/e2e/General/HTTPTest.php @@ -25,7 +25,7 @@ class HTTPTest extends Scope $this->assertEquals(204, $response['headers']['status-code']); $this->assertEquals('Appwrite', $response['headers']['server']); $this->assertEquals('GET, POST, PUT, PATCH, DELETE', $response['headers']['access-control-allow-methods']); - $this->assertEquals('Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, Content-Range, Range, Cache-Control, Expires, Pragma, X-Fallback-Cookies', $response['headers']['access-control-allow-headers']); + $this->assertEquals('Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, X-Appwrite-Timestamp, Content-Range, Range, Cache-Control, Expires, Pragma, X-Fallback-Cookies', $response['headers']['access-control-allow-headers']); $this->assertEquals('X-Fallback-Cookies', $response['headers']['access-control-expose-headers']); $this->assertEquals('http://localhost', $response['headers']['access-control-allow-origin']); $this->assertEquals('true', $response['headers']['access-control-allow-credentials']); diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index 3b4c8822e0..e5efa36965 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -2,7 +2,9 @@ namespace Tests\E2E\Services\Databases; +use Appwrite\Extend\Exception; use Tests\E2E\Client; +use Utopia\Database\DateTime; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; @@ -1576,6 +1578,50 @@ trait DatabasesBase $this->assertEquals($document['body']['title'], 'Thor: Ragnarok'); $this->assertEquals($document['body']['releaseYear'], 2017); + $response = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-timestamp' => DateTime::formatTz(DateTime::now()), + ], $this->getHeaders()), [ + 'data' => [ + 'title' => 'Thor: Ragnarok', + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + /** + * Test for failure + */ + + $response = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-timestamp' => 'invalid', + ], $this->getHeaders()), [ + 'data' => [ + 'title' => 'Thor: Ragnarok', + ], + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertEquals('Invalid X-Appwrite-Timestamp header value', $response['body']['message']); + $this->assertEquals(Exception::GENERAL_ARGUMENT_INVALID, $response['body']['type']); + + $response = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-timestamp' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -1000)), + ], $this->getHeaders()), [ + 'data' => [ + 'title' => 'Thor: Ragnarok', + ], + ]); + + $this->assertEquals(409, $response['headers']['status-code']); + $this->assertEquals('Remote document is newer than local.', $response['body']['message']); + $this->assertEquals(Exception::DOCUMENT_UPDATE_CONFLICT, $response['body']['type']); + return []; }