-
Notifications
You must be signed in to change notification settings - Fork 5.3k
POC - website screenshots #10675
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
POC - website screenshots #10675
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
5073a86
POC - website screenshots
eldadfux 6cc5d15
Fixes
eldadfux f35b80b
Refactor avatar screenshot handling and add comprehensive tests for v…
eldadfux dc7bb62
Enhance avatar screenshot API with new parameters and validations; ad…
eldadfux bb77319
Update browser endpoint in avatar screenshot API to use service name …
eldadfux dfe87a0
Rename avatar screenshot endpoint from '/v1/avatars/screenshot' to '/…
eldadfux e00a73f
Enhance avatar screenshot API by adding 'scale' parameter for browser…
eldadfux d8bde64
Update appwrite-browser image to version 0.3.1; enhance avatar screen…
eldadfux f13c029
Refactor avatar screenshot API to replace 'viewport' parameter with s…
eldadfux 61f4c79
Add new configuration variable '_APP_BROWSER_HOST' for browser servic…
eldadfux 2afba50
Feat: usage stats for screenshot generated
lohanidamodar 74bcfe1
Merge pull request #10706 from appwrite/feat-screenshot-endpoint-stats
lohanidamodar 637bf4f
add abuse limit
lohanidamodar 60a4266
validate data length
lohanidamodar b303bd1
add correct assertion
lohanidamodar 8f87b9c
Merge branch '1.8.x' into feat-screenshots-endpoint
lohanidamodar 7156036
remove viewport fullpage duplicate
lohanidamodar 5c1f624
remove dump
lohanidamodar b30890c
remove duplicate configs
lohanidamodar a39970c
Merge branch '1.8.x' into feat-screenshots-endpoint
lohanidamodar d56bd44
Fix label parsing
lohanidamodar File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
|
@@ -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; | ||
|
|
@@ -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}') | ||
|
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) | ||
|
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) | ||
|
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); | ||
| } | ||
|
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SSRF hardening and timeout tuning.
Would you like a hardening checklist and example IP resolution guard for the browser service? Also applies to: 824-836 🤖 Prompt for AI Agents |
||
|
|
||
| // 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']) | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.