Merge pull request #9417 from appwrite/feat-screenshot-task

Feat: Screenshot task for templates
This commit is contained in:
Matej Bačo 2025-03-04 13:56:59 +01:00 committed by GitHub
commit f5a0008445
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 354 additions and 18 deletions

View file

@ -68,6 +68,7 @@ RUN chmod +x /usr/local/bin/doctor && \
chmod +x /usr/local/bin/sdks && \
chmod +x /usr/local/bin/specs && \
chmod +x /usr/local/bin/ssl && \
chmod +x /usr/local/bin/screenshot && \
chmod +x /usr/local/bin/test && \
chmod +x /usr/local/bin/upgrade && \
chmod +x /usr/local/bin/vars && \

View file

@ -121,7 +121,8 @@ return [
'key' => 'template-for-onelink',
'name' => 'Onelink template',
'useCases' => ['starter'],
'demoImage' => $url . '/console/images/sites/templates/template-for-onelink.png',
'screenshotDark' => $url . '/images/sites/templates/template-for-onelink-dark.png',
'screenshotLight' => $url . '/images/sites/templates/template-for-onelink-light.png',
'frameworks' => [
getFramework('NUXT', [
'providerRootDirectory' => './onelink',
@ -140,7 +141,8 @@ return [
'key' => 'starter-for-svelte',
'name' => 'Svelte starter',
'useCases' => ['starter'],
'demoImage' => $url . '/console/images/sites/templates/starter-for-svelte.png',
'screenshotDark' => $url . '/images/sites/templates/starter-for-svelte-dark.png',
'screenshotLight' => $url . '/images/sites/templates/starter-for-svelte-light.png',
'frameworks' => [
getFramework('SVELTEKIT', [
'providerRootDirectory' => './',
@ -181,7 +183,8 @@ return [
'key' => 'starter-for-react',
'name' => 'React starter',
'useCases' => ['starter'],
'demoImage' => $url . '/console/images/sites/templates/starter-for-react.png',
'screenshotDark' => $url . '/images/sites/templates/starter-for-react-dark.png',
'screenshotLight' => $url . '/images/sites/templates/starter-for-react-light.png',
'frameworks' => [
getFramework('REACT', [
'providerRootDirectory' => './',
@ -222,7 +225,8 @@ return [
'key' => 'starter-for-vue',
'name' => 'Vue starter',
'useCases' => ['starter'],
'demoImage' => $url . '/console/images/sites/templates/starter-for-vue.png',
'screenshotDark' => $url . '/images/sites/templates/starter-for-vue-dark.png',
'screenshotLight' => $url . '/images/sites/templates/starter-for-vue-light.png',
'frameworks' => [
getFramework('VUE', [
'providerRootDirectory' => './',
@ -263,7 +267,8 @@ return [
'key' => 'starter-for-react-native',
'name' => 'React Native starter',
'useCases' => ['starter'],
'demoImage' => $url . '/console/images/sites/templates/starter-for-react-native.png',
'screenshotDark' => $url . '/images/sites/templates/starter-for-react-native-dark.png',
'screenshotLight' => $url . '/images/sites/templates/starter-for-react-native-light.png',
'frameworks' => [
getFramework('REACT', [
'providerRootDirectory' => './',
@ -305,7 +310,8 @@ return [
'key' => 'starter-for-nextjs',
'name' => 'Next.js starter',
'useCases' => ['starter'],
'demoImage' => $url . '/console/images/sites/templates/starter-for-nextjs.png',
'screenshotDark' => $url . '/images/sites/templates/starter-for-nextjs-dark.png',
'screenshotLight' => $url . '/images/sites/templates/starter-for-nextjs-light.png',
'frameworks' => [
getFramework('NEXTJS', [
'providerRootDirectory' => './',
@ -346,7 +352,8 @@ return [
'key' => 'starter-for-nuxt',
'name' => 'Nuxt starter',
'useCases' => ['starter'],
'demoImage' => $url . '/console/images/sites/templates/starter-for-nuxt.png',
'screenshotDark' => $url . '/images/sites/templates/starter-for-nuxt-dark.png',
'screenshotLight' => $url . '/images/sites/templates/starter-for-nuxt-light.png',
'frameworks' => [
getFramework('NUXT', [
'providerRootDirectory' => './',
@ -387,7 +394,8 @@ return [
'key' => 'template-for-event',
'name' => 'Event template',
'useCases' => ['starter'],
'demoImage' => $url . '/console/images/sites/templates/template-for-event.png',
'screenshotDark' => $url . '/images/sites/templates/template-for-event-dark.png',
'screenshotLight' => $url . '/images/sites/templates/template-for-event-light.png',
'frameworks' => [
getFramework('NEXTJS', [
'providerRootDirectory' => './',
@ -422,7 +430,8 @@ return [
'key' => 'template-for-portfolio',
'name' => 'Portfolio template',
'useCases' => ['starter'],
'demoImage' => $url . '/console/images/sites/templates/template-for-portfolio.png',
'screenshotDark' => $url . '/images/sites/templates/template-for-portfolio-dark.png',
'screenshotLight' => $url . '/images/sites/templates/template-for-portfolio-light.png',
'frameworks' => [
getFramework('NEXTJS', [
'providerRootDirectory' => './',
@ -438,7 +447,8 @@ return [
'key' => 'template-for-store',
'name' => 'Store template',
'useCases' => ['starter'],
'demoImage' => $url . '/console/images/sites/templates/template-for-store.png',
'screenshotDark' => $url . '/images/sites/templates/template-for-store-dark.png',
'screenshotLight' => $url . '/images/sites/templates/template-for-store-light.png',
'frameworks' => [
getFramework('SVELTEKIT', [
'providerRootDirectory' => './',
@ -479,7 +489,8 @@ return [
'key' => 'template-for-blog',
'name' => 'Blog template',
'useCases' => ['starter'],
'demoImage' => $url . '/console/images/sites/templates/template-for-blog.png',
'screenshotDark' => $url . '/images/sites/templates/template-for-blog-dark.png',
'screenshotLight' => $url . '/images/sites/templates/template-for-blog-light.png',
'frameworks' => [
getFramework('SVELTEKIT', [
'providerRootDirectory' => './',
@ -495,7 +506,8 @@ return [
'key' => 'astro-starter',
'name' => 'Astro starter',
'useCases' => ['starter'],
'demoImage' => '',
'screenshotDark' => $url . '/images/sites/templates/astro-starter-dark.png',
'screenshotLight' => $url . '/images/sites/templates/astro-starter-light.png',
'frameworks' => [
getFramework('ASTRO', [
'providerRootDirectory' => './astro/starter',
@ -511,7 +523,8 @@ return [
'key' => 'remix-starter',
'name' => 'Remix starter',
'useCases' => ['starter'],
'demoImage' => '',
'screenshotDark' => $url . '/images/sites/templates/remix-starter-dark.png',
'screenshotLight' => $url . '/images/sites/templates/remix-starter-light.png',
'frameworks' => [
getFramework('REMIX', [
'providerRootDirectory' => './remix/starter',
@ -527,7 +540,8 @@ return [
'key' => 'flutter-starter',
'name' => 'Flutter starter',
'useCases' => ['starter'],
'demoImage' => '',
'screenshotDark' => $url . '/images/sites/templates/flutter-starter-dark.png',
'screenshotLight' => $url . '/images/sites/templates/flutter-starter-light.png',
'frameworks' => [
getFramework('FLUTTER', [
'providerRootDirectory' => './flutter/starter',
@ -543,6 +557,8 @@ return [
'key' => 'nextjs-starter',
'name' => 'Next.js starter website',
'useCases' => ['starter'],
'screenshotDark' => $url . '/images/sites/templates/nextjs-starter-dark.png',
'screenshotLight' => $url . '/images/sites/templates/nextjs-starter-light.png',
'frameworks' => [
getFramework('NEXTJS', [
'providerRootDirectory' => './nextjs/starter',
@ -558,6 +574,8 @@ return [
'key' => 'nuxt-starter',
'name' => 'Nuxt starter website',
'useCases' => ['starter'],
'screenshotDark' => $url . '/images/sites/templates/nuxt-starter-dark.png',
'screenshotLight' => $url . '/images/sites/templates/nuxt-starter-light.png',
'frameworks' => [
getFramework('NUXT', [
'providerRootDirectory' => './nuxt/starter',
@ -573,6 +591,8 @@ return [
'key' => 'sveltekit-starter',
'name' => 'SvelteKit starter website',
'useCases' => ['starter'],
'screenshotDark' => $url . '/images/sites/templates/sveltekit-starter-dark.png',
'screenshotLight' => $url . '/images/sites/templates/sveltekit-starter-light.png',
'frameworks' => [
getFramework('SVELTEKIT', [
'providerRootDirectory' => './sveltekit/starter',

View file

@ -29,6 +29,8 @@ use Utopia\Pools\Group;
use Utopia\Swoole\Files;
use Utopia\System\System;
Files::load(__DIR__.'/../public');
const DOMAIN_SYNC_TIMER = 30; // 30 seconds
$domains = new Table(1_000_000); // 1 million rows

View file

@ -1977,6 +1977,7 @@ App::setResource('previewHostname', function (Request $request, ?Key $apiKey) {
}
}
return '';
}, ['request', 'apiKey']);

3
bin/screenshot Executable file
View file

@ -0,0 +1,3 @@
#!/bin/sh
php /usr/src/code/app/cli.php screenshot $@

View file

@ -953,7 +953,7 @@ services:
appwrite-browser:
container_name: appwrite-browser
image: appwrite/browser:0.2.0
image: appwrite/browser:0.2.1
networks:
- appwrite

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View file

@ -740,6 +740,7 @@ class Builds extends Action
}
$client = new FetchClient();
$client->setTimeout(\intval($resource->getAttribute('timeout', '15')));
$client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON);
$bucket = Authorization::skip(fn () => $dbForPlatform->getDocument('buckets', 'screenshots'));

View file

@ -10,6 +10,7 @@ use Appwrite\Platform\Tasks\QueueRetry;
use Appwrite\Platform\Tasks\ScheduleExecutions;
use Appwrite\Platform\Tasks\ScheduleFunctions;
use Appwrite\Platform\Tasks\ScheduleMessages;
use Appwrite\Platform\Tasks\Screenshot;
use Appwrite\Platform\Tasks\SDKs;
use Appwrite\Platform\Tasks\Specs;
use Appwrite\Platform\Tasks\SSL;
@ -32,6 +33,7 @@ class Tasks extends Service
->addAction(QueueRetry::getName(), new QueueRetry())
->addAction(SDKs::getName(), new SDKs())
->addAction(SSL::getName(), new SSL())
->addAction(Screenshot::getName(), new Screenshot())
->addAction(ScheduleFunctions::getName(), new ScheduleFunctions())
->addAction(ScheduleExecutions::getName(), new ScheduleExecutions())
->addAction(ScheduleMessages::getName(), new ScheduleMessages())

View file

@ -0,0 +1,300 @@
<?php
namespace Appwrite\Platform\Tasks;
use Appwrite\ID;
use Tests\E2E\Client;
use Utopia\CLI\Console;
use Utopia\Config\Config;
use Utopia\Platform\Action;
use Utopia\Validator\Text;
class Screenshot extends Action
{
public static function getName(): string
{
return 'screenshot';
}
public function __construct()
{
$this
->desc('Create Site template screenshot')
->param('templateId', '', new Text(128), 'Template ID.')
->callback(fn (string $templateId) => $this->action($templateId));
}
public function action(string $templateId): void
{
$templates = Config::getParam('site-templates', []);
$allowedTemplates = \array_filter($templates, function ($item) use ($templateId) {
return $item['key'] === $templateId;
});
$template = \array_shift($allowedTemplates);
if (empty($template)) {
throw new \Exception("Template {$templateId} not found. Find correct ID in app/config/site-templates.php");
}
Console::info("Found: " . $template['name']);
$client = new Client();
$client->setEndpoint('http://localhost/v1');
$client->addHeader('origin', 'http://localhost');
// Register
$email = uniqid() . 'user@localhost.test';
$password = 'password';
$user = $client->call(Client::METHOD_POST, '/account', [
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
], [
'userId' => ID::unique(),
'email' => $email,
'password' => $password,
]);
if ($user['headers']['status-code'] !== 201) {
Console::error(\json_encode($user));
throw new \Exception("Failed to register user");
}
Console::info("User created");
// Login
$session = $client->call(Client::METHOD_POST, '/account/sessions/email', [
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
], [
'email' => $email,
'password' => $password,
]);
if ($session['headers']['status-code'] !== 201) {
Console::error(\json_encode($session));
throw new \Exception("Failed to login user");
}
Console::info("Session created");
$secret = $session['cookies']['a_session_console'];
$cookieConsole = 'a_session_console=' . $secret;
// Create organization
$team = $client->call(Client::METHOD_POST, '/teams', [
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
'cookie' => $cookieConsole
], [
'teamId' => ID::unique(),
'name' => 'Demo Project Team',
]);
if ($team['headers']['status-code'] !== 201) {
Console::error(\json_encode($team));
throw new \Exception("Failed to create team");
}
Console::info("Team created");
$projectName = 'Demo Project';
$projectId = ID::unique();
// Create project
$project = $client->call(Client::METHOD_POST, '/projects', [
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
'cookie' => $cookieConsole
], [
'projectId' => $projectId,
'region' => 'default',
'name' => $projectName,
'teamId' => $team['body']['$id'],
'description' => 'Demo Project Description',
'url' => 'https://appwrite.io',
]);
if ($project['headers']['status-code'] !== 201) {
Console::error(\json_encode($project));
throw new \Exception("Failed to create project");
}
Console::info("Project created");
$projectId = $project['body']['$id'];
$framework = $template['frameworks'][0];
// Create site
$site = $client->call(Client::METHOD_POST, '/sites', [
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-mode' => 'admin',
'cookie' => $cookieConsole
], [
'siteId' => ID::unique(),
'name' => $template["name"],
'framework' => $framework['key'],
'adapter' => $framework['adapter'],
'buildCommand' => $framework['buildCommand'],
'buildRuntime' => $framework['buildRuntime'],
'fallbackFile' => $framework['fallbackFile'],
'installCommand' => $framework['installCommand'],
'outputDirectory' => $framework['outputDirectory'],
'providerRootDirectory' => $framework['providerRootDirectory'],
'timeout' => 60
]);
if ($site['headers']['status-code'] !== 201) {
Console::error(\json_encode($site));
throw new \Exception("Failed to create site");
}
Console::info("Site created");
$siteId = $site['body']['$id'];
// Create variables
if (!empty($template['variables'] ?? [])) {
foreach ($template['variables'] as $variable) {
if (empty($variable['value'] ?? '')) {
if (($variable['required'] ?? false) === true) {
throw new \Exception("Missing required variable: {$variable['name']}");
}
continue;
}
$value = $variable['value'];
$value = \str_replace('{projectName}', $projectName, $value);
$value = \str_replace('{projectId}', $projectId, $value);
$value = \str_replace('{apiEndpoint}', 'http://localhost/v1', $value);
$response = $client->call(Client::METHOD_POST, '/sites/' . $siteId . '/variables', [
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-mode' => 'admin',
'cookie' => $cookieConsole
], [
'key' => $variable['name'],
'value' => $value
]);
if ($response['headers']['status-code'] !== 201) {
Console::error(\json_encode($response));
throw new \Exception("Failed to create variable");
}
}
Console::info("Variables created");
}
// Create deployment
$deployment = $client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments/template', [
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-mode' => 'admin',
'cookie' => $cookieConsole
], [
'owner' => $template['providerOwner'],
'repository' => $template['providerRepositoryId'],
'rootDirectory' => $framework['providerRootDirectory'],
'version' => $template['providerVersion'],
'activate' => true,
]);
if ($deployment['headers']['status-code'] !== 202) {
Console::error(\json_encode($deployment));
throw new \Exception("Failed to create deployment");
}
Console::info("Deployment created");
$deploymentId = $deployment['body']['$id'];
// Await screenshot
$attempts = 50;
$sleep = 5;
$idLight = '';
$idDark = '';
Console::log("Awaiting deployment (every $sleep seconds, $attempts attempts)");
for ($i = 0; $i < $attempts; $i++) {
$deployment = $client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments/' . $deploymentId, [
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-mode' => 'admin',
'cookie' => $cookieConsole
]);
if ($deployment['headers']['status-code'] !== 200) {
Console::error(\json_encode($deployment));
throw new \Exception("Failed to get deployment");
}
if ($deployment['body']['status'] === 'failed') {
Console::error(\json_encode($deployment));
throw new \Exception("Deployment build failed");
}
if ($deployment['body']['status'] !== 'ready') {
Console::log("Deployment not ready yet, status: " . $deployment['body']['status']);
\sleep($sleep);
continue;
}
if (empty($deployment['body']['screenshotLight'])) {
Console::log("Light screenshot not available yet");
\sleep($sleep);
continue;
}
if (empty($deployment['body']['screenshotDark'])) {
Console::log("Dark screenshot not available yet");
\sleep($sleep);
continue;
}
$idLight = $deployment['body']['screenshotLight'];
$idDark = $deployment['body']['screenshotDark'];
break;
}
if (empty($idLight) || empty($idDark)) {
Console::error(\json_encode($deployment));
throw new \Exception("Failed to get deployment screenshot");
}
Console::info("Screenshots created");
$themes = [
[ 'fileId' => $idLight, 'suffix' => 'light' ],
[ 'fileId' => $idDark, 'suffix' => 'dark' ]
];
foreach ($themes as $theme) {
$file = $client->call(Client::METHOD_GET, '/storage/buckets/screenshots/files/' . $theme['fileId'] . '/download', [
'x-appwrite-project' => 'console',
'x-appwrite-mode' => 'admin',
'cookie' => $cookieConsole
]);
if ($file['headers']['status-code'] !== 200) {
Console::error(\json_encode($file));
throw new \Exception("Failed to download {$theme['suffix']} screenshot");
}
$path = "/usr/src/code/public/images/sites/templates/{$template['key']}-{$theme['suffix']}.png";
if (!\file_put_contents($path, $file['body'])) {
throw new \Exception("Failed to save {$theme['suffix']} screenshot");
}
}
Console::success("Screenshots saved");
}
}

View file

@ -28,11 +28,17 @@ class TemplateSite extends Model
'default' => '',
'example' => 'https://nextjs-starter.appwrite.network/',
])
->addRule('demoImage', [
->addRule('screenshotDark', [
'type' => self::TYPE_STRING,
'description' => 'File URL with preview screenshot.',
'description' => 'File URL with preview screenshot in dark theme preference.',
'default' => '',
'example' => 'https://cloud.appwrite.io/console/images/sites/templates/nextjs-starter.png',
'example' => 'https://cloud.appwrite.io/images/sites/templates/template-for-blog-dark.png',
])
->addRule('screenshotLight', [
'type' => self::TYPE_STRING,
'description' => 'File URL with preview screenshot in light theme preference.',
'default' => '',
'example' => 'https://cloud.appwrite.io/images/sites/templates/template-for-blog-light.png',
])
->addRule('useCases', [
'type' => self::TYPE_STRING,