diff --git a/Dockerfile b/Dockerfile index f96138b150..1075f9a12a 100755 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ ENV VITE_APPWRITE_GROWTH_ENDPOINT=$VITE_APPWRITE_GROWTH_ENDPOINT RUN npm ci RUN npm run build -FROM appwrite/base:0.7.2 as final +FROM appwrite/base:0.8.0 as final LABEL maintainer="team@appwrite.io" diff --git a/app/config/collections.php b/app/config/collections.php index 54e72740de..e8a24c910e 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -1507,10 +1507,10 @@ $commonCollections = [ ] ], - 'stats_v2' => [ + 'stats' => [ '$collection' => ID::custom(Database::METADATA), - '$id' => ID::custom('stats_v2'), - 'name' => 'stats_v2', + '$id' => ID::custom('stats'), + 'name' => 'Stats', 'attributes' => [ [ '$id' => ID::custom('metric'), @@ -1903,7 +1903,29 @@ $commonCollections = [ 'filters' => [], ], [ - '$id' => ID::custom('total'), + '$id' => ID::custom('emailTotal'), + 'type' => Database::VAR_INTEGER, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => 0, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('smsTotal'), + 'type' => Database::VAR_INTEGER, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => 0, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('pushTotal'), 'type' => Database::VAR_INTEGER, 'format' => '', 'size' => 0, diff --git a/app/config/errors.php b/app/config/errors.php index 7932dcd3a9..3f12b5953a 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -245,7 +245,7 @@ return [ Exception::USER_MORE_FACTORS_REQUIRED => [ 'name' => Exception::USER_MORE_FACTORS_REQUIRED, 'description' => 'More factors are required to complete the sign in process.', - 'code' => 400, + 'code' => 401, ], Exception::USER_OAUTH2_BAD_REQUEST => [ 'name' => Exception::USER_OAUTH2_BAD_REQUEST, @@ -647,11 +647,6 @@ return [ 'description' => 'Project with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.', 'code' => 409, ], - Exception::PROJECT_UNKNOWN => [ - 'name' => Exception::PROJECT_UNKNOWN, - 'description' => 'The project ID is either missing or not valid. Please check the value of the X-Appwrite-Project header to ensure the correct project ID is being used.', - 'code' => 400, - ], Exception::PROJECT_PROVIDER_DISABLED => [ 'name' => Exception::PROJECT_PROVIDER_DISABLED, 'description' => 'The chosen OAuth provider is disabled. You can enable the OAuth provider using the Appwrite console.', @@ -872,7 +867,7 @@ return [ ], Exception::MESSAGE_MISSING_TARGET => [ 'name' => Exception::MESSAGE_MISSING_TARGET, - 'description' => 'Message with the requested ID is missing a target (Topics or Users or Targets).', + 'description' => 'Message with the requested ID has no recipients (topics or users or targets).', 'code' => 400, ], Exception::MESSAGE_ALREADY_SENT => [ @@ -920,4 +915,11 @@ return [ 'description' => 'Schedule with the requested ID could not be found.', 'code' => 404, ], + + /** Targets */ + Exception::TARGET_PROVIDER_INVALID_TYPE => [ + 'name' => Exception::TARGET_PROVIDER_INVALID_TYPE, + 'description' => 'Target has an invalid provider type.', + 'code' => 400, + ], ]; diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 5bc6bbc222..dd009864a7 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -163,6 +163,11 @@ App::post('/v1/account') $user = Authorization::skip(fn() => $dbForProject->createDocument('users', $user)); try { $target = Authorization::skip(fn() => $dbForProject->createDocument('targets', new Document([ + '$permissions' => [ + Permission::read(Role::user($user->getId())), + Permission::update(Role::user($user->getId())), + Permission::delete(Role::user($user->getId())), + ], 'userId' => $user->getId(), 'userInternalId' => $user->getInternalId(), 'providerType' => MESSAGE_TYPE_EMAIL, @@ -707,7 +712,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') $userDoc = Authorization::skip(fn() => $dbForProject->createDocument('users', $user)); $dbForProject->createDocument('targets', new Document([ '$permissions' => [ - Permission::read(Role::any()), + Permission::read(Role::user($user->getId())), Permission::update(Role::user($user->getId())), Permission::delete(Role::user($user->getId())), ], @@ -1699,6 +1704,11 @@ App::post('/v1/account/tokens/phone') Authorization::skip(fn () => $dbForProject->createDocument('users', $user)); try { $target = Authorization::skip(fn() => $dbForProject->createDocument('targets', new Document([ + '$permissions' => [ + Permission::read(Role::user($user->getId())), + Permission::update(Role::user($user->getId())), + Permission::delete(Role::user($user->getId())), + ], 'userId' => $user->getId(), 'userInternalId' => $user->getInternalId(), 'providerType' => MESSAGE_TYPE_SMS, diff --git a/app/controllers/api/databases.php b/app/controllers/api/databases.php index c0cf3164cc..4163ae258f 100644 --- a/app/controllers/api/databases.php +++ b/app/controllers/api/databases.php @@ -3612,7 +3612,7 @@ App::get('/v1/databases/usage') Authorization::skip(function () use ($dbForProject, $days, $metrics, &$stats) { foreach ($metrics as $metric) { - $result = $dbForProject->findOne('stats_v2', [ + $result = $dbForProject->findOne('stats', [ Query::equal('metric', [$metric]), Query::equal('period', ['inf']) ]); @@ -3620,7 +3620,7 @@ App::get('/v1/databases/usage') $stats[$metric]['total'] = $result['value'] ?? 0; $limit = $days['limit']; $period = $days['period']; - $results = $dbForProject->find('stats_v2', [ + $results = $dbForProject->find('stats', [ Query::equal('metric', [$metric]), Query::equal('period', [$period]), Query::limit($limit), @@ -3696,7 +3696,7 @@ App::get('/v1/databases/:databaseId/usage') Authorization::skip(function () use ($dbForProject, $days, $metrics, &$stats) { foreach ($metrics as $metric) { - $result = $dbForProject->findOne('stats_v2', [ + $result = $dbForProject->findOne('stats', [ Query::equal('metric', [$metric]), Query::equal('period', ['inf']) ]); @@ -3704,7 +3704,7 @@ App::get('/v1/databases/:databaseId/usage') $stats[$metric]['total'] = $result['value'] ?? 0; $limit = $days['limit']; $period = $days['period']; - $results = $dbForProject->find('stats_v2', [ + $results = $dbForProject->find('stats', [ Query::equal('metric', [$metric]), Query::equal('period', [$period]), Query::limit($limit), @@ -3782,7 +3782,7 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/usage') Authorization::skip(function () use ($dbForProject, $days, $metrics, &$stats) { foreach ($metrics as $metric) { - $result = $dbForProject->findOne('stats_v2', [ + $result = $dbForProject->findOne('stats', [ Query::equal('metric', [$metric]), Query::equal('period', ['inf']) ]); @@ -3790,7 +3790,7 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/usage') $stats[$metric]['total'] = $result['value'] ?? 0; $limit = $days['limit']; $period = $days['period']; - $results = $dbForProject->find('stats_v2', [ + $results = $dbForProject->find('stats', [ Query::equal('metric', [$metric]), Query::equal('period', [$period]), Query::limit($limit), diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index 3aae36f98a..21d5928267 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -492,7 +492,7 @@ App::get('/v1/functions/:functionId/usage') Authorization::skip(function () use ($dbForProject, $days, $metrics, &$stats) { foreach ($metrics as $metric) { - $result = $dbForProject->findOne('stats_v2', [ + $result = $dbForProject->findOne('stats', [ Query::equal('metric', [$metric]), Query::equal('period', ['inf']) ]); @@ -500,7 +500,7 @@ App::get('/v1/functions/:functionId/usage') $stats[$metric]['total'] = $result['value'] ?? 0; $limit = $days['limit']; $period = $days['period']; - $results = $dbForProject->find('stats_v2', [ + $results = $dbForProject->find('stats', [ Query::equal('metric', [$metric]), Query::equal('period', [$period]), Query::limit($limit), @@ -584,7 +584,7 @@ App::get('/v1/functions/usage') Authorization::skip(function () use ($dbForProject, $days, $metrics, &$stats) { foreach ($metrics as $metric) { - $result = $dbForProject->findOne('stats_v2', [ + $result = $dbForProject->findOne('stats', [ Query::equal('metric', [$metric]), Query::equal('period', ['inf']) ]); @@ -592,7 +592,7 @@ App::get('/v1/functions/usage') $stats[$metric]['total'] = $result['value'] ?? 0; $limit = $days['limit']; $period = $days['period']; - $results = $dbForProject->find('stats_v2', [ + $results = $dbForProject->find('stats', [ Query::equal('metric', [$metric]), Query::equal('period', [$period]), Query::limit($limit), diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php index 0e865a7184..8e6c73f3bc 100644 --- a/app/controllers/api/messaging.php +++ b/app/controllers/api/messaging.php @@ -2260,7 +2260,19 @@ App::post('/v1/messaging/topics/:topicId/subscribers') try { $subscriber = $dbForProject->createDocument('subscribers', $subscriber); - Authorization::skip(fn () => $dbForProject->increaseDocumentAttribute('topics', $topicId, 'total', 1)); + + $totalAttribute = match ($target->getAttribute('providerType')) { + MESSAGE_TYPE_EMAIL => 'emailTotal', + MESSAGE_TYPE_SMS => 'smsTotal', + MESSAGE_TYPE_PUSH => 'pushTotal', + default => throw new Exception(Exception::TARGET_PROVIDER_INVALID_TYPE), + }; + + Authorization::skip(fn () => $dbForProject->increaseDocumentAttribute( + 'topics', + $topicId, + $totalAttribute, + )); } catch (DuplicateException) { throw new Exception(Exception::SUBSCRIBER_ALREADY_EXISTS); } @@ -2311,7 +2323,7 @@ App::get('/v1/messaging/topics/:topicId/subscribers') throw new Exception(Exception::TOPIC_NOT_FOUND); } - \array_push($queries, Query::equal('topicInternalId', [$topic->getInternalId()])); + $queries[] = Query::equal('topicInternalId', [$topic->getInternalId()]); /** * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries @@ -2512,8 +2524,23 @@ App::delete('/v1/messaging/topics/:topicId/subscribers/:subscriberId') throw new Exception(Exception::SUBSCRIBER_NOT_FOUND); } + $target = $dbForProject->getDocument('targets', $subscriber->getAttribute('targetId')); + $dbForProject->deleteDocument('subscribers', $subscriberId); - Authorization::skip(fn () => $dbForProject->decreaseDocumentAttribute('topics', $topicId, 'total', 1)); + + $totalAttribute = match ($target->getAttribute('providerType')) { + MESSAGE_TYPE_EMAIL => 'emailTotal', + MESSAGE_TYPE_SMS => 'smsTotal', + MESSAGE_TYPE_PUSH => 'pushTotal', + default => throw new Exception(Exception::TARGET_PROVIDER_INVALID_TYPE), + }; + + Authorization::skip(fn () => $dbForProject->decreaseDocumentAttribute( + 'topics', + $topicId, + $totalAttribute, + min: 0 + )); $queueForEvents ->setParam('topicId', $topic->getId()) diff --git a/app/controllers/api/project.php b/app/controllers/api/project.php index 2269cb81c7..8dd6f8b82e 100644 --- a/app/controllers/api/project.php +++ b/app/controllers/api/project.php @@ -73,7 +73,7 @@ App::get('/v1/project/usage') Authorization::skip(function () use ($dbForProject, $firstDay, $lastDay, $period, $metrics, &$total, &$stats) { foreach ($metrics['total'] as $metric) { - $result = $dbForProject->findOne('stats_v2', [ + $result = $dbForProject->findOne('stats', [ Query::equal('metric', [$metric]), Query::equal('period', ['inf']) ]); @@ -81,7 +81,7 @@ App::get('/v1/project/usage') } foreach ($metrics['period'] as $metric) { - $results = $dbForProject->find('stats_v2', [ + $results = $dbForProject->find('stats', [ Query::equal('metric', [$metric]), Query::equal('period', [$period]), Query::greaterThanEqual('time', $firstDay), @@ -116,7 +116,7 @@ App::get('/v1/project/usage') $id = $function->getId(); $name = $function->getAttribute('name'); $metric = str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS); - $value = $dbForProject->findOne('stats_v2', [ + $value = $dbForProject->findOne('stats', [ Query::equal('metric', [$metric]), Query::equal('period', ['inf']) ]); @@ -132,7 +132,7 @@ App::get('/v1/project/usage') $id = $bucket->getId(); $name = $bucket->getAttribute('name'); $metric = str_replace('{bucketInternalId}', $bucket->getInternalId(), METRIC_BUCKET_ID_FILES_STORAGE); - $value = $dbForProject->findOne('stats_v2', [ + $value = $dbForProject->findOne('stats', [ Query::equal('metric', [$metric]), Query::equal('period', ['inf']) ]); diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php index c96fd90e93..7fe2580aec 100644 --- a/app/controllers/api/storage.php +++ b/app/controllers/api/storage.php @@ -1539,7 +1539,7 @@ App::get('/v1/storage/usage') $total = []; Authorization::skip(function () use ($dbForProject, $days, $metrics, &$stats, &$total) { foreach ($metrics as $metric) { - $result = $dbForProject->findOne('stats_v2', [ + $result = $dbForProject->findOne('stats', [ Query::equal('metric', [$metric]), Query::equal('period', ['inf']) ]); @@ -1547,7 +1547,7 @@ App::get('/v1/storage/usage') $stats[$metric]['total'] = $result['value'] ?? 0; $limit = $days['limit']; $period = $days['period']; - $results = $dbForProject->find('stats_v2', [ + $results = $dbForProject->find('stats', [ Query::equal('metric', [$metric]), Query::equal('period', [$period]), Query::limit($limit), @@ -1624,7 +1624,7 @@ App::get('/v1/storage/:bucketId/usage') Authorization::skip(function () use ($dbForProject, $days, $metrics, &$stats, &$total) { foreach ($metrics as $metric) { - $result = $dbForProject->findOne('stats_v2', [ + $result = $dbForProject->findOne('stats', [ Query::equal('metric', [$metric]), Query::equal('period', ['inf']) ]); @@ -1632,7 +1632,7 @@ App::get('/v1/storage/:bucketId/usage') $stats[$metric]['total'] = $result['value'] ?? 0; $limit = $days['limit']; $period = $days['period']; - $results = $dbForProject->find('stats_v2', [ + $results = $dbForProject->find('stats', [ Query::equal('metric', [$metric]), Query::equal('period', [$period]), Query::limit($limit), diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 942641a68a..55ad758637 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -115,6 +115,11 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e if ($email) { try { $target = $dbForProject->createDocument('targets', new Document([ + '$permissions' => [ + Permission::read(Role::user($user->getId())), + Permission::update(Role::user($user->getId())), + Permission::delete(Role::user($user->getId())), + ], 'userId' => $user->getId(), 'userInternalId' => $user->getInternalId(), 'providerType' => 'email', @@ -132,6 +137,11 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e if ($phone) { try { $target = $dbForProject->createDocument('targets', new Document([ + '$permissions' => [ + Permission::read(Role::user($user->getId())), + Permission::update(Role::user($user->getId())), + Permission::delete(Role::user($user->getId())), + ], 'userId' => $user->getId(), 'userInternalId' => $user->getInternalId(), 'providerType' => 'sms', @@ -498,6 +508,11 @@ App::post('/v1/users/:userId/targets') try { $target = $dbForProject->createDocument('targets', new Document([ '$id' => $targetId, + '$permissions' => [ + Permission::read(Role::user($user->getId())), + Permission::update(Role::user($user->getId())), + Permission::delete(Role::user($user->getId())), + ], 'providerId' => $providerId ?? null, 'providerInternalId' => $provider->getInternalId() ?? null, 'providerType' => $providerType, @@ -1227,6 +1242,11 @@ App::patch('/v1/users/:userId/email') } else { if (\strlen($email) !== 0) { $target = $dbForProject->createDocument('targets', new Document([ + '$permissions' => [ + Permission::read(Role::user($user->getId())), + Permission::update(Role::user($user->getId())), + Permission::delete(Role::user($user->getId())), + ], 'userId' => $user->getId(), 'userInternalId' => $user->getInternalId(), 'providerType' => 'email', @@ -1305,6 +1325,11 @@ App::patch('/v1/users/:userId/phone') } else { if (\strlen($number) !== 0) { $target = $dbForProject->createDocument('targets', new Document([ + '$permissions' => [ + Permission::read(Role::user($user->getId())), + Permission::update(Role::user($user->getId())), + Permission::delete(Role::user($user->getId())), + ], 'userId' => $user->getId(), 'userInternalId' => $user->getInternalId(), 'providerType' => 'sms', @@ -1976,7 +2001,7 @@ App::get('/v1/users/usage') Authorization::skip(function () use ($dbForProject, $days, $metrics, &$stats) { foreach ($metrics as $count => $metric) { - $result = $dbForProject->findOne('stats_v2', [ + $result = $dbForProject->findOne('stats', [ Query::equal('metric', [$metric]), Query::equal('period', ['inf']) ]); @@ -1984,7 +2009,7 @@ App::get('/v1/users/usage') $stats[$metric]['total'] = $result['value'] ?? 0; $limit = $days['limit']; $period = $days['period']; - $results = $dbForProject->find('stats_v2', [ + $results = $dbForProject->find('stats', [ Query::equal('metric', [$metric]), Query::equal('period', [$period]), Query::limit($limit), diff --git a/app/controllers/general.php b/app/controllers/general.php index 0a776c71c1..99ed12b668 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -3,7 +3,6 @@ require_once __DIR__ . '/../init.php'; use Utopia\App; -use Utopia\Database\Helpers\Role; use Utopia\Locale\Locale; use Utopia\Logger\Logger; use Utopia\Logger\Log; @@ -15,7 +14,6 @@ use Appwrite\Utopia\View; use Appwrite\Extend\Exception as AppwriteException; use Utopia\Config\Config; use Utopia\Domains\Domain; -use Appwrite\Auth\Auth; use Appwrite\Event\Certificate; use Appwrite\Network\Validator\Origin; use Appwrite\Utopia\Response\Filters\V11 as ResponseV11; @@ -24,9 +22,9 @@ use Appwrite\Utopia\Response\Filters\V13 as ResponseV13; use Appwrite\Utopia\Response\Filters\V14 as ResponseV14; use Appwrite\Utopia\Response\Filters\V15 as ResponseV15; use Appwrite\Utopia\Response\Filters\V16 as ResponseV16; +use Appwrite\Utopia\Response\Filters\V17 as ResponseV17; use Utopia\CLI\Console; use Utopia\Database\Database; -use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; @@ -36,8 +34,8 @@ use Appwrite\Utopia\Request\Filters\V13 as RequestV13; use Appwrite\Utopia\Request\Filters\V14 as RequestV14; use Appwrite\Utopia\Request\Filters\V15 as RequestV15; use Appwrite\Utopia\Request\Filters\V16 as RequestV16; +use Appwrite\Utopia\Request\Filters\V17 as RequestV17; use Utopia\Validator\Text; -use Utopia\Validator\WhiteList; Config::setParam('domainVerification', false); Config::setParam('cookieDomain', 'localhost'); @@ -203,15 +201,11 @@ App::init() ->inject('console') ->inject('project') ->inject('dbForConsole') - ->inject('user') ->inject('locale') ->inject('localeCodes') ->inject('clients') - ->inject('servers') - ->inject('session') - ->inject('mode') ->inject('queueForCertificates') - ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Document $console, Document $project, Database $dbForConsole, Document $user, Locale $locale, array $localeCodes, array $clients, array $servers, ?Document $session, string $mode, Certificate $queueForCertificates) { + ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Document $console, Document $project, Database $dbForConsole, Locale $locale, array $localeCodes, array $clients, Certificate $queueForCertificates) { /* * Appwrite Router */ @@ -252,6 +246,9 @@ App::init() case version_compare($requestFormat, '1.4.0', '<'): Request::setFilter(new RequestV16()); break; + case version_compare($requestFormat, '1.5.0', '<'): + Request::setFilter(new RequestV17()); + break; default: Request::setFilter(null); } @@ -319,14 +316,6 @@ App::init() $locale->setDefault($localeParam); } - if ($project->isEmpty()) { - throw new AppwriteException(AppwriteException::PROJECT_NOT_FOUND); - } - - if (!empty($route->getLabel('sdk.auth', [])) && $project->isEmpty() && ($route->getLabel('scope', '') !== 'public')) { - throw new AppwriteException(AppwriteException::PROJECT_UNKNOWN); - } - $referrer = $request->getReferer(); $origin = \parse_url($request->getOrigin($referrer), PHP_URL_HOST); $protocol = \parse_url($request->getOrigin($referrer), PHP_URL_SCHEME); @@ -393,6 +382,9 @@ App::init() case version_compare($responseFormat, '1.4.0', '<'): Response::setFilter(new ResponseV16()); break; + case version_compare($responseFormat, '1.5.0', '<'): + Response::setFilter(new ResponseV17()); + break; default: Response::setFilter(null); } @@ -445,138 +437,6 @@ App::init() ) { throw new AppwriteException(AppwriteException::GENERAL_UNKNOWN_ORIGIN, $originValidator->getDescription()); } - - /* - * ACL Check - */ - $role = ($user->isEmpty()) - ? Role::guests()->toString() - : Role::users()->toString(); - - // Add user roles - $memberships = $user->find('teamId', $project->getAttribute('teamId'), 'memberships'); - - if ($memberships) { - foreach ($memberships->getAttribute('roles', []) as $memberRole) { - switch ($memberRole) { - case 'owner': - $role = Auth::USER_ROLE_OWNER; - break; - case 'admin': - $role = Auth::USER_ROLE_ADMIN; - break; - case 'developer': - $role = Auth::USER_ROLE_DEVELOPER; - break; - } - } - } - - $roles = Config::getParam('roles', []); - $scope = $route->getLabel('scope', 'none'); // Allowed scope for chosen route - $scopes = $roles[$role]['scopes']; // Allowed scopes for user role - - $authKey = $request->getHeader('x-appwrite-key', ''); - - if (!empty($authKey)) { // API Key authentication - // Check if given key match project API keys - $key = $project->find('secret', $authKey, 'keys'); - - /* - * Try app auth when we have project key and no user - * Mock user to app and grant API key scopes in addition to default app scopes - */ - if ($key && $user->isEmpty()) { - $user = new Document([ - '$id' => '', - 'status' => true, - 'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(), - 'password' => '', - 'name' => $project->getAttribute('name', 'Untitled'), - ]); - - $role = Auth::USER_ROLE_APPS; - $scopes = \array_merge($roles[$role]['scopes'], $key->getAttribute('scopes', [])); - - $expire = $key->getAttribute('expire'); - if (!empty($expire) && $expire < DateTime::formatTz(DateTime::now())) { - throw new AppwriteException(AppwriteException::PROJECT_KEY_EXPIRED); - } - - Authorization::setRole(Auth::USER_ROLE_APPS); - Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys. - - $accessedAt = $key->getAttribute('accessedAt', ''); - if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_KEY_ACCCESS)) > $accessedAt) { - $key->setAttribute('accessedAt', DateTime::now()); - $dbForConsole->updateDocument('keys', $key->getId(), $key); - $dbForConsole->purgeCachedDocument('projects', $project->getId()); - } - - $sdkValidator = new WhiteList($servers, true); - $sdk = $request->getHeader('x-sdk-name', 'UNKNOWN'); - if ($sdkValidator->isValid($sdk)) { - $sdks = $key->getAttribute('sdks', []); - if (!in_array($sdk, $sdks)) { - array_push($sdks, $sdk); - $key->setAttribute('sdks', $sdks); - - /** Update access time as well */ - $key->setAttribute('accessedAt', Datetime::now()); - $dbForConsole->updateDocument('keys', $key->getId(), $key); - $dbForConsole->purgeCachedDocument('projects', $project->getId()); - } - } - } - } - - Authorization::setRole($role); - - foreach (Auth::getRoles($user) as $authRole) { - Authorization::setRole($authRole); - } - - $service = $route->getLabel('sdk.namespace', ''); - if (!empty($service)) { - if ( - array_key_exists($service, $project->getAttribute('services', [])) - && !$project->getAttribute('services', [])[$service] - && !(Auth::isPrivilegedUser(Authorization::getRoles()) || Auth::isAppUser(Authorization::getRoles())) - ) { - throw new AppwriteException(AppwriteException::GENERAL_SERVICE_DISABLED); - } - } - - if (!\in_array($scope, $scopes)) { - if ($project->isEmpty()) { // Check if permission is denied because project is missing - throw new AppwriteException(AppwriteException::PROJECT_NOT_FOUND); - } - - throw new AppwriteException(AppwriteException::GENERAL_UNAUTHORIZED_SCOPE, $user->getAttribute('email', 'User') . ' (role: ' . \strtolower($roles[$role]['label']) . ') missing scope (' . $scope . ')'); - } - - if (false === $user->getAttribute('status')) { // Account is blocked - throw new AppwriteException(AppwriteException::USER_BLOCKED); - } - - if ($user->getAttribute('reset')) { - throw new AppwriteException(AppwriteException::USER_PASSWORD_RESET_REQUIRED); - } - - if ($mode !== APP_MODE_ADMIN) { - $mfaEnabled = $user->getAttribute('mfa', false); - $hasVerifiedAuthenticator = $user->getAttribute('totpVerification', false); - $hasVerifiedEmail = $user->getAttribute('emailVerification', false); - $hasVerifiedPhone = $user->getAttribute('phoneVerification', false); - $hasMoreFactors = $hasVerifiedEmail || $hasVerifiedPhone || $hasVerifiedAuthenticator; - $minimumFactors = ($mfaEnabled && $hasMoreFactors) ? 2 : 1; - - if (!in_array('mfa', $route->getGroups())) { - if ($session && \count($session->getAttribute('factors')) < $minimumFactors) { - throw new AppwriteException(AppwriteException::USER_MORE_FACTORS_REQUIRED); - } - } - } }); App::options() diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 1a9c9f7380..810d778a21 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -6,7 +6,6 @@ use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; -use Appwrite\Event\Mail; use Appwrite\Event\Messaging; use Appwrite\Extend\Exception; use Appwrite\Event\Usage; @@ -22,7 +21,9 @@ use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; -use MaxMind\Db\Reader; +use Utopia\Config\Config; +use Utopia\Database\Helpers\Role; +use Utopia\Validator\WhiteList; $parseLabel = function (string $label, array $responsePayload, array $requestParams, Document $user) { preg_match_all('/{(.*?)}/', $label, $matches); @@ -135,7 +136,7 @@ $databaseListener = function (string $event, Document $document, Document $proje $queueForUsage ->addMetric(METRIC_DEPLOYMENTS, $value) // per project ->addMetric(METRIC_DEPLOYMENTS_STORAGE, $document->getAttribute('size') * $value) // per project - ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$document->getAttribute('resourceType'), $document->getAttribute('resourceInternalId')], METRIC_FUNCTION_ID_DEPLOYMENTS), $value)// per function + ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$document->getAttribute('resourceType'), $document->getAttribute('resourceInternalId')], METRIC_FUNCTION_ID_DEPLOYMENTS), $value) // per function ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$document->getAttribute('resourceType'), $document->getAttribute('resourceInternalId')], METRIC_FUNCTION_ID_DEPLOYMENTS_STORAGE), $document->getAttribute('size') * $value); break; default: @@ -143,6 +144,155 @@ $databaseListener = function (string $event, Document $document, Document $proje } }; +App::init() + ->groups(['api']) + ->inject('utopia') + ->inject('request') + ->inject('dbForConsole') + ->inject('project') + ->inject('user') + ->inject('session') + ->inject('servers') + ->inject('mode') + ->action(function (App $utopia, Request $request, Database $dbForConsole, Document $project, Document $user, ?Document $session, array $servers, string $mode) { + $route = $utopia->getRoute(); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + /** + * ACL Check + */ + $role = ($user->isEmpty()) + ? Role::guests()->toString() + : Role::users()->toString(); + + // Add user roles + $memberships = $user->find('teamId', $project->getAttribute('teamId'), 'memberships'); + + if ($memberships) { + foreach ($memberships->getAttribute('roles', []) as $memberRole) { + switch ($memberRole) { + case 'owner': + $role = Auth::USER_ROLE_OWNER; + break; + case 'admin': + $role = Auth::USER_ROLE_ADMIN; + break; + case 'developer': + $role = Auth::USER_ROLE_DEVELOPER; + break; + } + } + } + + $roles = Config::getParam('roles', []); + $scope = $route->getLabel('scope', 'none'); // Allowed scope for chosen route + $scopes = $roles[$role]['scopes']; // Allowed scopes for user role + + $authKey = $request->getHeader('x-appwrite-key', ''); + + if (!empty($authKey)) { // API Key authentication + // Check if given key match project API keys + $key = $project->find('secret', $authKey, 'keys'); + + /* + * Try app auth when we have project key and no user + * Mock user to app and grant API key scopes in addition to default app scopes + */ + if ($key && $user->isEmpty()) { + $user = new Document([ + '$id' => '', + 'status' => true, + 'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(), + 'password' => '', + 'name' => $project->getAttribute('name', 'Untitled'), + ]); + + $role = Auth::USER_ROLE_APPS; + $scopes = \array_merge($roles[$role]['scopes'], $key->getAttribute('scopes', [])); + + $expire = $key->getAttribute('expire'); + if (!empty($expire) && $expire < DateTime::formatTz(DateTime::now())) { + throw new Exception(Exception::PROJECT_KEY_EXPIRED); + } + + Authorization::setRole(Auth::USER_ROLE_APPS); + Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys. + + $accessedAt = $key->getAttribute('accessedAt', ''); + if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_KEY_ACCCESS)) > $accessedAt) { + $key->setAttribute('accessedAt', DateTime::now()); + $dbForConsole->updateDocument('keys', $key->getId(), $key); + $dbForConsole->purgeCachedDocument('projects', $project->getId()); + } + + $sdkValidator = new WhiteList($servers, true); + $sdk = $request->getHeader('x-sdk-name', 'UNKNOWN'); + if ($sdkValidator->isValid($sdk)) { + $sdks = $key->getAttribute('sdks', []); + if (!in_array($sdk, $sdks)) { + array_push($sdks, $sdk); + $key->setAttribute('sdks', $sdks); + + /** Update access time as well */ + $key->setAttribute('accessedAt', Datetime::now()); + $dbForConsole->updateDocument('keys', $key->getId(), $key); + $dbForConsole->purgeCachedDocument('projects', $project->getId()); + } + } + } + } + + Authorization::setRole($role); + + foreach (Auth::getRoles($user) as $authRole) { + Authorization::setRole($authRole); + } + + $service = $route->getLabel('sdk.namespace', ''); + if (!empty($service)) { + if ( + array_key_exists($service, $project->getAttribute('services', [])) + && !$project->getAttribute('services', [])[$service] + && !(Auth::isPrivilegedUser(Authorization::getRoles()) || Auth::isAppUser(Authorization::getRoles())) + ) { + throw new Exception(Exception::GENERAL_SERVICE_DISABLED); + } + } + if (!\in_array($scope, $scopes)) { + if ($project->isEmpty()) { // Check if permission is denied because project is missing + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, $user->getAttribute('email', 'User') . ' (role: ' . \strtolower($roles[$role]['label']) . ') missing scope (' . $scope . ')'); + } + + if (false === $user->getAttribute('status')) { // Account is blocked + throw new Exception(Exception::USER_BLOCKED); + } + + if ($user->getAttribute('reset')) { + throw new Exception(Exception::USER_PASSWORD_RESET_REQUIRED); + } + + if ($mode !== APP_MODE_ADMIN) { + $mfaEnabled = $user->getAttribute('mfa', false); + $hasVerifiedAuthenticator = $user->getAttribute('totpVerification', false); + $hasVerifiedEmail = $user->getAttribute('emailVerification', false); + $hasVerifiedPhone = $user->getAttribute('phoneVerification', false); + $hasMoreFactors = $hasVerifiedEmail || $hasVerifiedPhone || $hasVerifiedAuthenticator; + $minimumFactors = ($mfaEnabled && $hasMoreFactors) ? 2 : 1; + + if (!in_array('mfa', $route->getGroups())) { + if ($session && \count($session->getAttribute('factors')) < $minimumFactors) { + throw new Exception(Exception::USER_MORE_FACTORS_REQUIRED); + } + } + } + }); + App::init() ->groups(['api']) ->inject('utopia') @@ -162,10 +312,6 @@ App::init() $route = $utopia->getRoute(); - if ($project->isEmpty() && $route->getLabel('abuse-limit', 0) > 0) { // Abuse limit requires an active project scope - throw new Exception(Exception::PROJECT_UNKNOWN); - } - /* * Abuse Check */ @@ -296,7 +442,7 @@ App::init() if ($fileSecurity && !$valid) { $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); } else { - $file = Authorization::skip(fn() => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); + $file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); } if ($file->isEmpty()) { @@ -497,7 +643,7 @@ App::shutdown() 'resource' => $resource, 'contentType' => $response->getContentType(), 'payload' => base64_encode($data['payload']), - ]) ; + ]); $signature = md5($data); $cacheLog = Authorization::skip(fn () => $dbForProject->getDocument('cache', $key)); @@ -505,10 +651,10 @@ App::shutdown() $now = DateTime::now(); if ($cacheLog->isEmpty()) { Authorization::skip(fn () => $dbForProject->createDocument('cache', new Document([ - '$id' => $key, - 'resource' => $resource, - 'accessedAt' => $now, - 'signature' => $signature, + '$id' => $key, + 'resource' => $resource, + 'accessedAt' => $now, + 'signature' => $signature, ]))); } elseif (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) { $cacheLog->setAttribute('accessedAt', $now); diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 98dee95c94..eba88480c4 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -192,7 +192,6 @@ class Exception extends \Exception /** Projects */ public const PROJECT_NOT_FOUND = 'project_not_found'; - public const PROJECT_UNKNOWN = 'project_unknown'; public const PROJECT_PROVIDER_DISABLED = 'project_provider_disabled'; public const PROJECT_PROVIDER_UNSUPPORTED = 'project_provider_unsupported'; public const PROJECT_ALREADY_EXISTS = 'project_already_exists'; @@ -252,6 +251,7 @@ class Exception extends \Exception public const PROVIDER_NOT_FOUND = 'provider_not_found'; public const PROVIDER_ALREADY_EXISTS = 'provider_already_exists'; public const PROVIDER_INCORRECT_TYPE = 'provider_incorrect_type'; + public const PROVIDER_MISSING_CREDENTIALS = 'provider_missing_credentials'; /** Topic */ @@ -274,6 +274,9 @@ class Exception extends \Exception public const MESSAGE_TARGET_NOT_PUSH = 'message_target_not_push'; public const MESSAGE_MISSING_SCHEDULE = 'message_missing_schedule'; + /** Targets */ + public const TARGET_PROVIDER_INVALID_TYPE = 'target_provider_invalid_type'; + /** Schedules */ public const SCHEDULE_NOT_FOUND = 'schedule_not_found'; diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index 1cbe0d1813..4038362720 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -78,7 +78,7 @@ abstract class Migration '1.4.11' => 'V19', '1.4.12' => 'V19', '1.4.13' => 'V19', - '1.4.14' => 'V20' + '1.5.0' => 'V20', ]; /** diff --git a/src/Appwrite/Migration/Version/V20.php b/src/Appwrite/Migration/Version/V20.php index 209068883c..0e7434c56c 100644 --- a/src/Appwrite/Migration/Version/V20.php +++ b/src/Appwrite/Migration/Version/V20.php @@ -2,16 +2,19 @@ namespace Appwrite\Migration\Version; +use Appwrite\Auth\Auth; use Appwrite\Migration\Migration; +use Exception; use PDOException; use Throwable; use Utopia\CLI\Console; use Utopia\Database\Database; +use Utopia\Database\DateTime; use Utopia\Database\Document; -use Utopia\Database\Exception; use Utopia\Database\Exception\Authorization; use Utopia\Database\Exception\Duplicate; use Utopia\Database\Exception\Structure; +use Utopia\Database\Helpers\ID; use Utopia\Database\Query; class V20 extends Migration @@ -21,18 +24,14 @@ class V20 extends Migration */ public function execute(): void { - if ($this->project->getInternalId() == 'console') { - return; - } - /** * Disable SubQueries for Performance. */ - foreach (['subQueryIndexes', 'subQueryPlatforms', 'subQueryDomains', 'subQueryKeys', 'subQueryWebhooks', 'subQuerySessions', 'subQueryTokens', 'subQueryMemberships', 'subQueryVariables'] as $name) { + foreach (['subQueryIndexes', 'subQueryPlatforms', 'subQueryDomains', 'subQueryKeys', 'subQueryWebhooks', 'subQuerySessions', 'subQueryTokens', 'subQueryMemberships', 'subQueryVariables', 'subQueryChallenges', 'subQueryProjectVariables', 'subQueryTargets', 'subQueryTopicTargets'] as $name) { Database::addFilter( $name, - fn () => null, - fn () => [] + fn() => null, + fn() => [] ); } @@ -45,19 +44,239 @@ class V20 extends Migration Console::log('Migrating Project: ' . $this->project->getAttribute('name') . ' (' . $this->project->getId() . ')'); $this->projectDB->setNamespace("_{$this->project->getInternalId()}"); + Console::info('Migrating Collections'); + $this->migrateCollections(); + Console::info('Migrating Functions'); $this->migrateFunctions(); Console::info('Migrating Databases'); $this->migrateDatabases(); - Console::info('Migrating Collections'); - $this->migrateCollections(); - Console::info('Migrating Buckets'); $this->migrateBuckets(); + + Console::info('Migrating Documents'); + $this->forEachDocument([$this, 'fixDocument']); } + /** + * Migrate Collections. + * + * @return void + * @throws Exception|Throwable + */ + private function migrateCollections(): void + { + $internalProjectId = $this->project->getInternalId(); + $collectionType = match ($internalProjectId) { + 'console' => 'console', + default => 'projects', + }; + + // Support database array type migration (user collections) + foreach ( + $this->documentsIterator('attributes', [ + Query::equal('array', [true]), + ]) as $attribute + ) { + $foundIndex = false; + foreach ( + $this->documentsIterator('indexes', [ + Query::equal('databaseInternalId', [$attribute['databaseInternalId']]), + Query::equal('collectionInternalId', [$attribute['collectionInternalId']]), + ]) as $index + ) { + if (in_array($attribute['key'], $index['attributes'])) { + $this->projectDB->deleteIndex($index['collectionId'], $index['$id']); + $foundIndex = true; + } + } + if ($foundIndex === true) { + $this->projectDB->updateAttribute($attribute['collectionInternalId'], $attribute['key'], $attribute['type']); + } + } + + $collections = $this->collections[$collectionType]; + foreach ($collections as $collection) { + $id = $collection['$id']; + + Console::log("Migrating Collection \"{$id}\""); + + $this->projectDB->setNamespace("_$internalProjectId"); + + // Support database array type migration + $foundIndex = false; + foreach ($collection['attributes'] ?? [] as $attribute) { + if ($attribute['array'] === true) { + foreach ($collection['indexes'] ?? [] as $index) { + if (in_array($attribute['$id'], $index['attributes'])) { + $this->projectDB->deleteIndex($id, $index['$id']); + $foundIndex = true; + } + } + if ($foundIndex === true) { + $this->projectDB->updateAttribute($id, $attribute['$id'], $attribute['type']); + } + } + } + + switch ($id) { + case '_metadata': + $this->createCollection('providers'); + $this->createCollection('messages'); + $this->createCollection('topics'); + $this->createCollection('subscribers'); + $this->createCollection('targets'); + $this->createCollection('challenges'); + + break; + case 'cache': + // Create resourceType attribute + try { + $this->createAttributeFromCollection($this->projectDB, $id, 'resourceType'); + $this->projectDB->purgeCachedCollection($id); + } catch (Throwable $th) { + Console::warning("'resourceType' from {$id}: {$th->getMessage()}"); + } + + // Create mimeType attribute + try { + $this->createAttributeFromCollection($this->projectDB, $id, 'mimeType'); + $this->projectDB->purgeCachedCollection($id); + } catch (Throwable $th) { + Console::warning("'mimeType' from {$id}: {$th->getMessage()}"); + } + + break; + case 'stats': + try { + /** + * Delete 'type' attribute + */ + $this->projectDB->deleteAttribute($id, 'type'); + /** + * Alter `signed` internal type on `value` attr + */ + $this->projectDB->updateAttribute(collection: $id, id: 'value', signed: true); + $this->projectDB->purgeCachedCollection($id); + } catch (Throwable $th) { + Console::warning("'type' from {$id}: {$th->getMessage()}"); + } + + // update stats index + $index = '_key_metric_period_time'; + + try { + $this->projectDB->deleteIndex($id, $index); + } catch (\Throwable $th) { + Console::warning("'$index' from {$id}: {$th->getMessage()}"); + } + + try { + $this->createIndexFromCollection($this->projectDB, $id, $index); + } catch (\Throwable $th) { + Console::warning("'$index' from {$id}: {$th->getMessage()}"); + } + + break; + case 'sessions': + // Create expire attribute + try { + $this->createAttributeFromCollection($this->projectDB, $id, 'expire'); + $this->projectDB->purgeCachedCollection($id); + } catch (Throwable $th) { + Console::warning("'expire' from {$id}: {$th->getMessage()}"); + } + + // Create factors attribute + try { + $this->createAttributeFromCollection($this->projectDB, $id, 'factors'); + $this->projectDB->purgeCachedCollection($id); + } catch (Throwable $th) { + Console::warning("'factors' from {$id}: {$th->getMessage()}"); + } + + break; + case 'users': + // Create targets attribute + try { + $this->createAttributeFromCollection($this->projectDB, $id, 'targets'); + $this->projectDB->purgeCachedCollection($id); + } catch (Throwable $th) { + Console::warning("'targets' from {$id}: {$th->getMessage()}"); + } + + // Create mfa attribute + try { + $this->createAttributeFromCollection($this->projectDB, $id, 'mfa'); + $this->projectDB->purgeCachedCollection($id); + } catch (Throwable $th) { + Console::warning("'mfa' from {$id}: {$th->getMessage()}"); + } + + // Create totp attribute + try { + $this->createAttributeFromCollection($this->projectDB, $id, 'totp'); + $this->projectDB->purgeCachedCollection($id); + } catch (Throwable $th) { + Console::warning("'totp' from {$id}: {$th->getMessage()}"); + } + + // Create totpVerification attribute + try { + $this->createAttributeFromCollection($this->projectDB, $id, 'totpVerification'); + $this->projectDB->purgeCachedCollection($id); + } catch (Throwable $th) { + Console::warning("'totpVerification' from {$id}: {$th->getMessage()}"); + } + + // Create totpSecret attribute + try { + $this->createAttributeFromCollection($this->projectDB, $id, 'totpSecret'); + $this->projectDB->purgeCachedCollection($id); + } catch (Throwable $th) { + Console::warning("'totpSecret' from {$id}: {$th->getMessage()}"); + } + + // Create totpBackup attribute + try { + $this->createAttributeFromCollection($this->projectDB, $id, 'totpBackup'); + $this->projectDB->purgeCachedCollection($id); + } catch (Throwable $th) { + Console::warning("'totpBackup' from {$id}: {$th->getMessage()}"); + } + + break; + case 'projects': + // Rename providers authProviders to oAuthProviders + try { + $this->projectDB->renameAttribute($id, 'authProviders', 'oAuthProviders'); + $this->projectDB->purgeCachedCollection($id); + } catch (Throwable $th) { + Console::warning("'oAuthProviders' from {$id}: {$th->getMessage()}"); + } + break; + case 'webhooks': + try { + $this->createAttributeFromCollection($this->projectDB, $id, 'enabled'); + $this->createAttributeFromCollection($this->projectDB, $id, 'logs'); + $this->createAttributeFromCollection($this->projectDB, $id, 'attempts'); + $this->projectDB->purgeCachedCollection($id); + } catch (Throwable $th) { + Console::warning("'webhooks' from {$id}: {$th->getMessage()}"); + } + break; + default: + break; + } + + usleep(50000); + } + } + + + /** * @return void * @throws Authorization @@ -89,7 +308,7 @@ class V20 extends Migration Query::equal('period', ['1d']), ]); - $sessionsDeleted = $query['value'] ?? 0; + $sessionsDeleted = $query['value'] ?? 0; $value = $sessionsCreated - $sessionsDeleted; $this->createInfMetric('sessions', $value); } @@ -115,8 +334,8 @@ class V20 extends Migration '$id' => $id, 'metric' => $metric, 'period' => 'inf', - 'value' => $value, - 'time' => null, + 'value' => $value, + 'time' => null, 'region' => 'default', ])); } catch (Duplicate $th) { @@ -132,6 +351,7 @@ class V20 extends Migration */ protected function migrateUsageMetrics(string $from, string $to): void { + /** * inf metric */ @@ -159,7 +379,7 @@ class V20 extends Migration while ($sum === $limit) { $paginationQueries = [Query::limit($limit)]; if ($latestDocument !== null) { - $paginationQueries[] = Query::cursorAfter($latestDocument); + $paginationQueries[] = Query::cursorAfter($latestDocument); } $stats = $this->projectDB->find('stats', \array_merge($paginationQueries, [ Query::equal('metric', [$from]), @@ -182,11 +402,12 @@ class V20 extends Migration Console::warning("Error while updating metric {$from} " . $th->getMessage()); } } + /** * Migrate functions. * * @return void - * @throws \Exception + * @throws Exception */ private function migrateFunctions(): void { @@ -215,7 +436,7 @@ class V20 extends Migration * Migrate Databases. * * @return void - * @throws \Exception + * @throws Exception */ private function migrateDatabases(): void { @@ -241,7 +462,7 @@ class V20 extends Migration Console::log("Migrating Collections of {$collectionTable} {$collection->getId()} ({$collection->getAttribute('name')})"); // Collection level - $collectionId = $collection->getId() ; + $collectionId = $collection->getId(); $collectionInternalId = $collection->getInternalId(); $this->migrateUsageMetrics("documents.$databaseId/$collectionId.count.total", "$databaseInternalId.$collectionInternalId.documents"); @@ -250,53 +471,10 @@ class V20 extends Migration } /** - * Migrate Collections. + * Migrating Buckets. * * @return void - * @throws \Exception - */ - private function migrateCollections(): void - { - $internalProjectId = $this->project->getInternalId(); - $collectionType = match ($internalProjectId) { - 'console' => 'console', - default => 'projects', - }; - - $collections = $this->collections[$collectionType]; - - foreach ($collections as $collection) { - $id = $collection['$id']; - - Console::log("Migrating Collection \"{$id}\""); - - $this->projectDB->setNamespace("_$internalProjectId"); - - switch ($id) { - case 'stats': - try { - /** - * Delete 'type' attribute - */ - $this->projectDB->deleteAttribute($id, 'type'); - /** - * Alter `signed` internal type on `value` attr - */ - $this->projectDB->updateAttribute($id, 'value', null, null, null, null, true); - $this->projectDB->deleteCachedCollection($id); - } catch (Throwable $th) { - Console::warning("'type' from {$id}: {$th->getMessage()}"); - } - break; - } - } - } - - /** - * Migrating all Bucket tables. - * - * @return void - * @throws \Exception + * @throws Exception * @throws PDOException */ protected function migrateBuckets(): void @@ -305,7 +483,6 @@ class V20 extends Migration $this->migrateUsageMetrics('buckets.$all.count.total', 'buckets'); $this->migrateUsageMetrics('files.$all.count.total', 'files'); $this->migrateUsageMetrics('files.$all.storage.size', 'files.storage'); - // There is also project.$all.storage.size which is the same as files.$all.storage.size foreach ($this->documentsIterator('buckets') as $bucket) { $id = "bucket_{$bucket->getInternalId()}"; @@ -315,9 +492,55 @@ class V20 extends Migration $bucketId = $bucket->getId(); $bucketInternalId = $bucket->getInternalId(); - $this->migrateUsageMetrics("files.$bucketId.count.total", "$bucketInternalId.files"); + $this->migrateUsageMetrics("files.$bucketId.count.total", "$bucketInternalId.files"); $this->migrateUsageMetrics("files.$bucketId.storage.size", "$bucketInternalId.files.storage"); - // some stats come with $ prefix in front of the id -> files.$650c3fda307b7fec4934.storage.size; } } + + /** + * Fix run on each document + * + * @param Document $document + * @return Document + */ + protected function fixDocument(Document $document): Document + { + switch ($document->getCollection()) { + case 'projects': + /** + * Bump version number. + */ + $document->setAttribute('version', '1.5.0'); + break; + case 'users': + if ($document->getAttribute('email', '') !== '') { + $target = new Document([ + '$id' => ID::unique(), + 'userId' => $document->getId(), + 'userInternalId' => $document->getInternalId(), + 'providerType' => MESSAGE_TYPE_EMAIL, + 'identifier' => $document->getAttribute('email'), + ]); + $this->projectDB->createDocument('targets', $target); + } + + if ($document->getAttribute('phone', '') !== '') { + $target = new Document([ + '$id' => ID::unique(), + 'userId' => $document->getId(), + 'userInternalId' => $document->getInternalId(), + 'providerType' => MESSAGE_TYPE_SMS, + 'identifier' => $document->getAttribute('phone'), + ]); + $this->projectDB->createDocument('targets', $target); + } + break; + case 'sessions': + $duration = $this->project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; + $expire = DateTime::addSeconds(new \DateTime(), $duration); + $document->setAttribute('expire', $expire); + break; + } + return $document; + } } diff --git a/src/Appwrite/Platform/Tasks/CalcTierStats.php b/src/Appwrite/Platform/Tasks/CalcTierStats.php index d8f7ad3537..5723e256d5 100644 --- a/src/Appwrite/Platform/Tasks/CalcTierStats.php +++ b/src/Appwrite/Platform/Tasks/CalcTierStats.php @@ -269,7 +269,7 @@ class CalcTierStats extends Action $limit = $periods[$range]['limit']; $period = $periods[$range]['period']; - $requestDocs = $dbForProject->find('stats_v2', [ + $requestDocs = $dbForProject->find('stats', [ Query::equal('metric', [$metric]), Query::equal('period', [$period]), Query::limit($limit), diff --git a/src/Appwrite/Platform/Tasks/CreateInfMetric.php b/src/Appwrite/Platform/Tasks/CreateInfMetric.php index 0e78e02bd8..cfb7486782 100644 --- a/src/Appwrite/Platform/Tasks/CreateInfMetric.php +++ b/src/Appwrite/Platform/Tasks/CreateInfMetric.php @@ -163,8 +163,8 @@ class CreateInfMetric extends Action try { $id = \md5("_inf_{$metric}"); - $dbForProject->deleteDocument('stats_v2', $id); - $dbForProject->createDocument('stats_v2', new Document([ + $dbForProject->deleteDocument('stats', $id); + $dbForProject->createDocument('stats', new Document([ '$id' => $id, 'metric' => $metric, 'period' => 'inf', @@ -186,7 +186,7 @@ class CreateInfMetric extends Action protected function getFromMetric(database $dbForProject, string $metric): int|float { - return $dbForProject->sum('stats_v2', 'value', [ + return $dbForProject->sum('stats', 'value', [ Query::equal('metric', [ $metric, ]), diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 7a7070b9e4..542f7f41db 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Workers; use Appwrite\Auth\Auth; +use Appwrite\Extend\Exception; use Executor\Executor; use Throwable; use Utopia\Abuse\Abuse; @@ -263,12 +264,23 @@ class Deletes extends Action Query::equal('targetInternalId', [$target->getInternalId()]) ], $dbForProject, - function (Document $subscriber) use ($dbForProject) { + function (Document $subscriber) use ($dbForProject, $target) { $topicId = $subscriber->getAttribute('topicId'); $topicInternalId = $subscriber->getAttribute('topicInternalId'); $topic = $dbForProject->getDocument('topics', $topicId); if (!$topic->isEmpty() && $topic->getInternalId() === $topicInternalId) { - $dbForProject->decreaseDocumentAttribute('topics', $topicId, 'total', min: 0); + $totalAttribute = match ($target->getAttribute('providerType')) { + MESSAGE_TYPE_EMAIL => 'emailTotal', + MESSAGE_TYPE_SMS => 'smsTotal', + MESSAGE_TYPE_PUSH => 'pushTotal', + default => throw new Exception('Invalid target provider type'), + }; + $dbForProject->decreaseDocumentAttribute( + 'topics', + $topicId, + $totalAttribute, + min: 0 + ); } } ); @@ -457,7 +469,7 @@ class Deletes extends Action { $dbForProject = $getProjectDB($project); // Delete Usage stats - $this->deleteByGroup('stats_v2', [ + $this->deleteByGroup('stats', [ Query::lessThan('time', $hourlyUsageRetentionDatetime), Query::equal('period', ['1h']), ], $dbForProject); diff --git a/src/Appwrite/Platform/Workers/Hamster.php b/src/Appwrite/Platform/Workers/Hamster.php index 71d7e9012e..1069ff96fa 100644 --- a/src/Appwrite/Platform/Workers/Hamster.php +++ b/src/Appwrite/Platform/Workers/Hamster.php @@ -286,7 +286,7 @@ class Hamster extends Action $limit = $periodValue['limit']; $period = $periodValue['period']; - $requestDocs = $dbForProject->find('stats_v2', [ + $requestDocs = $dbForProject->find('stats', [ Query::equal('period', [$period]), Query::equal('metric', [$metric]), Query::limit($limit), diff --git a/src/Appwrite/Platform/Workers/Messaging.php b/src/Appwrite/Platform/Workers/Messaging.php index 99730d475f..083eae4e0a 100644 --- a/src/Appwrite/Platform/Workers/Messaging.php +++ b/src/Appwrite/Platform/Workers/Messaging.php @@ -249,7 +249,7 @@ class Messaging extends Action } // Deleting push targets when token has expired. - if (($result['error'] ?? '') === 'Expired device token.') { + if (($result['error'] ?? '') === 'Expired device token') { $target = $dbForProject->findOne('targets', [ Query::equal('identifier', [$result['recipient']]) ]); diff --git a/src/Appwrite/Platform/Workers/Usage.php b/src/Appwrite/Platform/Workers/Usage.php index 6a516d48ad..2271dd5909 100644 --- a/src/Appwrite/Platform/Workers/Usage.php +++ b/src/Appwrite/Platform/Workers/Usage.php @@ -69,6 +69,7 @@ class Usage extends Action getProjectDB: $getProjectDB ); } + self::$stats[$projectId]['project'] = $project; foreach ($payload['metrics'] ?? [] as $metric) { if (!isset(self::$stats[$projectId]['keys'][$metric['key']])) { @@ -105,8 +106,8 @@ class Usage extends Action } break; case $document->getCollection() === 'databases': // databases - $collections = $dbForProject->getDocument('stats_v2', md5(self::INFINITY_PERIOD . str_replace('{databaseInternalId}', $document->getInternalId(), METRIC_DATABASE_ID_COLLECTIONS))); - $documents = $dbForProject->getDocument('stats_v2', md5(self::INFINITY_PERIOD . str_replace('{databaseInternalId}', $document->getInternalId(), METRIC_DATABASE_ID_DOCUMENTS))); + $collections = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{databaseInternalId}', $document->getInternalId(), METRIC_DATABASE_ID_COLLECTIONS))); + $documents = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{databaseInternalId}', $document->getInternalId(), METRIC_DATABASE_ID_DOCUMENTS))); if (!empty($collections['value'])) { $metrics[] = [ 'key' => METRIC_COLLECTIONS, @@ -124,7 +125,7 @@ class Usage extends Action case str_starts_with($document->getCollection(), 'database_') && !str_contains($document->getCollection(), 'collection'): //collections $parts = explode('_', $document->getCollection()); $databaseInternalId = $parts[1] ?? 0; - $documents = $dbForProject->getDocument('stats_v2', md5(self::INFINITY_PERIOD . str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $document->getInternalId()], METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS))); + $documents = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $document->getInternalId()], METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS))); if (!empty($documents['value'])) { $metrics[] = [ @@ -139,8 +140,8 @@ class Usage extends Action break; case $document->getCollection() === 'buckets': - $files = $dbForProject->getDocument('stats_v2', md5(self::INFINITY_PERIOD . str_replace('{bucketInternalId}', $document->getInternalId(), METRIC_BUCKET_ID_FILES))); - $storage = $dbForProject->getDocument('stats_v2', md5(self::INFINITY_PERIOD . str_replace('{bucketInternalId}', $document->getInternalId(), METRIC_BUCKET_ID_FILES_STORAGE))); + $files = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{bucketInternalId}', $document->getInternalId(), METRIC_BUCKET_ID_FILES))); + $storage = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{bucketInternalId}', $document->getInternalId(), METRIC_BUCKET_ID_FILES_STORAGE))); if (!empty($files['value'])) { $metrics[] = [ @@ -158,13 +159,13 @@ class Usage extends Action break; case $document->getCollection() === 'functions': - $deployments = $dbForProject->getDocument('stats_v2', md5(self::INFINITY_PERIOD . str_replace(['{resourceType}', '{resourceInternalId}'], ['functions', $document->getInternalId()], METRIC_FUNCTION_ID_DEPLOYMENTS))); - $deploymentsStorage = $dbForProject->getDocument('stats_v2', md5(self::INFINITY_PERIOD . str_replace(['{resourceType}', '{resourceInternalId}'], ['functions', $document->getInternalId()], METRIC_FUNCTION_ID_DEPLOYMENTS_STORAGE))); - $builds = $dbForProject->getDocument('stats_v2', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS))); - $buildsStorage = $dbForProject->getDocument('stats_v2', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS_STORAGE))); - $buildsCompute = $dbForProject->getDocument('stats_v2', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS_COMPUTE))); - $executions = $dbForProject->getDocument('stats_v2', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS))); - $executionsCompute = $dbForProject->getDocument('stats_v2', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS_COMPUTE))); + $deployments = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace(['{resourceType}', '{resourceInternalId}'], ['functions', $document->getInternalId()], METRIC_FUNCTION_ID_DEPLOYMENTS))); + $deploymentsStorage = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace(['{resourceType}', '{resourceInternalId}'], ['functions', $document->getInternalId()], METRIC_FUNCTION_ID_DEPLOYMENTS_STORAGE))); + $builds = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS))); + $buildsStorage = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS_STORAGE))); + $buildsCompute = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS_COMPUTE))); + $executions = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS))); + $executionsCompute = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS_COMPUTE))); if (!empty($deployments['value'])) { $metrics[] = [ diff --git a/src/Appwrite/Platform/Workers/UsageHook.php b/src/Appwrite/Platform/Workers/UsageHook.php index 1b7d939525..81729812e3 100644 --- a/src/Appwrite/Platform/Workers/UsageHook.php +++ b/src/Appwrite/Platform/Workers/UsageHook.php @@ -67,7 +67,7 @@ class UsageHook extends Usage $id = \md5("{$time}_{$period}_{$key}"); try { - $dbForProject->createDocument('stats_v2', new Document([ + $dbForProject->createDocument('stats', new Document([ '$id' => $id, 'period' => $period, 'time' => $time, @@ -78,14 +78,14 @@ class UsageHook extends Usage } catch (Duplicate $th) { if ($value < 0) { $dbForProject->decreaseDocumentAttribute( - 'stats_v2', + 'stats', $id, 'value', abs($value) ); } else { $dbForProject->increaseDocumentAttribute( - 'stats_v2', + 'stats', $id, 'value', $value diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Topics.php b/src/Appwrite/Utopia/Database/Validator/Queries/Topics.php index 27c818d319..b73d93470f 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Topics.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Topics.php @@ -7,7 +7,9 @@ class Topics extends Base public const ALLOWED_ATTRIBUTES = [ 'name', 'description', - 'total' + 'emailTotal', + 'smsTotal', + 'pushTotal', ]; /** diff --git a/src/Appwrite/Utopia/Request/Filters/V17.php b/src/Appwrite/Utopia/Request/Filters/V17.php new file mode 100644 index 0000000000..246b52deae --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V17.php @@ -0,0 +1,341 @@ +convertOldQueries($content); + break; + } + return $content; + } + + private function convertOldQueries(array $content): array + { + $parsed = []; + foreach ($content['queries'] as $query) { + try { + $query = $this->parseQuery($query); + $parsed[] = json_encode(array_filter($query->toArray())); + } catch (\Throwable $th) { + throw new \Exception("Invalid query: {$query}", previous: $th); + } + } + + $content['queries'] = $parsed; + + return $content; + } + + // 1.4 query parser + public function parseQuery(string $filter): Query + { + // Init empty vars we fill later + $method = ''; + $params = []; + + // Separate method from filter + $paramsStart = mb_strpos($filter, '('); + + if ($paramsStart === false) { + throw new \Exception('Invalid query'); + } + + $method = mb_substr($filter, 0, $paramsStart); + + // Separate params from filter + $paramsEnd = \strlen($filter) - 1; // -1 to ignore ) + $parametersStart = $paramsStart + 1; // +1 to ignore ( + + // Check for deprecated query syntax + if (\str_contains($method, '.')) { + throw new \Exception('Invalid query method'); + } + + $currentParam = ""; // We build param here before pushing when it's ended + $currentArrayParam = []; // We build array param here before pushing when it's ended + + $stack = []; // State for stack of parentheses + $stackCount = 0; // Length of stack array. Kept as variable to improve performance + $stringStackState = null; // State for string support + + + // Loop thorough all characters + for ($i = $parametersStart; $i < $paramsEnd; $i++) { + $char = $filter[$i]; + + $isStringStack = $stringStackState !== null; + $isArrayStack = !$isStringStack && $stackCount > 0; + + if ($char === static::CHAR_BACKSLASH) { + if (!(static::isSpecialChar($filter[$i + 1]))) { + static::appendSymbol($isStringStack, $filter[$i], $i, $filter, $currentParam); + } + + static::appendSymbol($isStringStack, $filter[$i + 1], $i, $filter, $currentParam); + $i++; + + continue; + } + + // String support + escaping support + if ( + (self::isQuote($char)) && // Must be string indicator + ($filter[$i - 1] !== static::CHAR_BACKSLASH || $filter[$i - 2] === static::CHAR_BACKSLASH) // Must not be escaped; + ) { + if ($isStringStack) { + // Dont mix-up string symbols. Only allow the same as on start + if ($char === $stringStackState) { + // End of string + $stringStackState = null; + } + + // Either way, add symbol to builder + static::appendSymbol($isStringStack, $char, $i, $filter, $currentParam); + } else { + // Start of string + $stringStackState = $char; + static::appendSymbol($isStringStack, $char, $i, $filter, $currentParam); + } + + continue; + } + + // Array support + if (!($isStringStack)) { + if ($char === static::CHAR_BRACKET_START) { + // Start of array + $stack[] = $char; + $stackCount++; + continue; + } elseif ($char === static::CHAR_BRACKET_END) { + // End of array + \array_pop($stack); + $stackCount--; + + if (strlen($currentParam)) { + $currentArrayParam[] = $currentParam; + } + + $params[] = $currentArrayParam; + $currentArrayParam = []; + $currentParam = ""; + + continue; + } elseif ($char === static::CHAR_COMMA) { // Params separation support + // If in array stack, dont merge yet, just mark it in array param builder + if ($isArrayStack) { + $currentArrayParam[] = $currentParam; + $currentParam = ""; + } else { + // Append from parap builder. Either value, or array + if (empty($currentArrayParam)) { + if (strlen($currentParam)) { + $params[] = $currentParam; + } + + $currentParam = ""; + } + } + continue; + } + } + + // Value, not relevant to syntax + static::appendSymbol($isStringStack, $char, $i, $filter, $currentParam); + } + + if (strlen($currentParam)) { + $params[] = $currentParam; + $currentParam = ""; + } + + $parsedParams = []; + + foreach ($params as $param) { + // If array, parse each child separatelly + if (\is_array($param)) { + foreach ($param as $element) { + $arr[] = self::parseValue($element); + } + + $parsedParams[] = $arr ?? []; + } else { + $parsedParams[] = self::parseValue($param); + } + } + + switch ($method) { + case Query::TYPE_EQUAL: + case Query::TYPE_NOT_EQUAL: + case Query::TYPE_LESSER: + case Query::TYPE_LESSER_EQUAL: + case Query::TYPE_GREATER: + case Query::TYPE_GREATER_EQUAL: + case Query::TYPE_CONTAINS: + case Query::TYPE_SEARCH: + case Query::TYPE_IS_NULL: + case Query::TYPE_IS_NOT_NULL: + case Query::TYPE_STARTS_WITH: + case Query::TYPE_ENDS_WITH: + $attribute = $parsedParams[0] ?? ''; + if (count($parsedParams) < 2) { + return new Query($method, $attribute); + } + return new Query($method, $attribute, \is_array($parsedParams[1]) ? $parsedParams[1] : [$parsedParams[1]]); + + case Query::TYPE_BETWEEN: + return new Query($method, $parsedParams[0], [$parsedParams[1], $parsedParams[2]]); + case Query::TYPE_SELECT: + return new Query($method, values: $parsedParams[0]); + case Query::TYPE_ORDER_ASC: + case Query::TYPE_ORDER_DESC: + return new Query($method, $parsedParams[0] ?? ''); + + case Query::TYPE_LIMIT: + case Query::TYPE_OFFSET: + case Query::TYPE_CURSOR_AFTER: + case Query::TYPE_CURSOR_BEFORE: + if (count($parsedParams) > 0) { + return new Query($method, values: [$parsedParams[0]]); + } + return new Query($method); + + default: + return new Query($method); + } + } + + /** + * Parses value. + * + * @param string $value + * @return mixed + */ + private function parseValue(string $value): mixed + { + $value = \trim($value); + + if ($value === 'false') { // Boolean value + return false; + } elseif ($value === 'true') { + return true; + } elseif ($value === 'null') { // Null value + return null; + } elseif (\is_numeric($value)) { // Numeric value + // Cast to number + return $value + 0; + } elseif (\str_starts_with($value, static::CHAR_DOUBLE_QUOTE) || \str_starts_with($value, static::CHAR_SINGLE_QUOTE)) { // String param + $value = \substr($value, 1, -1); // Remove '' or "" + return $value; + } + + // Unknown format + return $value; + } + + /** + * Utility method to only append symbol if relevant. + * + * @param bool $isStringStack + * @param string $char + * @param int $index + * @param string $filter + * @param string $currentParam + * @return void + */ + private function appendSymbol(bool $isStringStack, string $char, int $index, string $filter, string &$currentParam): void + { + // Ignore spaces and commas outside of string + $canBeIgnored = false; + + if ($char === static::CHAR_SPACE) { + $canBeIgnored = true; + } elseif ($char === static::CHAR_COMMA) { + $canBeIgnored = true; + } + + if ($canBeIgnored) { + if ($isStringStack) { + $currentParam .= $char; + } + } else { + $currentParam .= $char; + } + } + + private function isQuote(string $char): bool + { + if ($char === self::CHAR_SINGLE_QUOTE) { + return true; + } elseif ($char === self::CHAR_DOUBLE_QUOTE) { + return true; + } + + return false; + } + + private function isSpecialChar(string $char): bool + { + if ($char === static::CHAR_COMMA) { + return true; + } elseif ($char === static::CHAR_BRACKET_END) { + return true; + } elseif ($char === static::CHAR_BRACKET_START) { + return true; + } elseif ($char === static::CHAR_DOUBLE_QUOTE) { + return true; + } elseif ($char === static::CHAR_SINGLE_QUOTE) { + return true; + } + + return false; + } +} diff --git a/src/Appwrite/Utopia/Response/Filters/V17.php b/src/Appwrite/Utopia/Response/Filters/V17.php new file mode 100644 index 0000000000..cf62bcf488 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Filters/V17.php @@ -0,0 +1,48 @@ +parseProject($parsedResponse); + break; + case Response::MODEL_USER: + $parsedResponse = $this->parseUser($parsedResponse); + break; + case Response::MODEL_TOKEN: + $parsedResponse = $this->parseToken($parsedResponse); + break; + } + + return $parsedResponse; + } + + protected function parseUser(array $content) + { + unset($content['targets']); + return $content; + } + + protected function parseProject(array $content) + { + $content['providers'] = $content['oAuthProviders']; + unset($content['oAuthProviders']); + return $content; + } + + protected function parseToken(array $content) + { + unset($content['phrase']); + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Topic.php b/src/Appwrite/Utopia/Response/Model/Topic.php index 25889095fd..dd81430164 100644 --- a/src/Appwrite/Utopia/Response/Model/Topic.php +++ b/src/Appwrite/Utopia/Response/Model/Topic.php @@ -34,9 +34,21 @@ class Topic extends Model 'default' => '', 'example' => 'events', ]) - ->addRule('total', [ + ->addRule('emailTotal', [ 'type' => self::TYPE_INTEGER, - 'description' => 'Total count of subscribers subscribed to topic.', + 'description' => 'Total count of email subscribers subscribed to the topic.', + 'default' => 0, + 'example' => 100, + ]) + ->addRule('smsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total count of SMS subscribers subscribed to the topic.', + 'default' => 0, + 'example' => 100, + ]) + ->addRule('pushTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total count of push subscribers subscribed to the topic.', 'default' => 0, 'example' => 100, ]) diff --git a/tests/e2e/General/HooksTest.php b/tests/e2e/General/HooksTest.php new file mode 100644 index 0000000000..f4933428d1 --- /dev/null +++ b/tests/e2e/General/HooksTest.php @@ -0,0 +1,158 @@ +client->setEndpoint('http://localhost'); + } + + public function testProjectHooks() + { + /** + * Test for api controllers + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/locale', \array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + ]), [ + 'project' => 'console' + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/v1/locale', \array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + ]), [ + 'project' => '$this_project_doesnt_exist' + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + + /** + * Test for web controllers + */ + $response = $this->client->call(Client::METHOD_GET, headers: [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + ], params: [ + 'project' => 'console' + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, headers: [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + ], params: [ + 'project' => '$this_project_doesnt_exist' + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + } + + public function testUserHooks() + { + /** + * Setup blocked user + */ + $email = uniqid() . 'user@localhost.test'; + $password = 'password'; + + $response = $this->client->call(Client::METHOD_POST, '/v1/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + ]); + + $id = $response['body']['$id']; + + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/v1/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $email, + 'password' => $password, + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; + $cookie = 'a_session_' . $this->getProject()['$id'] . '=' . $session; + + $response = $this->client->call(Client::METHOD_GET, '/v1/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => $cookie, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/v1/account/status', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => $cookie, + ], [ + 'status' => false, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/v1/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => $cookie, + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + + /** + * Test for api controllers + */ + $response = $this->client->call(Client::METHOD_GET, '/v1/locale', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => $cookie, + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + $this->assertEquals(Exception::USER_BLOCKED, $response['body']['type']); + + /** + * Test for web controllers + */ + $response = $this->client->call(Client::METHOD_GET, headers: [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => $cookie, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/GraphQL/Base.php b/tests/e2e/Services/GraphQL/Base.php index a0e720de88..d4f290c3db 100644 --- a/tests/e2e/Services/GraphQL/Base.php +++ b/tests/e2e/Services/GraphQL/Base.php @@ -2026,6 +2026,9 @@ trait Base messagingCreateTopic(topicId: $topicId, name: $name) { _id name + emailTotal + smsTotal + pushTotal } }'; case self::$LIST_TOPICS: @@ -2035,6 +2038,9 @@ trait Base topics { _id name + emailTotal + smsTotal + pushTotal } } }'; @@ -2043,6 +2049,9 @@ trait Base messagingGetTopic(topicId: $topicId) { _id name + emailTotal + smsTotal + pushTotal } }'; case self::$UPDATE_TOPIC: @@ -2050,6 +2059,9 @@ trait Base messagingUpdateTopic(topicId: $topicId, name: $name) { _id name + emailTotal + smsTotal + pushTotal } }'; case self::$DELETE_TOPIC: diff --git a/tests/e2e/Services/Messaging/MessagingBase.php b/tests/e2e/Services/Messaging/MessagingBase.php index d93506e146..956ac5f68a 100644 --- a/tests/e2e/Services/Messaging/MessagingBase.php +++ b/tests/e2e/Services/Messaging/MessagingBase.php @@ -355,7 +355,9 @@ trait MessagingBase 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'queries' => [ - Query::equal('total', [0])->toString(), + Query::equal('emailTotal', [0])->toString(), + Query::equal('smsTotal', [0])->toString(), + Query::equal('pushTotal', [0])->toString(), ], ]); @@ -368,7 +370,9 @@ trait MessagingBase 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'queries' => [ - Query::greaterThan('total', 0)->toString(), + Query::greaterThan('emailTotal', 0)->toString(), + Query::greaterThan('smsTotal', 0)->toString(), + Query::greaterThan('pushTotal', 0)->toString(), ], ]); @@ -390,7 +394,9 @@ trait MessagingBase ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('android-app', $response['body']['name']); - $this->assertEquals(0, $response['body']['total']); + $this->assertEquals(0, $response['body']['emailTotal']); + $this->assertEquals(0, $response['body']['smsTotal']); + $this->assertEquals(0, $response['body']['pushTotal']); } /** @@ -446,7 +452,9 @@ trait MessagingBase $this->assertEquals(200, $topic['headers']['status-code']); $this->assertEquals('android-app', $topic['body']['name']); - $this->assertEquals(1, $topic['body']['total']); + $this->assertEquals(1, $topic['body']['emailTotal']); + $this->assertEquals(0, $topic['body']['smsTotal']); + $this->assertEquals(0, $topic['body']['pushTotal']); $response2 = $this->client->call(Client::METHOD_POST, '/messaging/topics/' . $topics['private']['$id'] . '/subscribers', \array_merge([ 'content-type' => 'application/json', @@ -695,7 +703,9 @@ trait MessagingBase $this->assertEquals(200, $topic['headers']['status-code']); $this->assertEquals('android-app', $topic['body']['name']); - $this->assertEquals(0, $topic['body']['total']); + $this->assertEquals(0, $topic['body']['emailTotal']); + $this->assertEquals(0, $topic['body']['smsTotal']); + $this->assertEquals(0, $topic['body']['pushTotal']); } /** diff --git a/tests/unit/Utopia/Request/Filters/V17Test.php b/tests/unit/Utopia/Request/Filters/V17Test.php new file mode 100644 index 0000000000..4c0e155ec5 --- /dev/null +++ b/tests/unit/Utopia/Request/Filters/V17Test.php @@ -0,0 +1,89 @@ +filter = new V17(); + } + + public function tearDown(): void + { + } + + public function createUpdateRecoveryProvider() + { + return [ + 'remove passwordAgain' => [ + [ + 'userId' => 'test', + 'secret' => 'test', + 'password' => '123456', + 'passwordAgain' => '123456' + ], + [ + 'userId' => 'test', + 'secret' => 'test', + 'password' => '123456', + ] + ] + ]; + } + + /** + * @dataProvider createUpdateRecoveryProvider + */ + public function testUpdateRecovery(array $content, array $expected): void + { + $model = 'account.updateRecovery'; + + $result = $this->filter->parse($content, $model); + + $this->assertEquals($expected, $result); + } + + public function createQueryProvider() + { + return [ + 'convert queries' => [ + [ + 'queries' => [ + 'cursorAfter("exampleId")', + 'search("name", ["example"])', + 'isNotNull("name")' + ] + ], + [ + 'queries' => [ + '{"method":"cursorAfter","values":["exampleId"]}', + '{"method":"search","attribute":"name","values":["example"]}', + '{"method":"isNotNull","attribute":"name"}' + ] + ], + ] + ]; + } + + /** + * @dataProvider createQueryProvider + */ + public function testQuery(array $content, array $expected): void + { + $model = 'databases.getDocument'; + + $result = $this->filter->parse($content, $model); + + $this->assertEquals($expected, $result); + } +} diff --git a/tests/unit/Utopia/Response/Filters/V17Test.php b/tests/unit/Utopia/Response/Filters/V17Test.php new file mode 100644 index 0000000000..25f4fb2f2e --- /dev/null +++ b/tests/unit/Utopia/Response/Filters/V17Test.php @@ -0,0 +1,119 @@ +filter = new V17(); + } + + public function tearDown(): void + { + } + + public function projectProvider(): array + { + return [ + 'rename providers' => [ + [ + 'oAuthProviders' => [ + [ + 'key' => 'github', + 'name' => 'GitHub', + 'appId' => 'client_id', + 'secret' => 'client_secret', + 'enabled' => true, + ], + ], + ], + [ + 'providers' => [ + [ + 'key' => 'github', + 'name' => 'GitHub', + 'appId' => 'client_id', + 'secret' => 'client_secret', + 'enabled' => true, + ], + ], + ], + ], + ]; + } + + /** + * @dataProvider projectProvider + */ + public function testProject(array $content, array $expected): void + { + $model = Response::MODEL_PROJECT; + + $result = $this->filter->parse($content, $model); + + $this->assertEquals($expected, $result); + } + + public function userProvider(): array + { + return [ + 'remove targets' => [ + [ + 'targets' => 'test', + ], + [ + ], + ], + ]; + } + + /** + * @dataProvider userProvider + */ + public function testUser(array $content, array $expected): void + { + $model = Response::MODEL_USER; + + $result = $this->filter->parse($content, $model); + + $this->assertEquals($expected, $result); + } + + public function tokenProvider(): array + { + return [ + 'remove securityPhrase' => [ + [ + 'phrase' => 'Lorum Ipsum', + ], + [ + ], + ], + ]; + } + + /** + * @dataProvider tokenProvider + */ + public function testToken(array $content, array $expected): void + { + $model = Response::MODEL_TOKEN; + + $result = $this->filter->parse($content, $model); + + $this->assertEquals($expected, $result); + } +}