diff --git a/CHANGES.md b/CHANGES.md index 99e480f788..f06be2e2f7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,15 @@ # Version 0.8.0 (Not Released Yet) -- Anonymous login +## Features + +- Added Anonymous Login ([RFC-010](https://github.com/appwrite/rfc/blob/main/010-anonymous-login.md), #914) +- Added new Environment Variable to enable or disable Anonymous Login +- Added events for functions and executions (#971) + +## Breaking Changes + +- Only logged in users can execute functions (for guests, use anonymous login) +- Only the user who has triggered the execution get access to the relevant execution logs # Version 0.7.2 (Not Released Yet) @@ -31,6 +40,7 @@ - Force adding a security email on setup - SMTP is now disabled by default, no dummy SMTP is included in setup - Added a new endpoint that returns the server and SDKs latest versions numbers #941 +- Custom data strings, userId, and JWT available for cloud functions #967 ## Upgrades diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8d25b2578a..f2f1bc02a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,7 +92,7 @@ After finishing the installation process, you can start writing and editing code #### Advanced Topics -We love to create issues that are good for begginers and label them as `good for begginers` or `hacktoberfest`, but some more advanced topics might require extra knowledge. Below is a list of links you can use to learn more about some of the more advance topics that will help you master the Appwrite codebase. +We love to create issues that are good for beginners and label them as `good first issue` or `hacktoberfest`, but some more advanced topics might require extra knowledge. Below is a list of links you can use to learn more about some of the more advance topics that will help you master the Appwrite codebase. ##### Tools and Libs - [Docker](https://www.docker.com/get-started) @@ -365,6 +365,7 @@ From time to time, our team will add tutorials that will help contributors find * [Adding Support for a New OAuth2 Provider](./docs/tutorials/add-oauth2-provider.md) * [Appwrite Environment Variables](./docs/tutorials/environment-variables.md) * [Running in Production](./docs/tutorials/running-in-production.md) +* [Adding Storage Adapter](./docs/tutorials/add-storage-adapter.md) ## Other Ways to Help diff --git a/README.md b/README.md index ede2f0c4e3..84d47883ff 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ docker run -it --rm , Once the Docker installation completes, go to http://localhost to access the Appwrite console from your browser. Please note that on non-linux native hosts, the server might take a few minutes to start after installation completes. -For advanced production and custom installation, check out our Docker [environment variables](docs/tutorials/environment-variables.md) docs. You can also use our public [docker-compose.yml](https://appwrite.io/docker-compose.yml) file to manually set up an environment. +For advanced production and custom installation, check out our Docker [environment variables](https://appwrite.io/docs/environment-variables) docs. You can also use our public [docker-compose.yml](https://gist.github.com/eldadfux/977869ff6bdd7312adfd4e629ee15cc5#file-docker-compose-yml) file to manually set up an environment. ### Upgrade from an Older Version diff --git a/app/config/collections.php b/app/config/collections.php index 9170b07573..3307e7a12c 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -204,7 +204,7 @@ $collections = [ 'key' => 'email', 'type' => Database::SYSTEM_VAR_TYPE_EMAIL, 'default' => '', - 'required' => true, + 'required' => false, 'array' => false, ], [ @@ -222,7 +222,7 @@ $collections = [ 'key' => 'password', 'type' => Database::SYSTEM_VAR_TYPE_TEXT, 'default' => '', - 'required' => true, + 'required' => false, 'array' => false, ], [ diff --git a/app/config/events.php b/app/config/events.php index 601b502163..cb0df42a06 100644 --- a/app/config/events.php +++ b/app/config/events.php @@ -97,6 +97,46 @@ return [ 'model' => Response::MODEL_ANY, 'note' => '', ], + 'functions.create' => [ + 'description' => 'This event triggers when a function is created.', + 'model' => Response::MODEL_FUNCTION, + 'note' => 'version >= 0.7', + ], + 'functions.update' => [ + 'description' => 'This event triggers when a function is updated.', + 'model' => Response::MODEL_FUNCTION, + 'note' => 'version >= 0.7', + ], + 'functions.delete' => [ + 'description' => 'This event triggers when a function is deleted.', + 'model' => Response::MODEL_ANY, + 'note' => 'version >= 0.7', + ], + 'functions.tags.create' => [ + 'description' => 'This event triggers when a function tag is created.', + 'model' => Response::MODEL_TAG, + 'note' => 'version >= 0.7', + ], + 'functions.tags.update' => [ + 'description' => 'This event triggers when a function tag is updated.', + 'model' => Response::MODEL_FUNCTION, + 'note' => 'version >= 0.7', + ], + 'functions.tags.delete' => [ + 'description' => 'This event triggers when a function tag is deleted.', + 'model' => Response::MODEL_ANY, + 'note' => 'version >= 0.7', + ], + 'functions.executions.create' => [ + 'description' => 'This event triggers when a function execution is created.', + 'model' => Response::MODEL_EXECUTION, + 'note' => 'version >= 0.7', + ], + 'functions.executions.update' => [ + 'description' => 'This event triggers when a function execution is updated.', + 'model' => Response::MODEL_EXECUTION, + 'note' => 'version >= 0.7', + ], 'storage.files.create' => [ 'description' => 'This event triggers when a storage file is created.', 'model' => Response::MODEL_FILE, @@ -167,4 +207,4 @@ return [ 'model' => Response::MODEL_MEMBERSHIP, 'note' => 'version >= 0.7', ], -]; \ No newline at end of file +]; diff --git a/app/config/platforms.php b/app/config/platforms.php index 1538898e7b..0b65f2675c 100644 --- a/app/config/platforms.php +++ b/app/config/platforms.php @@ -32,7 +32,7 @@ return [ [ 'key' => 'flutter', 'name' => 'Flutter', - 'version' => '0.4.0-dev.3', + 'version' => '0.4.0', 'url' => 'https://github.com/appwrite/sdk-for-flutter', 'package' => 'https://pub.dev/packages/appwrite', 'enabled' => true, diff --git a/app/config/roles.php b/app/config/roles.php index 78dd24ad45..3e06ddbfde 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -60,8 +60,6 @@ return [ 'files.read', 'locale.read', 'avatars.read', - 'execution.read', - 'execution.write', ], ], Auth::USER_ROLE_MEMBER => [ diff --git a/app/config/variables.php b/app/config/variables.php index cd7db9ba66..82baefb8f1 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -118,7 +118,7 @@ return [ 'default' => 'enabled', 'required' => false, 'question' => '', - ], + ] ], ], [ diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index fd09aaa3d5..067fe58767 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -109,7 +109,7 @@ App::post('/v1/account') throw new Exception('Account already exists', 409); } - Authorization::enable(); + Authorization::reset(); Authorization::unsetRole('role:'.Auth::USER_ROLE_GUEST); Authorization::setRole('user:'.$user->getId()); @@ -487,7 +487,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') throw new Exception('Account already exists', 409); } - Authorization::enable(); + Authorization::reset(); if (false === $user) { throw new Exception('Failed saving user to DB', 500); @@ -517,6 +517,15 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', ], $detector->getOS(), $detector->getClient(), $detector->getDevice())); + $isAnonymousUser = is_null($user->getAttribute('email')) && is_null($user->getAttribute('password')); + + if ($isAnonymousUser) { + $user + ->setAttribute('name', $oauth2->getUserName($accessToken)) + ->setAttribute('email', $oauth2->getUserEmail($accessToken)) + ; + } + $user ->setAttribute('oauth2'.\ucfirst($provider), $oauth2ID) ->setAttribute('oauth2'.\ucfirst($provider).'AccessToken', $accessToken) @@ -566,6 +575,130 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') ; }); +App::post('/v1/account/sessions/anonymous') + ->desc('Create Anonymous Session') + ->groups(['api', 'account']) + ->label('event', 'account.sessions.create') + ->label('scope', 'public') + ->label('sdk.platform', [APP_PLATFORM_CLIENT]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'createAnonymousSession') + ->label('sdk.description', '/docs/references/account/create-session-anonymous.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_SESSION) + ->label('abuse-limit', 50) + ->label('abuse-key', 'ip:{ip}') + ->inject('request') + ->inject('response') + ->inject('locale') + ->inject('user') + ->inject('project') + ->inject('projectDB') + ->inject('geodb') + ->inject('audits') + ->action(function ($request, $response, $locale, $user, $project, $projectDB, $geodb, $audits) { + /** @var Utopia\Swoole\Request $request */ + /** @var Appwrite\Utopia\Response $response */ + /** @var Utopia\Locale\Locale $locale */ + /** @var Appwrite\Database\Document $user */ + /** @var Appwrite\Database\Document $project */ + /** @var Appwrite\Database\Database $projectDB */ + /** @var MaxMind\Db\Reader $geodb */ + /** @var Appwrite\Event\Event $audits */ + + $protocol = $request->getProtocol(); + + if ($user->getId() || 'console' === $project->getId()) { + throw new Exception('Failed to create anonymous user.', 401); + } + + Authorization::disable(); + try { + $user = $projectDB->createDocument([ + '$collection' => Database::SYSTEM_COLLECTION_USERS, + '$permissions' => [ + 'read' => ['*'], + 'write' => ['user:{self}'] + ], + 'email' => null, + 'emailVerification' => false, + 'status' => Auth::USER_STATUS_UNACTIVATED, + 'password' => null, + 'passwordUpdate' => \time(), + 'registration' => \time(), + 'reset' => false, + 'name' => null + ]); + } catch (Exception $th) { + throw new Exception('Failed saving user to DB', 500); + } + + Authorization::reset(); + + if (false === $user) { + throw new Exception('Failed saving user to DB', 500); + } + + // Create session token + + $detector = new Detector($request->getUserAgent('UNKNOWN')); + $record = $geodb->get($request->getIP()); + $secret = Auth::tokenGenerator(); + $expiry = \time() + Auth::TOKEN_EXPIRATION_LOGIN_LONG; + $session = new Document(array_merge( + [ + '$collection' => Database::SYSTEM_COLLECTION_TOKENS, + '$permissions' => ['read' => ['user:' . $user['$id']], 'write' => ['user:' . $user['$id']]], + 'userId' => $user->getId(), + 'type' => Auth::TOKEN_TYPE_LOGIN, + 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak + 'expire' => $expiry, + 'userAgent' => $request->getUserAgent('UNKNOWN'), + 'ip' => $request->getIP(), + 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', + ], + $detector->getOS(), + $detector->getClient(), + $detector->getDevice() + )); + + $user->setAttribute('tokens', $session, Document::SET_TYPE_APPEND); + + Authorization::setRole('user:'.$user->getId()); + + $user = $projectDB->updateDocument($user->getArrayCopy()); + + if (false === $user) { + throw new Exception('Failed saving user to DB', 500); + } + + $audits + ->setParam('userId', $user->getId()) + ->setParam('event', 'account.sessions.create') + ->setParam('resource', 'users/'.$user->getId()) + ; + + if (!Config::getParam('domainVerification')) { + $response + ->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])) + ; + } + + $response + ->addCookie(Auth::$cookieName.'_legacy', Auth::encodeSession($user->getId(), $secret), $expiry, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) + ->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $secret), $expiry, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) + ->setStatusCode(Response::STATUS_CODE_CREATED) + ; + + $session + ->setAttribute('current', true) + ->setAttribute('countryName', (isset($countries[$session->getAttribute('countryCode')])) ? $countries[$session->getAttribute('countryCode')] : $locale->getText('locale.country.unknown')) + ; + + $response->dynamic($session, Response::MODEL_SESSION); + }); + App::post('/v1/account/jwt') ->desc('Create Account JWT') ->groups(['api', 'account']) @@ -880,7 +1013,12 @@ App::patch('/v1/account/email') /** @var Appwrite\Database\Database $projectDB */ /** @var Appwrite\Event\Event $audits */ - if (!Auth::passwordVerify($password, $user->getAttribute('password'))) { // Double check user password + $isAnonymousUser = is_null($user->getAttribute('email')) && is_null($user->getAttribute('password')); // Check if request is from an anonymous account for converting + + if ( + !$isAnonymousUser && + !Auth::passwordVerify($password, $user->getAttribute('password')) + ) { // Double check user password throw new Exception('Invalid credentials', 401); } @@ -898,10 +1036,14 @@ App::patch('/v1/account/email') // TODO after this user needs to confirm mail again - $user = $projectDB->updateDocument(\array_merge($user->getArrayCopy(), [ - 'email' => $email, - 'emailVerification' => false, - ])); + $user = $projectDB->updateDocument(\array_merge( + $user->getArrayCopy(), + ($isAnonymousUser ? [ 'password' => Auth::passwordHash($password) ] : []), + [ + 'email' => $email, + 'emailVerification' => false, + ] + )); if (false === $user) { throw new Exception('Failed saving user to DB', 500); diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index 92708527a3..c2664f9dc3 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -1,5 +1,7 @@ groups(['api', 'functions']) ->desc('Create Function') ->label('scope', 'functions.write') + ->label('event', 'functions.create') ->label('sdk.platform', [APP_PLATFORM_SERVER]) ->label('sdk.namespace', 'functions') ->label('sdk.method', 'create') @@ -265,6 +268,7 @@ App::put('/v1/functions/:functionId') ->groups(['api', 'functions']) ->desc('Update Function') ->label('scope', 'functions.write') + ->label('event', 'functions.update') ->label('sdk.platform', [APP_PLATFORM_SERVER]) ->label('sdk.namespace', 'functions') ->label('sdk.method', 'update') @@ -330,6 +334,7 @@ App::patch('/v1/functions/:functionId/tag') ->groups(['api', 'functions']) ->desc('Update Function Tag') ->label('scope', 'functions.write') + ->label('event', 'functions.tags.update') ->label('sdk.platform', [APP_PLATFORM_SERVER]) ->label('sdk.namespace', 'functions') ->label('sdk.method', 'updateTag') @@ -387,6 +392,7 @@ App::delete('/v1/functions/:functionId') ->groups(['api', 'functions']) ->desc('Delete Function') ->label('scope', 'functions.write') + ->label('event', 'functions.delete') ->label('sdk.platform', [APP_PLATFORM_SERVER]) ->label('sdk.namespace', 'functions') ->label('sdk.method', 'delete') @@ -424,6 +430,7 @@ App::post('/v1/functions/:functionId/tags') ->groups(['api', 'functions']) ->desc('Create Tag') ->label('scope', 'functions.write') + ->label('event', 'functions.tags.create') ->label('sdk.platform', [APP_PLATFORM_SERVER]) ->label('sdk.namespace', 'functions') ->label('sdk.method', 'createTag') @@ -601,6 +608,7 @@ App::delete('/v1/functions/:functionId/tags/:tagId') ->groups(['api', 'functions']) ->desc('Delete Tag') ->label('scope', 'functions.write') + ->label('event', 'functions.tags.delete') ->label('sdk.platform', [APP_PLATFORM_SERVER]) ->label('sdk.namespace', 'functions') ->label('sdk.method', 'deleteTag') @@ -662,6 +670,7 @@ App::post('/v1/functions/:functionId/executions') ->groups(['api', 'functions']) ->desc('Create Execution') ->label('scope', 'execution.write') + ->label('event', 'functions.executions.create') ->label('sdk.platform', [APP_PLATFORM_CLIENT, APP_PLATFORM_SERVER]) ->label('sdk.namespace', 'functions') ->label('sdk.method', 'createExecution') @@ -672,14 +681,17 @@ App::post('/v1/functions/:functionId/executions') ->label('abuse-limit', 60) ->label('abuse-time', 60) ->param('functionId', '', new UID(), 'Function unique ID.') + ->param('data', '', new Text(8192), 'String of custom data to send to function.', true) // ->param('async', 1, new Range(0, 1), 'Execute code asynchronously. Pass 1 for true, 0 for false. Default value is 1.', true) ->inject('response') ->inject('project') ->inject('projectDB') - ->action(function ($functionId, /*$async,*/ $response, $project, $projectDB) { + ->inject('user') + ->action(function ($functionId, $data, /*$async,*/ $response, $project, $projectDB, $user) { /** @var Appwrite\Utopia\Response $response */ /** @var Appwrite\Database\Document $project */ /** @var Appwrite\Database\Database $projectDB */ + /** @var Appwrite\Database\Document $user */ Authorization::disable(); @@ -712,7 +724,7 @@ App::post('/v1/functions/:functionId/executions') $execution = $projectDB->createDocument([ '$collection' => Database::SYSTEM_COLLECTION_EXECUTIONS, '$permissions' => [ - 'read' => $function->getPermissions()['execute'] ?? [], + 'read' => (!empty($user->getId())) ? ['user:' . $user->getId()] : [], 'write' => [], ], 'dateCreated' => time(), @@ -730,12 +742,36 @@ App::post('/v1/functions/:functionId/executions') if (false === $execution) { throw new Exception('Failed saving execution to DB', 500); } - + + $jwt = ''; // initialize + if (!empty($user->getId())) { // If userId exists, generate a JWT for function + + $tokens = $user->getAttribute('tokens', []); + $session = new Document(); + + foreach ($tokens as $token) { /** @var Appwrite\Database\Document $token */ + if ($token->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too + $session = $token; + } + } + + if(!$session->isEmpty()) { + $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); // Instantiate with key, algo, maxAge and leeway. + $jwt = $jwtObj->encode([ + 'userId' => $user->getId(), + 'sessionId' => $session->getId(), + ]); + } + } + Resque::enqueue('v1-functions', 'FunctionsV1', [ 'projectId' => $project->getId(), 'functionId' => $function->getId(), 'executionId' => $execution->getId(), 'trigger' => 'http', + 'data' => $data, + 'userId' => $user->getId(), + 'jwt' => $jwt, ]); $response diff --git a/app/views/console/functions/function.phtml b/app/views/console/functions/function.phtml index 4ae65f491e..15ba114350 100644 --- a/app/views/console/functions/function.phtml +++ b/app/views/console/functions/function.phtml @@ -50,24 +50,9 @@ $usageStatsEnabled = $this->getParam('usageStatsEnabled',true);

- -
-   View Logs -
+
+   View Logs +
@@ -575,6 +560,31 @@ $usageStatsEnabled = $this->getParam('usageStatsEnabled',true); +