Merge pull request #9366 from appwrite/feat-site-screenshots

Feat: Site screenshots
This commit is contained in:
Matej Bačo 2025-02-21 22:11:03 +01:00 committed by GitHub
commit c8f4667b6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 552 additions and 104 deletions

View file

@ -1528,7 +1528,29 @@ return [
'default' => false,
'array' => false,
'filters' => [],
]
],
[
'$id' => ID::custom('screenshotLight'), // File ID from 'screenshots' Console bucket
'type' => Database::VAR_STRING,
'format' => '',
'size' => 32,
'signed' => false,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('screenshotDark'), // File ID from 'screenshots' Console bucket
'type' => Database::VAR_STRING,
'format' => '',
'size' => 32,
'signed' => false,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
],
'indexes' => [
[

View file

@ -539,4 +539,49 @@ return [
'providerVersion' => '0.2.*',
'variables' => [],
],
[
'key' => 'nextjs-starter',
'name' => 'Next.js starter website',
'useCases' => ['starter'],
'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'],
'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'],
'frameworks' => [
getFramework('SVELTEKIT', [
'providerRootDirectory' => './sveltekit/starter',
]),
],
'vcsProvider' => 'github',
'providerRepositoryId' => 'templates-for-sites',
'providerOwner' => 'appwrite',
'providerVersion' => '0.2.*',
'variables' => [],
],
];

View file

@ -236,10 +236,9 @@ $createGitDeployments = function (GitHub $github, string $providerInstallationId
$projectId = $project->getId();
$sitesDomain = System::getEnv('_APP_DOMAIN_SITES', '');
$domain = "{$deploymentId}-{$projectId}.{$sitesDomain}";
$domain = ID::unique() . "." . $sitesDomain;
$ruleId = md5($domain);
$rule = Authorization::skip(
Authorization::skip(
fn () => $dbForPlatform->createDocument('rules', new Document([
'$id' => $ruleId,
'projectId' => $project->getId(),

View file

@ -4,6 +4,7 @@ require_once __DIR__ . '/../init.php';
use Ahc\Jwt\JWT;
use Appwrite\Auth\Auth;
use Appwrite\Auth\Key;
use Appwrite\Event\Certificate;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
@ -54,7 +55,7 @@ Config::setParam('domainVerification', false);
Config::setParam('cookieDomain', 'localhost');
Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE);
function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Reader $geodb, callable $isResourceBlocked, string $previewHostname)
function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Reader $geodb, callable $isResourceBlocked, string $previewHostname, ?Key $apiKey)
{
$utopia->getRoute()?->label('error', __DIR__ . '/../views/general/error.phtml');
@ -269,11 +270,11 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw
if ($type === 'function') {
$jwtExpiry = $resource->getAttribute('timeout', 900);
$jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0);
$apiKey = $jwtObj->encode([
$jwtKey = $jwtObj->encode([
'projectId' => $project->getId(),
'scopes' => $resource->getAttribute('scopes', [])
]);
$headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey;
$headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $jwtKey;
$headers['x-appwrite-trigger'] = 'http';
$headers['x-appwrite-user-jwt'] = '';
}
@ -448,18 +449,19 @@ 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();
if (\is_null($apiKey) || $apiKey->isBannerDisabled() === false) {
$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']);
foreach ($executionResponse['headers'] as $key => $value) {
if (\strtolower($key) === 'content-length') {
$executionResponse['headers'][$key] = \strlen($executionResponse['body']);
}
}
}
}
@ -585,7 +587,7 @@ App::init()
*/
App::init()
->groups(['database', 'functions', 'storage', 'messaging'])
->groups(['database', 'functions', 'messaging'])
->inject('project')
->inject('request')
->action(function (Document $project, Request $request) {
@ -617,7 +619,8 @@ App::init()
->inject('queueForFunctions')
->inject('isResourceBlocked')
->inject('previewHostname')
->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Document $console, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, array $clients, Reader $geodb, StatsUsage $queueForStatsUsage, Event $queueForEvents, Certificate $queueForCertificates, Func $queueForFunctions, callable $isResourceBlocked, string $previewHostname) {
->inject('apiKey')
->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Document $console, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, array $clients, Reader $geodb, StatsUsage $queueForStatsUsage, Event $queueForEvents, Certificate $queueForCertificates, Func $queueForFunctions, callable $isResourceBlocked, string $previewHostname, ?Key $apiKey) {
/*
* Appwrite Router
*/
@ -625,7 +628,7 @@ App::init()
$mainDomain = System::getEnv('_APP_DOMAIN', '');
// Only run Router when external domain
if ($host !== $mainDomain || !empty($previewHostname)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $geodb, $isResourceBlocked, $previewHostname)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $geodb, $isResourceBlocked, $previewHostname, $apiKey)) {
$utopia->getRoute()?->label('router', true);
}
}
@ -865,7 +868,8 @@ App::options()
->inject('geodb')
->inject('isResourceBlocked')
->inject('previewHostname')
->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Reader $geodb, callable $isResourceBlocked, string $previewHostname) {
->inject('apiKey')
->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Reader $geodb, callable $isResourceBlocked, string $previewHostname, ?Key $apiKey) {
/*
* Appwrite Router
*/
@ -873,7 +877,7 @@ App::options()
$mainDomain = System::getEnv('_APP_DOMAIN', '');
// Only run Router when external domain
if ($host !== $mainDomain || !empty($previewHostname)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $geodb, $isResourceBlocked, $previewHostname)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $geodb, $isResourceBlocked, $previewHostname, $apiKey)) {
$utopia->getRoute()?->label('router', true);
}
}
@ -1173,7 +1177,8 @@ App::get('/robots.txt')
->inject('geodb')
->inject('isResourceBlocked')
->inject('previewHostname')
->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Reader $geodb, callable $isResourceBlocked, string $previewHostname) {
->inject('apiKey')
->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Reader $geodb, callable $isResourceBlocked, string $previewHostname, ?Key $apiKey) {
$host = $request->getHostname() ?? '';
$mainDomain = System::getEnv('_APP_DOMAIN', '');
@ -1181,7 +1186,7 @@ App::get('/robots.txt')
$template = new View(__DIR__ . '/../views/general/robots.phtml');
$response->text($template->render(false));
} else {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $geodb, $isResourceBlocked, $previewHostname)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $geodb, $isResourceBlocked, $previewHostname, $apiKey)) {
$utopia->getRoute()?->label('router', true);
}
}
@ -1203,7 +1208,8 @@ App::get('/humans.txt')
->inject('geodb')
->inject('isResourceBlocked')
->inject('previewHostname')
->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Reader $geodb, callable $isResourceBlocked, string $previewHostname) {
->inject('apiKey')
->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Reader $geodb, callable $isResourceBlocked, string $previewHostname, ?Key $apiKey) {
$host = $request->getHostname() ?? '';
$mainDomain = System::getEnv('_APP_DOMAIN', '');
@ -1211,7 +1217,7 @@ App::get('/humans.txt')
$template = new View(__DIR__ . '/../views/general/humans.phtml');
$response->text($template->render(false));
} else {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $geodb, $isResourceBlocked, $previewHostname)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $geodb, $isResourceBlocked, $previewHostname, $apiKey)) {
$utopia->getRoute()?->label('router', true);
}
}

View file

@ -13,6 +13,7 @@ use Swoole\Table;
use Utopia\App;
use Utopia\Audit\Audit;
use Utopia\CLI\Console;
use Utopia\Compression\Compression;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
@ -249,8 +250,7 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $reg
$audit->setup();
}
if ($dbForPlatform->getDocument('buckets', 'default')->isEmpty() &&
!$dbForPlatform->exists($dbForPlatform->getDatabase(), 'bucket_1')) {
if ($dbForPlatform->getDocument('buckets', 'default')->isEmpty()) {
Console::info(" └── Creating default bucket...");
$dbForPlatform->createDocument('buckets', new Document([
'$id' => ID::custom('default'),
@ -302,6 +302,59 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $reg
$dbForPlatform->createCollection('bucket_' . $bucket->getInternalId(), $attributes, $indexes);
}
if ($dbForPlatform->getDocument('buckets', 'screenshots')->isEmpty()) {
Console::info(" └── Creating screenshots bucket...");
$dbForPlatform->createDocument('buckets', new Document([
'$id' => ID::custom('screenshots'),
'$collection' => ID::custom('buckets'),
'name' => 'Screenshots',
'maximumFileSize' => 5000000, // ~5MB
'allowedFileExtensions' => [ 'png' ],
'enabled' => true,
'compression' => Compression::GZIP,
'encryption' => false,
'antivirus' => false,
'fileSecurity' => true,
'$permissions' => [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
'search' => 'buckets Screenshots',
]));
$bucket = $dbForPlatform->getDocument('buckets', 'screenshots');
Console::info(" └── Creating files collection for screenshots bucket...");
$files = $collections['buckets']['files'] ?? [];
if (empty($files)) {
throw new Exception('Files collection is not configured.');
}
$attributes = array_map(fn ($attr) => new Document([
'$id' => ID::custom($attr['$id']),
'type' => $attr['type'],
'size' => $attr['size'],
'required' => $attr['required'],
'signed' => $attr['signed'],
'array' => $attr['array'],
'filters' => $attr['filters'],
'default' => $attr['default'] ?? null,
'format' => $attr['format'] ?? ''
]), $files['attributes']);
$indexes = array_map(fn ($index) => new Document([
'$id' => ID::custom($index['$id']),
'type' => $index['type'],
'attributes' => $index['attributes'],
'lengths' => $index['lengths'],
'orders' => $index['orders'],
]), $files['indexes']);
$dbForPlatform->createCollection('bucket_' . $bucket->getInternalId(), $attributes, $indexes);
}
});
$pools->reclaim();

View file

@ -1961,16 +1961,24 @@ App::setResource(
fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false
);
App::setResource('previewHostname', function (Request $request) {
App::setResource('previewHostname', function (Request $request, ?Key $apiKey) {
$allowed = false;
if (App::isDevelopment()) {
$host = $request->getQuery('appwrite-hostname') ?? '';
$allowed = true;
} elseif (!\is_null($apiKey) && $apiKey->getHostnameOverride() === true) {
$allowed = true;
}
if ($allowed) {
$host = $request->getQuery('appwrite-hostname', $request->getHeader('x-appwrite-hostname', ''));
if (!empty($host)) {
return $host;
}
}
return '';
}, ['request']);
}, ['request', 'apiKey']);
App::setResource('apiKey', function (Request $request, Document $project): ?Key {
$key = $request->getHeader('x-appwrite-key');

12
composer.lock generated
View file

@ -6190,16 +6190,16 @@
},
{
"name": "phpstan/phpdoc-parser",
"version": "2.0.2",
"version": "2.1.0",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "51087f87dcce2663e1fed4dfd4e56eccd580297e"
"reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/51087f87dcce2663e1fed4dfd4e56eccd580297e",
"reference": "51087f87dcce2663e1fed4dfd4e56eccd580297e",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/9b30d6fd026b2c132b3985ce6b23bec09ab3aa68",
"reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68",
"shasum": ""
},
"require": {
@ -6231,9 +6231,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/2.0.2"
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.1.0"
},
"time": "2025-02-17T20:25:51+00:00"
"time": "2025-02-19T13:28:12+00:00"
},
{
"name": "phpstan/phpstan",

View file

@ -435,6 +435,7 @@ services:
volumes:
- appwrite-functions:/storage/functions:rw
- appwrite-builds:/storage/builds:rw
- appwrite-uploads:/storage/uploads:rw
- ./app:/usr/src/code/app
- ./src:/usr/src/code/src
depends_on:
@ -947,6 +948,12 @@ services:
environment:
- _APP_ASSISTANT_OPENAI_API_KEY
appwrite-browser:
container_name: appwrite-browser
image: appwrite/browser:0.2.0
networks:
- appwrite
openruntimes-executor:
container_name: openruntimes-executor
hostname: exc1

View file

@ -20,6 +20,9 @@ class Key
protected string $name,
protected bool $expired = false,
protected array $disabledMetrics = [],
protected bool $hostnameOverride = false,
protected bool $bannerDisabled = false,
protected bool $projectCheckDisabled = false,
) {
}
@ -58,6 +61,23 @@ class Key
return $this->disabledMetrics;
}
public function getHostnameOverride(): bool
{
return $this->hostnameOverride;
}
public function isBannerDisabled(): bool
{
return $this->bannerDisabled;
}
public function isProjectCheckDisabled(): bool
{
return $this->projectCheckDisabled;
}
/**
* Decode the given secret key into a Key object, containing the project ID, type, role, scopes, and name.
* Can be a stored API key or a dynamic key (JWT).
@ -109,9 +129,12 @@ class Key
$name = $payload['name'] ?? 'Dynamic Key';
$projectId = $payload['projectId'] ?? '';
$disabledMetrics = $payload['disabledMetrics'] ?? [];
$hostnameOverride = $payload['hostnameOverride'] ?? false;
$bannerDisabled = $payload['bannerDisabled'] ?? false;
$projectCheckDisabled = $payload['projectCheckDisabled'] ?? false;
$scopes = \array_merge($payload['scopes'] ?? [], $scopes);
if ($projectId !== $project->getId()) {
if (!$projectCheckDisabled && $projectId !== $project->getId()) {
return $guestKey;
}
@ -122,7 +145,10 @@ class Key
$scopes,
$name,
$expired,
$disabledMetrics
$disabledMetrics,
$hostnameOverride,
$bannerDisabled,
$projectCheckDisabled
);
case API_KEY_STANDARD:
$key = $project->find(

View file

@ -172,14 +172,10 @@ class Base extends Action
'activate' => $activate,
]));
// Preview deployments for sites
$projectId = $project->getId();
$sitesDomain = System::getEnv('_APP_DOMAIN_SITES', '');
$domain = "{$deploymentId}-{$projectId}.{$sitesDomain}";
$domain = ID::unique() . "." . $sitesDomain;
$ruleId = md5($domain);
$rule = Authorization::skip(
Authorization::skip(
fn () => $dbForPlatform->createDocument('rules', new Document([
'$id' => $ruleId,
'projectId' => $project->getId(),

View file

@ -7,6 +7,8 @@ use Appwrite\Event\Event;
use Appwrite\Event\Func;
use Appwrite\Event\StatsUsage;
use Appwrite\Messaging\Adapter\Realtime;
use Appwrite\Permission;
use Appwrite\Role;
use Appwrite\Utopia\Response\Model\Deployment;
use Appwrite\Vcs\Comment;
use Exception;
@ -24,9 +26,11 @@ use Utopia\Database\Exception\Structure;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Fetch\Client as FetchClient;
use Utopia\Logger\Log;
use Utopia\Platform\Action;
use Utopia\Queue\Message;
use Utopia\Storage\Compression\Compression;
use Utopia\Storage\Device;
use Utopia\Storage\Device\Local;
use Utopia\System\System;
@ -56,9 +60,10 @@ class Builds extends Action
->inject('dbForProject')
->inject('deviceForFunctions')
->inject('isResourceBlocked')
->inject('deviceForFiles')
->inject('log')
->callback(fn ($message, Document $project, Database $dbForPlatform, Event $queueForEvents, Func $queueForFunctions, StatsUsage $usage, Cache $cache, Database $dbForProject, Device $deviceForFunctions, callable $isResourceBlocked, Log $log) =>
$this->action($message, $project, $dbForPlatform, $queueForEvents, $queueForFunctions, $usage, $cache, $dbForProject, $deviceForFunctions, $isResourceBlocked, $log));
->callback(fn ($message, Document $project, Database $dbForPlatform, Event $queueForEvents, Func $queueForFunctions, StatsUsage $usage, Cache $cache, Database $dbForProject, Device $deviceForFunctions, callable $isResourceBlocked, Device $deviceForFiles, Log $log) =>
$this->action($message, $project, $dbForPlatform, $queueForEvents, $queueForFunctions, $usage, $cache, $dbForProject, $deviceForFunctions, $isResourceBlocked, $deviceForFiles, $log));
}
/**
@ -71,11 +76,12 @@ class Builds extends Action
* @param Cache $cache
* @param Database $dbForProject
* @param Device $deviceForFunctions
* @param Device $deviceForFiles
* @param Log $log
* @return void
* @throws \Utopia\Database\Exception
*/
public function action(Message $message, Document $project, Database $dbForPlatform, Event $queueForEvents, Func $queueForFunctions, StatsUsage $queueForStatsUsage, Cache $cache, Database $dbForProject, Device $deviceForFunctions, callable $isResourceBlocked, Log $log): void
public function action(Message $message, Document $project, Database $dbForPlatform, Event $queueForEvents, Func $queueForFunctions, StatsUsage $queueForStatsUsage, Cache $cache, Database $dbForProject, Device $deviceForFunctions, callable $isResourceBlocked, Device $deviceForFiles, Log $log): void
{
$payload = $message->getPayload() ?? [];
@ -96,7 +102,7 @@ class Builds extends Action
case BUILD_TYPE_RETRY:
Console::info('Creating build for deployment: ' . $deployment->getId());
$github = new GitHub($cache);
$this->buildDeployment($deviceForFunctions, $queueForFunctions, $queueForEvents, $queueForStatsUsage, $dbForPlatform, $dbForProject, $github, $project, $resource, $deployment, $template, $isResourceBlocked, $log);
$this->buildDeployment($deviceForFunctions, $deviceForFiles, $queueForFunctions, $queueForEvents, $queueForStatsUsage, $dbForPlatform, $dbForProject, $github, $project, $resource, $deployment, $template, $isResourceBlocked, $log);
break;
default:
@ -106,6 +112,7 @@ class Builds extends Action
/**
* @param Device $deviceForFunctions
* @param Device $deviceForFiles
* @param Func $queueForFunctions
* @param Event $queueForEvents
* @param StatsUsage $queueForStatsUsage
@ -122,7 +129,7 @@ class Builds extends Action
*
* @throws Exception
*/
protected function buildDeployment(Device $deviceForFunctions, Func $queueForFunctions, Event $queueForEvents, StatsUsage $queueForStatsUsage, Database $dbForPlatform, Database $dbForProject, GitHub $github, Document $project, Document $resource, Document $deployment, Document $template, callable $isResourceBlocked, Log $log): void
protected function buildDeployment(Device $deviceForFunctions, Device $deviceForFiles, Func $queueForFunctions, Event $queueForEvents, StatsUsage $queueForStatsUsage, Database $dbForPlatform, Database $dbForProject, GitHub $github, Document $project, Document $resource, Document $deployment, Document $template, callable $isResourceBlocked, Log $log): void
{
$resourceKey = match($resource->getCollection()) {
'functions' => 'functionId',
@ -713,6 +720,109 @@ class Builds extends Action
Console::success("Build id: $buildId created");
if ($resource->getCollection() === 'sites') {
try {
$rule = Authorization::skip(fn () => $dbForPlatform->findOne('rules', [
Query::equal("projectInternalId", [$project->getInternalId()]),
Query::equal("resourceType", ["deployment"]),
Query::equal("resourceInternalId", [$deployment->getInternalId()])
]));
if ($rule->isEmpty()) {
throw new \Exception("Rule for build not found");
}
$client = new FetchClient();
$client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON);
$bucket = $dbForPlatform->getDocument('buckets', 'screenshots');
$configs = [
'screenshotLight' => [
'headers' => [ 'x-appwrite-hostname' => $rule->getAttribute('domain') ],
'url' => 'http://appwrite/?appwrite-preview=1&appwrite-theme=light',
'theme' => 'light'
],
'screenshotDark' => [
'headers' => [ 'x-appwrite-hostname' => $rule->getAttribute('domain') ],
'url' => 'http://appwrite/?appwrite-preview=1&appwrite-theme=dark',
'theme' => 'dark'
],
];
$jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 0);
$apiKey = $jwtObj->encode([
'hostnameOverride' => true,
'bannerDisabled' => true,
'projectCheckDisabled' => true
]);
// TODO: @Meldiron if becomes too slow, do concurrently
foreach ($configs as $key => $config) {
$config['headers'] = \array_merge($config['headers'] ?? [], [
'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey
]);
$response = $client->fetch(
url: 'http://appwrite-browser:3000/v1/screenshots',
method: 'POST',
body: $config
);
if ($response->getStatusCode() >= 400) {
throw new \Exception("Screenshot failed to generate: " . $response->getBody());
}
$screenshot = $response->getBody();
$fileId = ID::unique();
$fileName = $fileId . '.png';
$path = $deviceForFiles->getPath($fileName);
$path = str_ireplace($deviceForFiles->getRoot(), $deviceForFiles->getRoot() . DIRECTORY_SEPARATOR . $bucket->getId(), $path); // Add bucket id to path after root
$success = $deviceForFiles->write($path, $screenshot, "image/png");
if (!$success) {
throw new \Exception("Screenshot failed to save");
}
$teamId = $project->getAttribute('teamId', '');
$file = new Document([
'$id' => $fileId,
'$permissions' => [
Permission::read(Role::team(ID::custom($teamId))),
],
'bucketId' => $bucket->getId(),
'bucketInternalId' => $bucket->getInternalId(),
'name' => $fileName,
'path' => $path,
'signature' => $deviceForFiles->getFileHash($path),
'mimeType' => $deviceForFiles->getFileMimeType($path),
'sizeOriginal' => \strlen($screenshot),
'sizeActual' => $deviceForFiles->getFileSize($path),
'algorithm' => Compression::GZIP,
'comment' => '',
'chunksTotal' => 1,
'chunksUploaded' => 1,
'openSSLVersion' => null,
'openSSLCipher' => null,
'openSSLTag' => null,
'openSSLIV' => null,
'search' => implode(' ', [$fileId, $fileName]),
'metadata' => ['content_type' => $deviceForFiles->getFileMimeType($path)],
]);
$file = Authorization::skip(fn () => $dbForPlatform->createDocument('bucket_' . $bucket->getInternalId(), $file));
$deployment->setAttribute($key, $fileId);
}
$dbForProject->updateDocument('deployments', $deployment->getId(), $deployment);
} catch (\Throwable $th) {
Console::warning("Screenshot failed to generate:");
Console::warning($th->getMessage());
Console::warning($th->getTraceAsString());
}
}
/** Set auto deploy */
if ($deployment->getAttribute('activate') === true) {
$resource->setAttribute('deploymentInternalId', $deployment->getInternalId());

View file

@ -10,11 +10,14 @@ use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Storage\Device;
use Utopia\System\System;
class Create extends Action
{
@ -53,7 +56,9 @@ class Create extends Action
->param('siteId', '', new UID(), 'Site ID.')
->param('deploymentId', '', new UID(), 'Deployment ID.')
->inject('response')
->inject('project')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('queueForEvents')
->inject('queueForBuilds')
->inject('deviceForSites')
@ -61,7 +66,7 @@ class Create extends Action
->callback([$this, 'action']);
}
public function action(string $siteId, string $deploymentId, Response $response, Database $dbForProject, Event $queueForEvents, Build $queueForBuilds, Device $deviceForSites, Device $deviceForFunctions)
public function action(string $siteId, string $deploymentId, Response $response, Document $project, Database $dbForProject, Database $dbForPlatform, Event $queueForEvents, Build $queueForBuilds, Device $deviceForSites, Device $deviceForFunctions)
{
$site = $dbForProject->getDocument('sites', $siteId);
@ -97,6 +102,24 @@ class Create extends Action
'search' => implode(' ', [$deploymentId]),
]));
// Preview deployments for sites
$sitesDomain = System::getEnv('_APP_DOMAIN_SITES', '');
$domain = ID::unique() . "." . $sitesDomain;
$ruleId = md5($domain);
Authorization::skip(
fn () => $dbForPlatform->createDocument('rules', new Document([
'$id' => $ruleId,
'projectId' => $project->getId(),
'projectInternalId' => $project->getInternalId(),
'domain' => $domain,
'resourceType' => 'deployment',
'resourceId' => $deploymentId,
'resourceInternalId' => $deployment->getInternalId(),
'status' => 'verified',
'certificateId' => '',
]))
);
$queueForBuilds
->setType(BUILD_TYPE_DEPLOYMENT)
->setResource($site)

View file

@ -228,14 +228,10 @@ class Create extends Action
'type' => $type
]));
// Preview deployments for sites
$projectId = $project->getId();
$sitesDomain = System::getEnv('_APP_DOMAIN_SITES', '');
$domain = "{$deploymentId}-{$projectId}.{$sitesDomain}";
$domain = ID::unique() . "." . $sitesDomain;
$ruleId = md5($domain);
$rule = Authorization::skip(
Authorization::skip(
fn () => $dbForPlatform->createDocument('rules', new Document([
'$id' => $ruleId,
'projectId' => $project->getId(),
@ -283,14 +279,10 @@ class Create extends Action
'type' => $type
]));
// Preview deployments for sites
$projectId = $project->getId();
$sitesDomain = System::getEnv('_APP_DOMAIN_SITES', '');
$domain = "{$deploymentId}-{$projectId}.{$sitesDomain}";
$domain = ID::unique() . "." . $sitesDomain;
$ruleId = md5($domain);
$rule = Authorization::skip(
Authorization::skip(
fn () => $dbForPlatform->createDocument('rules', new Document([
'$id' => $ruleId,
'projectId' => $project->getId(),

View file

@ -139,18 +139,15 @@ class Create extends Base
'activate' => $activate,
]));
// Preview deployments url
$projectId = $project->getId();
$sitesDomain = System::getEnv('_APP_DOMAIN_SITES', '');
$previewDomain = "{$deploymentId}-{$projectId}.{$sitesDomain}";
$rule = Authorization::skip(
$domain = ID::unique() . "." . $sitesDomain;
$ruleId = md5($domain);
Authorization::skip(
fn () => $dbForPlatform->createDocument('rules', new Document([
'$id' => \md5($previewDomain),
'$id' => $ruleId,
'projectId' => $project->getId(),
'projectInternalId' => $project->getInternalId(),
'domain' => $previewDomain,
'domain' => $domain,
'resourceType' => 'deployment',
'resourceId' => $deploymentId,
'resourceInternalId' => $deployment->getInternalId(),

View file

@ -22,6 +22,7 @@ use Utopia\Database\Exception\Conflict;
use Utopia\Database\Exception\Restricted;
use Utopia\Database\Exception\Structure;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization as ValidatorAuthorization;
use Utopia\DSN\DSN;
use Utopia\Logger\Log;
use Utopia\Platform\Action;
@ -92,13 +93,13 @@ class Deletes extends Action
$this->deleteProject($dbForPlatform, $getProjectDB, $deviceForFiles, $deviceForSites, $deviceForFunctions, $deviceForBuilds, $deviceForCache, $certificates, $document);
break;
case DELETE_TYPE_SITES:
$this->deleteSite($dbForPlatform, $getProjectDB, $deviceForSites, $deviceForFunctions, $deviceForBuilds, $document, $certificates, $project);
$this->deleteSite($dbForPlatform, $getProjectDB, $deviceForSites, $deviceForFunctions, $deviceForBuilds, $deviceForFiles, $document, $certificates, $project);
break;
case DELETE_TYPE_FUNCTIONS:
$this->deleteFunction($dbForPlatform, $getProjectDB, $deviceForFunctions, $deviceForBuilds, $certificates, $document, $project);
break;
case DELETE_TYPE_DEPLOYMENTS:
$this->deleteDeployment($dbForPlatform, $getProjectDB, $deviceForFunctions, $deviceForBuilds, $document, $certificates, $project);
$this->deleteDeployment($dbForPlatform, $getProjectDB, $deviceForFunctions, $deviceForBuilds, $deviceForFiles, $document, $certificates, $project);
break;
case DELETE_TYPE_USERS:
$this->deleteUser($getProjectDB, $document, $project);
@ -749,7 +750,7 @@ class Deletes extends Action
* @return void
* @throws Exception
*/
private function deleteSite(Database $dbForPlatform, callable $getProjectDB, Device $deviceForSites, Device $deviceForFunctions, Device $deviceForBuilds, Document $document, CertificatesAdapter $certificates, Document $project): void
private function deleteSite(Database $dbForPlatform, callable $getProjectDB, Device $deviceForSites, Device $deviceForFunctions, Device $deviceForBuilds, Device $deviceForFiles, Document $document, CertificatesAdapter $certificates, Document $project): void
{
$dbForProject = $getProjectDB($project);
$siteId = $document->getId();
@ -783,9 +784,10 @@ class Deletes extends Action
$deploymentInternalIds = [];
$this->deleteByGroup('deployments', [
Query::equal('resourceInternalId', [$siteInternalId])
], $dbForProject, function (Document $document) use ($deviceForFunctions, &$deploymentInternalIds) {
], $dbForProject, function (Document $document) use ($deviceForFunctions, $deviceForFiles, $dbForPlatform, &$deploymentInternalIds) {
$deploymentInternalIds[] = $document->getInternalId();
$this->deleteDeploymentFiles($deviceForFunctions, $document);
$this->deleteDeploymentScreenshots($deviceForFiles, $dbForPlatform, $document);
});
/**
@ -928,6 +930,53 @@ class Deletes extends Action
$this->deleteRuntimes($getProjectDB, $document, $project);
}
private function deleteDeploymentScreenshots(Device $deviceForFiles, Database $dbForPlatform, Document $deployment): void
{
$screenshotIds = [];
if (!empty($deployment->getAttribute('screenshotLight', ''))) {
$screenshotIds[] = $deployment->getAttribute('screenshotLight', '');
}
if (!empty($deployment->getAttribute('screenshotDark', ''))) {
$screenshotIds[] = $deployment->getAttribute('screenshotDark', '');
}
if (empty($screenshotIds)) {
return;
}
Console::info("Deleting screenshots for deployment " . $deployment->getId());
$bucket = ValidatorAuthorization::skip(fn () => $dbForPlatform->getDocument('buckets', 'screenshots'));
if ($bucket->isEmpty()) {
Console::error('Failed to get bucket for deployment screenshots');
return;
}
foreach ($screenshotIds as $id) {
$file = ValidatorAuthorization::skip(fn () => $dbForPlatform->getDocument('bucket_' . $bucket->getInternalId(), $id));
if ($file->isEmpty()) {
Console::error('Failed to get deployment screenshot: ' . $id);
continue;
}
$path = $file->getAttribute('path', '');
try {
if ($deviceForFiles->delete($path, true)) {
Console::success('Deleted deployment screenshot: ' . $path);
} else {
Console::error('Failed to delete deployment screenshot: ' . $path);
}
} catch (\Throwable $th) {
Console::error('Failed to delete deployment screenshot: ' . $path);
Console::error('[Error] Type: ' . get_class($th));
Console::error('[Error] Message: ' . $th->getMessage());
Console::error('[Error] File: ' . $th->getFile());
Console::error('[Error] Line: ' . $th->getLine());
}
}
}
/**
* @param Device $device
* @param Document $deployment
@ -999,7 +1048,7 @@ class Deletes extends Action
* @return void
* @throws Exception
*/
private function deleteDeployment(Database $dbForPlatform, callable $getProjectDB, Device $deviceForFunctions, Device $deviceForBuilds, Document $document, CertificatesAdapter $certificates, Document $project): void
private function deleteDeployment(Database $dbForPlatform, callable $getProjectDB, Device $deviceForFunctions, Device $deviceForBuilds, Device $deviceForFiles, Document $document, CertificatesAdapter $certificates, Document $project): void
{
$projectId = $project->getId();
$dbForProject = $getProjectDB($project);
@ -1011,6 +1060,11 @@ class Deletes extends Action
*/
$this->deleteDeploymentFiles($deviceForFunctions, $document); //TODO: For sites, this should be deviceForSites
/**
* Delete deployment screenshots
*/
$this->deleteDeploymentScreenshots($deviceForFiles, $dbForPlatform, $document);
/**
* Delete builds
*/

View file

@ -76,6 +76,18 @@ class Deployment extends Model
'default' => false,
'example' => true,
])
->addRule('screenshotLight', [
'type' => self::TYPE_STRING,
'description' => 'Screenshot with light theme preference file ID.',
'default' => '',
'example' => '5e5ea5c16897e',
])
->addRule('screenshotDark', [
'type' => self::TYPE_STRING,
'description' => 'Screenshot with dark theme preference file ID.',
'default' => '',
'example' => '5e5ea5c16897e',
])
->addRule('status', [
'type' => self::TYPE_STRING,
'description' => 'The deployment status. Possible values are "processing", "building", "waiting", "ready", and "failed".',

View file

@ -51,6 +51,18 @@ trait FunctionsBase
$this->assertEquals('ready', $deployment['body']['status'], 'Deployment status is not ready, deployment: ' . json_encode($deployment['body'], JSON_PRETTY_PRINT));
}, 50000, 500);
// Not === so multipart/form-data works fine too
if (($params['activate'] ?? false) == true) {
$this->assertEventually(function () use ($functionId, $deploymentId) {
$function = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]));
$this->assertEquals($deploymentId, $function['body']['deployment'], 'Deployment is not activated, deployment: ' . json_encode($function['body'], JSON_PRETTY_PRINT));
}, 100000, 500);
}
return $deploymentId;
}

View file

@ -51,6 +51,18 @@ trait SitesBase
$this->assertEquals('ready', $deployment['body']['status'], 'Deployment status is not ready, deployment: ' . json_encode($deployment['body'], JSON_PRETTY_PRINT));
}, 100000, 500);
// Not === so multipart/form-data works fine too
if (($params['activate'] ?? false) == true) {
$this->assertEventually(function () use ($siteId, $deploymentId) {
$site = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]));
$this->assertEquals($deploymentId, $site['body']['deploymentId'], 'Deployment is not activated, deployment: ' . json_encode($site['body'], JSON_PRETTY_PRINT));
}, 100000, 500);
}
return $deploymentId;
}

View file

@ -313,10 +313,7 @@ class SitesCustomServerTest extends Scope
$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'],
]));
$response = $proxyClient->call(Client::METHOD_GET, '/');
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertStringContainsString("Env variable is Appwrite", $response['body']);
@ -1249,19 +1246,13 @@ class SitesCustomServerTest extends Scope
$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'],
]));
$response = $proxyClient->call(Client::METHOD_GET, '/');
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertStringContainsString("Astro Blog", $response['body']);
$this->assertStringContainsString("Hello, Astronaut!", $response['body']);
$response = $proxyClient->call(Client::METHOD_GET, '/about', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
]));
$response = $proxyClient->call(Client::METHOD_GET, '/about');
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertStringContainsString("Astro Blog", $response['body']);
@ -1300,10 +1291,7 @@ class SitesCustomServerTest extends Scope
$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'],
]));
$response = $proxyClient->call(Client::METHOD_GET, '/');
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertStringNotContainsString("This domain is not connected to any Appwrite resource yet", $response['body']);
@ -1350,10 +1338,7 @@ class SitesCustomServerTest extends Scope
$this->assertEquals(0, $rules['body']['total']);
}, 50000, 500);
$response = $proxyClient->call(Client::METHOD_GET, '/', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
]));
$response = $proxyClient->call(Client::METHOD_GET, '/');
$this->assertEquals(401, $response['headers']['status-code']);
$this->assertStringContainsString("This domain is not connected to any Appwrite resource yet", $response['body']);
@ -1416,10 +1401,7 @@ class SitesCustomServerTest extends Scope
$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'],
]));
$response = $proxyClient->call(Client::METHOD_GET, '/');
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertStringContainsString("Hello Appwrite", $response['body']);
@ -1430,10 +1412,7 @@ class SitesCustomServerTest extends Scope
$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'],
]));
$response = $proxyClient->call(Client::METHOD_GET, '/');
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertStringContainsString("Hello Appwrite", $response['body']);
@ -1500,5 +1479,77 @@ class SitesCustomServerTest extends Scope
$this->assertEquals('http://localhost', $response['headers']['access-control-allow-origin']);
}
public function testSiteScreenshot(): void
{
$siteId = $this->setupSite([
'siteId' => ID::unique(),
'name' => 'Themed site',
'framework' => 'other',
'adapter' => 'static',
'buildRuntime' => 'static-1',
'outputDirectory' => './',
'buildCommand' => '',
'installCommand' => '',
'fallbackFile' => '',
]);
$this->assertNotEmpty($siteId);
$domain = $this->setupSiteDomain($siteId);
$deploymentId = $this->setupDeployment($siteId, [
'code' => $this->packageSite('static-themed'),
'activate' => 'true'
]);
$this->assertNotEmpty($deploymentId);
$domain = $this->getSiteDomain($siteId);
$this->assertNotEmpty($domain);
$proxyClient = new Client();
$proxyClient->setEndpoint('http://' . $domain);
$response = $proxyClient->call(Client::METHOD_GET, '/');
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertStringContainsString("Themed website", $response['body']);
$this->assertStringContainsString("@media (prefers-color-scheme: dark)", $response['body']);
$deployment = $this->getDeployment($siteId, $deploymentId);
$this->assertEquals(200, $deployment['headers']['status-code']);
$this->assertNotEmpty($deployment['body']['screenshotLight']);
$this->assertNotEmpty($deployment['body']['screenshotDark']);
$screenshotId = $deployment['body']['screenshotLight'];
$file = $this->client->call(Client::METHOD_GET, "/storage/buckets/screenshots/files/$screenshotId/view?project=console&mode=admin", array_merge([
], $this->getHeaders()));
$this->assertEquals(200, $file['headers']['status-code']);
$this->assertNotEmpty(200, $file['body']);
$this->assertGreaterThan(1, $file['headers']['content-length']);
$this->assertEquals('image/png', $file['headers']['content-type']);
$screenshotHash = \md5($file['body']);
$this->assertNotEmpty($screenshotHash);
$screenshotId = $deployment['body']['screenshotDark'];
$file = $this->client->call(Client::METHOD_GET, "/storage/buckets/screenshots/files/$screenshotId/view?project=console&mode=admin", array_merge([
], $this->getHeaders()));
$this->assertEquals(200, $file['headers']['status-code']);
$this->assertNotEmpty(200, $file['body']);
$this->assertGreaterThan(1, $file['headers']['content-length']);
$this->assertEquals('image/png', $file['headers']['content-type']);
$screenshotDarkHash = \md5($file['body']);
$this->assertNotEmpty($screenshotDarkHash);
$this->assertNotEquals($screenshotDarkHash, $screenshotHash);
$this->cleanupSite($siteId);
}
// TODO: Add tests for deletion of resources when site is deleted
}

View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Themed website</title>
<style>
/* Light theme */
body {
background-color: #ffffff;
color: #000000;
}
/* Dark theme */
@media (prefers-color-scheme: dark) {
body {
background-color: #000000;
color: #ffffff;
}
}
</style>
</head>
<body></body>
</html>