Merge pull request #10675 from appwrite/feat-screenshots-endpoint

POC - website screenshots
This commit is contained in:
Eldad A. Fux 2025-11-01 18:03:37 +01:00 committed by GitHub
commit 745e9e2bea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1181 additions and 5 deletions

View file

@ -952,6 +952,16 @@ return [
'question' => '',
'filter' => ''
],
[
'name' => '_APP_BROWSER_HOST',
'description' => 'The host used by Appwrite to communicate with the browser service for screenshots.',
'introduction' => '1.8.0',
'default' => 'http://appwrite-browser:3000/v1',
'required' => false,
'overwrite' => true,
'question' => '',
'filter' => ''
],
[
'name' => '_APP_EXECUTOR_RUNTIME_NETWORK',
'description' => 'Deprecated with 0.14.0, use \'OPEN_RUNTIMES_NETWORK\' instead.',

View file

@ -1,5 +1,6 @@
<?php
use Appwrite\Event\StatsUsage;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@ -23,6 +24,8 @@ use Utopia\Fetch\Client;
use Utopia\Image\Image;
use Utopia\Logger\Logger;
use Utopia\System\System;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Assoc;
use Utopia\Validator\Boolean;
use Utopia\Validator\HexColor;
use Utopia\Validator\Range;
@ -635,6 +638,187 @@ App::get('/v1/avatars/initials')
->file($image->getImageBlob());
});
App::get('/v1/avatars/screenshots')
->desc('Get webpage screenshot')
->groups(['api', 'avatars'])
->label('scope', 'avatars.read')
->label('usage.metric', METRIC_AVATARS_SCREENSHOTS_GENERATED)
->label('abuse-limit', 60)
->label('cache', true)
->label('cache.resourceType', 'avatar/screenshot')
->label('cache.resource', 'screenshot/{request.url}/{request.width}/{request.height}/{request.scale}/{request.theme}/{request.userAgent}/{request.fullpage}/{request.locale}/{request.timezone}/{request.latitude}/{request.longitude}/{request.accuracy}/{request.touch}/{request.permissions}/{request.sleep}/{request.quality}/{request.output}')
->label('sdk', new Method(
namespace: 'avatars',
group: null,
name: 'getScreenshot',
description: '/docs/references/avatars/get-screenshot.md',
auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT],
type: MethodType::LOCATION,
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::IMAGE_PNG
))
->param('url', '', new URL(['http', 'https']), 'Website URL which you want to capture.')
->param('headers', [], new Assoc(), 'HTTP headers to send with the browser request. Defaults to empty.', true)
->param('viewportWidth', 1280, new Range(1, 1920), 'Browser viewport width. Pass an integer between 1 to 1920. Defaults to 1280.', true)
->param('viewportHeight', 720, new Range(1, 1080), 'Browser viewport height. Pass an integer between 1 to 1080. Defaults to 720.', true)
->param('scale', 1, new Range(0.1, 3, Range::TYPE_FLOAT), 'Browser scale factor. Pass a number between 0.1 to 3. Defaults to 1.', true)
->param('theme', 'light', new WhiteList(['light', 'dark']), 'Browser theme. Pass "light" or "dark". Defaults to "light".', true)
->param('userAgent', '', new Text(512), 'Custom user agent string. Defaults to browser default.', true)
->param('fullpage', false, new Boolean(true), 'Capture full page scroll. Pass 0 for viewport only, or 1 for full page. Defaults to 0.', true)
->param('locale', '', new Text(10), 'Browser locale (e.g., "en-US", "fr-FR"). Defaults to browser default.', true)
->param('timezone', '', new WhiteList(timezone_identifiers_list()), 'IANA timezone identifier (e.g., "America/New_York", "Europe/London"). Defaults to browser default.', true)
->param('latitude', 0, new Range(-90, 90, Range::TYPE_FLOAT), 'Geolocation latitude. Pass a number between -90 to 90. Defaults to 0.', true)
->param('longitude', 0, new Range(-180, 180, Range::TYPE_FLOAT), 'Geolocation longitude. Pass a number between -180 to 180. Defaults to 0.', true)
->param('accuracy', 0, new Range(0, 100000, Range::TYPE_FLOAT), 'Geolocation accuracy in meters. Pass a number between 0 to 100000. Defaults to 0.', true)
->param('touch', false, new Boolean(true), 'Enable touch support. Pass 0 for no touch, or 1 for touch enabled. Defaults to 0.', true)
->param('permissions', [], new ArrayList(new WhiteList(['geolocation', 'camera', 'microphone', 'notifications', 'midi', 'push', 'clipboard-read', 'clipboard-write', 'payment-handler', 'usb', 'bluetooth', 'accelerometer', 'gyroscope', 'magnetometer', 'ambient-light-sensor', 'background-sync', 'persistent-storage', 'screen-wake-lock', 'web-share', 'xr-spatial-tracking'])), 'Browser permissions to grant. Pass an array of permission names like ["geolocation", "camera", "microphone"]. Defaults to empty.', true)
->param('sleep', 0, new Range(0, 10), 'Wait time in seconds before taking the screenshot. Pass an integer between 0 to 10. Defaults to 0.', true)
->param('width', 0, new Range(0, 2000), 'Output image width. Pass 0 to use original width, or an integer between 1 to 2000. Defaults to 0 (original width).', true)
->param('height', 0, new Range(0, 2000), 'Output image height. Pass 0 to use original height, or an integer between 1 to 2000. Defaults to 0 (original height).', true)
->param('quality', -1, new Range(-1, 100), 'Screenshot quality. Pass an integer between 0 to 100. Defaults to keep existing image quality.', true)
->param('output', '', new WhiteList(\array_keys(Config::getParam('storage-outputs')), true), 'Output format type (jpeg, jpg, png, gif and webp).', true)
->inject('response')
->inject('queueForStatsUsage')
->action(function (string $url, array $headers, int $viewportWidth, int $viewportHeight, float $scale, string $theme, string $userAgent, bool $fullpage, string $locale, string $timezone, float $latitude, float $longitude, float $accuracy, bool $touch, array $permissions, int $sleep, int $width, int $height, int $quality, string $output, Response $response, StatsUsage $queueForStatsUsage) {
if (!\extension_loaded('imagick')) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing');
}
$domain = new Domain(\parse_url($url, PHP_URL_HOST));
if (!$domain->isKnown()) {
throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED);
}
$client = new Client();
$client->setTimeout(30);
$client->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON);
// Convert indexed array to empty array (should not happen due to Assoc validator)
if (is_array($headers) && count($headers) > 0 && array_keys($headers) === range(0, count($headers) - 1)) {
$headers = [];
}
// Create a new object to ensure proper JSON serialization
$headersObject = new \stdClass();
foreach ($headers as $key => $value) {
$headersObject->$key = $value;
}
// Create the config with headers as an object
// The custom browser service accepts: url, theme, headers, sleep, viewport, userAgent, fullPage, locale, timezoneId, geolocation, hasTouch, scale
$config = [
'url' => $url,
'theme' => $theme,
'headers' => $headersObject,
'sleep' => $sleep * 1000, // Convert seconds to milliseconds
'waitUntil' => 'load',
'viewport' => [
'width' => $viewportWidth,
'height' => $viewportHeight
]
];
// Add scale if not default
if ($scale != 1) {
$config['deviceScaleFactor'] = $scale;
}
// Add optional parameters that were set, preserving arrays as arrays
if (!empty($userAgent)) {
$config['userAgent'] = $userAgent;
}
if ($fullpage) {
$config['fullPage'] = true;
}
if (!empty($locale)) {
$config['locale'] = $locale;
}
if (!empty($timezone)) {
$config['timezoneId'] = $timezone;
}
// Add geolocation if any coordinates are provided
if ($latitude != 0 || $longitude != 0) {
$config['geolocation'] = [
'latitude' => $latitude,
'longitude' => $longitude,
'accuracy' => $accuracy
];
}
if ($touch) {
$config['hasTouch'] = true;
}
// Add permissions if provided (preserve as array)
if (!empty($permissions)) {
$config['permissions'] = $permissions; // Keep as array
}
try {
$browserEndpoint = System::getEnv('_APP_BROWSER_HOST', 'http://appwrite-browser:3000/v1');
$fetchResponse = $client->fetch(
url: $browserEndpoint . '/screenshots',
method: 'POST',
body: $config
);
if ($fetchResponse->getStatusCode() >= 400) {
throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED, 'Screenshot service failed: ' . $fetchResponse->getBody());
}
$screenshot = $fetchResponse->getBody();
if (empty($screenshot)) {
throw new Exception(Exception::AVATAR_IMAGE_NOT_FOUND, 'Screenshot not generated');
}
// Determine if image processing is needed
$needsProcessing = ($width > 0 || $height > 0) || $quality !== -1 || !empty($output);
if ($needsProcessing) {
// Process image with cropping, quality adjustment, or format conversion
$image = new Image($screenshot);
$image->crop($width, $height);
$output = $output ?: 'png'; // Default to PNG if not specified
$resizedScreenshot = $image->output($output, $quality);
unset($image);
} else {
// Return original screenshot without processing
$resizedScreenshot = $screenshot;
$output = 'png'; // Screenshots are typically PNG by default
}
// Set content type based on output format
$outputs = Config::getParam('storage-outputs');
$contentType = $outputs[$output] ?? $outputs['png'];
$queueForStatsUsage->addMetric(METRIC_AVATARS_SCREENSHOTS_GENERATED, 1);
$response
->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days
->setContentType($contentType)
->file($resizedScreenshot);
} catch (\Throwable $th) {
throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED, 'Screenshot generation failed: ' . $th->getMessage());
}
});
App::get('/v1/cards/cloud')
->desc('Get front Of Cloud Card')
->groups(['api', 'avatars'])

View file

@ -54,7 +54,20 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar
};
if (array_key_exists($replace, $params)) {
$label = \str_replace($find, $params[$replace], $label);
$replacement = $params[$replace];
// Convert to string if it's not already a string
if (!is_string($replacement)) {
if (is_array($replacement)) {
$replacement = json_encode($replacement);
} elseif (is_object($replacement) && method_exists($replacement, '__toString')) {
$replacement = (string)$replacement;
} elseif (is_scalar($replacement)) {
$replacement = (string)$replacement;
} else {
throw new Exception(Exception::GENERAL_SERVER_ERROR, "The server encountered an error while parsing the label: $label. Please create an issue on GitHub to allow us to investigate further https://github.com/appwrite/appwrite/issues/new/choose");
}
}
$label = \str_replace($find, $replacement, $label);
}
}
return $label;
@ -580,6 +593,10 @@ App::init()
$data = $cache->load($key, $timestamp);
if (!empty($data) && !$cacheLog->isEmpty()) {
$usageMetric = $route->getLabel('usage.metric', null);
if ($usageMetric === METRIC_AVATARS_SCREENSHOTS_GENERATED) {
$queueForStatsUsage->disableMetric(METRIC_AVATARS_SCREENSHOTS_GENERATED);
}
$parts = explode('/', $cacheLog->getAttribute('resourceType', ''));
$type = $parts[0] ?? null;

View file

@ -270,6 +270,7 @@ const METRIC_SITES_OUTBOUND = 'sites.outbound';
const METRIC_SITES_ID_REQUESTS = 'sites.{siteInternalId}.requests';
const METRIC_SITES_ID_INBOUND = 'sites.{siteInternalId}.inbound';
const METRIC_SITES_ID_OUTBOUND = 'sites.{siteInternalId}.outbound';
const METRIC_AVATARS_SCREENSHOTS_GENERATED = 'avatars.screenshotsGenerated';
// Resource types
const RESOURCE_TYPE_PROJECTS = 'projects';

View file

@ -859,7 +859,7 @@ $image = $this->getParam('image', '');
- _APP_ASSISTANT_OPENAI_API_KEY
appwrite-browser:
image: appwrite/browser:0.2.4
image: appwrite/browser:0.3.1
container_name: appwrite-browser
<<: *x-logging
restart: unless-stopped

View file

@ -958,7 +958,7 @@ services:
appwrite-browser:
container_name: appwrite-browser
image: appwrite/browser:0.2.4
image: appwrite/browser:0.3.1
networks:
- appwrite

View file

@ -0,0 +1,5 @@
Use this endpoint to capture a screenshot of any website URL. This endpoint uses a headless browser to render the webpage and capture it as an image.
You can configure the browser viewport size, theme, user agent, geolocation, permissions, and more. Capture either just the viewport or the full page scroll.
When width and height are specified, the image is resized accordingly. If both dimensions are 0, the API provides an image at original size. If dimensions are not specified, the default viewport size is 1280x720px.

View file

@ -331,9 +331,16 @@ class Mapper
break;
case 'Utopia\Validator\Integer':
case 'Utopia\Validator\Numeric':
case 'Utopia\Validator\Range':
$type = Type::int();
break;
case 'Utopia\Validator\Range':
// Check if the Range validator is for float or integer
if ($validator instanceof \Utopia\Validator\Range && $validator->getType() === \Utopia\Validator\Range::TYPE_FLOAT) {
$type = Type::float();
} else {
$type = Type::int();
}
break;
case 'Utopia\Validator\FloatValidator':
$type = Type::float();
break;

View file

@ -988,7 +988,7 @@ class Builds extends Action
$config['sleep'] = $framework['screenshotSleep'];
}
$browserEndpoint = Config::getParam('_APP_BROWSER_HOST', 'http://appwrite-browser:3000/v1');
$browserEndpoint = System::getEnv('_APP_BROWSER_HOST', 'http://appwrite-browser:3000/v1');
$fetchResponse = $client->fetch(
url: $browserEndpoint . '/screenshots',
method: 'POST',

View file

@ -558,4 +558,739 @@ trait AvatarsBase
$this->assertEquals('PNG', $image->getImageFormat());
$this->assertEquals(strlen(\file_get_contents(__DIR__ . '/../../../resources/initials.png')), strlen($response['body']));
}
public function testGetScreenshot(): array
{
/**
* Test for SUCCESS
*/
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('image/png', $response['headers']['content-type']);
$this->assertNotEmpty($response['body']);
$this->assertGreaterThan(100000, strlen($response['body']));
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'headers' => [
'User-Agent' => 'Mozilla/5.0 (compatible; AppwriteBot/1.0)',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
],
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('image/png', $response['headers']['content-type']);
$this->assertNotEmpty($response['body']);
/**
* Test for FAILURE - Invalid headers parameter types
*/
// Test with string headers (should fail)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'headers' => 'invalid-headers-string',
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test with numeric headers (should fail)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'headers' => 123,
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test with boolean headers (should fail)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'headers' => true,
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test with null headers - framework converts null to empty array, so this passes
// Skipping this test as null is converted to [] by the framework before validation
// Test with regular array (indexed array) - should fail
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'headers' => ['value1', 'value2', 'value3'], // Indexed array
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test with mixed array (some numeric keys) - Assoc validator allows this
// Mixed arrays are considered associative by the Assoc validator
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'headers' => ['User-Agent' => 'MyApp', 'value2', 'Accept' => 'text/html'], // Mixed array
]);
$this->assertEquals(200, $response['headers']['status-code']);
// Test with empty array (should pass - empty associative array)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'headers' => [], // Empty associative array should pass
]);
$this->assertEquals(200, $response['headers']['status-code']);
// Test with valid headers object (should pass)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'headers' => [
'User-Agent' => 'MyApp/1.0',
'Accept' => 'text/html,application/xhtml+xml',
'Accept-Language' => 'en-US,en;q=0.9'
],
]);
$this->assertEquals(200, $response['headers']['status-code']);
// Test with headers containing special characters (should pass)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'headers' => [
'X-Custom-Header' => 'custom-value',
'Authorization' => 'Bearer token123',
'Content-Type' => 'application/json'
],
]);
$this->assertEquals(200, $response['headers']['status-code']);
// Test with custom viewport width and height
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'viewportWidth' => 1920,
'viewportHeight' => 1080,
'width' => 800,
'height' => 600,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('image/png', $response['headers']['content-type']);
$this->assertNotEmpty($response['body']);
// Test with minimum valid viewport dimensions
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'viewportWidth' => 1,
'viewportHeight' => 1,
'width' => 800,
'height' => 600,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('image/png', $response['headers']['content-type']);
$this->assertNotEmpty($response['body']);
// Test with maximum valid viewport dimensions
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'viewportWidth' => 1920,
'viewportHeight' => 1080,
'width' => 800,
'height' => 600,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('image/png', $response['headers']['content-type']);
$this->assertNotEmpty($response['body']);
/**
* Test for FAILURE - Invalid URL parameter
*/
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'invalid-url',
'width' => 800,
'height' => 600,
]);
$this->assertEquals(400, $response['headers']['status-code']);
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'ftp://example.com', // Non-HTTP/HTTPS URL
'width' => 800,
'height' => 600,
]);
$this->assertEquals(400, $response['headers']['status-code']);
/**
* Test for FAILURE - Invalid viewport parameters
*/
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'viewportWidth' => 0, // Too small
'viewportHeight' => 720,
'width' => 800,
'height' => 600,
]);
$this->assertEquals(400, $response['headers']['status-code']);
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'viewportWidth' => 2000, // Too large
'viewportHeight' => 720,
'width' => 800,
'height' => 600,
]);
$this->assertEquals(400, $response['headers']['status-code']);
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'viewportWidth' => 1280,
'viewportHeight' => 0, // Too small
'width' => 800,
'height' => 600,
]);
$this->assertEquals(400, $response['headers']['status-code']);
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'viewportWidth' => 1280,
'viewportHeight' => 2000, // Too large
'width' => 800,
'height' => 600,
]);
$this->assertEquals(400, $response['headers']['status-code']);
/**
* Test for FAILURE - Invalid width/height parameters
*/
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => -1, // Invalid width (negative)
'height' => 600,
]);
$this->assertEquals(400, $response['headers']['status-code']);
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 3000, // Invalid height
]);
$this->assertEquals(400, $response['headers']['status-code']);
/**
* Test for FAILURE - Invalid sleep parameter
*/
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'sleep' => -1, // Negative sleep
]);
$this->assertEquals(400, $response['headers']['status-code']);
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'sleep' => 15, // Too large
]);
$this->assertEquals(400, $response['headers']['status-code']);
/**
* Test for FAILURE - Invalid quality parameter
*/
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'quality' => -2, // Too small
]);
$this->assertEquals(400, $response['headers']['status-code']);
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'quality' => 150, // Too large
]);
$this->assertEquals(400, $response['headers']['status-code']);
/**
* Test for FAILURE - Invalid output parameter
*/
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'output' => 'invalid-format',
]);
$this->assertEquals(400, $response['headers']['status-code']);
/**
* Test for SUCCESS - New screenshot parameters
*/
// Test with theme parameter
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'theme' => 'dark',
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('image/png', $response['headers']['content-type']);
$this->assertNotEmpty($response['body']);
// Test with scale parameter
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'scale' => 2.0,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('image/png', $response['headers']['content-type']);
$this->assertNotEmpty($response['body']);
// Test with userAgent parameter
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'userAgent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('image/png', $response['headers']['content-type']);
$this->assertNotEmpty($response['body']);
// Test with fullpage parameter
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'fullpage' => true,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('image/png', $response['headers']['content-type']);
$this->assertNotEmpty($response['body']);
// Test with locale parameter
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'locale' => 'en-US',
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('image/png', $response['headers']['content-type']);
$this->assertNotEmpty($response['body']);
// Test with timezone parameter
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'timezone' => 'America/New_York',
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('image/png', $response['headers']['content-type']);
$this->assertNotEmpty($response['body']);
// Test with geolocation parameters
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'latitude' => 40.7128,
'longitude' => -74.0060,
'accuracy' => 100,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('image/png', $response['headers']['content-type']);
$this->assertNotEmpty($response['body']);
// Test with touch parameter
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'touch' => true,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('image/png', $response['headers']['content-type']);
$this->assertNotEmpty($response['body']);
// Test with permissions parameter
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'permissions' => [
'geolocation',
'camera',
'microphone',
'notifications'
],
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('image/png', $response['headers']['content-type']);
$this->assertNotEmpty($response['body']);
// Test with original dimensions (width=0, height=0)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 0,
'height' => 0,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('image/png', $response['headers']['content-type']);
$this->assertNotEmpty($response['body']);
// Test with all new parameters combined
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'scale' => 1.5,
'theme' => 'dark',
'userAgent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
'fullpage' => true,
'locale' => 'en-GB',
'timezone' => 'Europe/London',
'latitude' => 51.5074,
'longitude' => -0.1278,
'accuracy' => 50,
'touch' => true,
'permissions' => [
'geolocation',
'camera',
'microphone',
'notifications',
'clipboard-read',
'clipboard-write'
],
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('image/png', $response['headers']['content-type']);
$this->assertNotEmpty($response['body']);
/**
* Test for FAILURE - Invalid new parameters
*/
// Test invalid theme parameter
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://test' . time() . '.com',
'width' => 800,
'height' => 600,
'theme' => 'invalid-theme',
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test invalid scale parameter (too small)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://test' . time() . '.com',
'width' => 800,
'height' => 600,
'scale' => 0.05, // Too small (min 0.1)
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test invalid scale parameter (too large)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://test' . time() . '.com',
'width' => 800,
'height' => 600,
'scale' => 5.0, // Too large (max 3.0)
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test invalid userAgent parameter (too long)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'userAgent' => str_repeat('A', 513), // Too long (max 512)
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test invalid fullpage parameter (non-boolean)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'fullpage' => 'invalid-boolean',
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test invalid locale parameter (too long)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'locale' => 'en-US-very-long-locale-string',
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test invalid timezone parameter
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'timezone' => 'Invalid/Timezone',
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test invalid latitude parameter (too high)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'latitude' => 91, // Too high (max 90)
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test invalid latitude parameter (too low)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'latitude' => -91, // Too low (min -90)
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test invalid longitude parameter (too high)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'longitude' => 181, // Too high (max 180)
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test invalid longitude parameter (too low)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'longitude' => -181, // Too low (min -180)
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test invalid accuracy parameter (too high)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'accuracy' => 100001, // Too high (max 100000)
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test invalid accuracy parameter (negative)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'accuracy' => -1, // Negative (min 0)
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test invalid touch parameter (non-boolean)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'touch' => 'invalid-boolean',
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test invalid permissions parameter (non-array)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'permissions' => 'invalid-permissions-string',
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test invalid permissions parameter (numeric array)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'permissions' => ['geolocation', 'camera', 'microphone'], // This should pass as it's a valid array
]);
$this->assertEquals(200, $response['headers']['status-code']);
// Test empty permissions array (should pass)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'permissions' => [], // Empty array should pass
]);
$this->assertEquals(200, $response['headers']['status-code']);
// Test invalid permission names (should fail)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'permissions' => ['invalid-permission', 'another-invalid'],
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test mixed valid and invalid permissions (should fail)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'permissions' => ['geolocation', 'invalid-permission'],
]);
$this->assertEquals(400, $response['headers']['status-code']);
// Test valid permission names (should pass)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'permissions' => ['geolocation', 'camera', 'microphone', 'notifications'],
]);
$this->assertEquals(200, $response['headers']['status-code']);
// Test advanced permission names (should pass)
$response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999),
'width' => 800,
'height' => 600,
'permissions' => ['geolocation', 'camera', 'microphone'],
]);
$this->assertEquals(200, $response['headers']['status-code']);
return [];
}
}

View file

@ -173,4 +173,214 @@ class AvatarsTest extends Scope
return $initials['body'];
}
public function testGetScreenshot()
{
$projectId = $this->getProject()['$id'];
$query = $this->getQuery(self::GET_SCREENSHOT);
$graphQLPayload = [
'query' => $query,
'variables' => [
'url' => 'https://appwrite.io',
'width' => 800,
'height' => 600,
],
];
$screenshot = $this->client->call(Client::METHOD_POST, '/graphql', \array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), $graphQLPayload);
$this->assertEquals(200, $screenshot['headers']['status-code']);
$this->assertNotEmpty($screenshot['body']);
// Debug: Print the actual response if it's not an image
if (!str_contains($screenshot['headers']['content-type'], 'image/')) {
echo "Response content-type: " . $screenshot['headers']['content-type'] . "\n";
echo "Response body: " . print_r($screenshot['body'], true) . "\n";
}
$this->assertStringContainsString('image/', $screenshot['headers']['content-type']);
return $screenshot['body'];
}
public function testGetScreenshotWithOriginalDimensions()
{
$projectId = $this->getProject()['$id'];
$query = $this->getQuery(self::GET_SCREENSHOT);
$graphQLPayload = [
'query' => $query,
'variables' => [
'url' => 'https://appwrite.io',
'width' => 0,
'height' => 0,
],
];
$screenshot = $this->client->call(Client::METHOD_POST, '/graphql', \array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), $graphQLPayload);
$this->assertEquals(200, $screenshot['headers']['status-code']);
$this->assertNotEmpty($screenshot['body']);
// Debug: Print the actual response if it's not an image
if (!str_contains($screenshot['headers']['content-type'], 'image/')) {
echo "Response content-type: " . $screenshot['headers']['content-type'] . "\n";
echo "Response body: " . print_r($screenshot['body'], true) . "\n";
}
$this->assertStringContainsString('image/', $screenshot['headers']['content-type']);
return $screenshot['body'];
}
public function testGetScreenshotWithNewParameters()
{
$projectId = $this->getProject()['$id'];
$query = $this->getQuery(self::GET_SCREENSHOT);
$graphQLPayload = [
'query' => $query,
'variables' => [
'url' => 'https://appwrite.io',
'width' => 800,
'height' => 600,
'viewportWidth' => 1920,
'viewportHeight' => 1080,
'scale' => 1.5,
'theme' => 'dark',
'userAgent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'fullpage' => true,
'locale' => 'en-US',
'timezone' => 'America/New_York',
'latitude' => 40.7128,
'longitude' => -74.0060,
'accuracy' => 100,
'touch' => true,
'permissions' => [
'geolocation',
'camera',
'microphone',
'notifications',
'clipboard-read',
'clipboard-write'
],
],
];
$screenshot = $this->client->call(Client::METHOD_POST, '/graphql', \array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), $graphQLPayload);
$this->assertEquals(200, $screenshot['headers']['status-code']);
$this->assertNotEmpty($screenshot['body']);
// Debug: Print the actual response if it's not an image
if (!str_contains($screenshot['headers']['content-type'], 'image/')) {
echo "Response content-type: " . $screenshot['headers']['content-type'] . "\n";
echo "Response body: " . print_r($screenshot['body'], true) . "\n";
}
$this->assertStringContainsString('image/', $screenshot['headers']['content-type']);
return $screenshot['body'];
}
public function testGetScreenshotWithViewportParameters()
{
$projectId = $this->getProject()['$id'];
$query = $this->getQuery(self::GET_SCREENSHOT);
$graphQLPayload = [
'query' => $query,
'variables' => [
'url' => 'https://appwrite.io',
'width' => 800,
'height' => 600,
'viewportWidth' => 1920,
'viewportHeight' => 1080,
],
];
$screenshot = $this->client->call(Client::METHOD_POST, '/graphql', \array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), $graphQLPayload);
$this->assertEquals(200, $screenshot['headers']['status-code']);
$this->assertNotEmpty($screenshot['body']);
$this->assertStringContainsString('image/', $screenshot['headers']['content-type']);
return $screenshot['body'];
}
public function testGetScreenshotWithPermissions()
{
$projectId = $this->getProject()['$id'];
$query = $this->getQuery(self::GET_SCREENSHOT);
$graphQLPayload = [
'query' => $query,
'variables' => [
'url' => 'https://appwrite.io',
'width' => 800,
'height' => 600,
'permissions' => [
'geolocation',
'camera',
'microphone',
'notifications'
],
],
];
$screenshot = $this->client->call(Client::METHOD_POST, '/graphql', \array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), $graphQLPayload);
$this->assertEquals(200, $screenshot['headers']['status-code']);
$this->assertNotEmpty($screenshot['body']);
// Debug: Print the actual response if it's not an image
if (!str_contains($screenshot['headers']['content-type'], 'image/')) {
echo "Response content-type: " . $screenshot['headers']['content-type'] . "\n";
echo "Response body: " . print_r($screenshot['body'], true) . "\n";
}
$this->assertStringContainsString('image/', $screenshot['headers']['content-type']);
return $screenshot['body'];
}
public function testGetScreenshotWithInvalidPermissions()
{
$projectId = $this->getProject()['$id'];
$query = $this->getQuery(self::GET_SCREENSHOT);
$graphQLPayload = [
'query' => $query,
'variables' => [
'url' => 'https://appwrite.io',
'width' => 800,
'height' => 600,
'permissions' => [
'geolocation',
'invalid-permission',
'camera'
],
],
];
$screenshot = $this->client->call(Client::METHOD_POST, '/graphql', \array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), $graphQLPayload);
$this->assertEquals(200, $screenshot['headers']['status-code']);
$this->assertArrayHasKey('errors', $screenshot['body']);
$this->assertNotEmpty($screenshot['body']['errors']);
$this->assertStringContainsString('Invalid `permissions` param', $screenshot['body']['errors'][0]['message']);
return $screenshot['body'];
}
}

View file

@ -288,6 +288,7 @@ trait Base
public const string GET_FAVICON = 'get_favicon';
public const string GET_QRCODE = 'get_qrcode';
public const string GET_USER_INITIALS = 'get_user_initials';
public const string GET_SCREENSHOT = 'get_screenshot';
// Providers
public const string CREATE_MAILGUN_PROVIDER = 'create_mailgun_provider';
@ -1781,6 +1782,12 @@ trait Base
status
}
}';
case self::GET_SCREENSHOT:
return 'query getScreenshot($url: String!, $width: Int, $height: Int, $viewportWidth: Int, $viewportHeight: Int, $scale: Float, $theme: String, $userAgent: String, $fullpage: Boolean, $locale: String, $timezone: String, $latitude: Float, $longitude: Float, $accuracy: Float, $touch: Boolean, $permissions: [String!]) {
avatarsGetScreenshot(url: $url, width: $width, height: $height, viewportWidth: $viewportWidth, viewportHeight: $viewportHeight, scale: $scale, theme: $theme, userAgent: $userAgent, fullpage: $fullpage, locale: $locale, timezone: $timezone, latitude: $latitude, longitude: $longitude, accuracy: $accuracy, touch: $touch, permissions: $permissions) {
status
}
}';
case self::GET_ACCOUNT:
return 'query getAccount {
accountGet {