diff --git a/Dockerfile b/Dockerfile index e08e3dbfb..beec0d4ab 100755 --- a/Dockerfile +++ b/Dockerfile @@ -177,7 +177,8 @@ RUN chmod +x /usr/local/bin/doctor && \ chmod +x /usr/local/bin/worker-builds && \ chmod +x /usr/local/bin/worker-mails && \ chmod +x /usr/local/bin/worker-messaging && \ - chmod +x /usr/local/bin/worker-webhooks + chmod +x /usr/local/bin/worker-webhooks && \ + chmod +x /usr/local/bin/worker-migrations # Letsencrypt Permissions RUN mkdir -p /etc/letsencrypt/live/ && chmod -Rf 755 /etc/letsencrypt/live/ diff --git a/app/config/collections.php b/app/config/collections.php index 6a608fe5f..fab4361f8 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -3664,6 +3664,143 @@ $collections = [ ], ], ], + + 'migrations' => [ + '$collection' => ID::custom(Database::METADATA), + '$id' => ID::custom('migrations'), + 'name' => 'Migrations', + 'attributes' => [ + [ + '$id' => ID::custom('status'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('stage'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('source'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 8192, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('credentials'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 65536, + 'signed' => true, + 'required' => false, + 'default' => [], + 'array' => false, + 'filters' => ['json'], + ], + [ + '$id' => ID::custom('resources'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => [], + 'array' => true, + 'filters' => [], + ], + [ + '$id' => ID::custom('statusCounters'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 3000, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => ['json'], + ], + [ + '$id' => ID::custom('resourceData'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 131070, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => ['json'], + ], + [ + '$id' => ID::custom('errors'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 65535, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => true, + 'filters' => [], + ], + [ + '$id' => ID::custom('search'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ] + ], + 'indexes' => [ + [ + '$id' => '_key_status', + 'type' => Database::INDEX_KEY, + 'attributes' => ['status'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => '_key_stage', + 'type' => Database::INDEX_KEY, + 'attributes' => ['stage'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => '_key_source', + 'type' => Database::INDEX_KEY, + 'attributes' => ['source'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_fulltext_search'), + 'type' => Database::INDEX_FULLTEXT, + 'attributes' => ['search'], + 'lengths' => [], + 'orders' => [], + ] + ], + ], ]; return $collections; diff --git a/app/config/errors.php b/app/config/errors.php index 37e4c5eba..cd2f653fc 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -629,4 +629,21 @@ return [ 'description' => 'Too many queries.', 'code' => 400, ], + + /** Migrations */ + Exception::MIGRATION_NOT_FOUND => [ + 'name' => Exception::MIGRATION_NOT_FOUND, + 'description' => 'Migration with the requested ID could not be found.', + 'code' => 404, + ], + Exception::MIGRATION_ALREADY_EXISTS => [ + 'name' => Exception::MIGRATION_ALREADY_EXISTS, + 'description' => 'Migration with the requested ID already exists.', + 'code' => 409, + ], + Exception::MIGRATION_IN_PROGRESS => [ + 'name' => Exception::MIGRATION_IN_PROGRESS, + 'description' => 'Migration is already in progress.', + 'code' => 409, + ], ]; diff --git a/app/config/roles.php b/app/config/roles.php index f0039841d..928d33258 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -51,6 +51,8 @@ $admins = [ 'functions.write', 'execution.read', 'execution.write', + 'migrations.read', + 'migrations.write', ]; return [ diff --git a/app/config/scopes.php b/app/config/scopes.php index e05845491..40fce95c5 100644 --- a/app/config/scopes.php +++ b/app/config/scopes.php @@ -76,4 +76,10 @@ return [ // List of publicly visible scopes 'health.read' => [ 'description' => 'Access to read your project\'s health status', ], + 'migrations.read' => [ + 'description' => 'Access to read your project\'s migrations', + ], + 'migrations.write' => [ + 'description' => 'Access to create, update, and delete your project\'s migrations.', + ] ]; diff --git a/app/config/services.php b/app/config/services.php index 8383b44c8..e0228e6c2 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -199,4 +199,17 @@ return [ 'optional' => false, 'icon' => '', ], + 'migrations' => [ + 'key' => 'migrations', + 'name' => 'Migrations', + 'subtitle' => 'The Migrations service allows you to migrate third-party data to your Appwrite server.', + 'description' => '/docs/services/migrations.md', + 'controller' => 'api/migrations.php', + 'sdk' => true, + 'docs' => true, + 'docsUrl' => 'https://appwrite.io/docs/migrations', + 'tests' => true, + 'optional' => true, + 'icon' => '/images/services/migrations.png', + ], ]; diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php new file mode 100644 index 000000000..cf429c391 --- /dev/null +++ b/app/controllers/api/migrations.php @@ -0,0 +1,888 @@ +groups(['api', 'migrations']) + ->desc('Migrate Appwrite Data') + ->label('scope', 'migrations.write') + ->label('event', 'migrations.create') + ->label('audits.event', 'migration.create') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'migrations') + ->label('sdk.method', 'createAppwriteMigration') + ->label('sdk.description', '/docs/references/migrations/migration-appwrite.md') + ->label('sdk.response.code', Response::STATUS_CODE_ACCEPTED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MIGRATION) + ->param('resources', [], new ArrayList(new WhiteList(Appwrite::getSupportedResources())), 'List of resources to migrate') + ->param('endpoint', '', new URL(), "Source's Appwrite Endpoint") + ->param('projectId', '', new UID(), "Source's Project ID") + ->param('apiKey', '', new Text(512), "Source's API Key") + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('user') + ->inject('events') + ->action(function (array $resources, string $endpoint, string $projectId, string $apiKey, Response $response, Database $dbForProject, Document $project, Document $user, Event $events) { + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => Appwrite::getName(), + 'credentials' => [ + 'endpoint' => $endpoint, + 'projectId' => $projectId, + 'apiKey' => $apiKey, + ], + 'resources' => $resources, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + ])); + + $events->setParam('migrationId', $migration->getId()); + + // Trigger Transfer + $event = new Migration(); + $event + ->setMigration($migration) + ->setProject($project) + ->setUser($user) + ->trigger(); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + }); + +App::post('/v1/migrations/firebase') + ->groups(['api', 'migrations']) + ->desc('Migrate Firebase Data (Service Account)') + ->label('scope', 'migrations.write') + ->label('event', 'migrations.create') + ->label('audits.event', 'migration.create') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'migrations') + ->label('sdk.method', 'createFirebaseMigration') + ->label('sdk.description', '/docs/references/migrations/migration-firebase.md') + ->label('sdk.response.code', Response::STATUS_CODE_ACCEPTED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MIGRATION) + ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate') + ->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials') + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('user') + ->inject('events') + ->action(function (array $resources, string $serviceAccount, Response $response, Database $dbForProject, Document $project, Document $user, Event $events) { + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => Firebase::getName(), + 'credentials' => [ + 'serviceAccount' => $serviceAccount, + ], + 'resources' => $resources, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + ])); + + $events->setParam('migrationId', $migration->getId()); + + // Trigger Transfer + $event = new Migration(); + $event + ->setMigration($migration) + ->setProject($project) + ->setUser($user) + ->trigger(); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + }); + +App::post('/v1/migrations/firebase/oauth') + ->groups(['api', 'migrations']) + ->desc('Migrate Firebase Data (OAuth)') + ->label('scope', 'migrations.write') + ->label('event', 'migrations.create') + ->label('audits.event', 'migration.create') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'migrations') + ->label('sdk.method', 'createFirebaseOAuthMigration') + ->label('sdk.description', '/docs/references/migrations/migration-firebase.md') + ->label('sdk.response.code', Response::STATUS_CODE_ACCEPTED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MIGRATION) + ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate') + ->param('projectId', '', new Text(65536), 'Project ID of the Firebase Project') + ->inject('response') + ->inject('dbForProject') + ->inject('dbForConsole') + ->inject('project') + ->inject('user') + ->inject('events') + ->inject('request') + ->action(function (array $resources, string $projectId, Response $response, Database $dbForProject, Database $dbForConsole, Document $project, Document $user, Event $events, Request $request) { + $firebase = new OAuth2Firebase( + App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_ID', ''), + App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_SECRET', ''), + $request->getProtocol() . '://' . $request->getHostname() . '/v1/migrations/firebase/redirect' + ); + + $accessToken = $user->getAttribute('migrationsFirebaseAccessToken'); + $refreshToken = $user->getAttribute('migrationsFirebaseRefreshToken'); + $accessTokenExpiry = $user->getAttribute('migrationsFirebaseAccessTokenExpiry'); + + $isExpired = new \DateTime($accessTokenExpiry) < new \DateTime('now'); + if ($isExpired) { + $firebase->refreshTokens($refreshToken); + + $accessToken = $firebase->getAccessToken(''); + $refreshToken = $firebase->getRefreshToken(''); + + $verificationId = $firebase->getUserID($accessToken); + + if (empty($verificationId)) { + throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Another request is currently refreshing OAuth token. Please try again.'); + } + + $user = $user + ->setAttribute('migrationsFirebaseAccessToken', $accessToken) + ->setAttribute('migrationsFirebaseRefreshToken', $refreshToken) + ->setAttribute('migrationsFirebaseAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int) $firebase->getAccessTokenExpiry(''))); + + $dbForConsole->updateDocument('users', $user->getId(), $user); + } + + $serviceAccount = $firebase->createServiceAccount($accessToken, $projectId); + + // $migration = $dbForProject->createDocument('migrations', new Document([ + // '$id' => ID::unique(), + // 'status' => 'pending', + // 'stage' => 'init', + // 'source' => Firebase::getName(), + // 'credentials' => [ + // 'serviceAccount' => $serviceAccount, + // ], + // 'resources' => $resources, + // 'statusCounters' => '{}', + // 'resourceData' => '{}', + // 'errors' => [] + // ])); + + // $events->setParam('migrationId', $migration->getId()); + + // // Trigger Transfer + // $event = new Migration(); + // $event + // ->setMigration($migration) + // ->setProject($project) + // ->setUser($user) + // ->trigger(); + + // $response + // ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + // ->dynamic($migration, Response::MODEL_MIGRATION); + }); + +App::post('/v1/migrations/supabase') + ->groups(['api', 'migrations']) + ->desc('Migrate Supabase Data') + ->label('scope', 'migrations.write') + ->label('event', 'migrations.create') + ->label('audits.event', 'migration.create') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'migrations') + ->label('sdk.method', 'createSupabaseMigration') + ->label('sdk.description', '/docs/references/migrations/migration-supabase.md') + ->label('sdk.response.code', Response::STATUS_CODE_ACCEPTED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MIGRATION) + ->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate') + ->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint') + ->param('apiKey', '', new Text(512), 'Source\'s API Key') + ->param('databaseHost', '', new Text(512), 'Source\'s Database Host') + ->param('username', '', new Text(512), 'Source\'s Database Username') + ->param('password', '', new Text(512), 'Source\'s Database Password') + ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true) + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('user') + ->inject('events') + ->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, Document $user, Event $events) { + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => Supabase::getName(), + 'credentials' => [ + 'endpoint' => $endpoint, + 'apiKey' => $apiKey, + 'databaseHost' => $databaseHost, + 'username' => $username, + 'password' => $password, + 'port' => $port, + ], + 'resources' => $resources, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + ])); + + $events->setParam('migrationId', $migration->getId()); + + // Trigger Transfer + $event = new Migration(); + $event + ->setMigration($migration) + ->setProject($project) + ->setUser($user) + ->trigger(); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + }); + +App::post('/v1/migrations/nhost') + ->groups(['api', 'migrations']) + ->desc('Migrate NHost Data') + ->label('scope', 'migrations.write') + ->label('event', 'migrations.create') + ->label('audits.event', 'migration.create') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'migrations') + ->label('sdk.method', 'createNHostMigration') + ->label('sdk.description', '/docs/references/migrations/migration-nhost.md') + ->label('sdk.response.code', Response::STATUS_CODE_ACCEPTED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MIGRATION) + ->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate') + ->param('subdomain', '', new URL(), 'Source\'s Subdomain') + ->param('region', '', new Text(512), 'Source\'s Region') + ->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret') + ->param('database', '', new Text(512), 'Source\'s Database Name') + ->param('username', '', new Text(512), 'Source\'s Database Username') + ->param('password', '', new Text(512), 'Source\'s Database Password') + ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true) + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('user') + ->inject('events') + ->action(function (array $resources, string $subdomain, string $region, string $adminSecret, string $database, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, Document $user, Event $events) { + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => NHost::getName(), + 'credentials' => [ + 'subdomain' => $subdomain, + 'region' => $region, + 'adminSecret' => $adminSecret, + 'database' => $database, + 'username' => $username, + 'password' => $password, + 'port' => $port, + ], + 'resources' => $resources, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + ])); + + $events->setParam('migrationId', $migration->getId()); + + // Trigger Transfer + $event = new Migration(); + $event + ->setMigration($migration) + ->setProject($project) + ->setUser($user) + ->trigger(); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + }); + +App::get('/v1/migrations') + ->groups(['api', 'migrations']) + ->desc('List Migrations') + ->label('scope', 'migrations.read') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'migrations') + ->label('sdk.method', 'list') + ->label('sdk.description', '/docs/references/migrations/list-migrations.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MIGRATION_LIST) + ->param('queries', [], new Migrations(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Migrations::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->inject('response') + ->inject('dbForProject') + ->action(function (array $queries, string $search, Response $response, Database $dbForProject) { + $queries = Query::parseQueries($queries); + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + // Get cursor document if there was a cursor query + $cursor = Query::getByType($queries, [Query::TYPE_CURSORAFTER, Query::TYPE_CURSORBEFORE]); + $cursor = reset($cursor); + if ($cursor) { + /** @var Query $cursor */ + $migrationId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('migrations', $migrationId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Migration '{$migrationId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + $response->dynamic(new Document([ + 'migrations' => $dbForProject->find('migrations', $queries), + 'total' => $dbForProject->count('migrations', $filterQueries, APP_LIMIT_COUNT), + ]), Response::MODEL_MIGRATION_LIST); + }); + +App::get('/v1/migrations/:migrationId') + ->groups(['api', 'migrations']) + ->desc('Get Migration') + ->label('scope', 'migrations.read') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'migrations') + ->label('sdk.method', 'get') + ->label('sdk.description', '/docs/references/migrations/get-migration.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MIGRATION) + ->param('migrationId', '', new UID(), 'Migration unique ID.') + ->inject('response') + ->inject('dbForProject') + ->action(function (string $migrationId, Response $response, Database $dbForProject) { + $migration = $dbForProject->getDocument('migrations', $migrationId); + + if ($migration->isEmpty()) { + throw new Exception(Exception::MIGRATION_NOT_FOUND); + } + + $response->dynamic($migration, Response::MODEL_MIGRATION); + }); + +App::get('/v1/migrations/appwrite/report') + ->groups(['api', 'migrations']) + ->desc('Generate a report on Appwrite Data') + ->label('scope', 'migrations.write') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'migrations') + ->label('sdk.method', 'getAppwriteReport') + ->label('sdk.description', '/docs/references/migrations/migration-appwrite-report.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MIGRATION_REPORT) + ->param('resources', [], new ArrayList(new WhiteList(Appwrite::getSupportedResources())), 'List of resources to migrate') + ->param('endpoint', '', new URL(), "Source's Appwrite Endpoint") + ->param('projectID', '', new Text(512), "Source's Project ID") + ->param('key', '', new Text(512), "Source's API Key") + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('user') + ->action(function (array $resources, string $endpoint, string $projectID, string $key, Response $response) { + try { + $appwrite = new Appwrite($projectID, $endpoint, $key); + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($appwrite->report($resources)), Response::MODEL_MIGRATION_REPORT); + } catch (\Exception $e) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage()); + } + }); + +App::get('/v1/migrations/firebase/report') + ->groups(['api', 'migrations']) + ->desc('Generate a report on Firebase Data') + ->label('scope', 'migrations.write') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'migrations') + ->label('sdk.method', 'getFirebaseReport') + ->label('sdk.description', '/docs/references/migrations/migration-firebase-report.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MIGRATION_REPORT) + ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate') + ->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials') + ->inject('response') + ->action(function (array $resources, string $serviceAccount, Response $response) { + try { + $firebase = new Firebase(json_decode($serviceAccount, true)); + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($firebase->report($resources)), Response::MODEL_MIGRATION_REPORT); + } catch (\Exception $e) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage()); + } + }); + +App::get('/v1/migrations/firebase/report/oauth') + ->groups(['api', 'migrations']) + ->desc('Generate a report on Firebase Data using OAuth') + ->label('scope', 'migrations.write') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'migrations') + ->label('sdk.method', 'getFirebaseReport') + ->label('sdk.description', '/docs/references/migrations/migration-firebase-report.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MIGRATION_REPORT) + ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate') + ->param('projectId', '', new Text(65536), 'Project ID') + ->inject('response') + ->inject('request') + ->inject('user') + ->inject('dbForConsole') + ->action(function (array $resources, string $projectId, Response $response, Request $request, Document $user, Database $dbForConsole) { + try { + $firebase = new OAuth2Firebase( + App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_ID', ''), + App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_SECRET', ''), + $request->getProtocol() . '://' . $request->getHostname() . '/v1/migrations/firebase/redirect' + ); + + $accessToken = $user->getAttribute('migrationsFirebaseAccessToken'); + $refreshToken = $user->getAttribute('migrationsFirebaseRefreshToken'); + $accessTokenExpiry = $user->getAttribute('migrationsFirebaseAccessTokenExpiry'); + + $isExpired = new \DateTime($accessTokenExpiry) < new \DateTime('now'); + if ($isExpired) { + $firebase->refreshTokens($refreshToken); + + $accessToken = $firebase->getAccessToken(''); + $refreshToken = $firebase->getRefreshToken(''); + + $verificationId = $firebase->getUserID($accessToken); + + if (empty($verificationId)) { + throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Another request is currently refreshing OAuth token. Please try again.'); + } + + $user = $user + ->setAttribute('migrationsFirebaseAccessToken', $accessToken) + ->setAttribute('migrationsFirebaseRefreshToken', $refreshToken) + ->setAttribute('migrationsFirebaseAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int) $firebase->getAccessTokenExpiry(''))); + + $dbForConsole->updateDocument('users', $user->getId(), $user); + } + + $serviceAccount = $firebase->createServiceAccount($accessToken, $projectId); + + $firebase = new Firebase(json_decode($serviceAccount, true)); + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($firebase->report($resources)), Response::MODEL_MIGRATION_REPORT); + } catch (\Exception $e) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage()); + } + }); + +App::get('/v1/migrations/firebase/connect') + ->desc('Authorize with firebase') + ->groups(['api', 'migrations']) + ->label('scope', 'public') + ->label('origin', '*') + ->label('sdk.auth', []) + ->label('sdk.namespace', 'migrations') + ->label('sdk.method', 'createFirebaseAuth') + ->label('sdk.description', '') + ->label('sdk.response.code', Response::STATUS_CODE_MOVED_PERMANENTLY) + ->label('sdk.response.type', Response::CONTENT_TYPE_HTML) + ->label('sdk.methodType', 'webAuth') + ->param('redirect', '', fn ($clients) => new Host($clients), 'URL to redirect back to your Firebase authorization. Only console hostnames are allowed.', true, ['clients']) + ->param('projectId', '', new UID(), 'Project ID') + ->inject('response') + ->inject('request') + ->inject('user') + ->inject('dbForConsole') + ->action(function (string $redirect, string $projectId, Response $response, Request $request, Document $user, Database $dbForConsole) { + $state = \json_encode([ + 'projectId' => $projectId, + 'redirect' => $redirect, + ]); + + $prefs = $user->getAttribute('prefs', []); + $prefs['migrationState'] = $state; + $user->setAttribute('prefs', $prefs); + $dbForConsole->updateDocument('users', $user->getId(), $user); + + $oauth2 = new OAuth2Firebase( + App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_ID', ''), + App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_SECRET', ''), + $request->getProtocol() . '://' . $request->getHostname() . '/v1/migrations/firebase/redirect' + ); + $url = $oauth2->getLoginURL(); + + $response + ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->addHeader('Pragma', 'no-cache') + ->redirect($url); + }); + +App::get('/v1/migrations/firebase/redirect') + ->desc('Capture and receive data on Firebase authorization') + ->groups(['api', 'migrations']) + ->label('scope', 'public') + ->label('error', __DIR__ . '/../../views/general/error.phtml') + ->param('code', '', new Text(2048), 'OAuth2 code.', true) + ->inject('user') + ->inject('project') + ->inject('request') + ->inject('response') + ->inject('dbForConsole') + ->action(function (string $code, Document $user, Document $project, Request $request, Response $response, Database $dbForConsole) { + $state = $user['prefs']['migrationState'] ?? '{}'; + $prefs['migrationState'] = ''; + $user->setAttribute('prefs', $prefs); + $dbForConsole->updateDocument('users', $user->getId(), $user); + + if (empty($state)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Installation requests from organisation members for the Appwrite Google App are currently unsupported.'); + } + + $state = \json_decode($state, true); + $redirect = $state['redirect'] ?? ''; + $projectId = $state['projectId'] ?? ''; + + $project = $dbForConsole->getDocument('projects', $projectId); + + if (empty($redirect)) { + $redirect = $request->getProtocol() . '://' . $request->getHostname() . '/console/project-$projectId/settings/migrations'; + } + + if ($project->isEmpty()) { + $response + ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->addHeader('Pragma', 'no-cache') + ->redirect($redirect); + + return; + } + + // OAuth Authroization + if (!empty($code)) { + $oauth2 = new OAuth2Firebase( + App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_ID', ''), + App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_SECRET', ''), + $request->getProtocol() . '://' . $request->getHostname() . '/v1/migrations/firebase/redirect' + ); + + $accessToken = $oauth2->getAccessToken($code); + $refreshToken = $oauth2->getRefreshToken($code); + $accessTokenExpiry = $oauth2->getAccessTokenExpiry($code); + + if (empty($accessToken)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to get access token.'); + } + + if (empty($refreshToken)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to get refresh token.'); + } + + if (empty($accessTokenExpiry)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to get access token expiry.'); + } + + $user = $user + ->setAttribute('migrationsFirebaseAccessToken', $accessToken) + ->setAttribute('migrationsFirebaseRefreshToken', $refreshToken) + ->setAttribute('migrationsFirebaseAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int) $accessTokenExpiry)); + + $dbForConsole->updateDocument('users', $user->getId(), $user); + } else { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Missing OAuth2 code.'); + } + + $response + ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->addHeader('Pragma', 'no-cache') + ->redirect($redirect); + }); + +App::get('/v1/migrations/firebase/projects') + ->desc('List Firebase Projects') + ->groups(['api', 'migrations']) + ->label('scope', 'public') + ->label('sdk.namespace', 'migrations') + ->label('sdk.method', 'listFirebaseProjects') + ->label('sdk.description', '') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MIGRATION_FIREBASE_PROJECT_LIST) + ->inject('user') + ->inject('response') + ->inject('project') + ->inject('dbForConsole') + ->inject('request') + ->action(function (Document $user, Response $response, Document $project, Database $dbForConsole, Request $request) { + if (empty($user->getAttribute('migrationsFirebaseAccessToken')) || empty($user->getAttribute('migrationsFirebaseRefreshToken')) || empty($user->getAttribute('migrationsFirebaseAccessTokenExpiry'))) { + throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN, 'Not authenticated with Firebase'); + } + + if (App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_ID', '') === '' || App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_SECRET', '') === '') { + throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN, 'Missing Google OAuth credentials'); + } + + $firebase = new OAuth2Firebase( + App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_ID', ''), + App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_SECRET', ''), + $request->getProtocol() . '://' . $request->getHostname() . '/v1/migrations/firebase/redirect' + ); + + $accessToken = $user->getAttribute('migrationsFirebaseAccessToken'); + $refreshToken = $user->getAttribute('migrationsFirebaseRefreshToken'); + $accessTokenExpiry = $user->getAttribute('migrationsFirebaseAccessTokenExpiry'); + + $isExpired = new \DateTime($accessTokenExpiry) < new \DateTime('now'); + if ($isExpired) { + $firebase->refreshTokens($refreshToken); + + $accessToken = $firebase->getAccessToken(''); + $refreshToken = $firebase->getRefreshToken(''); + + $verificationId = $firebase->getUserID($accessToken); + + if (empty($verificationId)) { + throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Another request is currently refreshing OAuth token. Please try again.'); + } + + $user = $user + ->setAttribute('migrationsFirebaseAccessToken', $accessToken) + ->setAttribute('migrationsFirebaseRefreshToken', $refreshToken) + ->setAttribute('migrationsFirebaseAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int) $firebase->getAccessTokenExpiry(''))); + + $dbForConsole->updateDocument('users', $user->getId(), $user); + } + + $projects = $firebase->getProjects($accessToken); + + $output = []; + foreach ($projects as $project) { + $output[] = [ + 'displayName' => $project['displayName'], + 'projectId' => $project['projectId'], + ]; + } + + $response->dynamic(new Document([ + 'projects' => $output, + 'total' => count($output), + ]), Response::MODEL_MIGRATION_FIREBASE_PROJECT_LIST); + }); + +App::get('/v1/migrations/firebase/deauthorize') + ->desc('Revoke Appwrite\'s authorization to access Firebase Projects') + ->groups(['api', 'migrations']) + ->label('scope', 'public') + ->label('sdk.namespace', 'migrations') + ->label('sdk.method', 'deleteFirebaseAuth') + ->label('sdk.description', '') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->inject('user') + ->inject('response') + ->inject('dbForConsole') + ->action(function (Document $user, Response $response, Database $dbForConsole) { + $user = $user + ->setAttribute('migrationsFirebaseAccessToken', '') + ->setAttribute('migrationsFirebaseRefreshToken', '') + ->setAttribute('migrationsFirebaseAccessTokenExpiry', ''); + + $dbForConsole->updateDocument('users', $user->getId(), $user); + + $response->noContent(); + }); + +App::get('/v1/migrations/supabase/report') + ->groups(['api', 'migrations']) + ->desc('Generate a report on Supabase Data') + ->label('scope', 'migrations.write') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'migrations') + ->label('sdk.method', 'getSupabaseReport') + ->label('sdk.description', '/docs/references/migrations/migration-supabase-report.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MIGRATION_REPORT) + ->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate') + ->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint') + ->param('apiKey', '', new Text(512), 'Source\'s API Key') + ->param('databaseHost', '', new Text(512), 'Source\'s Database Host') + ->param('username', '', new Text(512), 'Source\'s Database Username') + ->param('password', '', new Text(512), 'Source\'s Database Password') + ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true) + ->inject('response') + ->inject('dbForProject') + ->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response) { + try { + $supabase = new Supabase($endpoint, $apiKey, $databaseHost, 'postgres', $username, $password, $port); + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($supabase->report($resources)), Response::MODEL_MIGRATION_REPORT); + } catch (\Exception $e) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage()); + } + }); + +App::get('/v1/migrations/nhost/report') + ->groups(['api', 'migrations']) + ->desc('Generate a report on NHost Data') + ->label('scope', 'migrations.write') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'migrations') + ->label('sdk.method', 'getNHostReport') + ->label('sdk.description', '/docs/references/migrations/migration-nhost-report.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MIGRATION_REPORT) + ->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate') + ->param('subdomain', '', new URL(), 'Source\'s Subdomain') + ->param('region', '', new Text(512), 'Source\'s Region') + ->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret') + ->param('database', '', new Text(512), 'Source\'s Database Name') + ->param('username', '', new Text(512), 'Source\'s Database Username') + ->param('password', '', new Text(512), 'Source\'s Database Password') + ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true) + ->inject('response') + ->action(function (array $resources, string $subdomain, string $region, string $adminSecret, string $database, string $username, string $password, int $port, Response $response) { + try { + $nhost = new NHost($subdomain, $region, $adminSecret, $database, $username, $password, $port); + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($nhost->report($resources)), Response::MODEL_MIGRATION_REPORT); + } catch (\Exception $e) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage()); + } + }); + +App::patch('/v1/migrations/:migrationId') + ->groups(['api', 'migrations']) + ->desc('Retry Migration') + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].retry') + ->label('audits.event', 'migration.retry') + ->label('audits.resource', 'migrations/{request.migrationId}') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'migrations') + ->label('sdk.method', 'retry') + ->label('sdk.description', '/docs/references/migrations/retry-migration.md') + ->label('sdk.response.code', Response::STATUS_CODE_ACCEPTED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MIGRATION) + ->param('migrationId', '', new UID(), 'Migration unique ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('user') + ->inject('events') + ->action(function (string $migrationId, Response $response, Database $dbForProject, Document $project, Document $user, Event $eventInstance) { + $migration = $dbForProject->getDocument('migrations', $migrationId); + + if ($migration->isEmpty()) { + throw new Exception(Exception::MIGRATION_NOT_FOUND); + } + + if ($migration->getAttribute('status') !== 'failed') { + throw new Exception(Exception::MIGRATION_IN_PROGRESS, 'Migration not failed yet'); + } + + $migration + ->setAttribute('status', 'pending') + ->setAttribute('dateUpdated', \time()); + + // Trigger Migration + $event = new Migration(); + $event + ->setMigration($migration) + ->setProject($project) + ->setUser($user) + ->trigger(); + + $response->noContent(); + }); + +App::delete('/v1/migrations/:migrationId') + ->groups(['api', 'migrations']) + ->desc('Delete Migration') + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].delete') + ->label('audits.event', 'migrationId.delete') + ->label('audits.resource', 'migrations/{request.migrationId}') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'migrations') + ->label('sdk.method', 'delete') + ->label('sdk.description', '/docs/references/functions/delete-migration.md') + ->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT) + ->label('sdk.response.model', Response::MODEL_NONE) + ->param('migrationId', '', new UID(), 'Migration ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('deletes') + ->inject('events') + ->action(function (string $migrationId, Response $response, Database $dbForProject, Delete $deletes, Event $events) { + $migration = $dbForProject->getDocument('migrations', $migrationId); + + if ($migration->isEmpty()) { + throw new Exception(Exception::MIGRATION_NOT_FOUND); + } + + if (!$dbForProject->deleteDocument('migrations', $migration->getId())) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove migration from DB', 500); + } + + $events->setParam('migrationId', $migration->getId()); + + $response->noContent(); + }); diff --git a/app/workers/migrations.php b/app/workers/migrations.php new file mode 100644 index 000000000..025de9a0f --- /dev/null +++ b/app/workers/migrations.php @@ -0,0 +1,315 @@ +args['type'] ?? ''; + $events = $this->args['events'] ?? []; + $project = new Document($this->args['project'] ?? []); + $user = new Document($this->args['user'] ?? []); + $payload = json_encode($this->args['payload'] ?? []); + + if ($project->getId() === 'console') { + return; + } + + /** + * Handle Event execution. + */ + if (! empty($events)) { + return; + } + + $this->dbForProject = $this->getProjectDB($this->args['project']['$id']); + + $this->processMigration(); + } + + /** + * Process Source + * + * @return Source + * + * @throws \Exception + */ + protected function processSource(string $source, array $credentials): Source + { + switch ($source) { + case Firebase::getName(): + return new Firebase( + json_decode($credentials['serviceAccount'], true), + ); + break; + case Supabase::getName(): + return new Supabase( + $credentials['endpoint'], + $credentials['apiKey'], + $credentials['databaseHost'], + 'postgres', + $credentials['username'], + $credentials['password'], + $credentials['port'], + ); + break; + case NHost::getName(): + return new NHost( + $credentials['subdomain'], + $credentials['region'], + $credentials['adminSecret'], + $credentials['database'], + $credentials['username'], + $credentials['password'], + $credentials['port'], + ); + break; + case Appwrite::getName(): + return new Appwrite($credentials['projectId'], str_starts_with($credentials['endpoint'], 'http://localhost/v1') ? 'http://appwrite/v1' : $credentials['endpoint'], $credentials['apiKey']); + break; + default: + throw new \Exception('Invalid source type'); + break; + } + } + + protected function updateMigrationDocument(Document $migration, Document $project): Document + { + /** Trigger Realtime */ + $allEvents = Event::generateEvents('migrations.[migrationId].update', [ + 'migrationId' => $migration->getId(), + ]); + + $target = Realtime::fromPayload( + event: $allEvents[0], + payload: $migration, + project: $project + ); + + Realtime::send( + projectId: 'console', + payload: $migration->getArrayCopy(), + events: $allEvents, + channels: $target['channels'], + roles: $target['roles'], + ); + + Realtime::send( + projectId: $project->getId(), + payload: $migration->getArrayCopy(), + events: $allEvents, + channels: $target['channels'], + roles: $target['roles'], + ); + + return $this->dbForProject->updateDocument('migrations', $migration->getId(), $migration); + } + + protected function removeAPIKey(Document $apiKey) + { + $consoleDB = $this->getConsoleDB(); + + $consoleDB->deleteDocument('keys', $apiKey->getId()); + } + + protected function generateAPIKey(Document $project): Document + { + $consoleDB = $this->getConsoleDB(); + $generatedSecret = bin2hex(\random_bytes(128)); + + $key = new Document([ + '$id' => ID::unique(), + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'projectInternalId' => $project->getInternalId(), + 'projectId' => $project->getId(), + 'name' => 'Transfer API Key', + 'scopes' => [ + 'users.read', + 'users.write', + 'teams.read', + 'teams.write', + 'databases.read', + 'databases.write', + 'collections.read', + 'collections.write', + 'documents.read', + 'documents.write', + 'buckets.read', + 'buckets.write', + 'files.read', + 'files.write', + 'functions.read', + 'functions.write', + ], + 'expire' => null, + 'sdks' => [], + 'accessedAt' => null, + 'secret' => $generatedSecret, + ]); + + $consoleDB->createDocument('keys', $key); + $consoleDB->deleteCachedDocument('projects', $project->getId()); + + return $key; + } + + /** + * Process Migration + * + * @return void + */ + protected function processMigration(): void + { + /** + * @var Document $migrationDocument + * @var Transfer $transfer + */ + $migrationDocument = null; + $transfer = null; + $projectDocument = $this->getConsoleDB()->getDocument('projects', $this->args['project']['$id']); + $tempAPIKey = $this->generateAPIKey($projectDocument); + + try { + $migrationDocument = $this->dbForProject->getDocument('migrations', $this->args['migration']['$id']); + $migrationDocument->setAttribute('stage', 'processing'); + $migrationDocument->setAttribute('status', 'processing'); + $this->updateMigrationDocument($migrationDocument, $projectDocument); + + $source = $this->processSource($migrationDocument->getAttribute('source'), $migrationDocument->getAttribute('credentials')); + + $source->report(); + + $destination = new DestinationsAppwrite( + $projectDocument->getId(), + 'http://appwrite/v1', + $tempAPIKey['secret'], + ); + + $transfer = new Transfer( + $source, + $destination + ); + + $migrationDocument->setAttribute('stage', 'source-check'); + $this->updateMigrationDocument($migrationDocument, $projectDocument); + + $migrationDocument->setAttribute('stage', 'destination-check'); + $this->updateMigrationDocument($migrationDocument, $projectDocument); + + /** Start Transfer */ + $migrationDocument->setAttribute('stage', 'migrating'); + $this->updateMigrationDocument($migrationDocument, $projectDocument); + $transfer->run($migrationDocument->getAttribute('resources'), function () use ($migrationDocument, $transfer, $projectDocument) { + $migrationDocument->setAttribute('resourceData', json_encode($transfer->getCache())); + $migrationDocument->setAttribute('statusCounters', json_encode($transfer->getStatusCounters())); + + $this->updateMigrationDocument($migrationDocument, $projectDocument); + }); + + $errors = $transfer->getReport(Resource::STATUS_ERROR); + + if (count($errors) > 0) { + $migrationDocument->setAttribute('status', 'failed'); + $migrationDocument->setAttribute('stage', 'finished'); + + $errorMessages = []; + foreach ($errors as $error) { + $errorMessages[] = "Failed to transfer resource '{$error['id']}:{$error['resource']}' with message '{$error['message']}'"; + } + + $migrationDocument->setAttribute('errors', $errorMessages); + $this->updateMigrationDocument($migrationDocument, $projectDocument); + + return; + } + + $migrationDocument->setAttribute('status', 'completed'); + $migrationDocument->setAttribute('stage', 'finished'); + } catch (\Throwable $th) { + Console::error($th->getMessage()); + + if ($migrationDocument) { + Console::error($th->getMessage()); + Console::error($th->getTraceAsString()); + $migrationDocument->setAttribute('status', 'failed'); + $migrationDocument->setAttribute('stage', 'finished'); + $migrationDocument->setAttribute('errors', [$th->getMessage()]); + + return; + } + + if ($transfer) { + $errors = $transfer->getReport(Resource::STATUS_ERROR); + + if (count($errors) > 0) { + $migrationDocument->setAttribute('status', 'failed'); + $migrationDocument->setAttribute('stage', 'finished'); + $migrationDocument->setAttribute('errors', $errors); + } + } + } finally { + if ($migrationDocument) { + $this->updateMigrationDocument($migrationDocument, $projectDocument); + } + if ($tempAPIKey) { + $this->removeAPIKey($tempAPIKey); + } + } + } + + /** + * Process Verification + * + * @return void + */ + protected function processVerification(): void + { + } + + public function shutdown(): void + { + } +} diff --git a/bin/worker-migrations b/bin/worker-migrations new file mode 100644 index 000000000..54e57001b --- /dev/null +++ b/bin/worker-migrations @@ -0,0 +1,10 @@ +#!/bin/sh + +if [ -z "$_APP_REDIS_USER" ] && [ -z "$_APP_REDIS_PASS" ] +then + REDIS_BACKEND="${_APP_REDIS_HOST}:${_APP_REDIS_PORT}" +else + REDIS_BACKEND="redis://${_APP_REDIS_USER}:${_APP_REDIS_PASS}@${_APP_REDIS_HOST}:${_APP_REDIS_PORT}" +fi + +INTERVAL=0.1 QUEUE='v1-migrations' APP_INCLUDE='/usr/src/code/app/workers/migrations.php' php /usr/src/code/vendor/bin/resque -dopcache.preload=opcache.preload=/usr/src/code/app/preload.php \ No newline at end of file diff --git a/composer.json b/composer.json index 511eeff05..9e2c57e37 100644 --- a/composer.json +++ b/composer.json @@ -62,6 +62,7 @@ "utopia-php/storage": "0.14.*", "utopia-php/swoole": "0.8.*", "utopia-php/websocket": "0.1.*", + "utopia-php/transfer": "dev-feat-improve-features", "resque/php-resque": "1.3.6", "matomo/device-detector": "6.1.*", "dragonmantank/cron-expression": "3.3.2", @@ -76,6 +77,10 @@ { "url": "https://github.com/appwrite/runtimes.git", "type": "git" + }, + { + "url": "https://github.com/utopia-php/transfer.git", + "type": "vcs" } ], "require-dev": { diff --git a/composer.lock b/composer.lock index 7bd2b3a78..f2b92bdf5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "acb2fc370254dfd67f7d44aa1fa0e694", + "content-hash": "ad6ca93acb312945f1f0edc58c8002ec", "packages": [ { "name": "adhocore/jwt", @@ -63,6 +63,47 @@ ], "time": "2021-02-20T09:56:44+00:00" }, + { + "name": "appwrite/appwrite", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/appwrite/sdk-for-php.git", + "reference": "2b9e966edf35c4061179ed98ea364698ab30de8b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/2b9e966edf35c4061179ed98ea364698ab30de8b", + "reference": "2b9e966edf35c4061179ed98ea364698ab30de8b", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "3.7.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "Appwrite\\": "src/Appwrite" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Appwrite is an open-source self-hosted backend server that abstract and simplify complex and repetitive development tasks behind a very simple REST API", + "support": { + "email": "team@appwrite.io", + "issues": "https://github.com/appwrite/sdk-for-php/issues", + "source": "https://github.com/appwrite/sdk-for-php/tree/8.0.0", + "url": "https://appwrite.io/support" + }, + "time": "2023-04-12T10:16:28+00:00" + }, { "name": "appwrite/php-clamav", "version": "2.0.0", @@ -607,16 +648,16 @@ }, { "name": "guzzlehttp/promises", - "version": "2.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "3a494dc7dc1d7d12e511890177ae2d0e6c107da6" + "reference": "111166291a0f8130081195ac4556a5587d7f1b5d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/3a494dc7dc1d7d12e511890177ae2d0e6c107da6", - "reference": "3a494dc7dc1d7d12e511890177ae2d0e6c107da6", + "url": "https://api.github.com/repos/guzzle/promises/zipball/111166291a0f8130081195ac4556a5587d7f1b5d", + "reference": "111166291a0f8130081195ac4556a5587d7f1b5d", "shasum": "" }, "require": { @@ -670,7 +711,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.0.0" + "source": "https://github.com/guzzle/promises/tree/2.0.1" }, "funding": [ { @@ -686,20 +727,20 @@ "type": "tidelift" } ], - "time": "2023-05-21T13:50:22+00:00" + "time": "2023-08-03T15:11:55+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.5.0", + "version": "2.6.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "b635f279edd83fc275f822a1188157ffea568ff6" + "reference": "8bd7c33a0734ae1c5d074360512beb716bef3f77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/b635f279edd83fc275f822a1188157ffea568ff6", - "reference": "b635f279edd83fc275f822a1188157ffea568ff6", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/8bd7c33a0734ae1c5d074360512beb716bef3f77", + "reference": "8bd7c33a0734ae1c5d074360512beb716bef3f77", "shasum": "" }, "require": { @@ -786,7 +827,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.5.0" + "source": "https://github.com/guzzle/psr7/tree/2.6.0" }, "funding": [ { @@ -802,7 +843,7 @@ "type": "tidelift" } ], - "time": "2023-04-17T16:11:26+00:00" + "time": "2023-08-03T15:06:02+00:00" }, { "name": "influxdb/influxdb-php", @@ -994,16 +1035,16 @@ }, { "name": "matomo/device-detector", - "version": "6.1.3", + "version": "6.1.4", "source": { "type": "git", "url": "https://github.com/matomo-org/device-detector.git", - "reference": "3e0fac7e77f3faadc3858fea9f5fa7efeb9cf239" + "reference": "74f6c4f6732b3ad6cdf25560746841d522969112" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/matomo-org/device-detector/zipball/3e0fac7e77f3faadc3858fea9f5fa7efeb9cf239", - "reference": "3e0fac7e77f3faadc3858fea9f5fa7efeb9cf239", + "url": "https://api.github.com/repos/matomo-org/device-detector/zipball/74f6c4f6732b3ad6cdf25560746841d522969112", + "reference": "74f6c4f6732b3ad6cdf25560746841d522969112", "shasum": "" }, "require": { @@ -1059,7 +1100,7 @@ "source": "https://github.com/matomo-org/matomo", "wiki": "https://dev.matomo.org/" }, - "time": "2023-06-06T11:58:07+00:00" + "time": "2023-08-02T08:48:53+00:00" }, { "name": "mongodb/mongodb", @@ -2846,6 +2887,76 @@ }, "time": "2022-11-07T13:51:59+00:00" }, + { + "name": "utopia-php/transfer", + "version": "dev-feat-improve-features", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/transfer.git", + "reference": "7b5fe7059a6e23f2a59a448d79ffb978fa3cc60a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/transfer/zipball/7b5fe7059a6e23f2a59a448d79ffb978fa3cc60a", + "reference": "7b5fe7059a6e23f2a59a448d79ffb978fa3cc60a", + "shasum": "" + }, + "require": { + "appwrite/appwrite": "^8.0", + "php": ">=8.0", + "utopia-php/cli": "^0.13.0" + }, + "require-dev": { + "laravel/pint": "^1.10", + "phpunit/phpunit": "^9.3", + "vlucas/phpdotenv": "^5.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Transfer\\": "src/Transfer" + } + }, + "autoload-dev": { + "psr-4": { + "Utopia\\Tests\\": "tests/Transfer" + } + }, + "scripts": { + "lint": [ + "./vendor/bin/pint --test" + ], + "format": [ + "./vendor/bin/pint" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eldad Fux", + "email": "eldad@appwrite.io" + }, + { + "name": "Bradley Schofield", + "email": "bradley@appwrite.io" + } + ], + "description": "A simple library to transfer resources between services.", + "keywords": [ + "framework", + "php", + "transfer", + "upf", + "utopia" + ], + "support": { + "source": "https://github.com/utopia-php/transfer/tree/feat-improve-features", + "issues": "https://github.com/utopia-php/transfer/issues" + }, + "time": "2023-08-04T16:09:30+00:00" + }, { "name": "utopia-php/websocket", "version": "0.1.0", @@ -3785,16 +3896,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.23.0", + "version": "1.23.1", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a2b24135c35852b348894320d47b3902a94bc494" + "reference": "846ae76eef31c6d7790fac9bc399ecee45160b26" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a2b24135c35852b348894320d47b3902a94bc494", - "reference": "a2b24135c35852b348894320d47b3902a94bc494", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/846ae76eef31c6d7790fac9bc399ecee45160b26", + "reference": "846ae76eef31c6d7790fac9bc399ecee45160b26", "shasum": "" }, "require": { @@ -3826,9 +3937,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.23.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.23.1" }, - "time": "2023-07-23T22:17:56+00:00" + "time": "2023-08-03T16:32:59+00:00" }, { "name": "phpunit/php-code-coverage", @@ -4758,16 +4869,16 @@ }, { "name": "sebastian/global-state", - "version": "5.0.5", + "version": "5.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2" + "reference": "bde739e7565280bda77be70044ac1047bc007e34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/0ca8db5a5fc9c8646244e629625ac486fa286bf2", - "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bde739e7565280bda77be70044ac1047bc007e34", + "reference": "bde739e7565280bda77be70044ac1047bc007e34", "shasum": "" }, "require": { @@ -4810,7 +4921,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.5" + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.6" }, "funding": [ { @@ -4818,7 +4929,7 @@ "type": "github" } ], - "time": "2022-02-14T08:28:10+00:00" + "time": "2023-08-02T09:26:13+00:00" }, { "name": "sebastian/lines-of-code", @@ -5643,7 +5754,9 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": { + "utopia-php/transfer": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -5667,5 +5780,5 @@ "platform-overrides": { "php": "8.0" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.1.0" } diff --git a/docker-compose.yml b/docker-compose.yml index 560c42ae3..9d9f6a888 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -576,6 +576,39 @@ services: - _APP_LOGGING_PROVIDER - _APP_LOGGING_CONFIG + appwrite-worker-migrations: + entrypoint: worker-migrations + <<: *x-logging + container_name: appwrite-worker-migrations + restart: unless-stopped + image: appwrite-dev + networks: + - appwrite + volumes: + - ./app:/usr/src/code/app + - ./src:/usr/src/code/src + - ./tests:/usr/src/code/tests + - ./vendor:/usr/src/code/tests + depends_on: + - mariadb + environment: + - _APP_ENV + - _APP_OPENSSL_KEY_V1 + - _APP_DOMAIN + - _APP_DOMAIN_TARGET + - _APP_SYSTEM_SECURITY_EMAIL_ADDRESS + - _APP_REDIS_HOST + - _APP_REDIS_PORT + - _APP_REDIS_USER + - _APP_REDIS_PASS + - _APP_DB_HOST + - _APP_DB_PORT + - _APP_DB_SCHEMA + - _APP_DB_USER + - _APP_DB_PASS + - _APP_LOGGING_PROVIDER + - _APP_LOGGING_CONFIG + appwrite-maintenance: entrypoint: maintenance <<: *x-logging diff --git a/src/Appwrite/Auth/OAuth2/Firebase.php b/src/Appwrite/Auth/OAuth2/Firebase.php new file mode 100644 index 000000000..892a9c095 --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/Firebase.php @@ -0,0 +1,217 @@ + 'offline', + 'client_id' => $this->appID, + 'redirect_uri' => $this->callback, + 'scope' => \implode(' ', $this->getScopes()), + 'state' => \json_encode($this->state), + 'response_type' => 'code', + 'prompt' => 'consent', + ]); + } + + /** + * @param string $code + * + * @return array + */ + protected function getTokens(string $code): array + { + if (empty($this->tokens)) { + $response = $this->request( + 'POST', + 'https://oauth2.googleapis.com/token', + [], + \http_build_query([ + 'client_id' => $this->appID, + 'redirect_uri' => $this->callback, + 'client_secret' => $this->appSecret, + 'code' => $code, + 'grant_type' => 'authorization_code' + ]) + ); + + $this->tokens = \json_decode($response, true); + } + + return $this->tokens; + } + + /** + * @param string $refreshToken + * + * @return array + */ + public function refreshTokens(string $refreshToken): array + { + $response = $this->request( + 'POST', + 'https://github.com/login/oauth/access_token', + [], + \http_build_query([ + 'client_id' => $this->appID, + 'client_secret' => $this->appSecret, + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken + ]) + ); + + $output = []; + \parse_str($response, $output); + $this->tokens = $output; + + if (empty($this->tokens['refresh_token'])) { + $this->tokens['refresh_token'] = $refreshToken; + } + + return $this->tokens; + } + + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserID(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['id'] ?? ''; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserEmail(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['email'] ?? ''; + } + + + /** + * Check if the OAuth email is verified + * + * @link https://docs.github.com/en/rest/users/emails#list-email-addresses-for-the-authenticated-user + * + * @param string $accessToken + * + * @return bool + */ + public function isEmailVerified(string $accessToken): bool + { + $user = $this->getUser($accessToken); + + if ($user['verified'] ?? false) { + return true; + } + + return false; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserName(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['name'] ?? ''; + } + + /** + * @param string $accessToken + * + * @return array + */ + protected function getUser(string $accessToken) + { + if (empty($this->user)) { + $response = $this->request( + 'GET', + 'https://www.googleapis.com/oauth2/v1/userinfo', + ['Authorization: Bearer ' . \urlencode($accessToken)] + ); + + $this->user = \json_decode($response, true); + } + + return $this->user; + } + + public function getProjects(string $accessToken): array + { + $projects = $this->request('GET', 'https://firebase.googleapis.com/v1beta1/projects', ['Authorization: Bearer ' . \urlencode($accessToken)]); + + $projects = \json_decode($projects, true); + + return $projects['results']; + } + + public function createServiceAccount(string $accessToken, string $projectID): string + { + $response = $this->request( + 'POST', + "https://iam.googleapis.com/v1/projects/{$projectID}/serviceAccounts", + [ + 'Authorization: Bearer ' . \urlencode($accessToken), + 'Content-Type: application/json' + ], + json_encode([ + 'accountId' => 'appwrite-migrations', + 'serviceAccount' => [ + 'displayName' => 'Appwrite Migrations' + ] + ]) + ); + + return $response; + } +} \ No newline at end of file diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php index 222cf5944..5c225990f 100644 --- a/src/Appwrite/Event/Event.php +++ b/src/Appwrite/Event/Event.php @@ -35,6 +35,9 @@ class Event public const MESSAGING_QUEUE_NAME = 'v1-messaging'; public const MESSAGING_CLASS_NAME = 'MessagingV1'; + public const MIGRATIONS_QUEUE_NAME = 'v1-migrations'; + public const MIGRATIONS_CLASS_NAME = 'MigrationsV1'; + protected string $queue = ''; protected string $class = ''; protected string $event = ''; diff --git a/src/Appwrite/Event/Migration.php b/src/Appwrite/Event/Migration.php new file mode 100644 index 000000000..4d53f1679 --- /dev/null +++ b/src/Appwrite/Event/Migration.php @@ -0,0 +1,98 @@ +migration = $migration; + + return $this; + } + + /** + * Returns set migration document for the function event. + * + * @return null|Document + */ + public function getMigration(): ?Document + { + return $this->migration; + } + + /** + * Sets migration type for the migration event. + * + * @param string $type + * + * @return self + */ + public function setType(string $type): self + { + $this->type = $type; + + return $this; + } + + /** + * Returns set migration type for the migration event. + * + * @return string + */ + public function getType(): string + { + return $this->type; + } + + /** + * Executes the migration event and sends it to the migrations worker. + * + * @return string|bool + * @throws \InvalidArgumentException + */ + public function trigger(): string|bool + { + return Resque::enqueue($this->queue, $this->class, [ + 'project' => $this->project, + 'user' => $this->user, + 'migration' => $this->migration + ]); + } + + /** + * Schedules the migration event and schedules it in the migrations worker queue. + * + * @param \DateTime|int $at + * @return void + * @throws \Resque_Exception + * @throws \ResqueScheduler_InvalidTimestampException + */ + public function schedule(DateTime|int $at): void + { + ResqueScheduler::enqueueAt($at, $this->queue, $this->class, [ + 'project' => $this->project, + 'user' => $this->user, + 'migration' => $this->migration + ]); + } +} diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 07c273b88..7a831be49 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -32,6 +32,7 @@ class Exception extends \Exception * - Platform * - Domain * - GraphQL + * - Migrations */ /** General */ @@ -199,6 +200,11 @@ class Exception extends \Exception public const GRAPHQL_NO_QUERY = 'graphql_no_query'; public const GRAPHQL_TOO_MANY_QUERIES = 'graphql_too_many_queries'; + /** Migrations */ + public const MIGRATION_NOT_FOUND = 'migration_not_found'; + public const MIGRATION_ALREADY_EXISTS = 'migration_already_exists'; + public const MIGRATION_IN_PROGRESS = 'migration_in_progress'; + protected $type = ''; public function __construct(string $type = Exception::GENERAL_UNKNOWN, string $message = null, int $code = null, \Throwable $previous = null) diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Migrations.php b/src/Appwrite/Utopia/Database/Validator/Queries/Migrations.php new file mode 100644 index 000000000..c13c5b3bb --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Migrations.php @@ -0,0 +1,25 @@ +setModel(new BaseList('Metric List', self::MODEL_METRIC_LIST, 'metrics', self::MODEL_METRIC, true, false)) ->setModel(new BaseList('Variables List', self::MODEL_VARIABLE_LIST, 'variables', self::MODEL_VARIABLE)) ->setModel(new BaseList('Locale codes list', self::MODEL_LOCALE_CODE_LIST, 'localeCodes', self::MODEL_LOCALE_CODE)) + ->setModel(new BaseList('Migrations List', self::MODEL_MIGRATION_LIST, 'migrations', self::MODEL_MIGRATION)) + ->setModel(new BaseList('Migrations Firebase Projects List', self::MODEL_MIGRATION_FIREBASE_PROJECT_LIST, 'projects', self::MODEL_MIGRATION_FIREBASE_PROJECT)) // Entities ->setModel(new Database()) ->setModel(new Collection()) @@ -360,6 +372,9 @@ class Response extends SwooleResponse ->setModel(new TemplateSMS()) ->setModel(new TemplateEmail()) ->setModel(new ConsoleVariables()) + ->setModel(new Migration()) + ->setModel(new MigrationReport()) + ->setModel(new MigrationFirebaseProject()) // Verification // Recovery // Tests (keep last) diff --git a/src/Appwrite/Utopia/Response/Model/Migration.php b/src/Appwrite/Utopia/Response/Model/Migration.php new file mode 100644 index 000000000..0a8fc94a1 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/Migration.php @@ -0,0 +1,96 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Migration ID.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('$createdAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Variable creation date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('$updatedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Variable creation date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('status', [ + 'type' => self::TYPE_STRING, + 'description' => 'Migration status ( pending, processing, failed. completed ) ', + 'default' => '', + 'example' => 'pending', + ]) + ->addRule('stage', [ + 'type' => self::TYPE_STRING, + 'description' => 'Migration stage ( init, processing, source-check, destination-check, migrating, finished )', + 'default' => '', + 'example' => 'init', + ]) + ->addRule('source', [ + 'type' => self::TYPE_STRING, + 'description' => 'A string containing the type of source of the migration.', + 'default' => '', + 'example' => 'Appwrite', + ]) + ->addRule('resources', [ + 'type' => self::TYPE_STRING, + 'description' => 'Resources to migration.', + 'default' => [], + 'example' => ['user'], + 'array' => true + ]) + ->addRule('statusCounters', [ + 'type' => self::TYPE_JSON, + 'description' => 'A group of counters that represent the total progress of the migration.', + 'default' => [], + 'example' => '{"Database": {"PENDING": 0, "SUCCESS": 1, "ERROR": 0, "SKIP": 0, "PROCESSING": 0, "WARNING": 0}}', + ]) + ->addRule('resourceData', [ + 'type' => self::TYPE_JSON, + 'description' => 'An array of objects containing the report data of the resources that were migrated.', + 'default' => [], + 'example' => '[{"resource":"Database","id":"public","status":"SUCCESS","message":""}]', + ]) + ->addRule('errors', [ + 'type' => self::TYPE_STRING, + 'description' => 'All errors that occurred during the migration process.', + 'default' => [], + 'example' => [], + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Migration'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_MIGRATION; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/MigrationFirebaseProject.php b/src/Appwrite/Utopia/Response/Model/MigrationFirebaseProject.php new file mode 100644 index 000000000..cf13c8148 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/MigrationFirebaseProject.php @@ -0,0 +1,46 @@ +addRule('projectId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Project ID.', + 'default' => '', + 'example' => 'my-project', + ]) + ->addRule('displayName', [ + 'type' => self::TYPE_STRING, + 'description' => 'Project display name.', + 'default' => '', + 'example' => 'My Project', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'MigrationFirebaseProject'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_MIGRATION_FIREBASE_PROJECT; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/MigrationReport.php b/src/Appwrite/Utopia/Response/Model/MigrationReport.php new file mode 100644 index 000000000..9e7f0ec08 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/MigrationReport.php @@ -0,0 +1,89 @@ +addRule(Resource::TYPE_USER, [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of users to be migrated.', + 'default' => 0, + 'example' => 20, + ]) + ->addRule(Resource::TYPE_TEAM, [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of teams to be migrated.', + 'default' => 0, + 'example' => 20, + ]) + ->addRule(Resource::TYPE_DATABASE, [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of databases to be migrated.', + 'default' => 0, + 'example' => 20, + ]) + ->addRule(Resource::TYPE_DOCUMENT, [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of documents to be migrated.', + 'default' => 0, + 'example' => 20, + ]) + ->addRule(Resource::TYPE_FILE, [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of files to be migrated.', + 'default' => 0, + 'example' => 20, + ]) + ->addRule(Resource::TYPE_BUCKET, [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of buckets to be migrated.', + 'default' => 0, + 'example' => 20, + ]) + ->addRule(Resource::TYPE_FUNCTION, [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of functions to be migrated.', + 'default' => 0, + 'example' => 20, + ]) + ->addRule('size', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Size of files to be migrated in mb.', + 'default' => 0, + 'example' => 30000, + ]) + ->addRule('version', [ + 'type' => self::TYPE_STRING, + 'description' => 'Version of the Appwrite instance to be migrated.', + 'default' => '', + 'example' => '1.4.0', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Migration Report'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_MIGRATION_REPORT; + } +}