diff --git a/CHANGES.md b/CHANGES.md index 1418fb453..f682c7dd2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -39,6 +39,7 @@ - New OAuth adapter for PayPal sandbox (@armino-dev - [#420](https://github.com/appwrite/appwrite/issues/410)) - Introducing new permssion types: role:guest, role:member, role:app - Disabled rate-limits on server side integrations +- Refactored migration script ### User Interface @@ -105,6 +106,7 @@ - Fixed OAuth redirect when using the self-hosted instance default success URL ([#454](https://github.com/appwrite/appwrite/issues/454)) - Fixed bug denying authentication with Github OAuth provider - Fixed a bug making read permission overwrite write permission in some cases +- Fixed consistent property names in databases by enforcing camel case ## Security @@ -113,6 +115,7 @@ - Now using your `_APP_SYSTEM_EMAIL_ADDRESS` as the email address for issuing and renewing SSL certificates - Block iframe access to Appwrite console using the `X-Frame-Options` header. - Fixed `roles` param input validator +- API Keys are now stored encrypted # Version 0.6.2 (PRE-RELEASE) diff --git a/app/config/collections.php b/app/config/collections.php index a9451d4a5..9170b0757 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -228,7 +228,7 @@ $collections = [ [ '$collection' => Database::SYSTEM_COLLECTION_RULES, 'label' => 'Password Update Date', - 'key' => 'password-update', + 'key' => 'passwordUpdate', 'type' => Database::SYSTEM_VAR_TYPE_NUMERIC, 'default' => '', 'required' => true, @@ -827,6 +827,7 @@ $collections = [ 'type' => Database::SYSTEM_VAR_TYPE_TEXT, 'default' => '', 'required' => false, + 'filter' => ['encrypt'], ], ], ], diff --git a/app/config/services.php b/app/config/services.php index e500f9a24..90a452cfc 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -2,98 +2,126 @@ return [ '/' => [ + 'key' => 'homepage', 'name' => 'Homepage', 'controller' => 'web/home.php', 'sdk' => false, + 'docs' => false, 'tests' => false, ], 'console/' => [ + 'key' => 'console', 'name' => 'Console', 'controller' => 'web/console.php', 'sdk' => false, + 'docs' => false, 'tests' => false, ], 'v1/account' => [ + 'key' => 'account', 'name' => 'Account', 'description' => '/docs/services/account.md', 'controller' => 'api/account.php', 'sdk' => true, + 'docs' => true, 'tests' => false, ], 'v1/avatars' => [ + 'key' => 'avatars', 'name' => 'Avatars', 'description' => '/docs/services/avatars.md', 'controller' => 'api/avatars.php', 'sdk' => true, + 'docs' => true, 'tests' => false, ], 'v1/database' => [ + 'key' => 'database', 'name' => 'Database', 'description' => '/docs/services/database.md', 'controller' => 'api/database.php', 'sdk' => true, + 'docs' => true, 'tests' => false, ], 'v1/locale' => [ + 'key' => 'locale', 'name' => 'Locale', 'description' => '/docs/services/locale.md', 'controller' => 'api/locale.php', 'sdk' => true, + 'docs' => true, 'tests' => false, ], 'v1/health' => [ + 'key' => 'health', 'name' => 'Health', 'description' => '/docs/services/health.md', 'controller' => 'api/health.php', 'sdk' => true, + 'docs' => true, 'tests' => false, ], 'v1/projects' => [ + 'key' => 'projects', 'name' => 'Projects', 'controller' => 'api/projects.php', 'sdk' => true, + 'docs' => true, 'tests' => false, ], 'v1/storage' => [ + 'key' => 'storage', 'name' => 'Storage', 'description' => '/docs/services/storage.md', 'controller' => 'api/storage.php', 'sdk' => true, + 'docs' => true, 'tests' => false, ], 'v1/teams' => [ + 'key' => 'teams', 'name' => 'Teams', 'description' => '/docs/services/teams.md', 'controller' => 'api/teams.php', 'sdk' => true, + 'docs' => true, 'tests' => false, ], 'v1/users' => [ + 'key' => 'users', 'name' => 'Users', 'description' => '/docs/services/users.md', 'controller' => 'api/users.php', 'sdk' => true, + 'docs' => true, 'tests' => false, ], 'v1/functions' => [ - 'name' => 'Users', + 'key' => 'functions', + 'name' => 'Functions', 'description' => '/docs/services/functions.md', 'controller' => 'api/functions.php', 'sdk' => true, + 'docs' => true, 'tests' => false, ], 'v1/mock' => [ + 'key' => 'mock', 'name' => 'Mock', 'description' => '', 'controller' => 'mock.php', 'sdk' => false, + 'docs' => false, 'tests' => true, ], 'v1/graphql' => [ + 'key' => 'graphql', 'name' => 'GraphQL', 'description' => 'GraphQL Endpoint', 'controller' => 'api/graphql.php', 'sdk' => false, + 'docs' => false, 'tests' => false, ], ]; diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 9e366abba..9adba0f6c 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -100,7 +100,7 @@ App::post('/v1/account') 'emailVerification' => false, 'status' => Auth::USER_STATUS_UNACTIVATED, 'password' => Auth::passwordHash($password), - 'password-update' => \time(), + 'passwordUpdate' => \time(), 'registration' => \time(), 'reset' => false, 'name' => $name, @@ -512,7 +512,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'emailVerification' => true, 'status' => Auth::USER_STATUS_ACTIVATED, // Email should already be authenticated by OAuth2 provider 'password' => Auth::passwordHash(Auth::passwordGenerator()), - 'password-update' => \time(), + 'passwordUpdate' => \time(), 'registration' => \time(), 'reset' => false, 'name' => $name, @@ -1456,7 +1456,7 @@ App::put('/v1/account/recovery') $profile = $projectDB->updateDocument(\array_merge($profile->getArrayCopy(), [ 'password' => Auth::passwordHash($password), - 'password-update' => \time(), + 'passwordUpdate' => \time(), 'emailVerification' => true, ])); diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index 733a20ddd..3f25755d1 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -44,6 +44,9 @@ App::post('/v1/functions') ->inject('response') ->inject('projectDB') ->action(function ($name, $execute, $env, $vars, $events, $schedule, $timeout, $response, $projectDB) { + /** @var Appwrite\Utopia\Response $response */ + /** @var Appwrite\Database\Database $projectDB */ + $function = $projectDB->createDocument([ '$collection' => Database::SYSTEM_COLLECTION_FUNCTIONS, '$permissions' => [ @@ -91,6 +94,9 @@ App::get('/v1/functions') ->inject('response') ->inject('projectDB') ->action(function ($search, $limit, $offset, $orderType, $response, $projectDB) { + /** @var Appwrite\Utopia\Response $response */ + /** @var Appwrite\Database\Database $projectDB */ + $results = $projectDB->getCollection([ 'limit' => $limit, 'offset' => $offset, @@ -122,6 +128,9 @@ App::get('/v1/functions/:functionId') ->inject('response') ->inject('projectDB') ->action(function ($functionId, $response, $projectDB) { + /** @var Appwrite\Utopia\Response $response */ + /** @var Appwrite\Database\Database $projectDB */ + $function = $projectDB->getDocument($functionId); if (empty($function->getId()) || Database::SYSTEM_COLLECTION_FUNCTIONS != $function->getCollection()) { @@ -272,13 +281,19 @@ App::put('/v1/functions/:functionId') ->param('timeout', 15, new Range(1, 900), 'Function maximum execution time in seconds.', true) ->inject('response') ->inject('projectDB') - ->action(function ($functionId, $name, $execute, $vars, $events, $schedule, $timeout, $response, $projectDB) { + ->inject('project') + ->action(function ($functionId, $name, $execute, $vars, $events, $schedule, $timeout, $response, $projectDB, $project) { + /** @var Appwrite\Utopia\Response $response */ + /** @var Appwrite\Database\Database $projectDB */ + /** @var Appwrite\Database\Document $project */ + $function = $projectDB->getDocument($functionId); if (empty($function->getId()) || Database::SYSTEM_COLLECTION_FUNCTIONS != $function->getCollection()) { throw new Exception('Function not found', 404); } + $original = $function->getAttribute('schedule', ''); $cron = (!empty($function->getAttribute('tag', null)) && !empty($schedule)) ? CronExpression::factory($schedule) : null; $next = (!empty($function->getAttribute('tag', null)) && !empty($schedule)) ? $cron->getNextRunDate()->format('U') : null; @@ -291,28 +306,23 @@ App::put('/v1/functions/:functionId') 'vars' => $vars, 'events' => $events, 'schedule' => $schedule, - 'schedulePrevious' => null, 'scheduleNext' => $next, - 'timeout' => $timeout, + 'timeout' => $timeout, ])); - if ($next) { - ResqueScheduler::enqueueAt($next, 'v1-functions', 'FunctionsV1', [ - - ]); - - // ->setParam('projectId', $project->getId()) - // ->setParam('event', $route->getLabel('event', '')) - // ->setParam('payload', []) - // ->setParam('functionId', null) - // ->setParam('executionId', null) - // ->setParam('trigger', 'event') - } - if (false === $function) { throw new Exception('Failed saving function to DB', 500); } + if ($next && $schedule !== $original) { + ResqueScheduler::enqueueAt($next, 'v1-functions', 'FunctionsV1', [ + 'projectId' => $project->getId(), + 'functionId' => $function->getId(), + 'executionId' => null, + 'trigger' => 'schedule', + ]); // Async task rescheduale + } + $response->dynamic($function, Response::MODEL_FUNCTION); }); @@ -331,7 +341,12 @@ App::patch('/v1/functions/:functionId/tag') ->param('tag', '', new UID(), 'Tag unique ID.') ->inject('response') ->inject('projectDB') - ->action(function ($functionId, $tag, $response, $projectDB) { + ->inject('project') + ->action(function ($functionId, $tag, $response, $projectDB, $project) { + /** @var Appwrite\Utopia\Response $response */ + /** @var Appwrite\Database\Database $projectDB */ + /** @var Appwrite\Database\Document $project */ + $function = $projectDB->getDocument($functionId); $tag = $projectDB->getDocument($tag); @@ -344,14 +359,23 @@ App::patch('/v1/functions/:functionId/tag') } $schedule = $function->getAttribute('schedule', ''); - $cron = (!empty($function->getAttribute('tag')&& !empty($schedule))) ? CronExpression::factory($schedule) : null; - $next = (!empty($function->getAttribute('tag')&& !empty($schedule))) ? $cron->getNextRunDate()->format('U') : null; + $cron = (empty($function->getAttribute('tag')) && !empty($schedule)) ? CronExpression::factory($schedule) : null; + $next = (empty($function->getAttribute('tag')) && !empty($schedule)) ? $cron->getNextRunDate()->format('U') : null; $function = $projectDB->updateDocument(array_merge($function->getArrayCopy(), [ 'tag' => $tag->getId(), 'scheduleNext' => $next, ])); + if ($next) { // Init first schedule + ResqueScheduler::enqueueAt($next, 'v1-functions', 'FunctionsV1', [ + 'projectId' => $project->getId(), + 'functionId' => $function->getId(), + 'executionId' => null, + 'trigger' => 'schedule', + ]); // Async task rescheduale + } + if (false === $function) { throw new Exception('Failed saving function to DB', 500); } @@ -418,6 +442,11 @@ App::post('/v1/functions/:functionId/tags') ->inject('projectDB') ->inject('usage') ->action(function ($functionId, $command, $code, $request, $response, $projectDB, $usage) { + /** @var Utopia\Swoole\Request $request */ + /** @var Appwrite\Utopia\Response $response */ + /** @var Appwrite\Database\Database $projectDB */ + /** @var Appwrite\Event\Event $usage */ + $function = $projectDB->getDocument($functionId); if (empty($function->getId()) || Database::SYSTEM_COLLECTION_FUNCTIONS != $function->getCollection()) { @@ -506,6 +535,9 @@ App::get('/v1/functions/:functionId/tags') ->inject('response') ->inject('projectDB') ->action(function ($functionId, $search, $limit, $offset, $orderType, $response, $projectDB) { + /** @var Appwrite\Utopia\Response $response */ + /** @var Appwrite\Database\Database $projectDB */ + $function = $projectDB->getDocument($functionId); if (empty($function->getId()) || Database::SYSTEM_COLLECTION_FUNCTIONS != $function->getCollection()) { @@ -545,6 +577,9 @@ App::get('/v1/functions/:functionId/tags/:tagId') ->inject('response') ->inject('projectDB') ->action(function ($functionId, $tagId, $response, $projectDB) { + /** @var Appwrite\Utopia\Response $response */ + /** @var Appwrite\Database\Database $projectDB */ + $function = $projectDB->getDocument($functionId); if (empty($function->getId()) || Database::SYSTEM_COLLECTION_FUNCTIONS != $function->getCollection()) { @@ -581,6 +616,10 @@ App::delete('/v1/functions/:functionId/tags/:tagId') ->inject('projectDB') ->inject('usage') ->action(function ($functionId, $tagId, $response, $projectDB, $usage) { + /** @var Appwrite\Utopia\Response $response */ + /** @var Appwrite\Database\Database $projectDB */ + /** @var Appwrite\Event\Event $usage */ + $function = $projectDB->getDocument($functionId); if (empty($function->getId()) || Database::SYSTEM_COLLECTION_FUNCTIONS != $function->getCollection()) { @@ -727,6 +766,9 @@ App::get('/v1/functions/:functionId/executions') ->inject('response') ->inject('projectDB') ->action(function ($functionId, $search, $limit, $offset, $orderType, $response, $projectDB) { + /** @var Appwrite\Utopia\Response $response */ + /** @var Appwrite\Database\Database $projectDB */ + $function = $projectDB->getDocument($functionId); if (empty($function->getId()) || Database::SYSTEM_COLLECTION_FUNCTIONS != $function->getCollection()) { @@ -766,6 +808,9 @@ App::get('/v1/functions/:functionId/executions/:executionId') ->inject('response') ->inject('projectDB') ->action(function ($functionId, $executionId, $response, $projectDB) { + /** @var Appwrite\Utopia\Response $response */ + /** @var Appwrite\Database\Database $projectDB */ + $function = $projectDB->getDocument($functionId); if (empty($function->getId()) || Database::SYSTEM_COLLECTION_FUNCTIONS != $function->getCollection()) { diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 084bdeeb1..5ebeb6366 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -324,7 +324,7 @@ App::post('/v1/teams/:teamId/memberships') 'emailVerification' => false, 'status' => Auth::USER_STATUS_UNACTIVATED, 'password' => Auth::passwordHash(Auth::passwordGenerator()), - 'password-update' => \time(), + 'passwordUpdate' => \time(), 'registration' => \time(), 'reset' => false, 'name' => $name, diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 76e20aad9..a0cff9723 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -62,7 +62,7 @@ App::post('/v1/users') 'emailVerification' => false, 'status' => Auth::USER_STATUS_UNACTIVATED, 'password' => Auth::passwordHash($password), - 'password-update' => \time(), + 'passwordUpdate' => \time(), 'registration' => \time(), 'reset' => false, 'name' => $name, diff --git a/app/controllers/web/home.php b/app/controllers/web/home.php index 8dc976986..c4cb6de31 100644 --- a/app/controllers/web/home.php +++ b/app/controllers/web/home.php @@ -216,6 +216,7 @@ App::get('/specs/:format') $routes = []; $models = []; + $services = []; $keys = [ APP_PLATFORM_CLIENT => [ @@ -317,6 +318,20 @@ App::get('/specs/:format') } } + foreach (Config::getParam('services', []) as $key => $service) { + if(!isset($service['docs']) // Skip service if not part of the public API + || !isset($service['sdk']) + || !$service['docs'] + || !$service['sdk']) { + continue; + } + + $services[] = [ + 'name' => $service['key'] ?? '', + 'description' => (!empty($service['description'])) ? file_get_contents(realpath(__DIR__.'/../../..'.$service['description'])) : '', + ]; + } + $models = $response->getModels(); foreach ($models as $key => $value) { @@ -327,11 +342,11 @@ App::get('/specs/:format') switch ($format) { case 'swagger2': - $format = new Swagger2($utopia, $routes, $models, $keys[$platform], $security[$platform]); + $format = new Swagger2($utopia, $services, $routes, $models, $keys[$platform], $security[$platform]); break; case 'open-api3': - $format = new OpenAPI3($utopia, $routes, $models, $keys[$platform], $security[$platform]); + $format = new OpenAPI3($utopia, $services, $routes, $models, $keys[$platform], $security[$platform]); break; default: diff --git a/app/http.php b/app/http.php index 74fb95082..9e45bc3b5 100644 --- a/app/http.php +++ b/app/http.php @@ -18,6 +18,7 @@ use Utopia\CLI\Console; ini_set('memory_limit','512M'); ini_set('display_errors', 1); ini_set('display_startup_errors', 1); +ini_set('default_socket_timeout', -1); error_reporting(E_ALL); $http = new Server("0.0.0.0", App::getEnv('PORT', 80)); diff --git a/app/tasks/maintenance.php b/app/tasks/maintenance.php index 78a31e6dd..eccdf61b1 100644 --- a/app/tasks/maintenance.php +++ b/app/tasks/maintenance.php @@ -8,38 +8,37 @@ use Appwrite\Event\Event; use Utopia\App; use Utopia\CLI\Console; -Console::title('Maintenance V1'); - -Console::success(APP_NAME.' maintenance process v1 has started'); - -function notifyDeleteExecutionLogs(int $interval) -{ - Resque::enqueue(Event::DELETE_QUEUE_NAME, Event::DELETE_CLASS_NAME, [ - 'type' => DELETE_TYPE_EXECUTIONS, - 'timestamp' => time() - $interval - ]); -} - -function notifyDeleteAbuseLogs(int $interval) -{ - Resque::enqueue(Event::DELETE_QUEUE_NAME, Event::DELETE_CLASS_NAME, [ - 'type' => DELETE_TYPE_ABUSE, - 'timestamp' => time() - $interval - ]); -} - -function notifyDeleteAuditLogs(int $interval) -{ - Resque::enqueue(Event::DELETE_QUEUE_NAME, Event::DELETE_CLASS_NAME, [ - 'type' => DELETE_TYPE_AUDIT, - 'timestamp' => time() - $interval - ]); -} - $cli ->task('maintenance') ->desc('Schedules maintenance tasks and publishes them to resque') ->action(function () { + Console::title('Maintenance V1'); + Console::success(APP_NAME.' maintenance process v1 has started'); + + function notifyDeleteExecutionLogs(int $interval) + { + Resque::enqueue(Event::DELETE_QUEUE_NAME, Event::DELETE_CLASS_NAME, [ + 'type' => DELETE_TYPE_EXECUTIONS, + 'timestamp' => time() - $interval + ]); + } + + function notifyDeleteAbuseLogs(int $interval) + { + Resque::enqueue(Event::DELETE_QUEUE_NAME, Event::DELETE_CLASS_NAME, [ + 'type' => DELETE_TYPE_ABUSE, + 'timestamp' => time() - $interval + ]); + } + + function notifyDeleteAuditLogs(int $interval) + { + Resque::enqueue(Event::DELETE_QUEUE_NAME, Event::DELETE_CLASS_NAME, [ + 'type' => DELETE_TYPE_AUDIT, + 'timestamp' => time() - $interval + ]); + } + // # of days in seconds (1 day = 86400s) $interval = (int) App::getEnv('_APP_MAINTENANCE_INTERVAL', '86400'); $executionLogsRetention = (int) App::getEnv('_APP_MAINTENANCE_RETENTION_EXECUTION', '1209600'); diff --git a/app/tasks/migrate.php b/app/tasks/migrate.php index bba8ad416..f9e92b24b 100644 --- a/app/tasks/migrate.php +++ b/app/tasks/migrate.php @@ -5,187 +5,26 @@ global $cli, $register, $projectDB, $console; use Utopia\Config\Config; use Utopia\CLI\Console; use Appwrite\Database\Database; -use Appwrite\Database\Document; use Appwrite\Database\Validator\Authorization; use Appwrite\Database\Adapter\MySQL as MySQLAdapter; use Appwrite\Database\Adapter\Redis as RedisAdapter; - -$callbacks = [ - '0.4.0' => function() { - Console::log('I got nothing to do.'); - }, - - '0.5.0' => function($project) use ($register, $projectDB) { - $db = $register->get('db'); - - Console::log('Migrating project: '.$project->getAttribute('name').' ('.$project->getId().')'); - - // Update all documents $uid -> $id - - $limit = 30; - $sum = 30; - $offset = 0; - - while ($sum >= 30) { - $all = $projectDB->getCollection([ - 'limit' => $limit, - 'offset' => $offset, - 'orderType' => 'DESC', - ]); - - $sum = \count($all); - - Console::log('Migrating: '.$offset.' / '.$projectDB->getSum()); - - foreach($all as $document) { - $document = fixDocument($document); - - if(empty($document->getId())) { - throw new Exception('Missing ID'); - } - - try { - $new = $projectDB->overwriteDocument($document->getArrayCopy()); - } catch (\Throwable $th) { - var_dump($document); - Console::error('Failed to update document: '.$th->getMessage()); - continue; - } - - if($new->getId() !== $document->getId()) { - throw new Exception('Duplication Error'); - } - } - - $offset = $offset + $limit; - } - - $schema = $_SERVER['_APP_DB_SCHEMA'] ?? ''; - - try { - $statement = $db->prepare(" - - CREATE TABLE IF NOT EXISTS `template.database.unique` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `key` varchar(128) DEFAULT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `index1` (`key`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - - CREATE TABLE IF NOT EXISTS `{$schema}`.`app_{$project->getId()}.database.unique` LIKE `template.database.unique`; - ALTER TABLE `{$schema}`.`app_{$project->getId()}.audit.audit` DROP COLUMN IF EXISTS `userType`; - ALTER TABLE `{$schema}`.`app_{$project->getId()}.audit.audit` DROP INDEX IF EXISTS `index_1`; - ALTER TABLE `{$schema}`.`app_{$project->getId()}.audit.audit` ADD INDEX IF NOT EXISTS `index_1` (`userId` ASC); - "); - - $statement->closeCursor(); - - $statement->execute(); - } - catch (\Exception $e) { - Console::error('Failed to alter table for project: '.$project->getId().' with message: '.$e->getMessage().'/'); - } - }, -]; - -function fixDocument(Document $document) { - $providers = Config::getParam('providers'); - - if($document->getAttribute('$collection') === Database::SYSTEM_COLLECTION_PROJECTS){ - foreach($providers as $key => $provider) { - if(!empty($document->getAttribute('usersOauth'.\ucfirst($key).'Appid'))) { - $document - ->setAttribute('usersOauth2'.\ucfirst($key).'Appid', $document->getAttribute('usersOauth'.\ucfirst($key).'Appid', '')) - ->removeAttribute('usersOauth'.\ucfirst($key).'Appid') - ; - } - - if(!empty($document->getAttribute('usersOauth'.\ucfirst($key).'Secret'))) { - $document - ->setAttribute('usersOauth2'.\ucfirst($key).'Secret', $document->getAttribute('usersOauth'.\ucfirst($key).'Secret', '')) - ->removeAttribute('usersOauth'.\ucfirst($key).'Secret') - ; - } - } - } - - if($document->getAttribute('$collection') === Database::SYSTEM_COLLECTION_WEBHOOKS){ - $document->setAttribute('security', ($document->getAttribute('security')) ? true : false); - } - - if($document->getAttribute('$collection') === Database::SYSTEM_COLLECTION_TASKS){ - $document->setAttribute('security', ($document->getAttribute('security')) ? true : false); - } - - if($document->getAttribute('$collection') === Database::SYSTEM_COLLECTION_USERS) { - foreach($providers as $key => $provider) { - if(!empty($document->getAttribute('oauth'.\ucfirst($key)))) { - $document - ->setAttribute('oauth2'.\ucfirst($key), $document->getAttribute('oauth'.\ucfirst($key), '')) - ->removeAttribute('oauth'.\ucfirst($key)) - ; - } - - if(!empty($document->getAttribute('oauth'.\ucfirst($key).'AccessToken'))) { - $document - ->setAttribute('oauth2'.\ucfirst($key).'AccessToken', $document->getAttribute('oauth'.\ucfirst($key).'AccessToken', '')) - ->removeAttribute('oauth'.\ucfirst($key).'AccessToken') - ; - } - } - - if($document->getAttribute('confirm', null) !== null) { - $document - ->setAttribute('emailVerification', $document->getAttribute('confirm', $document->getAttribute('emailVerification', false))) - ->removeAttribute('confirm') - ; - } - } - - if($document->getAttribute('$collection') === Database::SYSTEM_COLLECTION_PLATFORMS) { - if($document->getAttribute('url', null) !== null) { - $document - ->setAttribute('hostname', \parse_url($document->getAttribute('url', $document->getAttribute('hostname', '')), PHP_URL_HOST)) - ->removeAttribute('url') - ; - } - } - - $document - ->setAttribute('$id', $document->getAttribute('$uid', $document->getAttribute('$id'))) - ->removeAttribute('$uid') - ; - - foreach($document as &$attr) { // Handle child documents - if($attr instanceof Document) { - $attr = fixDocument($attr); - } - - if(\is_array($attr)) { - foreach($attr as &$child) { - if($child instanceof Document) { - $child = fixDocument($child); - } - } - } - } - - return $document; -} +use Appwrite\Migration\Version; $cli ->task('migrate') - ->action(function () use ($register, $callbacks) { + ->action(function () use ($register) { Console::success('Starting Data Migration'); $consoleDB = new Database(); - $consoleDB->setAdapter(new RedisAdapter(new MySQLAdapter($register), $register)); - $consoleDB->setNamespace('app_console'); // Main DB - $consoleDB->setMocks(Config::getParam('collections', [])); - + $consoleDB + ->setAdapter(new RedisAdapter(new MySQLAdapter($register), $register)) + ->setNamespace('app_console') // Main DB + ->setMocks(Config::getParam('collections', [])); + $projectDB = new Database(); - $projectDB->setAdapter(new RedisAdapter(new MySQLAdapter($register), $register)); - $projectDB->setMocks(Config::getParam('collections', [])); + $projectDB + ->setAdapter(new RedisAdapter(new MySQLAdapter($register), $register)) + ->setMocks(Config::getParam('collections', [])); $console = $consoleDB->getDocument('console'); @@ -197,13 +36,14 @@ $cli $projects = [$console]; $count = 0; - while ($sum >= 30) { - foreach($projects as $project) { - - $projectDB->setNamespace('app_'.$project->getId()); + $migration = new Version\V06($register->get('db')); //TODO: remove hardcoded version and move to dynamic migration + while ($sum > 0) { + foreach ($projects as $project) { try { - $callbacks['0.5.0']($project, $projectDB); + $migration + ->setProject($project, $projectDB) + ->execute(); } catch (\Throwable $th) { throw $th; Console::error('Failed to update project ("'.$project->getId().'") version with error: '.$th->getMessage()); @@ -221,9 +61,11 @@ $cli $sum = \count($projects); $offset = $offset + $limit; $count = $count + $sum; - - Console::log('Fetched '.$count.'/'.$consoleDB->getSum().' projects...'); + + if ($sum > 0) { + Console::log('Fetched '.$count.'/'.$consoleDB->getSum().' projects...'); + } } Console::success('Data Migration Completed'); - }); \ No newline at end of file + }); diff --git a/app/workers/functions.php b/app/workers/functions.php index 115cd16fa..5e6e55347 100644 --- a/app/workers/functions.php +++ b/app/workers/functions.php @@ -6,6 +6,7 @@ use Appwrite\Database\Adapter\MySQL as MySQLAdapter; use Appwrite\Database\Adapter\Redis as RedisAdapter; use Appwrite\Database\Validator\Authorization; use Appwrite\Event\Event; +use Cron\CronExpression; use Swoole\Runtime; use Utopia\App; use Utopia\CLI\Console; @@ -27,7 +28,7 @@ $environments = Config::getParam('environments'); $warmupStart = \microtime(true); Co\run(function() use ($environments) { // Warmup: make sure images are ready to run fast 🚀 - Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL); + Runtime::enableCoroutine(SWOOLE_HOOK_ALL); foreach($environments as $environment) { go(function() use ($environment) { @@ -79,14 +80,6 @@ $stdout = \explode("\n", $stdout); \parse_str($value, $container); if(isset($container['name'])) { - // $labels = []; - // $temp = explode(',', $container['labels'] ?? []); - - // foreach($temp as &$label) { - // $label = explode('=', $label); - // $labels[$label[0] || 0] = $label[1] || ''; - // } - $container = [ 'name' => $container['name'], 'online' => (\substr($container['status'], 0, 2) === 'Up'), @@ -142,6 +135,7 @@ class FunctionsV1 $executionId = $this->args['executionId'] ?? ''; $trigger = $this->args['trigger'] ?? ''; $event = $this->args['event'] ?? ''; + $scheduleOriginal = $this->args['scheduleOriginal'] ?? ''; $payload = (!empty($this->args['payload'])) ? json_encode($this->args['payload']) : ''; $database = new Database(); @@ -190,9 +184,7 @@ class FunctionsV1 Console::success('Triggered function: '.$event); - Swoole\Coroutine\run(function () use ($projectId, $database, $function, $event, $payload) { - $this->execute('event', $projectId, '', $database, $function, $event, $payload); - }); + $this->execute('event', $projectId, '', $database, $function, $event, $payload); } } break; @@ -210,6 +202,45 @@ class FunctionsV1 * On failure add error count * If error count bigger than allowed change status to pause */ + + // Reschedule + Authorization::disable(); + $function = $database->getDocument($functionId); + Authorization::reset(); + + if (empty($function->getId()) || Database::SYSTEM_COLLECTION_FUNCTIONS != $function->getCollection()) { + throw new Exception('Function not found ('.$functionId.')'); + } + + if($scheduleOriginal && $scheduleOriginal !== $function->getAttribute('schedule')) { // Schedule has changed from previous run, ignore this run. + return; + } + + $cron = CronExpression::factory($function->getAttribute('schedule')); + $next = (int) $cron->getNextRunDate()->format('U'); + + $function + ->setAttribute('scheduleNext', $next) + ->setAttribute('schedulePrevious', \time()) + ; + + Authorization::disable(); + + $function = $database->updateDocument(array_merge($function->getArrayCopy(), [ + 'scheduleNext' => $next, + ])); + + Authorization::reset(); + + ResqueScheduler::enqueueAt($next, 'v1-functions', 'FunctionsV1', [ + 'projectId' => $projectId, + 'functionId' => $function->getId(), + 'executionId' => null, + 'trigger' => 'schedule', + 'scheduleOriginal' => $function->getAttribute('schedule', ''), + ]); // Async task rescheduale + + $this->execute($trigger, $projectId, $executionId, $database, $function); break; @@ -222,9 +253,7 @@ class FunctionsV1 throw new Exception('Function not found ('.$functionId.')'); } - Swoole\Coroutine\run(function () use ($trigger, $projectId, $executionId, $database, $function) { - $this->execute($trigger, $projectId, $executionId, $database, $function); - }); + $this->execute($trigger, $projectId, $executionId, $database, $function); break; default: @@ -278,8 +307,8 @@ class FunctionsV1 'time' => 0, ]); - if(false === $execution) { - throw new Exception('Failed to create execution'); + if(false === $execution || ($execution instanceof Document && $execution->isEmpty())) { + throw new Exception('Failed to create or read execution'); } Authorization::reset(); diff --git a/src/Appwrite/Database/Database.php b/src/Appwrite/Database/Database.php index 8610aeac1..3005a3eec 100644 --- a/src/Appwrite/Database/Database.php +++ b/src/Appwrite/Database/Database.php @@ -57,6 +57,11 @@ class Database */ static protected $filters = []; + /** + * @var bool + */ + static protected $statusFilters = true; + /** * @var array */ @@ -214,7 +219,7 @@ class Database /** * @param array $data * - * @return Document|bool + * @return Document * * @throws AuthorizationException * @throws StructureException @@ -448,6 +453,26 @@ class Database ]; } + /** + * Disable Attribute decoding + * + * @return void + */ + public static function disableFilters(): void + { + self::$statusFilters = false; + } + + /** + * Enable Attribute decoding + * + * @return void + */ + public static function enableFilters(): void + { + self::$statusFilters = true; + } + public function encode(Document $document):Document { $collection = $this->getDocument($document->getCollection(), true , false); @@ -550,6 +575,10 @@ class Database */ static protected function decodeAttribute(string $name, $value) { + if (!self::$statusFilters) { + return $value; + } + if (!isset(self::$filters[$name])) { return $value; throw new Exception('Filter not found'); diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php new file mode 100644 index 000000000..fabf968b0 --- /dev/null +++ b/src/Appwrite/Migration/Migration.php @@ -0,0 +1,119 @@ +db = $db; + } + + /** + * Set project for migration. + * + * @param Document $project + * @param Database $projectDB + * + * @return Migration + */ + public function setProject(Document $project, Database $projectDB): Migration + { + $this->project = $project; + $this->projectDB = $projectDB; + $this->projectDB->setNamespace('app_' . $project->getId()); + return $this; + } + + /** + * Iterates through every document. + * + * @param callable $callback + */ + public function forEachDocument(callable $callback): void + { + $sum = $this->limit; + $offset = 0; + + while ($sum >= $this->limit) { + $all = $this->projectDB->getCollection([ + 'limit' => $this->limit, + 'offset' => $offset, + 'orderType' => 'DESC', + ]); + + $sum = \count($all); + Runtime::setHookFlags(SWOOLE_HOOK_ALL); + + Console::log('Migrating: ' . $offset . ' / ' . $this->projectDB->getSum()); + \Co\run(function () use ($all, $callback) { + Runtime::enableCoroutine(SWOOLE_HOOK_ALL); + + foreach ($all as $document) { + go(function () use ($document, $callback) { + + $old = $document->getArrayCopy(); + $new = call_user_func($callback, $document); + + if (empty($new->getId())) { + throw new Exception('Missing ID'); + } + if (!array_diff($new->getArrayCopy(), $old)) { + return; + } + + try { + $new = $this->projectDB->overwriteDocument($document->getArrayCopy()); + } catch (\Throwable $th) { + Console::error('Failed to update document: ' . $th->getMessage()); + return; + + if ($document && $new->getId() !== $document->getId()) { + throw new Exception('Duplication Error'); + } + } + }); + } + }); + + $offset += $this->limit; + } + } + + /** + * Executes migration for set project. + */ + abstract public function execute(): void; +} diff --git a/src/Appwrite/Migration/Version/V04.php b/src/Appwrite/Migration/Version/V04.php new file mode 100644 index 000000000..3b13d8920 --- /dev/null +++ b/src/Appwrite/Migration/Version/V04.php @@ -0,0 +1,14 @@ +db; + $project = $this->project; + Console::log('Migrating project: ' . $project->getAttribute('name') . ' (' . $project->getId() . ')'); + + // Update all documents $uid -> $id + + $this->forEachDocument([$this, 'fixDocument']); + + $schema = $_SERVER['_APP_DB_SCHEMA'] ?? ''; + + try { + $statement = $db->prepare(" + + CREATE TABLE IF NOT EXISTS `template.database.unique` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `key` varchar(128) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `index1` (`key`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + + CREATE TABLE IF NOT EXISTS `{$schema}`.`app_{$project->getId()}.database.unique` LIKE `template.database.unique`; + ALTER TABLE `{$schema}`.`app_{$project->getId()}.audit.audit` DROP COLUMN IF EXISTS `userType`; + ALTER TABLE `{$schema}`.`app_{$project->getId()}.audit.audit` DROP INDEX IF EXISTS `index_1`; + ALTER TABLE `{$schema}`.`app_{$project->getId()}.audit.audit` ADD INDEX IF NOT EXISTS `index_1` (`userId` ASC); + "); + + $statement->closeCursor(); + + $statement->execute(); + } catch (\Exception $e) { + Console::error('Failed to alter table for project: ' . $project->getId() . ' with message: ' . $e->getMessage() . '/'); + } + } + + protected function fixDocument(Document $document) + { + $providers = Config::getParam('providers'); + + switch ($document->getAttribute('$collection')) { + case Database::SYSTEM_COLLECTION_PROJECTS: + foreach ($providers as $key => $provider) { + if (!empty($document->getAttribute('usersOauth' . \ucfirst($key) . 'Appid'))) { + $document + ->setAttribute('usersOauth2' . \ucfirst($key) . 'Appid', $document->getAttribute('usersOauth' . \ucfirst($key) . 'Appid', '')) + ->removeAttribute('usersOauth' . \ucfirst($key) . 'Appid'); + } + + if (!empty($document->getAttribute('usersOauth' . \ucfirst($key) . 'Secret'))) { + $document + ->setAttribute('usersOauth2' . \ucfirst($key) . 'Secret', $document->getAttribute('usersOauth' . \ucfirst($key) . 'Secret', '')) + ->removeAttribute('usersOauth' . \ucfirst($key) . 'Secret'); + } + } + $document->setAttribute('security', $document->getAttribute('security') ? true : false); + break; + + case Database::SYSTEM_COLLECTION_TASKS: + $document->setAttribute('security', $document->getAttribute('security') ? true : false); + break; + + case Database::SYSTEM_COLLECTION_USERS: + foreach ($providers as $key => $provider) { + if (!empty($document->getAttribute('oauth' . \ucfirst($key)))) { + $document + ->setAttribute('oauth2' . \ucfirst($key), $document->getAttribute('oauth' . \ucfirst($key), '')) + ->removeAttribute('oauth' . \ucfirst($key)); + } + + if (!empty($document->getAttribute('oauth' . \ucfirst($key) . 'AccessToken'))) { + $document + ->setAttribute('oauth2' . \ucfirst($key) . 'AccessToken', $document->getAttribute('oauth' . \ucfirst($key) . 'AccessToken', '')) + ->removeAttribute('oauth' . \ucfirst($key) . 'AccessToken'); + } + } + + if ($document->getAttribute('confirm', null) !== null) { + $document + ->setAttribute('emailVerification', $document->getAttribute('confirm', $document->getAttribute('emailVerification', false))) + ->removeAttribute('confirm'); + } + break; + + case Database::SYSTEM_COLLECTION_PLATFORMS: + if ($document->getAttribute('url', null) !== null) { + $document + ->setAttribute('hostname', \parse_url($document->getAttribute('url', $document->getAttribute('hostname', '')), PHP_URL_HOST)) + ->removeAttribute('url'); + } + break; + } + + $document + ->setAttribute('$id', $document->getAttribute('$uid', $document->getAttribute('$id'))) + ->removeAttribute('$uid'); + + foreach ($document as &$attr) { // Handle child documents + if ($attr instanceof Document) { + $attr = $this->fixDocument($attr); + } + + if (\is_array($attr)) { + foreach ($attr as &$child) { + if ($child instanceof Document) { + $child = $this->fixDocument($child); + } + } + } + } + + return $document; + } +} diff --git a/src/Appwrite/Migration/Version/V06.php b/src/Appwrite/Migration/Version/V06.php new file mode 100644 index 000000000..686330620 --- /dev/null +++ b/src/Appwrite/Migration/Version/V06.php @@ -0,0 +1,60 @@ +project; + Console::log('Migrating project: ' . $project->getAttribute('name') . ' (' . $project->getId() . ')'); + + $this->projectDB->disableFilters(); + $this->forEachDocument([$this, 'fixDocument']); + $this->projectDB->enableFilters(); + } + + protected function fixDocument(Document $document) + { + switch ($document->getAttribute('$collection')) { + case Database::SYSTEM_COLLECTION_USERS: + if ($document->getAttribute('password-update', null)) { + $document + ->setAttribute('passwordUpdate', $document->getAttribute('password-update', $document->getAttribute('passwordUpdate', ''))) + ->removeAttribute('password-update'); + } + break; + case Database::SYSTEM_COLLECTION_KEYS: + if ($document->getAttribute('secret', null)) { + $json = \json_decode($document->getAttribute('secret')); + if ($json->{'data'} || $json->{'method'} || $json->{'iv'} || $json->{'tag'} || $json->{'version'}) + { + Console::log('Secret already encrypted. Skipped: ' . $document->getId()); + break; + } + + $key = App::getEnv('_APP_OPENSSL_KEY_V1'); + $iv = OpenSSL::randomPseudoBytes(OpenSSL::cipherIVLength(OpenSSL::CIPHER_AES_128_GCM)); + $tag = null; + + $document->setAttribute('secret', json_encode([ + 'data' => OpenSSL::encrypt($document->getAttribute('secret'), OpenSSL::CIPHER_AES_128_GCM, $key, 0, $iv, $tag), + 'method' => OpenSSL::CIPHER_AES_128_GCM, + 'iv' => bin2hex($iv), + 'tag' => bin2hex($tag), + 'version' => '1', + ])); + } + break; + } + return $document; + } +} diff --git a/src/Appwrite/Specification/Format.php b/src/Appwrite/Specification/Format.php index 6f2d154e4..cfe2a34a0 100644 --- a/src/Appwrite/Specification/Format.php +++ b/src/Appwrite/Specification/Format.php @@ -13,6 +13,11 @@ abstract class Format */ protected $app; + /** + * @var array + */ + protected $services; + /** * @var Route[] */ @@ -53,14 +58,16 @@ abstract class Format /** * @param App $app + * @param array $services * @param Route[] $routes * @param Model[] $models * @param array $keys * @param array $security */ - public function __construct(App $app, array $routes, array $models, array $keys, array $security) + public function __construct(App $app, array $services, array $routes, array $models, array $keys, array $security) { $this->app = $app; + $this->services = $services; $this->routes = $routes; $this->models = $models; $this->keys = $keys; diff --git a/src/Appwrite/Specification/Format/OpenAPI3.php b/src/Appwrite/Specification/Format/OpenAPI3.php index 117c7c93b..95ddf333f 100644 --- a/src/Appwrite/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/Specification/Format/OpenAPI3.php @@ -56,6 +56,7 @@ class OpenAPI3 extends Format ], ], 'paths' => [], + 'tags' => $this->services, 'components' => [ 'schemas' => [], 'securitySchemes' => $this->keys, diff --git a/src/Appwrite/Specification/Format/Swagger2.php b/src/Appwrite/Specification/Format/Swagger2.php index fbe8ded4e..7d4f5407f 100644 --- a/src/Appwrite/Specification/Format/Swagger2.php +++ b/src/Appwrite/Specification/Format/Swagger2.php @@ -57,6 +57,7 @@ class Swagger2 extends Format 'produces' => ['application/json'], 'securityDefinitions' => $this->keys, 'paths' => [], + 'tags' => $this->services, 'definitions' => [], 'externalDocs' => [ 'description' => $this->getParam('docs.description'), diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 1111b3786..8379a2f6b 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -561,8 +561,9 @@ class FunctionsCustomServerTest extends Scope ], ]; - sleep(count($envs) * 30); + sleep(count($envs) * 20); fwrite(STDERR, "."); + /** * Test for SUCCESS */ diff --git a/tests/unit/Migration/MigrationTest.php b/tests/unit/Migration/MigrationTest.php new file mode 100644 index 000000000..5a85ee23a --- /dev/null +++ b/tests/unit/Migration/MigrationTest.php @@ -0,0 +1,39 @@ +method->invokeArgs($this->migration, [ + $this->method->invokeArgs($this->migration, [$document]) + ]); + } + +} diff --git a/tests/unit/Migration/MigrationV05Test.php b/tests/unit/Migration/MigrationV05Test.php new file mode 100644 index 000000000..09e28dd05 --- /dev/null +++ b/tests/unit/Migration/MigrationV05Test.php @@ -0,0 +1,86 @@ +pdo = new \PDO('sqlite::memory:'); + $this->migration = new V05($this->pdo); + $reflector = new ReflectionClass('Appwrite\Migration\Version\V05'); + $this->method = $reflector->getMethod('fixDocument'); + $this->method->setAccessible(true); + } + + public function testMigration() + { + $document = $this->fixDocument(new Document([ + '$uid' => 'unique', + '$collection' => Database::SYSTEM_COLLECTION_PROJECTS, + 'usersOauthGithubAppid' => 123, + 'usersOauthGithubSecret' => 456 + ])); + + $this->assertEquals($document->getAttribute('$uid', null), null); + $this->assertEquals($document->getAttribute('$id', null), 'unique'); + + $this->assertEquals($document->getAttribute('usersOauthGithubAppid', null), null); + $this->assertEquals($document->getAttribute('usersOauth2GithubAppid', null), 123); + + $this->assertEquals($document->getAttribute('usersOauthGithubSecret', null), null); + $this->assertEquals($document->getAttribute('usersOauth2GithubSecret', null), 456); + + $this->assertEquals($document->getAttribute('security', true), false); + + $document = $this->fixDocument(new Document([ + '$uid' => 'unique', + '$collection' => Database::SYSTEM_COLLECTION_TASKS + ])); + + $this->assertEquals($document->getAttribute('$uid', null), null); + $this->assertEquals($document->getAttribute('$id', null), 'unique'); + + $this->assertEquals($document->getAttribute('security', true), false); + + $document = $this->fixDocument(new Document([ + '$uid' => 'unique', + '$collection' => Database::SYSTEM_COLLECTION_USERS, + 'oauthGithub' => 'id', + 'oauthGithubAccessToken' => 'token', + 'confirm' => false + ])); + + $this->assertEquals($document->getAttribute('$uid', null), null); + $this->assertEquals($document->getAttribute('$id', null), 'unique'); + + $this->assertEquals($document->getAttribute('confirm', null), null); + $this->assertEquals($document->getAttribute('emailVerification', true), false); + + $this->assertEquals($document->getAttribute('oauthGithub', null), null); + $this->assertEquals($document->getAttribute('oauth2Github', null), 'id'); + + $this->assertEquals($document->getAttribute('oauthGithubAccessToken', null), null); + $this->assertEquals($document->getAttribute('oauth2GithubAccessToken', null), 'token'); + + $document = $this->fixDocument(new Document([ + '$uid' => 'unique', + '$collection' => Database::SYSTEM_COLLECTION_PLATFORMS, + 'url' => 'https://appwrite.io' + ])); + + $this->assertEquals($document->getAttribute('$uid', null), null); + $this->assertEquals($document->getAttribute('$id', null), 'unique'); + + $this->assertEquals($document->getAttribute('url', null), null); + $this->assertEquals($document->getAttribute('hostname', null), 'appwrite.io'); + } +} diff --git a/tests/unit/Migration/MigrationV06Test.php b/tests/unit/Migration/MigrationV06Test.php new file mode 100644 index 000000000..19e892c43 --- /dev/null +++ b/tests/unit/Migration/MigrationV06Test.php @@ -0,0 +1,48 @@ +pdo = new \PDO('sqlite::memory:'); + + $this->migration = new V06($this->pdo); + $reflector = new ReflectionClass('Appwrite\Migration\Version\V06'); + $this->method = $reflector->getMethod('fixDocument'); + $this->method->setAccessible(true); + } + + public function testMigration() + { + $document = $this->fixDocument(new Document([ + '$id' => uniqid(), + '$collection' => Database::SYSTEM_COLLECTION_USERS, + 'password-update' => 123 + ])); + + $this->assertEquals($document->getAttribute('password-update', null), null); + $this->assertEquals($document->getAttribute('passwordUpdate', null), 123); + + $document = $this->fixDocument( + new Document([ + '$id' => uniqid(), + '$collection' => Database::SYSTEM_COLLECTION_KEYS, + 'secret' => 123 + ]) + ); + + $encrypted = json_decode($document->getAttribute('secret', null)); + $this->assertObjectHasAttribute('data', $encrypted); + $this->assertObjectHasAttribute('method', $encrypted); + $this->assertObjectHasAttribute('iv', $encrypted); + $this->assertObjectHasAttribute('tag', $encrypted); + $this->assertObjectHasAttribute('version', $encrypted); + } +}