diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000000..48b77eb4f8 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,16 @@ +name: "Static code analysis" + +on: [pull_request] +jobs: + lint: + name: CodeQL + runs-on: ubuntu-latest + + steps: + - name: Check out the repo + uses: actions/checkout@v2 + + - name: Run CodeQL + run: | + docker run --rm -v $PWD:/app composer:2.6 sh -c \ + "composer install --profile --ignore-platform-reqs && composer check" \ No newline at end of file diff --git a/app/config/site-templates.php b/app/config/site-templates.php index 1e64d78c09..64d8cf3729 100644 --- a/app/config/site-templates.php +++ b/app/config/site-templates.php @@ -1,9 +1,21 @@ 'nextjs-starter', - 'name' => 'Next.js starter website', + 'key' => 'starter-for-svelte', + 'name' => 'Svelte starter', 'useCases' => ['starter'], - 'demoUrl' => 'https://nextjs-starter.sites.qa17.appwrite.org/', - 'demoImage' => 'https://qa17.appwrite.org/console/images/sites/templates/nextjs-starter.png', - 'frameworks' => [ - getFramework('NEXTJS', [ - 'providerRootDirectory' => './nextjs/starter', - ]), - ], - 'vcsProvider' => 'github', - 'providerRepositoryId' => 'templates-for-sites', - 'providerOwner' => 'appwrite', - 'providerVersion' => '0.2.*', - 'variables' => [], - ], - [ - 'key' => 'nuxt-starter', - 'name' => 'Nuxt starter website', - 'useCases' => ['starter'], - 'demoUrl' => 'https://nuxt-starter.sites.qa17.appwrite.org/', - 'demoImage' => 'https://qa17.appwrite.org/console/images/sites/templates/nuxt-starter.png', - 'frameworks' => [ - getFramework('NUXT', [ - 'providerRootDirectory' => './nuxt/starter', - ]), - ], - 'vcsProvider' => 'github', - 'providerRepositoryId' => 'templates-for-sites', - 'providerOwner' => 'appwrite', - 'providerVersion' => '0.2.*', - 'variables' => [], - ], - [ - 'key' => 'sveltekit-starter', - 'name' => 'SvelteKit starter website', - 'useCases' => ['starter'], - 'demoUrl' => 'https://sveltekit-starter.sites.qa17.appwrite.org/', - 'demoImage' => 'https://qa17.appwrite.org/console/images/sites/templates/sveltekit-starter.png', - 'frameworks' => [ - getFramework('SVELTEKIT', [ - 'providerRootDirectory' => './sveltekit/starter', - ]), - ], - 'vcsProvider' => 'github', - 'providerRepositoryId' => 'templates-for-sites', - 'providerOwner' => 'appwrite', - 'providerVersion' => '0.2.*', - 'variables' => [], - ], - [ - 'key' => 'sveltekit-with-appwrite', - 'name' => 'SvelteKit with Appwrite', - 'useCases' => ['starter'], - 'demoUrl' => '', - 'demoImage' => '', + 'demoImage' => $url . '/console/images/sites/templates/starter-for-svelte.png', 'frameworks' => [ getFramework('SVELTEKIT', [ 'providerRootDirectory' => './', @@ -147,7 +107,7 @@ return [ 'name' => 'PUBLIC_APPWRITE_ENDPOINT', 'description' => 'Endpoint of Appwrite server', 'value' => '{apiEndpoint}', - 'placeholder' => 'https://cloud.appwrite.io/v1', + 'placeholder' => '{apiEndpoint}', 'required' => true, 'type' => 'text' ], @@ -167,14 +127,210 @@ return [ 'required' => true, 'type' => 'text' ], + ] + ], + [ + 'key' => 'starter-for-nextjs', + 'name' => 'Next.js starter', + 'useCases' => ['starter'], + 'demoImage' => $url . '/console/images/sites/templates/starter-for-nextjs.png', + 'frameworks' => [ + getFramework('NEXTJS', [ + 'providerRootDirectory' => './', + ]), ], + 'vcsProvider' => 'github', + 'providerRepositoryId' => 'starter-for-nextjs', + 'providerOwner' => 'appwrite', + 'providerVersion' => '0.1.*', + 'variables' => [ + [ + 'name' => 'NEXT_PUBLIC_APPWRITE_ENDPOINT', + 'description' => 'Endpoint of Appwrite server', + 'value' => '{apiEndpoint}', + 'placeholder' => '{apiEndpoint}', + 'required' => true, + 'type' => 'text' + ], + [ + 'name' => 'NEXT_PUBLIC_APPWRITE_PROJECT_ID', + 'description' => 'Your Appwrite project ID', + 'value' => '{projectId}', + 'placeholder' => '{projectId}', + 'required' => true, + 'type' => 'text' + ], + [ + 'name' => 'NEXT_PUBLIC_APPWRITE_PROJECT_NAME', + 'description' => 'Your Appwrite project name', + 'value' => '{projectName}', + 'placeholder' => '{projectName}', + 'required' => true, + 'type' => 'text' + ], + ] + ], + [ + 'key' => 'template-for-event', + 'name' => 'Event template', + 'useCases' => ['starter'], + 'demoImage' => $url . '/console/images/sites/templates/template-for-event.png', + 'frameworks' => [ + getFramework('NEXTJS', [ + 'providerRootDirectory' => './', + 'installCommand' => 'pnpm install', + 'buildCommand' => 'npm run build', + ]), + ], + 'vcsProvider' => 'github', + 'providerRepositoryId' => 'template-for-event', + 'providerOwner' => 'appwrite', + 'providerVersion' => '0.1.*', + 'variables' => [ + [ + 'name' => 'NEXT_PUBLIC_APPWRITE_FUNCTION_PROJECT_ID', + 'description' => 'Endpoint of Appwrite server', + 'value' => '{apiEndpoint}', + 'placeholder' => '{apiEndpoint}', + 'required' => true, + 'type' => 'text' + ], + [ + 'name' => 'NEXT_PUBLIC_APPWRITE_FUNCTION_API_ENDPOINT', + 'description' => 'Your Appwrite project ID', + 'value' => '{projectId}', + 'placeholder' => '{projectId}', + 'required' => true, + 'type' => 'text' + ], + ] + ], + [ + 'key' => 'template-for-portfolio', + 'name' => 'Portfolio template', + 'useCases' => ['starter'], + 'demoImage' => $url . '/console/images/sites/templates/template-for-portfolio.png', + 'frameworks' => [ + getFramework('NEXTJS', [ + 'providerRootDirectory' => './', + ]), + ], + 'vcsProvider' => 'github', + 'providerRepositoryId' => 'template-for-portfolio', + 'providerOwner' => 'appwrite', + 'providerVersion' => '0.1.*', + 'variables' => [] + ], + [ + 'key' => 'template-for-store', + 'name' => 'Store template', + 'useCases' => ['starter'], + 'demoImage' => $url . '/console/images/sites/templates/template-for-store.png', + 'frameworks' => [ + getFramework('SVELTEKIT', [ + 'providerRootDirectory' => './', + ]), + ], + 'vcsProvider' => 'github', + 'providerRepositoryId' => 'template-for-store', + 'providerOwner' => 'appwrite', + 'providerVersion' => '0.1.*', + 'variables' => [ + [ + 'name' => 'STRIPE_SECRET_KEY', + 'description' => 'Your Stripe secret key', + 'value' => 'disabled', + 'placeholder' => 'sk_.....', + 'required' => false, + 'type' => 'password' + ], + [ + 'name' => 'PUBLIC_APPWRITE_ENDPOINT', + 'description' => 'Endpoint of Appwrite server', + 'value' => '{apiEndpoint}', + 'placeholder' => '{apiEndpoint}', + 'required' => true, + 'type' => 'text' + ], + [ + 'name' => 'PUBLIC_APPWRITE_PROJECT_ID', + 'description' => 'Your Appwrite project ID', + 'value' => '{projectId}', + 'placeholder' => '{projectId}', + 'required' => true, + 'type' => 'text' + ], + ] + ], + [ + 'key' => 'template-for-blog', + 'name' => 'Blog template', + 'useCases' => ['starter'], + 'demoImage' => $url . '/console/images/sites/templates/template-for-blog.png', + 'frameworks' => [ + getFramework('SVELTEKIT', [ + 'providerRootDirectory' => './', + ]), + ], + 'vcsProvider' => 'github', + 'providerRepositoryId' => 'template-for-blog', + 'providerOwner' => 'appwrite', + 'providerVersion' => '0.1.*', + 'variables' => [] + ], + [ + 'key' => 'nextjs-starter', + 'name' => 'Next.js starter', + 'useCases' => ['starter'], + 'demoImage' => '', + 'frameworks' => [ + getFramework('NEXTJS', [ + 'providerRootDirectory' => './nextjs/starter', + ]), + ], + 'vcsProvider' => 'github', + 'providerRepositoryId' => 'templates-for-sites', + 'providerOwner' => 'appwrite', + 'providerVersion' => '0.2.*', + 'variables' => [], + ], + [ + 'key' => 'nuxt-starter', + 'name' => 'Nuxt starter', + 'useCases' => ['starter'], + 'demoImage' => '', + 'frameworks' => [ + getFramework('NUXT', [ + 'providerRootDirectory' => './nuxt/starter', + ]), + ], + 'vcsProvider' => 'github', + 'providerRepositoryId' => 'templates-for-sites', + 'providerOwner' => 'appwrite', + 'providerVersion' => '0.2.*', + 'variables' => [], + ], + [ + 'key' => 'sveltekit-starter', + 'name' => 'SvelteKit starter', + 'useCases' => ['starter'], + 'demoImage' => '', + 'frameworks' => [ + getFramework('SVELTEKIT', [ + 'providerRootDirectory' => './sveltekit/starter', + ]), + ], + 'vcsProvider' => 'github', + 'providerRepositoryId' => 'templates-for-sites', + 'providerOwner' => 'appwrite', + 'providerVersion' => '0.2.*', + 'variables' => [], ], [ 'key' => 'astro-starter', - 'name' => 'Astro starter website', + 'name' => 'Astro starter', 'useCases' => ['starter'], - 'demoUrl' => 'https://astro-starter.sites.qa17.appwrite.org/', - 'demoImage' => 'https://qa17.appwrite.org/console/images/sites/templates/astro-starter.png', + 'demoImage' => '', 'frameworks' => [ getFramework('ASTRO', [ 'providerRootDirectory' => './astro/starter', @@ -188,10 +344,9 @@ return [ ], [ 'key' => 'remix-starter', - 'name' => 'Remix starter website', + 'name' => 'Remix starter', 'useCases' => ['starter'], - 'demoUrl' => 'https://remix-starter.sites.qa17.appwrite.org/', - 'demoImage' => 'https://qa17.appwrite.org/console/images/sites/templates/remix-starter.png', + 'demoImage' => '', 'frameworks' => [ getFramework('REMIX', [ 'providerRootDirectory' => './remix/starter', @@ -205,10 +360,9 @@ return [ ], [ 'key' => 'flutter-starter', - 'name' => 'Flutter starter website', + 'name' => 'Flutter starter', 'useCases' => ['starter'], - 'demoUrl' => 'https://flutter-starter.sites.qa17.appwrite.org/', - 'demoImage' => 'https://qa17.appwrite.org/console/images/sites/templates/flutter-starter.png', + 'demoImage' => '', 'frameworks' => [ getFramework('FLUTTER', [ 'providerRootDirectory' => './flutter/starter', diff --git a/app/controllers/general.php b/app/controllers/general.php index c8beefec2d..c9efc90426 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -15,6 +15,8 @@ use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Transformation\Adapter\Preview; +use Appwrite\Transformation\Transformation; use Appwrite\Utopia\Request; use Appwrite\Utopia\Request\Filters\V16 as RequestV16; use Appwrite\Utopia\Request\Filters\V17 as RequestV17; @@ -123,14 +125,14 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw $type = $rule->getAttribute('resourceType'); if ($type === 'function' || $type === 'site' || $type === 'deployment') { - $resourceCollection = match($type) { + $resourceCollection = match ($type) { 'function' => 'functions', 'site' => 'sites', 'deployment' => 'deployments', }; } - if ($type === 'function' || $type === 'site' || $type === 'deployment') { + if ($type === 'function' || $type === 'site' || $type === 'deployment') { $method = $utopia->getRoute()?->getLabel('sdk', null); if (empty($method)) { @@ -198,7 +200,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw throw new AppwriteException(AppwriteException::GENERAL_RESOURCE_BLOCKED); } - $version = match($type) { + $version = match ($type) { 'function' => $resource->getAttribute('version', 'v2'), 'site' => 'v4', 'deployment' => 'v4' @@ -207,7 +209,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw $runtimes = Config::getParam($version === 'v2' ? 'runtimes-v2' : 'runtimes', []); $spec = Config::getParam('runtime-specifications')[$resource->getAttribute('specification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; - $runtime = match($type) { + $runtime = match ($type) { 'function' => $runtimes[$resource->getAttribute('runtime')] ?? null, 'site' => $runtimes[$resource->getAttribute('buildRuntime')] ?? null, 'deployment' => $runtimes[$resource->getAttribute('buildRuntime')] ?? null, @@ -222,7 +224,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw throw new AppwriteException(AppwriteException::FUNCTION_RUNTIME_UNSUPPORTED, 'Runtime "' . $resource->getAttribute('runtime', '') . '" is not supported'); } - $deploymentId = match($type) { + $deploymentId = match ($type) { 'function' => $resource->getAttribute('deployment', ''), 'site' => $resource->getAttribute('deploymentId', ''), 'deployment' => $subResource->getId() @@ -388,12 +390,12 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw /** Execute function */ $executor = new Executor(System::getEnv('_APP_EXECUTOR_HOST')); try { - $version = match($type) { + $version = match ($type) { 'function' => $resource->getAttribute('version', 'v2'), 'site' => 'v4', 'deployment' => 'v4' }; - $entrypoint = match($type) { + $entrypoint = match ($type) { 'function' => $deployment->getAttribute('entrypoint', ''), 'site' => '', 'deployment' => '' @@ -420,7 +422,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw $runtimeEntrypoint = 'cp /tmp/code.tar.gz /mnt/code/code.tar.gz && nohup helpers/start.sh "' . $startCommand . '"'; } - $entrypoint = match($type) { + $entrypoint = match ($type) { 'function' => $deployment->getAttribute('entrypoint', ''), 'site' => '', 'deployment' => '' @@ -446,6 +448,22 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw requestTimeout: 30 ); + + // Branded banner for previews + $transformation = new Transformation(); + $transformation->addAdapter(new Preview()); + $transformation->setInput($executionResponse['body']); + $transformation->setTraits($executionResponse['headers']); + if ($type === 'deployment' && $transformation->transform()) { + $executionResponse['body'] = $transformation->getOutput(); + + foreach ($executionResponse['headers'] as $key => $value) { + if (\strtolower($key) === 'content-length') { + $executionResponse['headers'][$key] = \strlen($executionResponse['body']); + } + } + } + $headersFiltered = []; foreach ($executionResponse['headers'] as $key => $value) { if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_RESPONSE)) { @@ -472,8 +490,8 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw if ($type === 'function') { $execution - ->setAttribute('status', 'failed') - ->setAttribute('errors', $th->getMessage() . '\nError Code: ' . $th->getCode()); + ->setAttribute('status', 'failed') + ->setAttribute('errors', $th->getMessage() . '\nError Code: ' . $th->getCode()); } Console::error($th->getMessage()); @@ -540,8 +558,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw ->addMetric(METRIC_EXECUTIONS_MB_SECONDS, (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT))) ->addMetric(str_replace('{functionInternalId}', $resource->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS_MB_SECONDS), (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT))) ->setProject($project) - ->trigger() - ; + ->trigger(); return true; } elseif ($type === 'api') { @@ -720,6 +737,12 @@ App::init() $validator = new Hostname($clients); if ($validator->isValid($origin)) { $refDomainOrigin = $origin; + } else { + // Auto-allow domains with linked rule + $rule = Authorization::skip(fn () => $dbForPlatform->getDocument('rules', md5($origin))); + if (!$rule->isEmpty() && $rule->getAttribute('projectInternalId') === $project->getInternalId()) { + $refDomainOrigin = $origin; + } } $refDomain = (!empty($protocol) ? $protocol : $request->getProtocol()) . '://' . $refDomainOrigin . (!empty($port) ? ':' . $port : ''); @@ -769,7 +792,7 @@ App::init() $response->addFilter(new ResponseV18()); } if (version_compare($responseFormat, APP_VERSION_STABLE, '>')) { - $response->addHeader('X-Appwrite-Warning', "The current SDK is built for Appwrite " . $responseFormat . ". However, the current Appwrite server version is ". APP_VERSION_STABLE . ". Please downgrade your SDK to match the Appwrite version: https://appwrite.io/docs/sdks"); + $response->addHeader('X-Appwrite-Warning', "The current SDK is built for Appwrite " . $responseFormat . ". However, the current Appwrite server version is " . APP_VERSION_STABLE . ". Please downgrade your SDK to match the Appwrite version: https://appwrite.io/docs/sdks"); } } diff --git a/composer.json b/composer.json index 199229c105..7b94a3f22c 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ "test": "vendor/bin/phpunit", "lint": "vendor/bin/pint --test", "format": "vendor/bin/pint", - "bench": "vendor/bin/phpbench run --report=benchmark" + "bench": "vendor/bin/phpbench run --report=benchmark", + "check": "./vendor/bin/phpstan analyse -c phpstan.neon" }, "autoload": { "psr-4": { @@ -89,7 +90,8 @@ "swoole/ide-helper": "5.1.2", "textalk/websocket": "1.5.7", "laravel/pint": "^1.14", - "phpbench/phpbench": "^1.2" + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "1.8.*" }, "provide": { "ext-phpiredis": "*" diff --git a/composer.lock b/composer.lock index 64c7a33cd3..7f589bf171 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": "4da9bee4423753c2e592c081b19b8711", + "content-hash": "5473f6b65d54314228e69b6bed489d78", "packages": [ { "name": "adhocore/jwt", @@ -5560,16 +5560,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.12.1", + "version": "1.13.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845" + "reference": "024473a478be9df5fdaca2c793f2232fe788e414" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", + "reference": "024473a478be9df5fdaca2c793f2232fe788e414", "shasum": "" }, "require": { @@ -5608,7 +5608,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.0" }, "funding": [ { @@ -5616,7 +5616,7 @@ "type": "tidelift" } ], - "time": "2024-11-08T17:47:46+00:00" + "time": "2025-02-12T12:17:51+00:00" }, { "name": "nikic/php-parser", @@ -6190,16 +6190,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "c00d78fb6b29658347f9d37ebe104bffadf36299" + "reference": "72e51f7c32c5aef7c8b462195b8c599b11199893" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/c00d78fb6b29658347f9d37ebe104bffadf36299", - "reference": "c00d78fb6b29658347f9d37ebe104bffadf36299", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/72e51f7c32c5aef7c8b462195b8c599b11199893", + "reference": "72e51f7c32c5aef7c8b462195b8c599b11199893", "shasum": "" }, "require": { @@ -6231,9 +6231,68 @@ "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/2.0.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.0.1" }, - "time": "2024-10-13T11:29:49+00:00" + "time": "2025-02-13T12:25:43+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "1.8.11", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "46e223dd68a620da18855c23046ddb00940b4014" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46e223dd68a620da18855c23046ddb00940b4014", + "reference": "46e223dd68a620da18855c23046ddb00940b4014", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan/issues", + "source": "https://github.com/phpstan/phpstan/tree/1.8.11" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpstan/phpstan", + "type": "tidelift" + } + ], + "time": "2022-10-24T15:45:13+00:00" }, { "name": "phpunit/php-code-coverage", @@ -8780,5 +8839,5 @@ "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/docker-compose.yml b/docker-compose.yml index 77b416c219..c827ec2108 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -204,7 +204,7 @@ services: appwrite-console: <<: *x-logging container_name: appwrite-console - image: appwrite/console:5.3.0-sites-rc.6 + image: appwrite/console:5.3.0-sites-rc.9 restart: unless-stopped networks: - appwrite diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000000..b18f3d6d58 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,8 @@ +parameters: + level: 8 + paths: + - src/Appwrite/Transformation + scanDirectories: + - vendor/swoole/ide-helper + excludePaths: + - tests/resources \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index 9ea0ef86c5..1c3b9f2ede 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -19,7 +19,7 @@ use Utopia\VCS\Exception\RepositoryNotFound; class Base extends Action { - public function redeployVcsFunction(Request $request, Document $function, Document $project, Document $installation, Database $dbForProject, Build $queueForBuilds, Document $template, GitHub $github) + public function redeployVcsFunction(Request $request, Document $function, Document $project, Document $installation, Database $dbForProject, Build $queueForBuilds, Document $template, GitHub $github): Document { $deploymentId = ID::unique(); $entrypoint = $function->getAttribute('entrypoint', ''); @@ -91,9 +91,11 @@ class Base extends Action ->setResource($function) ->setDeployment($deployment) ->setTemplate($template); + + return $deployment; } - public function redeployVcsSite(Request $request, Document $site, Document $project, Document $installation, Database $dbForProject, Database $dbForPlatform, Build $queueForBuilds, Document $template, GitHub $github) + public function redeployVcsSite(Request $request, Document $site, Document $project, Document $installation, Database $dbForProject, Database $dbForPlatform, Build $queueForBuilds, Document $template, GitHub $github): Document { $deploymentId = ID::unique(); $providerInstallationId = $installation->getAttribute('providerInstallationId', ''); @@ -187,5 +189,7 @@ class Base extends Action ->setResource($site) ->setDeployment($deployment) ->setTemplate($template); + + return $deployment; } } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php new file mode 100644 index 0000000000..5082c23db2 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php @@ -0,0 +1,143 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/functions/:functionId/deployments/template') + ->desc('Create template deployment') + ->groups(['api', 'functions']) + ->label('scope', 'functions.write') + ->label('event', 'functions.[functionId].deployments.[deploymentId].create') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('audits.event', 'deployment.create') + ->label('audits.resource', 'function/{request.functionId}') + ->label('sdk', new Method( + namespace: 'functions', + name: 'createTemplateDeployment', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('repository', '', new Text(128, 0), 'Repository name of the template.') + ->param('owner', '', new Text(128, 0), 'The name of the owner of the template.') + ->param('rootDirectory', '', new Text(128, 0), 'Path to function code in the template repo.') + ->param('version', '', new Text(128, 0), 'Version (tag) for the repo linked to the function template.') + ->param('activate', false, new Boolean(), 'Automatically activate the deployment when it is finished building.', true) + ->inject('request') + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('queueForEvents') + ->inject('project') + ->inject('queueForBuilds') + ->inject('gitHub') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $repository, string $owner, string $rootDirectory, string $version, bool $activate, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Event $queueForEvents, Document $project, Build $queueForBuilds, GitHub $github) + { + $function = $dbForProject->getDocument('functions', $functionId); + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $template = new Document([ + 'repositoryName' => $repository, + 'ownerName' => $owner, + 'rootDirectory' => $rootDirectory, + 'version' => $version + ]); + + if (!empty($function->getAttribute('providerRepositoryId'))) { + $installation = $dbForPlatform->getDocument('installations', $function->getAttribute('installationId')); + + $deployment = $this->redeployVcsFunction($request, $function, $project, $installation, $dbForProject, $queueForBuilds, $template, $github); + + $queueForEvents + ->setParam('functionId', $function->getId()) + ->setParam('deploymentId', $deployment->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($deployment, Response::MODEL_DEPLOYMENT); + + return; + } + + $deploymentId = ID::unique(); + $deployment = $dbForProject->createDocument('deployments', new Document([ + '$id' => $deploymentId, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'resourceId' => $function->getId(), + 'resourceInternalId' => $function->getInternalId(), + 'resourceType' => 'functions', + 'entrypoint' => $function->getAttribute('entrypoint', ''), + 'commands' => $function->getAttribute('commands', ''), + 'type' => 'manual', + 'search' => implode(' ', [$deploymentId, $function->getAttribute('entrypoint', '')]), + 'activate' => $activate, + ])); + + $queueForBuilds + ->setType(BUILD_TYPE_DEPLOYMENT) + ->setResource($function) + ->setDeployment($deployment) + ->setTemplate($template); + + $queueForEvents + ->setParam('functionId', $function->getId()) + ->setParam('deploymentId', $deployment->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($deployment, Response::MODEL_DEPLOYMENT); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index 8787d0db0b..0507c68068 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Functions\Http\Functions; -use Appwrite\Event\Build; use Appwrite\Event\Event; use Appwrite\Event\Validator\FunctionEvent; use Appwrite\Extend\Exception; @@ -27,14 +26,12 @@ use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Roles; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -use Utopia\Swoole\Request; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Range; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; -use Utopia\VCS\Adapter\Git\GitHub; class Create extends Base { @@ -88,30 +85,22 @@ class Create extends Base ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function.', true) ->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the function? In silent mode, comments will not be made on commits and pull requests.', true) ->param('providerRootDirectory', '', new Text(128, 0), 'Path to function code in the linked repo.', true) - ->param('templateRepository', '', new Text(128, 0), 'Repository name of the template.', true) - ->param('templateOwner', '', new Text(128, 0), 'The name of the owner of the template.', true) - ->param('templateRootDirectory', '', new Text(128, 0), 'Path to function code in the template repo.', true) - ->param('templateVersion', '', new Text(128, 0), 'Version (tag) for the repo linked to the function template.', true) ->param('specification', APP_COMPUTE_SPECIFICATION_DEFAULT, fn (array $plan) => new RuntimeSpecification( $plan, Config::getParam('runtime-specifications', []), App::getEnv('_APP_COMPUTE_CPUS', APP_COMPUTE_CPUS_DEFAULT), App::getEnv('_APP_COMPUTE_MEMORY', APP_COMPUTE_MEMORY_DEFAULT) ), 'Runtime specification for the function and builds.', true, ['plan']) - ->inject('request') ->inject('response') ->inject('dbForProject') ->inject('timelimit') ->inject('project') - ->inject('user') ->inject('queueForEvents') - ->inject('queueForBuilds') ->inject('dbForPlatform') - ->inject('gitHub') ->callback([$this, 'action']); } - public function action(string $functionId, string $name, string $runtime, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $commands, array $scopes, string $installationId, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, string $templateRepository, string $templateOwner, string $templateRootDirectory, string $templateVersion, string $specification, Request $request, Response $response, Database $dbForProject, callable $timelimit, Document $project, Document $user, Event $queueForEvents, Build $queueForBuilds, Database $dbForPlatform, GitHub $github) + public function action(string $functionId, string $name, string $runtime, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $commands, array $scopes, string $installationId, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, string $specification, Response $response, Database $dbForProject, callable $timelimit, Document $project, Event $queueForEvents, Database $dbForPlatform) { // Temporary abuse check @@ -151,20 +140,6 @@ class Create extends Base throw new Exception(Exception::FUNCTION_RUNTIME_UNSUPPORTED, 'Runtime "' . $runtime . '" is not supported'); } - // build from template - $template = new Document([]); - if ( - !empty($templateRepository) - && !empty($templateOwner) - && !empty($templateRootDirectory) - && !empty($templateVersion) - ) { - $template->setAttribute('repositoryName', $templateRepository) - ->setAttribute('ownerName', $templateOwner) - ->setAttribute('rootDirectory', $templateRootDirectory) - ->setAttribute('version', $templateVersion); - } - $installation = $dbForPlatform->getDocument('installations', $installationId); if (!empty($installationId) && $installation->isEmpty()) { @@ -252,36 +227,6 @@ class Create extends Base $function = $dbForProject->updateDocument('functions', $function->getId(), $function); - if (!empty($providerRepositoryId)) { - // Deploy VCS - $this->redeployVcsFunction($request, $function, $project, $installation, $dbForProject, $queueForBuilds, $template, $github); - } elseif (!$template->isEmpty()) { - // Deploy non-VCS from template - $deploymentId = ID::unique(); - $deployment = $dbForProject->createDocument('deployments', new Document([ - '$id' => $deploymentId, - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'resourceId' => $function->getId(), - 'resourceInternalId' => $function->getInternalId(), - 'resourceType' => 'functions', - 'entrypoint' => $function->getAttribute('entrypoint', ''), - 'commands' => $function->getAttribute('commands', ''), - 'type' => 'manual', - 'search' => implode(' ', [$deploymentId, $function->getAttribute('entrypoint', '')]), - 'activate' => true, - ])); - - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($function) - ->setDeployment($deployment) - ->setTemplate($template); - } - $queueForEvents->setParam('functionId', $function->getId()); $response diff --git a/src/Appwrite/Platform/Modules/Functions/Services/Http.php b/src/Appwrite/Platform/Modules/Functions/Services/Http.php index 6c74182776..141c4abb62 100644 --- a/src/Appwrite/Platform/Modules/Functions/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Functions/Services/Http.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Functions\Services; use Appwrite\Platform\Modules\Functions\Http\Deployments\Create as CreateDeployment; +use Appwrite\Platform\Modules\Functions\Http\Deployments\Template\Create as CreateTemplateDeployment; use Appwrite\Platform\Modules\Functions\Http\Functions\Create as CreateFunction; use Appwrite\Platform\Modules\Functions\Http\Functions\Update as UpdateFunction; use Appwrite\Platform\Modules\Functions\Http\Functions\XList as ListFunctions; @@ -19,5 +20,6 @@ class Http extends Service $this->addAction(ListFunctions::getName(), new ListFunctions()); $this->addAction(ListRuntimes::getName(), new ListRuntimes()); $this->addAction(CreateDeployment::getName(), new CreateDeployment()); + $this->addAction(CreateTemplateDeployment::getName(), new CreateTemplateDeployment()); } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php new file mode 100644 index 0000000000..507397d70c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php @@ -0,0 +1,165 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/sites/:siteId/deployments/template') + ->desc('Create deployment') + ->groups(['api', 'sites']) + ->label('scope', 'sites.write') + ->label('event', 'sites.[siteId].deployments.[deploymentId].create') + ->label('audits.event', 'deployment.create') + ->label('audits.resource', 'site/{request.siteId}') + ->label('sdk', new Method( + namespace: 'sites', + name: 'createTemplateDeployment', + description: <<param('siteId', '', new UID(), 'Site ID.') + ->param('repository', '', new Text(128, 0), 'Repository name of the template.') + ->param('owner', '', new Text(128, 0), 'The name of the owner of the template.') + ->param('rootDirectory', '', new Text(128, 0), 'Path to site code in the template repo.') + ->param('version', '', new Text(128, 0), 'Version (tag) for the repo linked to the site template.') + ->param('activate', false, new Boolean(), 'Automatically activate the deployment when it is finished building.', true) + ->inject('request') + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('project') + ->inject('queueForEvents') + ->inject('queueForBuilds') + ->inject('gitHub') + ->callback([$this, 'action']); + } + + public function action(string $siteId, string $repository, string $owner, string $rootDirectory, string $version, bool $activate, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Document $project, Event $queueForEvents, Build $queueForBuilds, GitHub $github) + { + $site = $dbForProject->getDocument('sites', $siteId); + + if ($site->isEmpty()) { + throw new Exception(Exception::SITE_NOT_FOUND); + } + + $template = new Document([ + 'repositoryName' => $repository, + 'ownerName' => $owner, + 'rootDirectory' => $rootDirectory, + 'version' => $version + ]); + + if (!empty($providerRepositoryId)) { + $installation = $dbForPlatform->getDocument('installations', $site->getAttribute('installationId')); + + $deployment = $this->redeployVcsSite($request, $site, $project, $installation, $dbForProject, $dbForPlatform, $queueForBuilds, $template, $github); + + $queueForEvents + ->setParam('siteId', $site->getId()) + ->setParam('deploymentId', $deployment->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($deployment, Response::MODEL_DEPLOYMENT); + + return; + } + + $deploymentId = ID::unique(); + $deployment = $dbForProject->createDocument('deployments', new Document([ + '$id' => $deploymentId, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'resourceId' => $site->getId(), + 'resourceInternalId' => $site->getInternalId(), + 'resourceType' => 'sites', + 'installCommand' => $site->getAttribute('installCommand', ''), + 'buildCommand' => $site->getAttribute('buildCommand', ''), + 'outputDirectory' => $site->getAttribute('outputDirectory', ''), + 'type' => 'manual', + 'search' => implode(' ', [$deploymentId]), + 'activate' => $activate, + ])); + + // Preview deployments url + $projectId = $project->getId(); + + $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); + $previewDomain = "{$deploymentId}-{$projectId}.{$sitesDomain}"; + + $rule = Authorization::skip( + fn () => $dbForPlatform->createDocument('rules', new Document([ + '$id' => \md5($previewDomain), + 'projectId' => $project->getId(), + 'projectInternalId' => $project->getInternalId(), + 'domain' => $previewDomain, + 'resourceType' => 'deployment', + 'resourceId' => $deploymentId, + 'resourceInternalId' => $deployment->getInternalId(), + 'status' => 'verified', + 'certificateId' => '', + ])) + ); + + $queueForBuilds + ->setType(BUILD_TYPE_DEPLOYMENT) + ->setResource($site) + ->setDeployment($deployment) + ->setTemplate($template); + + $queueForEvents + ->setParam('siteId', $site->getId()) + ->setParam('deploymentId', $deployment->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($deployment, Response::MODEL_DEPLOYMENT); + } +} diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php index 2fd7f4d1c1..35ca34021f 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Sites\Http\Sites; -use Appwrite\Event\Build; use Appwrite\Event\Event; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; @@ -22,13 +21,11 @@ use Utopia\Database\Helpers\Role; use Utopia\Database\Validator\Authorization; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -use Utopia\Swoole\Request; use Utopia\System\System; use Utopia\Validator\Boolean; use Utopia\Validator\Range; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; -use Utopia\VCS\Adapter\Git\GitHub; class Create extends Base { @@ -80,29 +77,21 @@ class Create extends Base ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the site.', true) ->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the site? In silent mode, comments will not be made on commits and pull requests.', true) ->param('providerRootDirectory', '', new Text(128, 0), 'Path to site code in the linked repo.', true) - ->param('templateRepository', '', new Text(128, 0), 'Repository name of the template.', true) - ->param('templateOwner', '', new Text(128, 0), 'The name of the owner of the template.', true) - ->param('templateRootDirectory', '', new Text(128, 0), 'Path to site code in the template repo.', true) - ->param('templateVersion', '', new Text(128, 0), 'Version (tag) for the repo linked to the site template.', true) ->param('specification', APP_COMPUTE_SPECIFICATION_DEFAULT, fn (array $plan) => new FrameworkSpecification( $plan, Config::getParam('framework-specifications', []), App::getEnv('_APP_COMPUTE_CPUS', APP_COMPUTE_CPUS_DEFAULT), App::getEnv('_APP_COMPUTE_MEMORY', APP_COMPUTE_MEMORY_DEFAULT) ), 'Framework specification for the site and builds.', true, ['plan']) - ->inject('request') ->inject('response') ->inject('dbForProject') ->inject('project') - ->inject('user') ->inject('queueForEvents') - ->inject('queueForBuilds') ->inject('dbForPlatform') - ->inject('gitHub') ->callback([$this, 'action']); } - public function action(string $siteId, string $name, string $framework, bool $enabled, int $timeout, string $installCommand, string $buildCommand, string $outputDirectory, string $buildRuntime, string $adapter, string $installationId, ?string $fallbackFile, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, string $templateRepository, string $templateOwner, string $templateRootDirectory, string $templateVersion, string $specification, Request $request, Response $response, Database $dbForProject, Document $project, Document $user, Event $queueForEvents, Build $queueForBuilds, Database $dbForPlatform, GitHub $github) + public function action(string $siteId, string $name, string $framework, bool $enabled, int $timeout, string $installCommand, string $buildCommand, string $outputDirectory, string $buildRuntime, string $adapter, string $installationId, ?string $fallbackFile, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, string $specification, Response $response, Database $dbForProject, Document $project, Event $queueForEvents, Database $dbForPlatform) { if (!empty($adapter)) { $configFramework = Config::getParam('frameworks')[$framework] ?? []; @@ -121,20 +110,6 @@ class Create extends Base throw new Exception(Exception::SITE_FRAMEWORK_UNSUPPORTED, 'Framework "' . $framework . '" is not supported'); } - // build from template - $template = new Document([]); - if ( - !empty($templateRepository) - && !empty($templateOwner) - && !empty($templateRootDirectory) - && !empty($templateVersion) - ) { - $template->setAttribute('repositoryName', $templateRepository) - ->setAttribute('ownerName', $templateOwner) - ->setAttribute('rootDirectory', $templateRootDirectory) - ->setAttribute('version', $templateVersion); - } - $installation = $dbForPlatform->getDocument('installations', $installationId); if (!empty($installationId) && $installation->isEmpty()) { @@ -202,57 +177,6 @@ class Create extends Base $site = $dbForProject->updateDocument('sites', $site->getId(), $site); - if (!empty($providerRepositoryId)) { - // Deploy VCS - $this->redeployVcsSite($request, $site, $project, $installation, $dbForProject, $dbForPlatform, $queueForBuilds, $template, $github); - } elseif (!$template->isEmpty()) { - // Deploy non-VCS from template - $deploymentId = ID::unique(); - $deployment = $dbForProject->createDocument('deployments', new Document([ - '$id' => $deploymentId, - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'resourceId' => $site->getId(), - 'resourceInternalId' => $site->getInternalId(), - 'resourceType' => 'sites', - 'installCommand' => $site->getAttribute('installCommand', ''), - 'buildCommand' => $site->getAttribute('buildCommand', ''), - 'outputDirectory' => $site->getAttribute('outputDirectory', ''), - 'type' => 'manual', - 'search' => implode(' ', [$deploymentId]), - 'activate' => true, - ])); - - // Preview deployments url - $projectId = $project->getId(); - - $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); - $previewDomain = "{$deploymentId}-{$projectId}.{$sitesDomain}"; - - $rule = Authorization::skip( - fn () => $dbForPlatform->createDocument('rules', new Document([ - '$id' => \md5($previewDomain), - 'projectId' => $project->getId(), - 'projectInternalId' => $project->getInternalId(), - 'domain' => $previewDomain, - 'resourceType' => 'deployment', - 'resourceId' => $deploymentId, - 'resourceInternalId' => $deployment->getInternalId(), - 'status' => 'verified', - 'certificateId' => '', - ])) - ); - - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($site) - ->setDeployment($deployment) - ->setTemplate($template); - } - $queueForEvents->setParam('siteId', $site->getId()); $response diff --git a/src/Appwrite/Platform/Modules/Sites/Services/Http.php b/src/Appwrite/Platform/Modules/Sites/Services/Http.php index 619702e761..b43919acb7 100644 --- a/src/Appwrite/Platform/Modules/Sites/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Sites/Services/Http.php @@ -9,6 +9,7 @@ use Appwrite\Platform\Modules\Sites\Http\Deployments\Create as CreateDeployment; use Appwrite\Platform\Modules\Sites\Http\Deployments\Delete as DeleteDeployment; use Appwrite\Platform\Modules\Sites\Http\Deployments\Download\Get as DownloadDeployment; use Appwrite\Platform\Modules\Sites\Http\Deployments\Get as GetDeployment; +use Appwrite\Platform\Modules\Sites\Http\Deployments\Template\Create as CreateTemplateDeployment; use Appwrite\Platform\Modules\Sites\Http\Deployments\Update as UpdateDeployment; use Appwrite\Platform\Modules\Sites\Http\Deployments\XList as ListDeployments; use Appwrite\Platform\Modules\Sites\Http\Frameworks\XList as ListFrameworks; @@ -49,6 +50,7 @@ class Http extends Service // Deployments $this->addAction(CreateDeployment::getName(), new CreateDeployment()); + $this->addAction(CreateTemplateDeployment::getName(), new CreateTemplateDeployment()); $this->addAction(GetDeployment::getName(), new GetDeployment()); $this->addAction(ListDeployments::getName(), new ListDeployments()); $this->addAction(UpdateDeployment::getName(), new UpdateDeployment()); diff --git a/src/Appwrite/Transformation/Adapter.php b/src/Appwrite/Transformation/Adapter.php new file mode 100644 index 0000000000..b4b5dce8a8 --- /dev/null +++ b/src/Appwrite/Transformation/Adapter.php @@ -0,0 +1,32 @@ +input = $input; + return $this; + } + + public function getOutput(): mixed + { + return $this->output; + } + + /** + * @param array $traits + */ + abstract public function isValid(array $traits): bool; + + abstract public function transform(): void; +} diff --git a/src/Appwrite/Transformation/Adapter/Mock.php b/src/Appwrite/Transformation/Adapter/Mock.php new file mode 100644 index 0000000000..13ec1cc88b --- /dev/null +++ b/src/Appwrite/Transformation/Adapter/Mock.php @@ -0,0 +1,26 @@ + $traits Mock traits + */ + public function isValid(array $traits): bool + { + if ($traits['mock'] === true) { + return true; + } + + return false; + } + + public function transform(): void + { + $this->output = $this->input; + $this->output = "Mock: " . $this->output; + } +} diff --git a/src/Appwrite/Transformation/Adapter/Preview.php b/src/Appwrite/Transformation/Adapter/Preview.php new file mode 100644 index 0000000000..94f1454593 --- /dev/null +++ b/src/Appwrite/Transformation/Adapter/Preview.php @@ -0,0 +1,201 @@ + $traits Proxied response headers + */ + public function isValid(array $traits): bool + { + $contentType = ''; + + foreach ($traits as $key => $value) { + if (\strtolower($key) === 'content-type') { + $contentType = $value; + break; + } + } + + if (\str_contains($contentType, 'text/html')) { + return true; + } + + return false; + } + + public function transform(): void + { + $this->output = $this->input; + + $banner = << + #appwrite-preview { + padding: 0; + margin: 0; + position: fixed; + right: 16px; + bottom: 16px; + z-index: 1; + border-radius: var(--border-radius-S, 8px); + border: var(--border-width-S, 1px) solid var(--color-border-neutral, #EDEDF0); + background: var(--color-bgColor-neutral-primary, #FFF); + box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.03), 0px 4px 4px 0px rgba(0, 0, 0, 0.04); + padding: var(--space-3, 6px) var(--space-4, 8px); + display: flex; + justify-content: center; + align-items: center; + gap: var(--gap-XXS, 4px); + cursor: pointer; + transition: opacity 0.3s; + } + + #appwrite-preview-close { + position: absolute; + right: 0px; + bottom: 0px; + border-radius: var(--border-radius-S, 8px); + background: linear-gradient(270deg, #FFF 69.64%, rgba(255, 255, 255, 0.00) 114.29%); + height: 100%; + aspect-ratio: 1 / 1; + display: flex; + justify-content: center; + align-items: center; + opacity: 0; + transition: opacity 0.3s; + } + + #appwrite-preview-logo-dark { + display: none; + } + + #appwrite-preview:hover #appwrite-preview-close { + opacity: 1; + } + + #appwrite-preview-text { + padding: 0; + margin: 0; + color: var(--color-fgColor-neutral-secondary, #56565C); + font-family: var(--font-family-sansSerif, Inter); + font-size: var(--font-size-XS, 12px); + font-style: normal; + font-weight: 500; + line-height: 130%; + letter-spacing: -0.12px; + } + + #appwrite-preview-close-text { + opacity: 0; + transition: opacity 0.3s; + position: absolute; + bottom: calc(15px + 4px); + display: flex; + padding: var(--space-1, 2px) var(--space-2, 4px); + color: var(--color-fgColor-neutral-secondary, #56565C); + text-align: center; + font-family: var(--font-family-sansSerif, Inter); + font-size: var(--font-size-XS, 12px); + font-style: normal; + font-weight: 400; + line-height: 130%; + letter-spacing: -0.12px; + border-radius: var(--border-radius-XS, 6px); + background: #EDEDF0; + } + + #appwrite-preview-close:hover #appwrite-preview-close-text { + opacity: 1; + } + + @media (prefers-color-scheme: dark) { + #appwrite-preview { + border: var(--border-width-S, 1px) solid var(--color-border-neutral, #2D2D31); + background: var(--color-bgColor-neutral-primary, #1D1D21); + box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.03), 0px 4px 4px 0px rgba(0, 0, 0, 0.04); + } + + #appwrite-preview-text { + color: var(--color-fgColor-neutral-secondary, #C3C3C6); + font-family: var(--font-family-sansSerif, Inter); + font-size: var(--font-size-XS, 12px); + } + + #appwrite-preview-logo-dark { + display: block; + } + + #appwrite-preview-logo-light { + display: none; + } + + #appwrite-preview-close { + background: linear-gradient(270deg, #1D1D21 69.64%, rgba(29, 29, 33, 0.00) 114.29%); + } + + #appwrite-preview-close-text { + background: #2D2D31; + color: var(--color-fgColor-neutral-secondary, #C3C3C6); + } + } + + + + + + EOT; + + $this->output .= $banner; + } +} diff --git a/src/Appwrite/Transformation/Transformation.php b/src/Appwrite/Transformation/Transformation.php new file mode 100644 index 0000000000..d01ee08179 --- /dev/null +++ b/src/Appwrite/Transformation/Transformation.php @@ -0,0 +1,74 @@ + $adapters + */ + protected array $adapters; + + /** + * @var array $traits + */ + protected array $traits; + + protected mixed $input; + protected mixed $output; + + /** + * @param array $adapters + */ + public function __construct(array $adapters = []) + { + $this->adapters = $adapters; + } + + /** + * @param array $traits + */ + public function setTraits(array $traits): self + { + $this->traits = $traits; + return $this; + } + + public function setInput(mixed $input): self + { + $this->input = $input; + return $this; + } + + public function addAdapter(Adapter $adapter): self + { + $this->adapters[] = $adapter; + return $this; + } + + public function transform(): bool + { + foreach ($this->adapters as $adapter) { + if (!$adapter->isValid($this->traits)) { + return false; + } + } + + $output = $this->input; + + foreach ($this->adapters as $adapter) { + $adapter->setInput($output); + $adapter->transform(); + $output = $adapter->getOutput(); + } + + $this->output = $output; + + return true; + } + + public function getOutput(): mixed + { + return $this->output; + } +} diff --git a/tests/e2e/Services/Functions/FunctionsBase.php b/tests/e2e/Services/Functions/FunctionsBase.php index 3c59e0131f..fab6bd4027 100644 --- a/tests/e2e/Services/Functions/FunctionsBase.php +++ b/tests/e2e/Services/Functions/FunctionsBase.php @@ -209,6 +209,16 @@ trait FunctionsBase return $deployment; } + protected function createTemplateDeployment(string $functionId, mixed $params = []): mixed + { + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments/template', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $deployment; + } + protected function getFunctionUsage(string $functionId, mixed $params): mixed { $usage = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/usage', array_merge([ diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 01157667ae..9ba474e4be 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -358,6 +358,21 @@ class FunctionsCustomServerTest extends Scope $functionId = $function['body']['$id'] ?? ''; + $deployment = $this->createTemplateDeployment( + $functionId, + [ + 'functionId' => ID::unique(), + 'activate' => true, + 'repository' => $starterTemplate['body']['providerRepositoryId'], + 'owner' => $starterTemplate['body']['providerOwner'], + 'rootDirectory' => $phpRuntime['providerRootDirectory'], + 'version' => $starterTemplate['body']['providerVersion'], + ] + ); + + $this->assertEquals(202, $deployment['headers']['status-code']); + $this->assertNotEmpty($deployment['body']['$id']); + $deployments = $this->listDeployments($functionId); $this->assertEquals(200, $deployments['headers']['status-code']); diff --git a/tests/e2e/Services/Sites/SitesBase.php b/tests/e2e/Services/Sites/SitesBase.php index 49a272dcde..371e36955e 100644 --- a/tests/e2e/Services/Sites/SitesBase.php +++ b/tests/e2e/Services/Sites/SitesBase.php @@ -230,6 +230,16 @@ trait SitesBase return $deployment; } + protected function createTemplateDeployment(string $siteId, mixed $params = []): mixed + { + $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments/template', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $deployment; + } + protected function getSiteUsage(string $siteId, mixed $params): mixed { $usage = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/usage', array_merge([ @@ -301,4 +311,27 @@ trait SitesBase return $domain; } + + + protected function getDeploymentDomain(string $deploymentId): string + { + $rules = $this->client->call(Client::METHOD_GET, '/proxy/rules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('resourceId', [$deploymentId])->toString(), + Query::equal('resourceType', ['deployment'])->toString(), + ], + ]); + + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $rules['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($rules['body']['rules'])); + $this->assertNotEmpty($rules['body']['rules'][0]['domain']); + + $domain = $rules['body']['rules'][0]['domain']; + + return $domain; + } } diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index c002dddbb5..c43d1937eb 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -570,11 +570,6 @@ class SitesCustomServerTest extends Scope 'installCommand' => $nextjsFramework['installCommand'], 'outputDirectory' => $nextjsFramework['outputDirectory'], 'providerRootDirectory' => $nextjsFramework['providerRootDirectory'], - 'templateOwner' => $starterTemplate['body']['providerOwner'], - 'templateRepository' => $starterTemplate['body']['providerRepositoryId'], - 'templateRootDirectory' => $nextjsFramework['providerRootDirectory'], - 'templateVersion' => $starterTemplate['body']['providerVersion'], - 'providerBranch' => 'main', ] ); @@ -583,6 +578,20 @@ class SitesCustomServerTest extends Scope $siteId = $site['body']['$id'] ?? ''; + $deployment = $this->createTemplateDeployment( + $siteId, + [ + 'owner' => $starterTemplate['body']['providerOwner'], + 'repository' => $starterTemplate['body']['providerRepositoryId'], + 'rootDirectory' => $nextjsFramework['providerRootDirectory'], + 'version' => $starterTemplate['body']['providerVersion'], + 'activate' => true, + ] + ); + + $this->assertEquals(202, $deployment['headers']['status-code']); + $this->assertNotEmpty($deployment['body']['$id']); + $deployments = $this->listDeployments($siteId); $this->assertEquals(200, $deployments['headers']['status-code']); @@ -1218,12 +1227,21 @@ class SitesCustomServerTest extends Scope 'buildCommand' => $template['frameworks'][0]['buildCommand'], 'installCommand' => $template['frameworks'][0]['installCommand'], 'fallbackFile' => $template['frameworks'][0]['fallbackFile'], - 'templateRepository' => $template['providerRepositoryId'], - 'templateOwner' => $template['providerOwner'], - 'templateRootDirectory' => $template['frameworks'][0]['providerRootDirectory'], - 'templateVersion' => $template['providerVersion'], ]); + $this->assertNotEmpty($siteId); + + $deployment = $this->createTemplateDeployment($siteId, [ + 'repository' => $template['providerRepositoryId'], + 'owner' => $template['providerOwner'], + 'rootDirectory' => $template['frameworks'][0]['providerRootDirectory'], + 'version' => $template['providerVersion'], + 'activate' => true + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + $this->assertNotEmpty($deployment['body']['$id']); + $this->assertEventually(function () use ($siteId) { $site = $this->getSite($siteId); $this->assertNotEmpty($site['body']['deploymentId']); @@ -1366,5 +1384,120 @@ class SitesCustomServerTest extends Scope $this->cleanupSite($site['body']['$id']); } + public function testSitePreviewBranding(): void + { + $siteId = $this->setupSite([ + 'siteId' => ID::unique(), + 'name' => 'A site', + 'framework' => 'other', + 'adapter' => 'static', + 'buildRuntime' => 'static-1', + 'outputDirectory' => './', + 'buildCommand' => '', + 'installCommand' => '', + 'fallbackFile' => '', + ]); + + $this->assertNotEmpty($siteId); + + $deploymentId = $this->setupDeployment($siteId, [ + 'code' => $this->packageSite('static'), + 'activate' => 'true' + ]); + + $this->assertNotEmpty($deploymentId); + + $domain = $this->getSiteDomain($siteId); + $previewDomain = $this->getDeploymentDomain($deploymentId); + + $this->assertNotEmpty($domain); + $this->assertNotEmpty($previewDomain); + + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://' . $domain); + + $response = $proxyClient->call(Client::METHOD_GET, '/', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertStringContainsString("Hello Appwrite", $response['body']); + $this->assertStringNotContainsString("Preview by", $response['body']); + + $contentLength = $response['headers']['content-length']; + + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://' . $previewDomain); + + $response = $proxyClient->call(Client::METHOD_GET, '/', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertStringContainsString("Hello Appwrite", $response['body']); + $this->assertStringContainsString("Preview by", $response['body']); + $this->assertGreaterThan($contentLength, $response['headers']['content-length']); + + $this->cleanupSite($siteId); + } + + public function testSiteCors(): void + { + // Create rule together with site + $subdomain = 'startup' . \uniqid(); + + $siteId = $this->setupSite([ + 'siteId' => ID::unique(), + 'name' => 'Startup site', + 'framework' => 'other', + 'adapter' => 'static', + 'buildRuntime' => 'static-1', + 'outputDirectory' => './', + 'buildCommand' => '', + 'installCommand' => '', + 'fallbackFile' => '', + 'subdomain' => $subdomain + ]); + + $this->assertNotEmpty($siteId); + + $domain = $this->getSiteDomain($siteId); + + $this->assertNotEmpty($domain); + + $url = 'http://' . $domain; + + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'referer' => $url, + 'origin' => $url + ])); + + $this->assertEquals($url, $response['headers']['access-control-allow-origin']); + + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => 'unknown', + 'referer' => $url, + 'origin' => $url + ])); + + $this->assertNotEquals($url, $response['headers']['access-control-allow-origin']); + $this->assertEquals('http://localhost', $response['headers']['access-control-allow-origin']); + + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'referer' => 'http://unknown.com', + 'origin' => 'http://unknown.com' + ])); + + $this->assertNotEquals($url, $response['headers']['access-control-allow-origin']); + $this->assertEquals('http://localhost', $response['headers']['access-control-allow-origin']); + } + // TODO: Add tests for deletion of resources when site is deleted } diff --git a/tests/unit/Transformation/TransformationTest.php b/tests/unit/Transformation/TransformationTest.php new file mode 100644 index 0000000000..a3169026a3 --- /dev/null +++ b/tests/unit/Transformation/TransformationTest.php @@ -0,0 +1,43 @@ +addAdapter(new Mock()); + + $transformer->setInput($input); + $transformer->setTraits([]); + + $this->assertFalse($transformer->transform()); + + $transformer->setTraits(['mock' => true]); + $this->assertFalse($transformer->transform()); + + $transformer->setTraits(['mock' => true, 'content-type' => 'text/plain']); + $this->assertFalse($transformer->transform()); + + $transformer->setTraits(['mock' => true, 'content-type' => 'tExT/HtML']); + $this->assertFalse($transformer->transform()); + + $transformer->setTraits(['mock' => false, 'content-type' => 'text/plain, text/html; charset=utf-8']); + $this->assertFalse($transformer->transform()); + + $transformer->setTraits(['mock' => true, 'content-type' => 'text/plain, text/html; charset=utf-8']); + $this->assertTrue($transformer->transform()); + + $this->assertStringContainsString("Hello world", $transformer->getOutput()); + $this->assertStringContainsString("Preview by", $transformer->getOutput()); + $this->assertStringContainsString("Mock:", $transformer->getOutput()); + } +}