Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5073a86
POC - website screenshots
eldadfux Oct 21, 2025
6cc5d15
Fixes
eldadfux Oct 21, 2025
f35b80b
Refactor avatar screenshot handling and add comprehensive tests for v…
eldadfux Oct 21, 2025
dc7bb62
Enhance avatar screenshot API with new parameters and validations; ad…
eldadfux Oct 22, 2025
bb77319
Update browser endpoint in avatar screenshot API to use service name …
eldadfux Oct 22, 2025
dfe87a0
Rename avatar screenshot endpoint from '/v1/avatars/screenshot' to '/…
eldadfux Oct 22, 2025
e00a73f
Enhance avatar screenshot API by adding 'scale' parameter for browser…
eldadfux Oct 22, 2025
d8bde64
Update appwrite-browser image to version 0.3.1; enhance avatar screen…
eldadfux Oct 24, 2025
f13c029
Refactor avatar screenshot API to replace 'viewport' parameter with s…
eldadfux Oct 24, 2025
61f4c79
Add new configuration variable '_APP_BROWSER_HOST' for browser servic…
eldadfux Oct 25, 2025
2afba50
Feat: usage stats for screenshot generated
lohanidamodar Oct 27, 2025
74bcfe1
Merge pull request #10706 from appwrite/feat-screenshot-endpoint-stats
lohanidamodar Oct 27, 2025
637bf4f
add abuse limit
lohanidamodar Oct 29, 2025
60a4266
validate data length
lohanidamodar Oct 29, 2025
b303bd1
add correct assertion
lohanidamodar Oct 29, 2025
8f87b9c
Merge branch '1.8.x' into feat-screenshots-endpoint
lohanidamodar Oct 29, 2025
7156036
remove viewport fullpage duplicate
lohanidamodar Oct 30, 2025
5c1f624
remove dump
lohanidamodar Oct 30, 2025
b30890c
remove duplicate configs
lohanidamodar Oct 30, 2025
a39970c
Merge branch '1.8.x' into feat-screenshots-endpoint
lohanidamodar Oct 30, 2025
d56bd44
Fix label parsing
lohanidamodar Oct 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/config/variables.php
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,16 @@
'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.',
Expand Down
184 changes: 184 additions & 0 deletions app/controllers/api/avatars.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php

use Appwrite\Event\StatsUsage;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
Expand All @@ -23,6 +24,8 @@
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;
Expand Down Expand Up @@ -635,6 +638,187 @@
->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}')
Comment thread
Meldiron marked this conversation as resolved.
Comment thread
lohanidamodar marked this conversation as resolved.
->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)
Comment thread
eldadfux marked this conversation as resolved.
->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)
Comment thread
Meldiron marked this conversation as resolved.
->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);
}
Comment thread
lohanidamodar marked this conversation as resolved.

$client = new Client();
$client->setTimeout(30);
$client->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON);
Comment on lines +693 to +701
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

SSRF hardening and timeout tuning.

  • Domain::isKnown() is a good first gate but won’t block DNS rebinding or public-looking domains resolving to private IPs. Ensure the browser service itself enforces private-network blocking, IP allow/deny lists, and redirect validation.
  • setTimeout(30) might be too low when sleep>0 and fullpage is true. Consider 60s or make it proportional.

Would you like a hardening checklist and example IP resolution guard for the browser service?

Also applies to: 824-836

🤖 Prompt for AI Agents
In app/controllers/api/avatars.php around lines 693-701 (and similarly at
824-836), the current code only uses Domain::isKnown() and sets a 30s client
timeout, which leaves SSRF and timeout issues; update to enforce SSRF hardening
by resolving the hostname before making requests and validating all resolved IPs
against allow/deny rules and private-network ranges (block RFC1918, link-local,
loopback, unique local, multicast, etc.) to prevent DNS rebinding and private IP
access, ensure redirects are validated (reject redirects to disallowed
IPs/ranges), and move IP validation logic to the browser service so it applies
consistently; increase the HTTP client timeout to 60s or make it proportional to
parameters like sleep/fullpage (e.g., baseTimeout * (sleep? factor:1)) and
surface errors with clear messages when validation fails.


// 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'])
Expand Down
19 changes: 18 additions & 1 deletion app/controllers/shared/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,20 @@
};

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;
Expand Down Expand Up @@ -580,6 +593,10 @@
$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;

Expand Down
1 change: 1 addition & 0 deletions app/init/constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@
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';
Expand Down
2 changes: 1 addition & 1 deletion app/views/install/compose.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions docs/references/avatars/get-screenshot.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion src/Appwrite/GraphQL/Types/Mapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -331,9 +331,16 @@ public static function param(
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;
Expand Down
2 changes: 1 addition & 1 deletion src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
Original file line number Diff line number Diff line change
Expand Up @@ -988,7 +988,7 @@ protected function buildDeployment(
$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',
Expand Down
Loading