diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dbf307f962..a7fc1cf0c6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -156,6 +156,7 @@ jobs: Sites, Proxy, Storage, + Tokens, Teams, Users, Webhooks, diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php index 5105304599..8da1688fc4 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -2461,8 +2461,19 @@ return [ 'required' => false, 'default' => null, 'array' => false, - 'filters' => [], - ] + 'filters' => ['datetime'], + ], + [ + '$id' => ID::custom('accessedAt'), + 'type' => Database::VAR_DATETIME, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ], ], 'indexes' => [ [ @@ -2472,7 +2483,13 @@ return [ 'lengths' => [], 'orders' => [Database::ORDER_ASC], ], - + [ + '$id' => '_key_accessedAt', + 'type' => Database::INDEX_KEY, + 'attributes' => ['accessedAt'], + 'lengths' => [], + 'orders' => [], + ], ], ], ]; diff --git a/app/config/oAuthProviders.php b/app/config/oAuthProviders.php index 261df5c07e..d8dfc807b1 100644 --- a/app/config/oAuthProviders.php +++ b/app/config/oAuthProviders.php @@ -11,6 +11,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Amazon', ], 'apple' => [ 'name' => 'Apple', @@ -21,6 +22,7 @@ return [ 'form' => 'apple.phtml', // Preparation for adding ability to customized OAuth UI forms, currently handled hardcoded. 'beta' => true, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Apple', ], 'auth0' => [ 'name' => 'Auth0', @@ -31,6 +33,7 @@ return [ 'form' => 'auth0.phtml', 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Auth0', ], 'authentik' => [ 'name' => 'Authentik', @@ -41,6 +44,7 @@ return [ 'form' => 'authentik.phtml', 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Authentik', ], 'autodesk' => [ 'name' => 'Autodesk', @@ -51,6 +55,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Autodesk', ], 'bitbucket' => [ 'name' => 'BitBucket', @@ -61,6 +66,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Bitbucket', ], 'bitly' => [ 'name' => 'Bitly', @@ -70,7 +76,8 @@ return [ 'sandbox' => false, 'form' => false, 'beta' => false, - 'mock' => false + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Bitly', ], 'box' => [ 'name' => 'Box', @@ -80,7 +87,8 @@ return [ 'sandbox' => false, 'form' => false, 'beta' => false, - 'mock' => false + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Box', ], 'dailymotion' => [ 'name' => 'Dailymotion', @@ -90,7 +98,8 @@ return [ 'sandbox' => false, 'form' => false, 'beta' => false, - 'mock' => false + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Dailymotion', ], 'discord' => [ 'name' => 'Discord', @@ -101,6 +110,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Discord', ], 'disqus' => [ 'name' => 'Disqus', @@ -111,6 +121,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Disqus', ], 'dropbox' => [ 'name' => 'Dropbox', @@ -121,6 +132,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Dropbox', ], 'etsy' => [ 'name' => 'Etsy', @@ -131,6 +143,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Etsy', ], 'facebook' => [ 'name' => 'Facebook', @@ -141,6 +154,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Facebook', ], 'figma' => [ 'name' => 'Figma', @@ -151,6 +165,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Figma', ], 'github' => [ 'name' => 'GitHub', @@ -161,6 +176,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Github', ], 'gitlab' => [ 'name' => 'GitLab', @@ -171,6 +187,7 @@ return [ 'form' => 'gitlab.phtml', 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Gitlab', ], 'google' => [ 'name' => 'Google', @@ -181,6 +198,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Google', ], 'linkedin' => [ 'name' => 'LinkedIn', @@ -191,6 +209,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Linkedin', ], 'microsoft' => [ 'name' => 'Microsoft', @@ -201,6 +220,7 @@ return [ 'form' => 'microsoft.phtml', 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Microsoft', ], 'notion' => [ 'name' => 'Notion', @@ -211,6 +231,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Notion', ], 'oidc' => [ 'name' => 'OpenID Connect', @@ -221,6 +242,7 @@ return [ 'form' => 'oidc.phtml', 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Oidc', ], 'okta' => [ 'name' => 'Okta', @@ -231,6 +253,7 @@ return [ 'form' => 'okta.phtml', 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Okta', ], 'paypal' => [ 'name' => 'PayPal', @@ -240,7 +263,8 @@ return [ 'sandbox' => false, 'form' => false, 'beta' => false, - 'mock' => false + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Paypal', ], 'paypalSandbox' => [ 'name' => 'PayPal Sandbox', @@ -250,7 +274,8 @@ return [ 'sandbox' => true, 'form' => false, 'beta' => false, - 'mock' => false + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Paypal', ], 'podio' => [ 'name' => 'Podio', @@ -261,6 +286,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Podio', ], 'salesforce' => [ 'name' => 'Salesforce', @@ -271,6 +297,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Salesforce', ], 'slack' => [ 'name' => 'Slack', @@ -281,6 +308,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Slack', ], 'spotify' => [ 'name' => 'Spotify', @@ -291,6 +319,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Spotify', ], 'stripe' => [ 'name' => 'Stripe', @@ -300,7 +329,8 @@ return [ 'sandbox' => false, 'form' => false, 'beta' => false, - 'mock' => false + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Stripe', ], 'tradeshift' => [ 'name' => 'Tradeshift', @@ -311,6 +341,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Tradeshift', ], 'tradeshiftBox' => [ 'name' => 'Tradeshift Sandbox', @@ -321,6 +352,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Tradeshift', ], 'twitch' => [ 'name' => 'Twitch', @@ -331,6 +363,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Twitch', ], 'wordpress' => [ 'name' => 'WordPress', @@ -340,7 +373,8 @@ return [ 'sandbox' => false, 'form' => false, 'beta' => false, - 'mock' => false + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Wordpress', ], 'yahoo' => [ 'name' => 'Yahoo', @@ -351,6 +385,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Yahoo', ], 'yammer' => [ 'name' => 'Yammer', @@ -361,6 +396,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Yammer', ], 'yandex' => [ 'name' => 'Yandex', @@ -371,6 +407,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Yandex', ], 'zoho' => [ 'name' => 'Zoho', @@ -381,6 +418,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Zoho', ], 'zoom' => [ 'name' => 'Zoom', @@ -391,6 +429,7 @@ return [ 'form' => false, 'beta' => false, 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Zoom', ], // 'instagram' => [ // 'name' => 'Instagram', @@ -399,6 +438,7 @@ return [ // 'enabled' => false, // 'beta' => false, // 'mock' => false, + // 'class' => 'Appwrite\\Auth\\OAuth2\\Instagram', // ], // 'twitter' => [ // 'name' => 'twitter', @@ -407,6 +447,7 @@ return [ // 'enabled' => false, // 'beta' => false, // 'mock' => false, + // 'class' => 'Appwrite\\Auth\\OAuth2\\Twitter', // ], // Keep Last @@ -419,5 +460,6 @@ return [ 'form' => false, 'beta' => false, 'mock' => true, + 'class' => 'Appwrite\\Auth\\OAuth2\\Mock', ], ]; diff --git a/app/config/specs/open-api3-latest-client.json b/app/config/specs/open-api3-latest-client.json index a539e26f03..ed31be27a5 100644 --- a/app/config/specs/open-api3-latest-client.json +++ b/app/config/specs/open-api3-latest-client.json @@ -9483,34 +9483,45 @@ "description": "Token creation date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" }, + "$permissions": { + "type": "array", + "description": "Token permissions. [Learn more about permissions](https:\/\/appwrite.io\/docs\/permissions).", + "items": { + "type": "string" + }, + "x-example": [ + "read(\"any\")" + ] + }, "resourceId": { "type": "string", "description": "Resource ID.", "x-example": "5e5ea5c168bb8:5e5ea5c168bb8" }, - "resourceInternalId": { - "type": "string", - "description": "File ID.", - "x-example": "1:1" - }, "resourceType": { "type": "string", "description": "Resource type.", - "x-example": "file" + "x-example": "files" }, "expire": { "type": "string", "description": "Token expiration date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" + }, + "accessedAt": { + "type": "string", + "description": "Most recent access date in ISO 8601 format. This attribute is only updated again after 24 hours.", + "x-example": "2020-10-15T06:38:00.000+00:00" } }, "required": [ "$id", "$createdAt", + "$permissions", "resourceId", - "resourceInternalId", "resourceType", - "expire" + "expire", + "accessedAt" ] }, "team": { diff --git a/app/config/specs/open-api3-latest-console.json b/app/config/specs/open-api3-latest-console.json index 4b9334cd68..5354c2f9de 100644 --- a/app/config/specs/open-api3-latest-console.json +++ b/app/config/specs/open-api3-latest-console.json @@ -38045,34 +38045,45 @@ "description": "Token creation date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" }, + "$permissions": { + "type": "array", + "description": "Token permissions. [Learn more about permissions](https:\/\/appwrite.io\/docs\/permissions).", + "items": { + "type": "string" + }, + "x-example": [ + "read(\"any\")" + ] + }, "resourceId": { "type": "string", "description": "Resource ID.", "x-example": "5e5ea5c168bb8:5e5ea5c168bb8" }, - "resourceInternalId": { - "type": "string", - "description": "File ID.", - "x-example": "1:1" - }, "resourceType": { "type": "string", "description": "Resource type.", - "x-example": "file" + "x-example": "files" }, "expire": { "type": "string", "description": "Token expiration date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" + }, + "accessedAt": { + "type": "string", + "description": "Most recent access date in ISO 8601 format. This attribute is only updated again after 24 hours.", + "x-example": "2020-10-15T06:38:00.000+00:00" } }, "required": [ "$id", "$createdAt", + "$permissions", "resourceId", - "resourceInternalId", "resourceType", - "expire" + "expire", + "accessedAt" ] }, "team": { diff --git a/app/config/specs/open-api3-latest-server.json b/app/config/specs/open-api3-latest-server.json index c5e83e4c0b..19b94378c2 100644 --- a/app/config/specs/open-api3-latest-server.json +++ b/app/config/specs/open-api3-latest-server.json @@ -28004,34 +28004,45 @@ "description": "Token creation date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" }, + "$permissions": { + "type": "array", + "description": "Token permissions. [Learn more about permissions](https:\/\/appwrite.io\/docs\/permissions).", + "items": { + "type": "string" + }, + "x-example": [ + "read(\"any\")" + ] + }, "resourceId": { "type": "string", "description": "Resource ID.", "x-example": "5e5ea5c168bb8:5e5ea5c168bb8" }, - "resourceInternalId": { - "type": "string", - "description": "File ID.", - "x-example": "1:1" - }, "resourceType": { "type": "string", "description": "Resource type.", - "x-example": "file" + "x-example": "files" }, "expire": { "type": "string", "description": "Token expiration date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" + }, + "accessedAt": { + "type": "string", + "description": "Most recent access date in ISO 8601 format. This attribute is only updated again after 24 hours.", + "x-example": "2020-10-15T06:38:00.000+00:00" } }, "required": [ "$id", "$createdAt", + "$permissions", "resourceId", - "resourceInternalId", "resourceType", - "expire" + "expire", + "accessedAt" ] }, "team": { diff --git a/app/config/specs/swagger2-latest-client.json b/app/config/specs/swagger2-latest-client.json index dfe5820976..e24eb83999 100644 --- a/app/config/specs/swagger2-latest-client.json +++ b/app/config/specs/swagger2-latest-client.json @@ -9586,34 +9586,45 @@ "description": "Token creation date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" }, + "$permissions": { + "type": "array", + "description": "Token permissions. [Learn more about permissions](https:\/\/appwrite.io\/docs\/permissions).", + "items": { + "type": "string" + }, + "x-example": [ + "read(\"any\")" + ] + }, "resourceId": { "type": "string", "description": "Resource ID.", "x-example": "5e5ea5c168bb8:5e5ea5c168bb8" }, - "resourceInternalId": { - "type": "string", - "description": "File ID.", - "x-example": "1:1" - }, "resourceType": { "type": "string", "description": "Resource type.", - "x-example": "file" + "x-example": "files" }, "expire": { "type": "string", "description": "Token expiration date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" + }, + "accessedAt": { + "type": "string", + "description": "Most recent access date in ISO 8601 format. This attribute is only updated again after 24 hours.", + "x-example": "2020-10-15T06:38:00.000+00:00" } }, "required": [ "$id", "$createdAt", + "$permissions", "resourceId", - "resourceInternalId", "resourceType", - "expire" + "expire", + "accessedAt" ] }, "team": { diff --git a/app/config/specs/swagger2-latest-console.json b/app/config/specs/swagger2-latest-console.json index 33e12bbef5..3fd625b154 100644 --- a/app/config/specs/swagger2-latest-console.json +++ b/app/config/specs/swagger2-latest-console.json @@ -38290,34 +38290,45 @@ "description": "Token creation date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" }, + "$permissions": { + "type": "array", + "description": "Token permissions. [Learn more about permissions](https:\/\/appwrite.io\/docs\/permissions).", + "items": { + "type": "string" + }, + "x-example": [ + "read(\"any\")" + ] + }, "resourceId": { "type": "string", "description": "Resource ID.", "x-example": "5e5ea5c168bb8:5e5ea5c168bb8" }, - "resourceInternalId": { - "type": "string", - "description": "File ID.", - "x-example": "1:1" - }, "resourceType": { "type": "string", "description": "Resource type.", - "x-example": "file" + "x-example": "files" }, "expire": { "type": "string", "description": "Token expiration date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" + }, + "accessedAt": { + "type": "string", + "description": "Most recent access date in ISO 8601 format. This attribute is only updated again after 24 hours.", + "x-example": "2020-10-15T06:38:00.000+00:00" } }, "required": [ "$id", "$createdAt", + "$permissions", "resourceId", - "resourceInternalId", "resourceType", - "expire" + "expire", + "accessedAt" ] }, "team": { diff --git a/app/config/specs/swagger2-latest-server.json b/app/config/specs/swagger2-latest-server.json index dd44d76a76..61a75e3dec 100644 --- a/app/config/specs/swagger2-latest-server.json +++ b/app/config/specs/swagger2-latest-server.json @@ -28318,34 +28318,45 @@ "description": "Token creation date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" }, + "$permissions": { + "type": "array", + "description": "Token permissions. [Learn more about permissions](https:\/\/appwrite.io\/docs\/permissions).", + "items": { + "type": "string" + }, + "x-example": [ + "read(\"any\")" + ] + }, "resourceId": { "type": "string", "description": "Resource ID.", "x-example": "5e5ea5c168bb8:5e5ea5c168bb8" }, - "resourceInternalId": { - "type": "string", - "description": "File ID.", - "x-example": "1:1" - }, "resourceType": { "type": "string", "description": "Resource type.", - "x-example": "file" + "x-example": "files" }, "expire": { "type": "string", "description": "Token expiration date in ISO 8601 format.", "x-example": "2020-10-15T06:38:00.000+00:00" + }, + "accessedAt": { + "type": "string", + "description": "Most recent access date in ISO 8601 format. This attribute is only updated again after 24 hours.", + "x-example": "2020-10-15T06:38:00.000+00:00" } }, "required": [ "$id", "$createdAt", + "$permissions", "resourceId", - "resourceInternalId", "resourceType", - "expire" + "expire", + "accessedAt" ] }, "team": { diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 1ee25c0f2c..f55bbb9387 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1212,8 +1212,8 @@ App::get('/v1/account/sessions/oauth2/:provider') throw new Exception(Exception::PROJECT_PROVIDER_DISABLED, 'This provider is disabled. Please configure the provider app ID and app secret key from your ' . APP_NAME . ' console to continue.'); } - $className = 'Appwrite\\Auth\\OAuth2\\' . \ucfirst($provider); - + $oAuthProviders = Config::getParam('oAuthProviders'); + $className = $oAuthProviders[$provider]['class']; if (!\class_exists($className)) { throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED); } diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php index 3cfa0bd714..604afff0b3 100644 --- a/app/controllers/api/storage.php +++ b/app/controllers/api/storage.php @@ -1133,8 +1133,9 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/download') ->inject('response') ->inject('dbForProject') ->inject('mode') + ->inject('resourceToken') ->inject('deviceForFiles') - ->action(function (string $bucketId, string $fileId, Request $request, Response $response, Database $dbForProject, string $mode, Device $deviceForFiles) { + ->action(function (string $bucketId, string $fileId, Request $request, Response $response, Database $dbForProject, string $mode, Document $resourceToken, Device $deviceForFiles) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); @@ -1145,10 +1146,11 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/download') throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); } + $isToken = !$resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getInternalId(); $fileSecurity = $bucket->getAttribute('fileSecurity', false); $validator = new Authorization(Database::PERMISSION_READ); $valid = $validator->isValid($bucket->getRead()); - if (!$fileSecurity && !$valid) { + if (!$fileSecurity && !$valid && !$isToken) { throw new Exception(Exception::USER_UNAUTHORIZED); } @@ -1158,6 +1160,10 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/download') $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); } + if (!$resourceToken->isEmpty() && $resourceToken->getAttribute('fileInternalId') !== $file->getInternalId()) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + if ($file->isEmpty()) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } @@ -1282,8 +1288,9 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/view') ->inject('request') ->inject('dbForProject') ->inject('mode') + ->inject('resourceToken') ->inject('deviceForFiles') - ->action(function (string $bucketId, string $fileId, Response $response, Request $request, Database $dbForProject, string $mode, Device $deviceForFiles) { + ->action(function (string $bucketId, string $fileId, Response $response, Request $request, Database $dbForProject, string $mode, Document $resourceToken, Device $deviceForFiles) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); $isAPIKey = Auth::isAppUser(Authorization::getRoles()); @@ -1293,10 +1300,11 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/view') throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); } + $isToken = !$resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getInternalId(); $fileSecurity = $bucket->getAttribute('fileSecurity', false); $validator = new Authorization(Database::PERMISSION_READ); $valid = $validator->isValid($bucket->getRead()); - if (!$fileSecurity && !$valid) { + if (!$fileSecurity && !$valid && !$isToken) { throw new Exception(Exception::USER_UNAUTHORIZED); } @@ -1306,6 +1314,10 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/view') $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); } + if (!$resourceToken->isEmpty() && $resourceToken->getAttribute('fileInternalId') !== $file->getInternalId()) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + if ($file->isEmpty()) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } diff --git a/app/init/constants.php b/app/init/constants.php index 2b15f9fa0b..143bba29bd 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -29,6 +29,7 @@ const APP_LIMIT_LIST_DEFAULT = 25; // Default maximum number of items to return const APP_KEY_ACCESS = 24 * 60 * 60; // 24 hours const APP_USER_ACCESS = 24 * 60 * 60; // 24 hours const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours +const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours const APP_CACHE_BUSTER = 4318; @@ -257,3 +258,10 @@ const RESOURCE_TYPE_PROVIDERS = 'providers'; const RESOURCE_TYPE_TOPICS = 'topics'; const RESOURCE_TYPE_SUBSCRIBERS = 'subscribers'; const RESOURCE_TYPE_MESSAGES = 'messages'; + +// Resource types for Tokens + +const TOKENS_RESOURCE_TYPE_FILES = 'files'; +const TOKENS_RESOURCE_TYPE_SITES = 'sites'; +const TOKENS_RESOURCE_TYPE_FUNCTIONS = 'functions'; +const TOKENS_RESOURCE_TYPE_DATABASES = 'databases'; diff --git a/app/init/resources.php b/app/init/resources.php index 3a1539e59b..1b941b63b3 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -929,7 +929,7 @@ App::setResource('resourceToken', function ($project, $dbForProject, $request) { return new Document([]); } - if ($token->getAttribute('resourceType') === 'file') { + if ($token->getAttribute('resourceType') === TOKENS_RESOURCE_TYPE_FILES) { $internalIds = explode(':', $token->getAttribute('resourceInternalId')); $ids = explode(':', $token->getAttribute('resourceId')); @@ -937,6 +937,12 @@ App::setResource('resourceToken', function ($project, $dbForProject, $request) { return new Document([]); } + $accessedAt = $token->getAttribute('accessedAt', 0); + if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), - APP_RESOURCE_TOKEN_ACCESS)) > $accessedAt) { + $token->setAttribute('accessedAt', DatabaseDateTime::now()); + Authorization::skip(fn () => $dbForProject->updateDocument('resourceTokens', $token->getId(), $token)); + } + return new Document([ 'bucketId' => $ids[0], 'fileId' => $ids[1], diff --git a/composer.lock b/composer.lock index 876dfc3b6c..0afed0f2ff 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": "3310705774e8be0baeb36d7783665c28", + "content-hash": "51959289a3f882160f5a9eeb605d41d7", "packages": [ { "name": "adhocore/jwt", @@ -1179,16 +1179,16 @@ }, { "name": "open-telemetry/context", - "version": "1.1.0", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/context.git", - "reference": "0cba875ea1953435f78aec7f1d75afa87bdbf7f3" + "reference": "5f553042b951d3fedf47925852c380159dfca801" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/0cba875ea1953435f78aec7f1d75afa87bdbf7f3", - "reference": "0cba875ea1953435f78aec7f1d75afa87bdbf7f3", + "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/5f553042b951d3fedf47925852c380159dfca801", + "reference": "5f553042b951d3fedf47925852c380159dfca801", "shasum": "" }, "require": { @@ -1234,7 +1234,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2024-08-21T00:29:20+00:00" + "time": "2025-05-02T01:57:57+00:00" }, { "name": "open-telemetry/exporter-otlp", @@ -1365,16 +1365,16 @@ }, { "name": "open-telemetry/sdk", - "version": "1.2.4", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "47fcb66ae5328c5a799195247b1dce551d85873e" + "reference": "05d9ceb6773b5bddcf485af6d4a6f543bbeb980b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/47fcb66ae5328c5a799195247b1dce551d85873e", - "reference": "47fcb66ae5328c5a799195247b1dce551d85873e", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/05d9ceb6773b5bddcf485af6d4a6f543bbeb980b", + "reference": "05d9ceb6773b5bddcf485af6d4a6f543bbeb980b", "shasum": "" }, "require": { @@ -1451,20 +1451,20 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-04-15T07:02:07+00:00" + "time": "2025-05-01T23:20:43+00:00" }, { "name": "open-telemetry/sem-conv", - "version": "1.30.0", + "version": "1.32.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sem-conv.git", - "reference": "4178c9f390da8e4dbca9b181a9d1efd50cf7ee0a" + "reference": "16585cc0dbc3032a318e274043454679430d2ebf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/4178c9f390da8e4dbca9b181a9d1efd50cf7ee0a", - "reference": "4178c9f390da8e4dbca9b181a9d1efd50cf7ee0a", + "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/16585cc0dbc3032a318e274043454679430d2ebf", + "reference": "16585cc0dbc3032a318e274043454679430d2ebf", "shasum": "" }, "require": { @@ -1508,7 +1508,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-02-06T00:21:48+00:00" + "time": "2025-05-05T03:58:53+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -2726,19 +2726,20 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -2786,7 +2787,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" }, "funding": [ { @@ -2802,11 +2803,11 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-12-23T08:48:59+00:00" }, { "name": "symfony/polyfill-php82", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", @@ -2862,7 +2863,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.32.0" }, "funding": [ { @@ -3300,16 +3301,16 @@ }, { "name": "utopia-php/cache", - "version": "0.13.0", + "version": "0.12.0", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "dee01dec33a211644d60f6cfa56b1b8176d3fae3" + "reference": "646038f1d470b759c129348be8fc14da3c00bbd9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/dee01dec33a211644d60f6cfa56b1b8176d3fae3", - "reference": "dee01dec33a211644d60f6cfa56b1b8176d3fae3", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/646038f1d470b759c129348be8fc14da3c00bbd9", + "reference": "646038f1d470b759c129348be8fc14da3c00bbd9", "shasum": "" }, "require": { @@ -3317,7 +3318,6 @@ "ext-memcached": "*", "ext-redis": "*", "php": ">=8.0", - "utopia-php/pools": "0.8.*", "utopia-php/telemetry": "0.1.*" }, "require-dev": { @@ -3346,9 +3346,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/0.13.0" + "source": "https://github.com/utopia-php/cache/tree/0.12.0" }, - "time": "2025-04-17T04:20:26+00:00" + "time": "2025-02-25T09:09:21+00:00" }, { "name": "utopia-php/cli", @@ -3498,17 +3498,23 @@ }, { "name": "utopia-php/database", - "version": "dev-manage-wildcards", + "version": "0.66.0", "source": { "type": "git", - "url": "https://github.com/utopia-php/database", - "reference": "5a98f7d7f7bb2dc15658abb93fac09c5c06ec5ac" + "url": "https://github.com/utopia-php/database.git", + "reference": "67d2ab418efba31dc76b3564cf043e2b3f98d027" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/database/zipball/67d2ab418efba31dc76b3564cf043e2b3f98d027", + "reference": "67d2ab418efba31dc76b3564cf043e2b3f98d027", + "shasum": "" }, "require": { "ext-mbstring": "*", "ext-pdo": "*", "php": ">=8.1", - "utopia-php/cache": "0.13.*", + "utopia-php/cache": "0.12.*", "utopia-php/framework": "0.33.*", "utopia-php/pools": "0.8.*" }, @@ -3528,38 +3534,7 @@ "Utopia\\Database\\": "src/Database" } }, - "autoload-dev": { - "psr-4": { - "Tests\\E2E\\": "tests/e2e", - "Tests\\Unit\\": "tests/unit" - } - }, - "scripts": { - "build": [ - "Composer\\Config::disableProcessTimeout", - "docker compose build" - ], - "start": [ - "Composer\\Config::disableProcessTimeout", - "docker compose up -d" - ], - "test": [ - "Composer\\Config::disableProcessTimeout", - "docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml" - ], - "lint": [ - "php -d memory_limit=2G ./vendor/bin/pint --test" - ], - "format": [ - "php -d memory_limit=2G ./vendor/bin/pint" - ], - "check": [ - "./vendor/bin/phpstan analyse --level 7 src tests --memory-limit 2G" - ], - "coverage": [ - "./vendor/bin/coverage-check ./tmp/clover.xml 90" - ] - }, + "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], @@ -3571,7 +3546,11 @@ "upf", "utopia" ], - "time": "2025-05-02T06:57:15+00:00" + "support": { + "issues": "https://github.com/utopia-php/database/issues", + "source": "https://github.com/utopia-php/database/tree/0.66.0" + }, + "time": "2025-04-16T07:10:27+00:00" }, { "name": "utopia-php/detector", @@ -4610,28 +4589,28 @@ }, { "name": "utopia-php/vcs", - "version": "0.10.2", + "version": "0.10.1", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "1f9823ebcb8fd098607de0074f18f48e28985012" + "reference": "6be02650cc361764900ade8c129f309df263eb74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/1f9823ebcb8fd098607de0074f18f48e28985012", - "reference": "1f9823ebcb8fd098607de0074f18f48e28985012", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/6be02650cc361764900ade8c129f309df263eb74", + "reference": "6be02650cc361764900ade8c129f309df263eb74", "shasum": "" }, "require": { "adhocore/jwt": "^1.1", "php": ">=8.0", - "utopia-php/cache": "0.13.*", + "utopia-php/cache": "0.12.*", "utopia-php/framework": "0.*.*", "utopia-php/system": "0.9.*" }, "require-dev": { - "laravel/pint": "1.*.*", - "phpstan/phpstan": "1.*.*", + "laravel/pint": "1.2.*", + "phpstan/phpstan": "1.8.*", "phpunit/phpunit": "^9.4" }, "type": "library", @@ -4653,9 +4632,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/0.10.2" + "source": "https://github.com/utopia-php/vcs/tree/0.10.1" }, - "time": "2025-04-17T04:35:25+00:00" + "time": "2025-03-18T11:44:09+00:00" }, { "name": "utopia-php/websocket", @@ -6060,16 +6039,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.22", + "version": "9.6.23", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "f80235cb4d3caa59ae09be3adf1ded27521d1a9c" + "reference": "43d2cb18d0675c38bd44982a5d1d88f6d53d8d95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f80235cb4d3caa59ae09be3adf1ded27521d1a9c", - "reference": "f80235cb4d3caa59ae09be3adf1ded27521d1a9c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/43d2cb18d0675c38bd44982a5d1d88f6d53d8d95", + "reference": "43d2cb18d0675c38bd44982a5d1d88f6d53d8d95", "shasum": "" }, "require": { @@ -6080,7 +6059,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.1", + "myclabs/deep-copy": "^1.13.1", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=7.3", @@ -6143,7 +6122,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.22" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.23" }, "funding": [ { @@ -6154,12 +6133,20 @@ "url": "https://github.com/sebastianbergmann", "type": "github" }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], - "time": "2024-12-05T13:48:26+00:00" + "time": "2025-05-02T06:40:34+00:00" }, { "name": "psr/cache", @@ -7271,16 +7258,16 @@ }, { "name": "symfony/console", - "version": "v7.2.5", + "version": "v7.2.6", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "e51498ea18570c062e7df29d05a7003585b19b88" + "reference": "0e2e3f38c192e93e622e41ec37f4ca70cfedf218" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/e51498ea18570c062e7df29d05a7003585b19b88", - "reference": "e51498ea18570c062e7df29d05a7003585b19b88", + "url": "https://api.github.com/repos/symfony/console/zipball/0e2e3f38c192e93e622e41ec37f4ca70cfedf218", + "reference": "0e2e3f38c192e93e622e41ec37f4ca70cfedf218", "shasum": "" }, "require": { @@ -7344,7 +7331,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.2.5" + "source": "https://github.com/symfony/console/tree/v7.2.6" }, "funding": [ { @@ -7360,7 +7347,7 @@ "type": "tidelift" } ], - "time": "2025-03-12T08:11:12+00:00" + "time": "2025-04-07T19:09:28+00:00" }, { "name": "symfony/filesystem", @@ -7561,7 +7548,7 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -7620,7 +7607,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" }, "funding": [ { @@ -7640,7 +7627,7 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", @@ -7698,7 +7685,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" }, "funding": [ { @@ -7718,7 +7705,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -7779,7 +7766,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" }, "funding": [ { @@ -7799,7 +7786,7 @@ }, { "name": "symfony/polyfill-php81", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -7855,7 +7842,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.32.0" }, "funding": [ { @@ -7936,16 +7923,16 @@ }, { "name": "symfony/string", - "version": "v7.2.0", + "version": "v7.2.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" + "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", + "url": "https://api.github.com/repos/symfony/string/zipball/a214fe7d62bd4df2a76447c67c6b26e1d5e74931", + "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931", "shasum": "" }, "require": { @@ -8003,7 +7990,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.2.0" + "source": "https://github.com/symfony/string/tree/v7.2.6" }, "funding": [ { @@ -8019,7 +8006,7 @@ "type": "tidelift" } ], - "time": "2024-11-13T13:31:26+00:00" + "time": "2025-04-20T20:18:16+00:00" }, { "name": "textalk/websocket", @@ -8249,18 +8236,9 @@ "time": "2024-03-07T20:33:40+00:00" } ], - "aliases": [ - { - "package": "utopia-php/database", - "version": "dev-manage-wildcards", - "alias": "0.66.0", - "alias_normalized": "0.66.0.0" - } - ], + "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "utopia-php/database": 20 - }, + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/docker-compose.yml b/docker-compose.yml index acdec401cc..792be5c752 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -213,7 +213,7 @@ services: appwrite-console: <<: *x-logging container_name: appwrite-console - image: appwrite/console:5.3.0-sites-rc.42 + image: appwrite/console:5.3.0-sites-rc.43 restart: unless-stopped networks: - appwrite @@ -951,7 +951,7 @@ services: hostname: exc1 <<: *x-logging stop_signal: SIGINT - image: openruntimes/executor:0.7.13 + image: openruntimes/executor:0.7.14 restart: unless-stopped networks: - appwrite diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index ae3d4d6646..5752432257 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -8,7 +8,7 @@ use Appwrite\Platform\Modules\Functions; use Appwrite\Platform\Modules\Projects; use Appwrite\Platform\Modules\Proxy; use Appwrite\Platform\Modules\Sites; -use Appwrite\Platform\Modules\Storage; +use Appwrite\Platform\Modules\Tokens; use Utopia\Platform\Platform; class Appwrite extends Platform @@ -21,6 +21,6 @@ class Appwrite extends Platform $this->addModule(new Sites\Module()); $this->addModule(new Console\Module()); $this->addModule(new Proxy\Module()); - $this->addModule(new Storage\Module()); + $this->addModule(new Tokens\Module()); } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php index 14ace4cd24..f9d73ee4fc 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php @@ -17,6 +17,7 @@ use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Storage\Device; +use Utopia\Swoole\Request; use Utopia\System\System; class Create extends Action @@ -56,6 +57,7 @@ class Create extends Action )) ->param('siteId', '', new UID(), 'Site ID.') ->param('deploymentId', '', new UID(), 'Deployment ID.') + ->inject('request') ->inject('response') ->inject('project') ->inject('dbForProject') @@ -69,6 +71,7 @@ class Create extends Action public function action( string $siteId, string $deploymentId, + Request $request, Response $response, Document $project, Database $dbForProject, @@ -127,6 +130,7 @@ class Create extends Action 'status' => 'waiting', 'buildPath' => '', 'buildLogs' => '', + 'type' => $request->getHeader('x-sdk-language') === 'cli' ? 'cli' : 'manual' ])); $site = $site diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/Action.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php similarity index 95% rename from src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/Action.php rename to src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php index aec665f406..565ab7bab7 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/Action.php +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php @@ -1,6 +1,6 @@ Auth::tokenGenerator(128), 'resourceId' => $bucketId . ':' . $fileId, 'resourceInternalId' => $bucket->getInternalId() . ':' . $file->getInternalId(), - 'resourceType' => 'files', + 'resourceType' => TOKENS_RESOURCE_TYPE_FILES, 'expire' => $expire, '$permissions' => $permissions ])); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/XList.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/XList.php similarity index 96% rename from src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/XList.php rename to src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/XList.php index e3e571ae32..dbc6575e6f 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Buckets/Files/XList.php +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/XList.php @@ -1,6 +1,6 @@ $bucket, 'file' => $file] = $this->getFileAndBucket($dbForProject, $bucketId, $fileId); $queries = Query::parseQueries($queries); - $queries[] = Query::equal('resourceType', ["files"]); + $queries[] = Query::equal('resourceType', [TOKENS_RESOURCE_TYPE_FILES]); $queries[] = Query::equal('resourceInternalId', [$bucket->getInternalId() . ':' . $file->getInternalId()]); // Get cursor document if there was a cursor query $cursor = \array_filter($queries, function ($query) { diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Delete.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Delete.php similarity index 97% rename from src/Appwrite/Platform/Modules/Storage/Http/Tokens/Delete.php rename to src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Delete.php index 0e413b5e4d..31bcd7f580 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Tokens/Delete.php +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Delete.php @@ -1,6 +1,6 @@ '', 'example' => self::TYPE_DATETIME_EXAMPLE, ]) + ->addRule('$permissions', [ + 'type' => self::TYPE_STRING, + 'description' => 'Token permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', + 'default' => '', + 'example' => ['read("any")'], + 'array' => true, + ]) ->addRule('resourceId', [ 'type' => self::TYPE_STRING, 'description' => 'Resource ID.', 'default' => '', 'example' => '5e5ea5c168bb8:5e5ea5c168bb8', ]) - ->addRule('resourceInternalId', [ - 'type' => self::TYPE_STRING, - 'description' => 'File ID.', - 'default' => '', - 'example' => '1:1', - ]) ->addRule('resourceType', [ 'type' => self::TYPE_STRING, 'description' => 'Resource type.', 'default' => '', - 'example' => 'file', + 'example' => TOKENS_RESOURCE_TYPE_FILES, ]) ->addRule('expire', [ 'type' => self::TYPE_DATETIME, @@ -46,6 +47,12 @@ class ResourceToken extends Model 'default' => '', 'example' => self::TYPE_DATETIME_EXAMPLE, ]) + ->addRule('accessedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Most recent access date in ISO 8601 format. This attribute is only updated again after ' . APP_RESOURCE_TOKEN_ACCESS / 60 / 60 . ' hours.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE + ]) ; } diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 5866bf2e27..3a5308f767 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -2005,7 +2005,14 @@ class SitesCustomServerTest extends Scope $this->assertEquals(200, $site['headers']['status-code']); $this->assertEquals('index.html', $site['body']['fallbackFile']); - $deployment = $this->createDuplicateDeployment($siteId, $deploymentId1); + $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments/duplicate', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-sdk-language' => 'cli' + ], $this->getHeaders()), [ + 'deploymentId' => $deploymentId1, + ]); + $this->assertEquals(202, $deployment['headers']['status-code']); $deploymentId2 = $deployment['body']['$id']; @@ -2016,6 +2023,27 @@ class SitesCustomServerTest extends Scope $this->assertGreaterThan(0, $deployment['body']['sourceSize']); $this->assertEquals(0, $deployment['body']['buildSize']); $this->assertEquals($deployment['body']['sourceSize'], $deployment['body']['totalSize']); + $this->assertEquals('cli', $deployment['body']['type']); + + // create another duplicate deployment with manual trigger + $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments/duplicate', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'deploymentId' => $deploymentId1, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + + $deploymentId2 = $deployment['body']['$id']; + $this->assertNotEmpty($deploymentId2); + + $deployment = $this->getDeployment($siteId, $deploymentId2); + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertGreaterThan(0, $deployment['body']['sourceSize']); + $this->assertEquals(0, $deployment['body']['buildSize']); + $this->assertEquals($deployment['body']['sourceSize'], $deployment['body']['totalSize']); + $this->assertEquals('manual', $deployment['body']['type']); $this->assertEventually(function () use ($siteId, $deploymentId2) { $site = $this->getSite($siteId); diff --git a/tests/e2e/Services/Tokens/TokensBase.php b/tests/e2e/Services/Tokens/TokensBase.php index 11f6897f15..c7ae1d0598 100644 --- a/tests/e2e/Services/Tokens/TokensBase.php +++ b/tests/e2e/Services/Tokens/TokensBase.php @@ -2,6 +2,287 @@ namespace Tests\E2E\Services\Tokens; +use CURLFile; +use Tests\E2E\Client; +use Utopia\Database\Helpers\ID; + trait TokensBase { + public function testCreateBucketAndFile(): array + { + $bucket = $this->client->call( + Client::METHOD_POST, + '/storage/buckets', + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], + [ + 'name' => 'Test Bucket', + 'bucketId' => ID::unique(), + 'allowedFileExtensions' => ['jpg', 'png', 'jfif'], + ] + ); + + $this->assertEquals(201, $bucket['headers']['status-code']); + $this->assertNotEmpty($bucket['body']['$id']); + + $bucketId = $bucket['body']['$id']; + + $file = $this->client->call( + Client::METHOD_POST, + '/storage/buckets/' . $bucketId . '/files', + [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], + [ + 'fileId' => ID::unique(), + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'), + ] + ); + + $this->assertEquals(201, $file['headers']['status-code']); + $this->assertNotEmpty($file['body']['$id']); + + $fileId = $file['body']['$id']; + + $token = $this->client->call( + Client::METHOD_POST, + '/tokens/buckets/' . $bucketId . '/files/' . $fileId, + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ] + ); + + $this->assertEquals(201, $token['headers']['status-code']); + $this->assertEquals($bucketId . ':' . $fileId, $token['body']['resourceId']); + $this->assertEquals(TOKENS_RESOURCE_TYPE_FILES, $token['body']['resourceType']); + + return [ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'tokenId' => $token['body']['$id'], + 'guestHeaders' => [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], + ]; + } + + /** + * @depends testCreateBucketAndFile + */ + public function testFailuresWithoutToken(array $data): array + { + $fileId = $data['fileId']; + $bucketId = $data['bucketId']; + $guestHeaders = $data['guestHeaders']; + + // File preview. Should fail as an anonymous user with no form of any access to the file. + $failedPreview = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', + $guestHeaders + ); + $this->assertEquals(401, $failedPreview['body']['code']); + $this->assertEquals(401, $failedPreview['headers']['status-code']); + $this->assertEquals('user_unauthorized', $failedPreview['body']['type']); + $this->assertEquals('The current user is not authorized to perform the requested action.', $failedPreview['body']['message']); + + // Extended file preview. Should fail as an anonymous user with no form of any access to the file. + $failedCustomPreview = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', + $guestHeaders, + [ + 'width' => 300, + 'height' => 100, + 'borderRadius' => '50', + 'opacity' => '0.5', + 'output' => 'png', + 'rotation' => '45' + ] + ); + $this->assertEquals(401, $failedCustomPreview['body']['code']); + $this->assertEquals(401, $failedCustomPreview['headers']['status-code']); + $this->assertEquals('user_unauthorized', $failedCustomPreview['body']['type']); + $this->assertEquals('The current user is not authorized to perform the requested action.', $failedCustomPreview['body']['message']); + + // File view. Should fail as an anonymous user with no form of any access to the file. + $failedView = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/view', + $guestHeaders + ); + $this->assertEquals(401, $failedView['body']['code']); + $this->assertEquals(401, $failedView['headers']['status-code']); + $this->assertEquals('user_unauthorized', $failedView['body']['type']); + $this->assertEquals('The current user is not authorized to perform the requested action.', $failedView['body']['message']); + + // File download. Should fail as an anonymous user with no form of any access to the file. + $failedDownload = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/download', + $guestHeaders + ); + $this->assertEquals(401, $failedDownload['body']['code']); + $this->assertEquals(401, $failedDownload['headers']['status-code']); + $this->assertEquals('user_unauthorized', $failedDownload['body']['type']); + $this->assertEquals('The current user is not authorized to perform the requested action.', $failedDownload['body']['message']); + + return $data; + } + + /** + * @depends testCreateBucketAndFile + */ + public function testPreviewFileWithToken(array $data): array + { + $fileId = $data['fileId']; + $tokenId = $data['tokenId']; + $bucketId = $data['bucketId']; + $guestHeaders = $data['guestHeaders']; + $adminHeaders = array_merge($guestHeaders, ['x-appwrite-key' => $this->getProject()['apiKey']]); + + // Generate JWT as an admin user. + $tokenJWT = $this->client->call( + Client::METHOD_GET, + '/tokens/' . $tokenId . '/jwt/', + $adminHeaders + ); + $this->assertEquals(200, $tokenJWT['headers']['status-code']); + $this->assertArrayHasKey('jwt', $tokenJWT['body']); + + $tokenJWT = $tokenJWT['body']['jwt']; + + // Generate a preview + $filePreview = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', + $guestHeaders, + [ + 'token' => $tokenJWT + ] + ); + $this->assertEquals(200, $filePreview['headers']['status-code']); + $this->assertEquals('image/png', $filePreview['headers']['content-type']); + $this->assertNotEmpty($filePreview['body']); + + $image = new \Imagick(); + $image->readImageBlob($filePreview['body']); + $original = new \Imagick(__DIR__ . '/../../../resources/logo.png'); + + $this->assertEquals($image->getImageWidth(), $original->getImageWidth()); + $this->assertEquals($image->getImageHeight(), $original->getImageHeight()); + $this->assertEquals('PNG', $image->getImageFormat()); + + $data['jwtToken'] = $tokenJWT; + return $data; + } + + /** + * @depends testPreviewFileWithToken + */ + public function testCustomPreviewFileWithToken(array $data): array + { + $fileId = $data['fileId']; + $bucketId = $data['bucketId']; + $jwtToken = $data['jwtToken']; + $guestHeaders = $data['guestHeaders']; + + // Generate an extended preview + $customFilePreview = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview/', + $guestHeaders, + [ + 'width' => 300, + 'height' => 100, + 'borderRadius' => '50', + 'opacity' => '0.5', + 'output' => 'png', + 'rotation' => '45', + 'token' => $jwtToken + ] + ); + + $this->assertEquals(200, $customFilePreview['headers']['status-code']); + $this->assertEquals('image/png', $customFilePreview['headers']['content-type']); + $this->assertNotEmpty($customFilePreview['body']); + + $image = new \Imagick(); + $image->readImageBlob($customFilePreview['body']); + $original = new \Imagick(__DIR__ . '/../../../resources/logo-after.png'); + + $this->assertEquals($image->getImageWidth(), $original->getImageWidth()); + $this->assertEquals($image->getImageHeight(), $original->getImageHeight()); + $this->assertEquals('PNG', $image->getImageFormat()); + + return $data; + } + + /** + * @depends testPreviewFileWithToken + */ + public function testViewFileWithToken(array $data): void + { + $fileId = $data['fileId']; + $bucketId = $data['bucketId']; + $jwtToken = $data['jwtToken']; + $guestHeaders = $data['guestHeaders']; + + $fileView = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/view', + $guestHeaders, + [ + 'token' => $jwtToken + ] + ); + + $this->assertEquals(200, $fileView['headers']['status-code']); + + $image = new \Imagick(); + $image->readImageBlob($fileView['body']); + $original = new \Imagick(__DIR__ . '/../../../resources/logo.png'); + + $this->assertEquals($image->getImageWidth(), $original->getImageWidth()); + $this->assertEquals($image->getImageHeight(), $original->getImageHeight()); + $this->assertEquals('PNG', $image->getImageFormat()); + } + + /** + * @depends testPreviewFileWithToken + */ + public function testDownloadFileWithToken(array $data): void + { + $fileId = $data['fileId']; + $bucketId = $data['bucketId']; + $jwtToken = $data['jwtToken']; + $guestHeaders = $data['guestHeaders']; + + $fileFailedDownload = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/download', + $guestHeaders, + [ + 'token' => $jwtToken + ] + ); + + $this->assertEquals(200, $fileFailedDownload['headers']['status-code']); + + $image = new \Imagick(); + $image->readImageBlob($fileFailedDownload['body']); + $original = new \Imagick(__DIR__ . '/../../../resources/logo.png'); + + $this->assertEquals($image->getImageWidth(), $original->getImageWidth()); + $this->assertEquals($image->getImageHeight(), $original->getImageHeight()); + $this->assertEquals('PNG', $image->getImageFormat()); + } } diff --git a/tests/e2e/Services/Tokens/TokensCustomServerTest.php b/tests/e2e/Services/Tokens/TokensCustomServerTest.php index 47c0600623..f95b22cea2 100644 --- a/tests/e2e/Services/Tokens/TokensCustomServerTest.php +++ b/tests/e2e/Services/Tokens/TokensCustomServerTest.php @@ -11,6 +11,7 @@ use Utopia\Database\DateTime; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Validator\Datetime as DatetimeValidator; class TokensCustomServerTest extends Scope { @@ -60,19 +61,19 @@ class TokensCustomServerTest extends Scope $fileId = $file['body']['$id']; - $res = $this->client->call(Client::METHOD_POST, '/tokens/buckets/' . $bucketId . '/files/' . $fileId, array_merge([ + $token = $this->client->call(Client::METHOD_POST, '/tokens/buckets/' . $bucketId . '/files/' . $fileId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'] ], $this->getHeaders())); - $this->assertEquals(201, $res['headers']['status-code']); - $this->assertEquals('files', $res['body']['resourceType']); + $this->assertEquals(201, $token['headers']['status-code']); + $this->assertEquals('files', $token['body']['resourceType']); - $data = []; - $data['fileId'] = $fileId; - $data['bucketId'] = $bucketId; - $data['tokenId'] = $res['body']['$id']; - return $data; + return [ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'tokenId' => $token['body']['$id'], + ]; } /** @@ -82,15 +83,28 @@ class TokensCustomServerTest extends Scope { $tokenId = $data['tokenId']; - $expiry = DateTime::now(); - $res = $this->client->call(Client::METHOD_PATCH, '/tokens/' . $tokenId, array_merge([ + // Finite expiry + $expiry = DateTime::addSeconds(new \DateTime(), 3600); + $token = $this->client->call(Client::METHOD_PATCH, '/tokens/' . $tokenId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'expire' => $expiry, ]); - $this->assertEquals($expiry, $res['body']['expire']); + $dateValidator = new DatetimeValidator(); + $this->assertTrue($dateValidator->isValid($token['body']['expire'])); + + // Infinite expiry + $token = $this->client->call(Client::METHOD_PATCH, '/tokens/' . $tokenId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'expire' => null, + ]); + + $this->assertEmpty($token['body']['expire']); + return $data; }