From 5073a865814845acbe5cf93876aacf2434dd4a78 Mon Sep 17 00:00:00 2001 From: Eldad Fux Date: Tue, 21 Oct 2025 15:37:05 +0100 Subject: [PATCH 01/18] POC - website screenshots --- app/controllers/api/avatars.php | 117 ++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/app/controllers/api/avatars.php b/app/controllers/api/avatars.php index 90364d997e..84e675de94 100644 --- a/app/controllers/api/avatars.php +++ b/app/controllers/api/avatars.php @@ -23,6 +23,8 @@ use Utopia\Fetch\Client; use Utopia\Image\Image; use Utopia\Logger\Logger; use Utopia\System\System; +use Utopia\Validator\AnyOf; +use Utopia\Validator\Assoc; use Utopia\Validator\Boolean; use Utopia\Validator\HexColor; use Utopia\Validator\Range; @@ -635,6 +637,121 @@ App::get('/v1/avatars/initials') ->file($image->getImageBlob()); }); +App::get('/v1/avatars/screenshot') + ->desc('Get webpage screenshot') + ->groups(['api', 'avatars']) + ->label('scope', 'avatars.read') + ->label('cache', true) + ->label('cache.resourceType', 'avatar/screenshot') + ->label('cache.resource', 'screenshot/{request.url}') + ->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 AnyOf([new Assoc(), new Text(65535)], AnyOf::TYPE_MIXED), 'HTTP headers to send with the browser request. Defaults to empty.', true) + ->param('viewport', '1280x720', new Text(20), 'Browser viewport size. Pass a string like "1280x720" or "1920x1080". Defaults to "1280x720".', true) + ->param('scale', 2, new Range(1, 5, Range::TYPE_FLOAT), 'Device pixel ratio. Pass a number between 1 to 5. Defaults to 2.', true) + ->param('fullPage', false, new Boolean(true), 'Capture full page. Pass 0 for viewport only, or 1 for full page. Default value is set to 0.', 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', 1280, new Range(1, 2000), 'Output image width. Pass an integer between 1 to 2000. Defaults to 1280.', true) + ->param('height', 720, new Range(1, 2000), 'Output image height. Pass an integer between 1 to 2000. Defaults to 720.', 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') + ->action(function (string $url, array $headers, string $viewport, float $scale, bool $fullPage, int $sleep, int $width, int $height, int $quality, string $output, Response $response) { + + 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); + } + + // Parse viewport parameter + $viewportParts = \explode('x', $viewport); + if (\count($viewportParts) !== 2 || !\is_numeric($viewportParts[0]) || !\is_numeric($viewportParts[1])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Viewport must be in format "WIDTHxHEIGHT" (e.g., "1280x720")'); + } + + $browserWidth = (int) $viewportParts[0]; + $browserHeight = (int) $viewportParts[1]; + + if ($browserWidth < 1 || $browserWidth > 1920 || $browserHeight < 1 || $browserHeight > 1080) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Browser viewport must be between 1x1 and 1920x1080'); + } + + $client = new Client(); + $client->setTimeout(30); + $client->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON); + + $config = [ + 'url' => $url, + 'width' => $browserWidth, + 'height' => $browserHeight, + 'fullPage' => $fullPage, + 'sleep' => $sleep * 1000, // Convert seconds to milliseconds + 'scale' => $scale, + 'headers' => $headers + ]; + + try { + $browserEndpoint = Config::getParam('_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'); + } + + // Resize the screenshot to the desired output dimensions + $image = new Image($screenshot); + $image->crop($width, $height); + + // Determine output format + $outputs = Config::getParam('storage-outputs'); + if (empty($output)) { + $output = 'png'; // Default to PNG for screenshots + } + + $resizedScreenshot = $image->output($output, $quality); + unset($image); + + $contentType = (\array_key_exists($output, $outputs)) ? $outputs[$output] : $outputs['png']; + + $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']) From 6cc5d1595d4410fefb02984df43e57dac52f43fc Mon Sep 17 00:00:00 2001 From: Eldad Fux Date: Tue, 21 Oct 2025 21:03:13 +0100 Subject: [PATCH 02/18] Fixes --- app/controllers/api/avatars.php | 38 +++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/avatars.php b/app/controllers/api/avatars.php index 84e675de94..12f3f3d4d6 100644 --- a/app/controllers/api/avatars.php +++ b/app/controllers/api/avatars.php @@ -660,7 +660,7 @@ App::get('/v1/avatars/screenshot') contentType: ContentType::IMAGE_PNG )) ->param('url', '', new URL(['http', 'https']), 'Website URL which you want to capture.') - ->param('headers', [], new AnyOf([new Assoc(), new Text(65535)], AnyOf::TYPE_MIXED), 'HTTP headers to send with the browser request. Defaults to empty.', true) + ->param('headers', [], new Assoc(), 'HTTP headers to send with the browser request. Defaults to empty.', true) ->param('viewport', '1280x720', new Text(20), 'Browser viewport size. Pass a string like "1280x720" or "1920x1080". Defaults to "1280x720".', true) ->param('scale', 2, new Range(1, 5, Range::TYPE_FLOAT), 'Device pixel ratio. Pass a number between 1 to 5. Defaults to 2.', true) ->param('fullPage', false, new Boolean(true), 'Capture full page. Pass 0 for viewport only, or 1 for full page. Default value is set to 0.', true) @@ -699,6 +699,23 @@ App::get('/v1/avatars/screenshot') $client->setTimeout(30); $client->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON); + // Ensure headers is always an associative array (object) + if (!is_array($headers)) { + $headers = []; + } + + // Convert to associative array if it's a regular array + if (is_array($headers) && 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 $config = [ 'url' => $url, 'width' => $browserWidth, @@ -706,11 +723,28 @@ App::get('/v1/avatars/screenshot') 'fullPage' => $fullPage, 'sleep' => $sleep * 1000, // Convert seconds to milliseconds 'scale' => $scale, - 'headers' => $headers + 'headers' => $headersObject + ]; + + // Ensure the entire config is properly serialized as JSON + // This is a workaround to ensure headers are sent as an object + $configJson = json_encode($config, JSON_FORCE_OBJECT); + $configObject = json_decode($configJson, false); // false to keep objects as objects + + // Convert back to array for the fetch method, but ensure headers remains an object + $config = [ + 'url' => $configObject->url, + 'width' => $configObject->width, + 'height' => $configObject->height, + 'fullPage' => $configObject->fullPage, + 'sleep' => $configObject->sleep, + 'scale' => $configObject->scale, + 'headers' => $configObject->headers // Keep as object ]; try { $browserEndpoint = Config::getParam('_APP_BROWSER_HOST', 'http://appwrite-browser:3000/v1'); + $fetchResponse = $client->fetch( url: $browserEndpoint . '/screenshots', method: 'POST', From f35b80ba1ade8726851382a9e12569f79b98f214 Mon Sep 17 00:00:00 2001 From: Eldad Fux Date: Tue, 21 Oct 2025 21:20:31 +0100 Subject: [PATCH 03/18] Refactor avatar screenshot handling and add comprehensive tests for various header and parameter validations --- app/controllers/api/avatars.php | 16 +- tests/e2e/Services/Avatars/AvatarsBase.php | 287 +++++++++++++++++++++ 2 files changed, 292 insertions(+), 11 deletions(-) diff --git a/app/controllers/api/avatars.php b/app/controllers/api/avatars.php index 12f3f3d4d6..45ebce195d 100644 --- a/app/controllers/api/avatars.php +++ b/app/controllers/api/avatars.php @@ -23,7 +23,6 @@ use Utopia\Fetch\Client; use Utopia\Image\Image; use Utopia\Logger\Logger; use Utopia\System\System; -use Utopia\Validator\AnyOf; use Utopia\Validator\Assoc; use Utopia\Validator\Boolean; use Utopia\Validator\HexColor; @@ -699,16 +698,11 @@ App::get('/v1/avatars/screenshot') $client->setTimeout(30); $client->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON); - // Ensure headers is always an associative array (object) - if (!is_array($headers)) { + // 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 = []; } - - // Convert to associative array if it's a regular array - if (is_array($headers) && 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) { @@ -725,12 +719,12 @@ App::get('/v1/avatars/screenshot') 'scale' => $scale, 'headers' => $headersObject ]; - + // Ensure the entire config is properly serialized as JSON // This is a workaround to ensure headers are sent as an object $configJson = json_encode($config, JSON_FORCE_OBJECT); $configObject = json_decode($configJson, false); // false to keep objects as objects - + // Convert back to array for the fetch method, but ensure headers remains an object $config = [ 'url' => $configObject->url, diff --git a/tests/e2e/Services/Avatars/AvatarsBase.php b/tests/e2e/Services/Avatars/AvatarsBase.php index 83f70b8978..c94bf6bcb9 100644 --- a/tests/e2e/Services/Avatars/AvatarsBase.php +++ b/tests/e2e/Services/Avatars/AvatarsBase.php @@ -558,4 +558,291 @@ 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/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + 'width' => 800, + 'height' => 600, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('image/png', $response['headers']['content-type']); + $this->assertNotEmpty($response['body']); + + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + '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/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + '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/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + '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/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + '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/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + '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/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + '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/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + '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/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + '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/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + '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 for FAILURE - Invalid URL parameter + */ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + '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/screenshot', [ + '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 parameter + */ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + 'viewport' => 'invalid-viewport', + 'width' => 800, + 'height' => 600, + ]); + $this->assertEquals(400, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + 'viewport' => '2000x1000', // 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/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + 'width' => 0, // Invalid width + 'height' => 600, + ]); + $this->assertEquals(400, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + 'width' => 800, + 'height' => 3000, // Invalid height + ]); + $this->assertEquals(400, $response['headers']['status-code']); + + /** + * Test for FAILURE - Invalid scale parameter + */ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + 'width' => 800, + 'height' => 600, + 'scale' => 0.5, // Too small + ]); + $this->assertEquals(400, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + 'width' => 800, + 'height' => 600, + 'scale' => 10, // Too large + ]); + $this->assertEquals(400, $response['headers']['status-code']); + + /** + * Test for FAILURE - Invalid sleep parameter + */ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + 'width' => 800, + 'height' => 600, + 'sleep' => -1, // Negative sleep + ]); + $this->assertEquals(400, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + '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/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + 'width' => 800, + 'height' => 600, + 'quality' => -2, // Too small + ]); + $this->assertEquals(400, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + '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/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io', + 'width' => 800, + 'height' => 600, + 'output' => 'invalid-format', + ]); + $this->assertEquals(400, $response['headers']['status-code']); + + return []; + } } From dc7bb627d53521f6a29d54ef72b264dd0a0ab7ee Mon Sep 17 00:00:00 2001 From: Eldad Fux Date: Wed, 22 Oct 2025 01:24:31 +0100 Subject: [PATCH 04/18] Enhance avatar screenshot API with new parameters and validations; add GraphQL support and extensive tests for various scenarios --- app/controllers/api/avatars.php | 141 +++++-- src/Appwrite/GraphQL/Types/Mapper.php | 9 +- tests/e2e/Services/Avatars/AvatarsBase.php | 433 ++++++++++++++++++--- tests/e2e/Services/GraphQL/AvatarsTest.php | 180 +++++++++ tests/e2e/Services/GraphQL/Base.php | 7 + 5 files changed, 696 insertions(+), 74 deletions(-) diff --git a/app/controllers/api/avatars.php b/app/controllers/api/avatars.php index 45ebce195d..6a240cb1ff 100644 --- a/app/controllers/api/avatars.php +++ b/app/controllers/api/avatars.php @@ -23,6 +23,7 @@ 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; @@ -642,7 +643,7 @@ App::get('/v1/avatars/screenshot') ->label('scope', 'avatars.read') ->label('cache', true) ->label('cache.resourceType', 'avatar/screenshot') - ->label('cache.resource', 'screenshot/{request.url}') + ->label('cache.resource', 'screenshot/{request.url}/{request.width}/{request.height}/{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, @@ -661,15 +662,23 @@ App::get('/v1/avatars/screenshot') ->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('viewport', '1280x720', new Text(20), 'Browser viewport size. Pass a string like "1280x720" or "1920x1080". Defaults to "1280x720".', true) - ->param('scale', 2, new Range(1, 5, Range::TYPE_FLOAT), 'Device pixel ratio. Pass a number between 1 to 5. Defaults to 2.', true) - ->param('fullPage', false, new Boolean(true), 'Capture full page. Pass 0 for viewport only, or 1 for full page. Default value is set to 0.', 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', 1280, new Range(1, 2000), 'Output image width. Pass an integer between 1 to 2000. Defaults to 1280.', true) - ->param('height', 720, new Range(1, 2000), 'Output image height. Pass an integer between 1 to 2000. Defaults to 720.', 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') - ->action(function (string $url, array $headers, string $viewport, float $scale, bool $fullPage, int $sleep, int $width, int $height, int $quality, string $output, Response $response) { + ->action(function (string $url, array $headers, string $viewport, 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) { if (!\extension_loaded('imagick')) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing'); @@ -710,34 +719,106 @@ App::get('/v1/avatars/screenshot') } // Create the config with headers as an object + // The custom browser service accepts: url, theme, headers, sleep, viewport, userAgent, fullPage, locale, timezoneId, geolocation, hasTouch $config = [ 'url' => $url, - 'width' => $browserWidth, - 'height' => $browserHeight, - 'fullPage' => $fullPage, + 'theme' => $theme, + 'headers' => $headersObject, 'sleep' => $sleep * 1000, // Convert seconds to milliseconds - 'scale' => $scale, - 'headers' => $headersObject + 'viewport' => [ + 'width' => $browserWidth, + 'height' => $browserHeight + ] ]; - // Ensure the entire config is properly serialized as JSON - // This is a workaround to ensure headers are sent as an object - $configJson = json_encode($config, JSON_FORCE_OBJECT); - $configObject = json_decode($configJson, false); // false to keep objects as objects + // Add fullPage to viewport if enabled + if ($fullpage) { + $config['viewport']['fullPage'] = true; + } - // Convert back to array for the fetch method, but ensure headers remains an object - $config = [ - 'url' => $configObject->url, - 'width' => $configObject->width, - 'height' => $configObject->height, - 'fullPage' => $configObject->fullPage, - 'sleep' => $configObject->sleep, - 'scale' => $configObject->scale, - 'headers' => $configObject->headers // Keep as object + // Add optional parameters only if they have meaningful values + 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 + if (!empty($permissions)) { + $config['permissions'] = $permissions; + } + + // Manually handle the config to ensure headers is an object but arrays remain arrays + $finalConfig = [ + 'url' => $config['url'], + 'theme' => $config['theme'], + 'headers' => $config['headers'], // Keep as object + 'sleep' => $config['sleep'], + 'viewport' => $config['viewport'] // Keep as object ]; + // Add optional parameters that were set, preserving arrays as arrays + if (!empty($userAgent)) { + $finalConfig['userAgent'] = $userAgent; + } + + if ($fullpage) { + $finalConfig['fullPage'] = true; + } + + if (!empty($locale)) { + $finalConfig['locale'] = $locale; + } + + if (!empty($timezone)) { + $finalConfig['timezoneId'] = $timezone; + } + + // Add geolocation if any coordinates are provided + if ($latitude != 0 || $longitude != 0) { + $finalConfig['geolocation'] = [ + 'latitude' => $latitude, + 'longitude' => $longitude, + 'accuracy' => $accuracy + ]; + } + + if ($touch) { + $finalConfig['hasTouch'] = true; + } + + // Add permissions if provided (preserve as array) + if (!empty($permissions)) { + $finalConfig['permissions'] = $permissions; // Keep as array + } + + $config = $finalConfig; + try { - $browserEndpoint = Config::getParam('_APP_BROWSER_HOST', 'http://appwrite-browser:3000/v1'); + $browserEndpoint = Config::getParam('_APP_BROWSER_HOST', 'http://192.168.1.43:3000/v1'); $fetchResponse = $client->fetch( url: $browserEndpoint . '/screenshots', @@ -755,16 +836,18 @@ App::get('/v1/avatars/screenshot') throw new Exception(Exception::AVATAR_IMAGE_NOT_FOUND, 'Screenshot not generated'); } - // Resize the screenshot to the desired output dimensions - $image = new Image($screenshot); - $image->crop($width, $height); - // Determine output format $outputs = Config::getParam('storage-outputs'); if (empty($output)) { $output = 'png'; // Default to PNG for screenshots } + // Only resize if width and height are explicitly set (not 0) + $image = new Image($screenshot); + if ($width > 0 && $height > 0) { + $image->crop($width, $height); + } + $resizedScreenshot = $image->output($output, $quality); unset($image); diff --git a/src/Appwrite/GraphQL/Types/Mapper.php b/src/Appwrite/GraphQL/Types/Mapper.php index b74e2a7549..c9ae84f1c3 100644 --- a/src/Appwrite/GraphQL/Types/Mapper.php +++ b/src/Appwrite/GraphQL/Types/Mapper.php @@ -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; diff --git a/tests/e2e/Services/Avatars/AvatarsBase.php b/tests/e2e/Services/Avatars/AvatarsBase.php index c94bf6bcb9..f331fa0359 100644 --- a/tests/e2e/Services/Avatars/AvatarsBase.php +++ b/tests/e2e/Services/Avatars/AvatarsBase.php @@ -567,7 +567,7 @@ trait AvatarsBase $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), 'width' => 800, 'height' => 600, ]); @@ -579,7 +579,7 @@ trait AvatarsBase $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), 'width' => 800, 'height' => 600, 'headers' => [ @@ -595,12 +595,12 @@ trait AvatarsBase /** * Test for FAILURE - Invalid headers parameter types */ - + // Test with string headers (should fail) $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), 'width' => 800, 'height' => 600, 'headers' => 'invalid-headers-string', @@ -611,7 +611,7 @@ trait AvatarsBase $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), 'width' => 800, 'height' => 600, 'headers' => 123, @@ -622,7 +622,7 @@ trait AvatarsBase $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), 'width' => 800, 'height' => 600, 'headers' => true, @@ -636,7 +636,7 @@ trait AvatarsBase $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), 'width' => 800, 'height' => 600, 'headers' => ['value1', 'value2', 'value3'], // Indexed array @@ -648,7 +648,7 @@ trait AvatarsBase $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), 'width' => 800, 'height' => 600, 'headers' => ['User-Agent' => 'MyApp', 'value2', 'Accept' => 'text/html'], // Mixed array @@ -659,7 +659,7 @@ trait AvatarsBase $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), 'width' => 800, 'height' => 600, 'headers' => [], // Empty associative array should pass @@ -670,7 +670,7 @@ trait AvatarsBase $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), 'width' => 800, 'height' => 600, 'headers' => [ @@ -685,7 +685,7 @@ trait AvatarsBase $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), 'width' => 800, 'height' => 600, 'headers' => [ @@ -723,7 +723,7 @@ trait AvatarsBase $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), 'viewport' => 'invalid-viewport', 'width' => 800, 'height' => 600, @@ -733,7 +733,7 @@ trait AvatarsBase $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), 'viewport' => '2000x1000', // Too large 'width' => 800, 'height' => 600, @@ -746,8 +746,8 @@ trait AvatarsBase $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', - 'width' => 0, // Invalid width + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), + 'width' => -1, // Invalid width (negative) 'height' => 600, ]); $this->assertEquals(400, $response['headers']['status-code']); @@ -755,42 +755,19 @@ trait AvatarsBase $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', + '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 scale parameter - */ - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ - 'x-appwrite-project' => $this->getProject()['$id'], - ], [ - 'url' => 'https://appwrite.io', - 'width' => 800, - 'height' => 600, - 'scale' => 0.5, // Too small - ]); - $this->assertEquals(400, $response['headers']['status-code']); - - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ - 'x-appwrite-project' => $this->getProject()['$id'], - ], [ - 'url' => 'https://appwrite.io', - 'width' => 800, - 'height' => 600, - 'scale' => 10, // Too large - ]); - $this->assertEquals(400, $response['headers']['status-code']); - /** * Test for FAILURE - Invalid sleep parameter */ $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), 'width' => 800, 'height' => 600, 'sleep' => -1, // Negative sleep @@ -800,7 +777,7 @@ trait AvatarsBase $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), 'width' => 800, 'height' => 600, 'sleep' => 15, // Too large @@ -813,7 +790,7 @@ trait AvatarsBase $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), 'width' => 800, 'height' => 600, 'quality' => -2, // Too small @@ -823,7 +800,7 @@ trait AvatarsBase $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), 'width' => 800, 'height' => 600, 'quality' => 150, // Too large @@ -836,13 +813,381 @@ trait AvatarsBase $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io', + '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/screenshot', [ + '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 userAgent parameter + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), + 'width' => 800, + 'height' => 600, + '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/screenshot', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), + 'width' => 800, + 'height' => 600, + 'theme' => 'invalid-theme', + ]); + $this->assertEquals(400, $response['headers']['status-code']); + + // Test invalid userAgent parameter (too long) + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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/screenshot', [ + '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 []; } } diff --git a/tests/e2e/Services/GraphQL/AvatarsTest.php b/tests/e2e/Services/GraphQL/AvatarsTest.php index 4c33aedcd7..085f0eaecb 100644 --- a/tests/e2e/Services/GraphQL/AvatarsTest.php +++ b/tests/e2e/Services/GraphQL/AvatarsTest.php @@ -173,4 +173,184 @@ 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, + '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 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']; + } } diff --git a/tests/e2e/Services/GraphQL/Base.php b/tests/e2e/Services/GraphQL/Base.php index 9f5a93dd00..4b0d2630e6 100644 --- a/tests/e2e/Services/GraphQL/Base.php +++ b/tests/e2e/Services/GraphQL/Base.php @@ -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'; @@ -1779,6 +1780,12 @@ trait Base status } }'; + case self::GET_SCREENSHOT: + return 'query getScreenshot($url: String!, $width: Int, $height: Int, $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, 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 { From bb7731970395df182cb16cd09e09d99e56823508 Mon Sep 17 00:00:00 2001 From: Eldad Fux Date: Wed, 22 Oct 2025 01:30:04 +0100 Subject: [PATCH 05/18] Update browser endpoint in avatar screenshot API to use service name for improved reliability --- app/controllers/api/avatars.php | 2 +- docs/references/avatars/get-screenshot.md | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 docs/references/avatars/get-screenshot.md diff --git a/app/controllers/api/avatars.php b/app/controllers/api/avatars.php index 6a240cb1ff..addad2346c 100644 --- a/app/controllers/api/avatars.php +++ b/app/controllers/api/avatars.php @@ -818,7 +818,7 @@ App::get('/v1/avatars/screenshot') $config = $finalConfig; try { - $browserEndpoint = Config::getParam('_APP_BROWSER_HOST', 'http://192.168.1.43:3000/v1'); + $browserEndpoint = Config::getParam('_APP_BROWSER_HOST', 'http://appwrite-browser:3000/v1'); $fetchResponse = $client->fetch( url: $browserEndpoint . '/screenshots', diff --git a/docs/references/avatars/get-screenshot.md b/docs/references/avatars/get-screenshot.md new file mode 100644 index 0000000000..41fdf4c7c9 --- /dev/null +++ b/docs/references/avatars/get-screenshot.md @@ -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. \ No newline at end of file From dfe87a0d37f38390dff1a3f6409898db27ccc8bd Mon Sep 17 00:00:00 2001 From: Eldad Fux Date: Wed, 22 Oct 2025 01:40:31 +0100 Subject: [PATCH 06/18] Rename avatar screenshot endpoint from '/v1/avatars/screenshot' to '/v1/avatars/screenshots' for consistency; update related tests accordingly. --- app/controllers/api/avatars.php | 2 +- tests/e2e/Services/Avatars/AvatarsBase.php | 100 ++++++++++----------- 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/app/controllers/api/avatars.php b/app/controllers/api/avatars.php index addad2346c..5b06eb1a22 100644 --- a/app/controllers/api/avatars.php +++ b/app/controllers/api/avatars.php @@ -637,7 +637,7 @@ App::get('/v1/avatars/initials') ->file($image->getImageBlob()); }); -App::get('/v1/avatars/screenshot') +App::get('/v1/avatars/screenshots') ->desc('Get webpage screenshot') ->groups(['api', 'avatars']) ->label('scope', 'avatars.read') diff --git a/tests/e2e/Services/Avatars/AvatarsBase.php b/tests/e2e/Services/Avatars/AvatarsBase.php index f331fa0359..68bf24250f 100644 --- a/tests/e2e/Services/Avatars/AvatarsBase.php +++ b/tests/e2e/Services/Avatars/AvatarsBase.php @@ -564,7 +564,7 @@ trait AvatarsBase /** * Test for SUCCESS */ - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -576,7 +576,7 @@ trait AvatarsBase $this->assertEquals('image/png', $response['headers']['content-type']); $this->assertNotEmpty($response['body']); - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -597,7 +597,7 @@ trait AvatarsBase */ // Test with string headers (should fail) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -608,7 +608,7 @@ trait AvatarsBase $this->assertEquals(400, $response['headers']['status-code']); // Test with numeric headers (should fail) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -619,7 +619,7 @@ trait AvatarsBase $this->assertEquals(400, $response['headers']['status-code']); // Test with boolean headers (should fail) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -633,7 +633,7 @@ trait AvatarsBase // 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/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -645,7 +645,7 @@ trait AvatarsBase // 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/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -656,7 +656,7 @@ trait AvatarsBase $this->assertEquals(200, $response['headers']['status-code']); // Test with empty array (should pass - empty associative array) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -667,7 +667,7 @@ trait AvatarsBase $this->assertEquals(200, $response['headers']['status-code']); // Test with valid headers object (should pass) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -682,7 +682,7 @@ trait AvatarsBase $this->assertEquals(200, $response['headers']['status-code']); // Test with headers containing special characters (should pass) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -699,7 +699,7 @@ trait AvatarsBase /** * Test for FAILURE - Invalid URL parameter */ - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'invalid-url', @@ -708,7 +708,7 @@ trait AvatarsBase ]); $this->assertEquals(400, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'ftp://example.com', // Non-HTTP/HTTPS URL @@ -720,7 +720,7 @@ trait AvatarsBase /** * Test for FAILURE - Invalid viewport parameter */ - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -730,7 +730,7 @@ trait AvatarsBase ]); $this->assertEquals(400, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -743,7 +743,7 @@ trait AvatarsBase /** * Test for FAILURE - Invalid width/height parameters */ - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -752,7 +752,7 @@ trait AvatarsBase ]); $this->assertEquals(400, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -764,7 +764,7 @@ trait AvatarsBase /** * Test for FAILURE - Invalid sleep parameter */ - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -774,7 +774,7 @@ trait AvatarsBase ]); $this->assertEquals(400, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -787,7 +787,7 @@ trait AvatarsBase /** * Test for FAILURE - Invalid quality parameter */ - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -797,7 +797,7 @@ trait AvatarsBase ]); $this->assertEquals(400, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -810,7 +810,7 @@ trait AvatarsBase /** * Test for FAILURE - Invalid output parameter */ - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -824,7 +824,7 @@ trait AvatarsBase * Test for SUCCESS - New screenshot parameters */ // Test with theme parameter - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -837,7 +837,7 @@ trait AvatarsBase $this->assertNotEmpty($response['body']); // Test with userAgent parameter - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -850,7 +850,7 @@ trait AvatarsBase $this->assertNotEmpty($response['body']); // Test with fullpage parameter - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -863,7 +863,7 @@ trait AvatarsBase $this->assertNotEmpty($response['body']); // Test with locale parameter - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -876,7 +876,7 @@ trait AvatarsBase $this->assertNotEmpty($response['body']); // Test with timezone parameter - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -889,7 +889,7 @@ trait AvatarsBase $this->assertNotEmpty($response['body']); // Test with geolocation parameters - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -904,7 +904,7 @@ trait AvatarsBase $this->assertNotEmpty($response['body']); // Test with touch parameter - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -917,7 +917,7 @@ trait AvatarsBase $this->assertNotEmpty($response['body']); // Test with permissions parameter - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -935,7 +935,7 @@ trait AvatarsBase $this->assertNotEmpty($response['body']); // Test with original dimensions (width=0, height=0) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -947,7 +947,7 @@ trait AvatarsBase $this->assertNotEmpty($response['body']); // Test with all new parameters combined - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -980,7 +980,7 @@ trait AvatarsBase */ // Test invalid theme parameter - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -991,7 +991,7 @@ trait AvatarsBase $this->assertEquals(400, $response['headers']['status-code']); // Test invalid userAgent parameter (too long) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -1002,7 +1002,7 @@ trait AvatarsBase $this->assertEquals(400, $response['headers']['status-code']); // Test invalid fullpage parameter (non-boolean) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -1013,7 +1013,7 @@ trait AvatarsBase $this->assertEquals(400, $response['headers']['status-code']); // Test invalid locale parameter (too long) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -1024,7 +1024,7 @@ trait AvatarsBase $this->assertEquals(400, $response['headers']['status-code']); // Test invalid timezone parameter - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -1035,7 +1035,7 @@ trait AvatarsBase $this->assertEquals(400, $response['headers']['status-code']); // Test invalid latitude parameter (too high) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -1046,7 +1046,7 @@ trait AvatarsBase $this->assertEquals(400, $response['headers']['status-code']); // Test invalid latitude parameter (too low) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -1057,7 +1057,7 @@ trait AvatarsBase $this->assertEquals(400, $response['headers']['status-code']); // Test invalid longitude parameter (too high) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -1068,7 +1068,7 @@ trait AvatarsBase $this->assertEquals(400, $response['headers']['status-code']); // Test invalid longitude parameter (too low) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -1079,7 +1079,7 @@ trait AvatarsBase $this->assertEquals(400, $response['headers']['status-code']); // Test invalid accuracy parameter (too high) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -1090,7 +1090,7 @@ trait AvatarsBase $this->assertEquals(400, $response['headers']['status-code']); // Test invalid accuracy parameter (negative) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -1101,7 +1101,7 @@ trait AvatarsBase $this->assertEquals(400, $response['headers']['status-code']); // Test invalid touch parameter (non-boolean) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -1112,7 +1112,7 @@ trait AvatarsBase $this->assertEquals(400, $response['headers']['status-code']); // Test invalid permissions parameter (non-array) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -1123,7 +1123,7 @@ trait AvatarsBase $this->assertEquals(400, $response['headers']['status-code']); // Test invalid permissions parameter (numeric array) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -1134,7 +1134,7 @@ trait AvatarsBase $this->assertEquals(200, $response['headers']['status-code']); // Test empty permissions array (should pass) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -1145,7 +1145,7 @@ trait AvatarsBase $this->assertEquals(200, $response['headers']['status-code']); // Test invalid permission names (should fail) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -1156,7 +1156,7 @@ trait AvatarsBase $this->assertEquals(400, $response['headers']['status-code']); // Test mixed valid and invalid permissions (should fail) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -1167,7 +1167,7 @@ trait AvatarsBase $this->assertEquals(400, $response['headers']['status-code']); // Test valid permission names (should pass) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), @@ -1178,7 +1178,7 @@ trait AvatarsBase $this->assertEquals(200, $response['headers']['status-code']); // Test advanced permission names (should pass) - $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshot', [ + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), From e00a73f44262fa717344992de84701bb26d6f948 Mon Sep 17 00:00:00 2001 From: Eldad Fux Date: Wed, 22 Oct 2025 10:22:17 +0100 Subject: [PATCH 07/18] Enhance avatar screenshot API by adding 'scale' parameter for browser scaling; update related tests to validate new functionality and edge cases. --- app/controllers/api/avatars.php | 17 ++++++++-- tests/e2e/Services/Avatars/AvatarsBase.php | 38 +++++++++++++++++++++- tests/e2e/Services/GraphQL/AvatarsTest.php | 1 + tests/e2e/Services/GraphQL/Base.php | 4 +-- 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/app/controllers/api/avatars.php b/app/controllers/api/avatars.php index 5b06eb1a22..e77426ad76 100644 --- a/app/controllers/api/avatars.php +++ b/app/controllers/api/avatars.php @@ -643,7 +643,7 @@ App::get('/v1/avatars/screenshots') ->label('scope', 'avatars.read') ->label('cache', true) ->label('cache.resourceType', 'avatar/screenshot') - ->label('cache.resource', 'screenshot/{request.url}/{request.width}/{request.height}/{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('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, @@ -662,6 +662,7 @@ App::get('/v1/avatars/screenshots') ->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('viewport', '1280x720', new Text(20), 'Browser viewport size. Pass a string like "1280x720" or "1920x1080". Defaults to "1280x720".', 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) @@ -678,7 +679,7 @@ App::get('/v1/avatars/screenshots') ->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') - ->action(function (string $url, array $headers, string $viewport, 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) { + ->action(function (string $url, array $headers, string $viewport, 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) { if (!\extension_loaded('imagick')) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing'); @@ -719,7 +720,7 @@ App::get('/v1/avatars/screenshots') } // Create the config with headers as an object - // The custom browser service accepts: url, theme, headers, sleep, viewport, userAgent, fullPage, locale, timezoneId, geolocation, hasTouch + // The custom browser service accepts: url, theme, headers, sleep, viewport, userAgent, fullPage, locale, timezoneId, geolocation, hasTouch, scale $config = [ 'url' => $url, 'theme' => $theme, @@ -731,6 +732,11 @@ App::get('/v1/avatars/screenshots') ] ]; + // Add scale if not default + if ($scale != 1) { + $config['scale'] = $scale; + } + // Add fullPage to viewport if enabled if ($fullpage) { $config['viewport']['fullPage'] = true; @@ -779,6 +785,11 @@ App::get('/v1/avatars/screenshots') 'sleep' => $config['sleep'], 'viewport' => $config['viewport'] // Keep as object ]; + + // Add scale if not default + if ($scale != 1) { + $finalConfig['scale'] = $scale; + } // Add optional parameters that were set, preserving arrays as arrays if (!empty($userAgent)) { diff --git a/tests/e2e/Services/Avatars/AvatarsBase.php b/tests/e2e/Services/Avatars/AvatarsBase.php index 68bf24250f..9003f45208 100644 --- a/tests/e2e/Services/Avatars/AvatarsBase.php +++ b/tests/e2e/Services/Avatars/AvatarsBase.php @@ -836,6 +836,19 @@ trait AvatarsBase $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'], @@ -953,6 +966,7 @@ trait AvatarsBase '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, @@ -983,13 +997,35 @@ trait AvatarsBase $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ 'x-appwrite-project' => $this->getProject()['$id'], ], [ - 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), + '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'], diff --git a/tests/e2e/Services/GraphQL/AvatarsTest.php b/tests/e2e/Services/GraphQL/AvatarsTest.php index 085f0eaecb..e7b4c2d993 100644 --- a/tests/e2e/Services/GraphQL/AvatarsTest.php +++ b/tests/e2e/Services/GraphQL/AvatarsTest.php @@ -247,6 +247,7 @@ class AvatarsTest extends Scope 'url' => 'https://appwrite.io', 'width' => 800, 'height' => 600, + 'scale' => 1.5, 'theme' => 'dark', 'userAgent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'fullpage' => true, diff --git a/tests/e2e/Services/GraphQL/Base.php b/tests/e2e/Services/GraphQL/Base.php index 4b0d2630e6..9047ed4510 100644 --- a/tests/e2e/Services/GraphQL/Base.php +++ b/tests/e2e/Services/GraphQL/Base.php @@ -1781,8 +1781,8 @@ trait Base } }'; case self::GET_SCREENSHOT: - return 'query getScreenshot($url: String!, $width: Int, $height: Int, $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, theme: $theme, userAgent: $userAgent, fullpage: $fullpage, locale: $locale, timezone: $timezone, latitude: $latitude, longitude: $longitude, accuracy: $accuracy, touch: $touch, permissions: $permissions) { + return 'query getScreenshot($url: String!, $width: Int, $height: 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, scale: $scale, theme: $theme, userAgent: $userAgent, fullpage: $fullpage, locale: $locale, timezone: $timezone, latitude: $latitude, longitude: $longitude, accuracy: $accuracy, touch: $touch, permissions: $permissions) { status } }'; From d8bde641e99d36ae5dec917eb003a424f3070598 Mon Sep 17 00:00:00 2001 From: Eldad Fux Date: Fri, 24 Oct 2025 21:42:10 +0100 Subject: [PATCH 08/18] Update appwrite-browser image to version 0.3.1; enhance avatar screenshot API by adding 'waitUntil' parameter and refactoring image processing logic for improved efficiency. --- app/controllers/api/avatars.php | 42 ++++++++++++++++++++------------- app/controllers/shared/api.php | 9 ++++++- app/views/install/compose.phtml | 2 +- docker-compose.yml | 2 +- 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/app/controllers/api/avatars.php b/app/controllers/api/avatars.php index e77426ad76..8653a9e437 100644 --- a/app/controllers/api/avatars.php +++ b/app/controllers/api/avatars.php @@ -726,6 +726,7 @@ App::get('/v1/avatars/screenshots') 'theme' => $theme, 'headers' => $headersObject, 'sleep' => $sleep * 1000, // Convert seconds to milliseconds + 'waitUntil' => 'load', 'viewport' => [ 'width' => $browserWidth, 'height' => $browserHeight @@ -734,7 +735,7 @@ App::get('/v1/avatars/screenshots') // Add scale if not default if ($scale != 1) { - $config['scale'] = $scale; + $config['deviceScaleFactor'] = $scale; } // Add fullPage to viewport if enabled @@ -788,7 +789,7 @@ App::get('/v1/avatars/screenshots') // Add scale if not default if ($scale != 1) { - $finalConfig['scale'] = $scale; + $finalConfig['deviceScaleFactor'] = $scale; } // Add optional parameters that were set, preserving arrays as arrays @@ -847,22 +848,29 @@ App::get('/v1/avatars/screenshots') throw new Exception(Exception::AVATAR_IMAGE_NOT_FOUND, 'Screenshot not generated'); } - // Determine output format + // 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); + + if ($width > 0 && $height > 0) { + $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'); - if (empty($output)) { - $output = 'png'; // Default to PNG for screenshots - } - - // Only resize if width and height are explicitly set (not 0) - $image = new Image($screenshot); - if ($width > 0 && $height > 0) { - $image->crop($width, $height); - } - - $resizedScreenshot = $image->output($output, $quality); - unset($image); - - $contentType = (\array_key_exists($output, $outputs)) ? $outputs[$output] : $outputs['png']; + $contentType = $outputs[$output] ?? $outputs['png']; $response ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 959ee77b7d..d244ebd7f5 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -54,7 +54,12 @@ $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)) { + $replacement = is_array($replacement) ? json_encode($replacement) : (string)$replacement; + } + $label = \str_replace($find, $replacement, $label); } } return $label; @@ -831,11 +836,13 @@ App::shutdown() $pattern = $route->getLabel('cache.resource', null); if (!empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); + var_dump($resource); } $pattern = $route->getLabel('cache.resourceType', null); if (!empty($pattern)) { $resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user); + var_dump($resourceType); } $cache = new Cache( diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index ed4de38d2b..22fa371734 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index da6362b4c4..2ece9ec20c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -957,7 +957,7 @@ services: appwrite-browser: container_name: appwrite-browser - image: appwrite/browser:0.2.4 + image: appwrite/browser:0.3.1 networks: - appwrite From f13c02975159b04f92f75b82c65a475d602f9277 Mon Sep 17 00:00:00 2001 From: eldadfux Date: Fri, 24 Oct 2025 22:53:10 +0100 Subject: [PATCH 09/18] Refactor avatar screenshot API to replace 'viewport' parameter with separate 'viewportWidth' and 'viewportHeight' parameters; update related tests for validation of new dimensions and edge cases. --- app/controllers/api/avatars.php | 36 ++++------- tests/e2e/Services/Avatars/AvatarsBase.php | 72 +++++++++++++++++++++- tests/e2e/Services/GraphQL/AvatarsTest.php | 29 +++++++++ tests/e2e/Services/GraphQL/Base.php | 4 +- 4 files changed, 111 insertions(+), 30 deletions(-) diff --git a/app/controllers/api/avatars.php b/app/controllers/api/avatars.php index 8653a9e437..c4d8f613c0 100644 --- a/app/controllers/api/avatars.php +++ b/app/controllers/api/avatars.php @@ -661,7 +661,8 @@ App::get('/v1/avatars/screenshots') )) ->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('viewport', '1280x720', new Text(20), 'Browser viewport size. Pass a string like "1280x720" or "1920x1080". Defaults to "1280x720".', 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) @@ -679,7 +680,7 @@ App::get('/v1/avatars/screenshots') ->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') - ->action(function (string $url, array $headers, string $viewport, 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) { + ->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) { if (!\extension_loaded('imagick')) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing'); @@ -691,19 +692,6 @@ App::get('/v1/avatars/screenshots') throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); } - // Parse viewport parameter - $viewportParts = \explode('x', $viewport); - if (\count($viewportParts) !== 2 || !\is_numeric($viewportParts[0]) || !\is_numeric($viewportParts[1])) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Viewport must be in format "WIDTHxHEIGHT" (e.g., "1280x720")'); - } - - $browserWidth = (int) $viewportParts[0]; - $browserHeight = (int) $viewportParts[1]; - - if ($browserWidth < 1 || $browserWidth > 1920 || $browserHeight < 1 || $browserHeight > 1080) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Browser viewport must be between 1x1 and 1920x1080'); - } - $client = new Client(); $client->setTimeout(30); $client->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON); @@ -728,8 +716,8 @@ App::get('/v1/avatars/screenshots') 'sleep' => $sleep * 1000, // Convert seconds to milliseconds 'waitUntil' => 'load', 'viewport' => [ - 'width' => $browserWidth, - 'height' => $browserHeight + 'width' => $viewportWidth, + 'height' => $viewportHeight ] ]; @@ -786,7 +774,7 @@ App::get('/v1/avatars/screenshots') 'sleep' => $config['sleep'], 'viewport' => $config['viewport'] // Keep as object ]; - + // Add scale if not default if ($scale != 1) { $finalConfig['deviceScaleFactor'] = $scale; @@ -849,16 +837,14 @@ App::get('/v1/avatars/screenshots') } // Determine if image processing is needed - $needsProcessing = ($width > 0 && $height > 0) || $quality !== -1 || !empty($output); - + $needsProcessing = ($width > 0 || $height > 0) || $quality !== -1 || !empty($output); + if ($needsProcessing) { // Process image with cropping, quality adjustment, or format conversion $image = new Image($screenshot); - - if ($width > 0 && $height > 0) { - $image->crop($width, $height); - } - + + $image->crop($width, $height); + $output = $output ?: 'png'; // Default to PNG if not specified $resizedScreenshot = $image->output($output, $quality); unset($image); diff --git a/tests/e2e/Services/Avatars/AvatarsBase.php b/tests/e2e/Services/Avatars/AvatarsBase.php index 9003f45208..f3f7fdc7b8 100644 --- a/tests/e2e/Services/Avatars/AvatarsBase.php +++ b/tests/e2e/Services/Avatars/AvatarsBase.php @@ -696,6 +696,48 @@ trait AvatarsBase ]); $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 */ @@ -718,13 +760,14 @@ trait AvatarsBase $this->assertEquals(400, $response['headers']['status-code']); /** - * Test for FAILURE - Invalid viewport parameter + * 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), - 'viewport' => 'invalid-viewport', + 'viewportWidth' => 0, // Too small + 'viewportHeight' => 720, 'width' => 800, 'height' => 600, ]); @@ -734,7 +777,30 @@ trait AvatarsBase 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'url' => 'https://appwrite.io?x=' . time() . rand(1000, 9999), - 'viewport' => '2000x1000', // Too large + '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, ]); diff --git a/tests/e2e/Services/GraphQL/AvatarsTest.php b/tests/e2e/Services/GraphQL/AvatarsTest.php index e7b4c2d993..345be27372 100644 --- a/tests/e2e/Services/GraphQL/AvatarsTest.php +++ b/tests/e2e/Services/GraphQL/AvatarsTest.php @@ -247,6 +247,8 @@ class AvatarsTest extends Scope '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', @@ -287,6 +289,33 @@ class AvatarsTest extends Scope 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']; diff --git a/tests/e2e/Services/GraphQL/Base.php b/tests/e2e/Services/GraphQL/Base.php index 9047ed4510..f1732f8ed5 100644 --- a/tests/e2e/Services/GraphQL/Base.php +++ b/tests/e2e/Services/GraphQL/Base.php @@ -1781,8 +1781,8 @@ trait Base } }'; case self::GET_SCREENSHOT: - return 'query getScreenshot($url: String!, $width: Int, $height: 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, scale: $scale, theme: $theme, userAgent: $userAgent, fullpage: $fullpage, locale: $locale, timezone: $timezone, latitude: $latitude, longitude: $longitude, accuracy: $accuracy, touch: $touch, permissions: $permissions) { + 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 } }'; From 61f4c7957a100177129c14190db0e6f7c9389c4d Mon Sep 17 00:00:00 2001 From: eldadfux Date: Sat, 25 Oct 2025 10:22:53 +0100 Subject: [PATCH 10/18] Add new configuration variable '_APP_BROWSER_HOST' for browser service communication; update avatar screenshot API to use System::getEnv for environment variable retrieval --- app/config/variables.php | 10 ++++++++++ app/controllers/api/avatars.php | 2 +- .../Platform/Modules/Functions/Workers/Builds.php | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/config/variables.php b/app/config/variables.php index 8fd00557b3..e1b707c553 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -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.', diff --git a/app/controllers/api/avatars.php b/app/controllers/api/avatars.php index c4d8f613c0..50dd337fa3 100644 --- a/app/controllers/api/avatars.php +++ b/app/controllers/api/avatars.php @@ -818,7 +818,7 @@ App::get('/v1/avatars/screenshots') $config = $finalConfig; try { - $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', diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index d6385c1f40..015ec226ea 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -985,7 +985,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', From 2afba50a8c9bf8fa9db94560f6e824d12042aa00 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 27 Oct 2025 06:44:04 +0000 Subject: [PATCH 11/18] Feat: usage stats for screenshot generated --- app/controllers/api/avatars.php | 8 +++++++- app/controllers/shared/api.php | 4 ++++ app/init/constants.php | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/avatars.php b/app/controllers/api/avatars.php index 50dd337fa3..08e9617add 100644 --- a/app/controllers/api/avatars.php +++ b/app/controllers/api/avatars.php @@ -1,5 +1,6 @@ desc('Get webpage screenshot') ->groups(['api', 'avatars']) ->label('scope', 'avatars.read') + ->label('usage.metric', METRIC_AVATARS_SCREENSHOTS_GENERATED) ->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}') @@ -680,7 +682,8 @@ App::get('/v1/avatars/screenshots') ->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') - ->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) { + ->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'); @@ -858,11 +861,14 @@ App::get('/v1/avatars/screenshots') $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()); } diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index d244ebd7f5..63987e32f5 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -585,6 +585,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; diff --git a/app/init/constants.php b/app/init/constants.php index 3c8485aa4f..3b1d68840b 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -269,6 +269,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'; From 637bf4f231a021d53218c87d07cee617227f6563 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 29 Oct 2025 03:30:43 +0000 Subject: [PATCH 12/18] add abuse limit --- app/controllers/api/avatars.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/controllers/api/avatars.php b/app/controllers/api/avatars.php index 08e9617add..b715ec8d56 100644 --- a/app/controllers/api/avatars.php +++ b/app/controllers/api/avatars.php @@ -643,6 +643,7 @@ App::get('/v1/avatars/screenshots') ->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}') From 60a4266204249570c89af36e9d96e4d5083bd097 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 29 Oct 2025 03:30:59 +0000 Subject: [PATCH 13/18] validate data length --- tests/e2e/Services/Avatars/AvatarsBase.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e/Services/Avatars/AvatarsBase.php b/tests/e2e/Services/Avatars/AvatarsBase.php index f3f7fdc7b8..428f9dbb00 100644 --- a/tests/e2e/Services/Avatars/AvatarsBase.php +++ b/tests/e2e/Services/Avatars/AvatarsBase.php @@ -591,6 +591,7 @@ trait AvatarsBase $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('image/png', $response['headers']['content-type']); $this->assertNotEmpty($response['body']); + $this->assertGreaterThan(10000, strlen($response['body'])); /** * Test for FAILURE - Invalid headers parameter types From b303bd105196a5964ce0575d0bcc02199b56fcc4 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 29 Oct 2025 03:34:25 +0000 Subject: [PATCH 14/18] add correct assertion --- tests/e2e/Services/Avatars/AvatarsBase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Services/Avatars/AvatarsBase.php b/tests/e2e/Services/Avatars/AvatarsBase.php index 428f9dbb00..cc626be99a 100644 --- a/tests/e2e/Services/Avatars/AvatarsBase.php +++ b/tests/e2e/Services/Avatars/AvatarsBase.php @@ -575,6 +575,7 @@ trait AvatarsBase $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'], @@ -591,7 +592,6 @@ trait AvatarsBase $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('image/png', $response['headers']['content-type']); $this->assertNotEmpty($response['body']); - $this->assertGreaterThan(10000, strlen($response['body'])); /** * Test for FAILURE - Invalid headers parameter types From 715603612086f5a1f299444fa2fe37bcd3fd79b4 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 30 Oct 2025 01:31:45 +0000 Subject: [PATCH 15/18] remove viewport fullpage duplicate --- app/controllers/api/avatars.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/controllers/api/avatars.php b/app/controllers/api/avatars.php index b715ec8d56..ec0314c6d7 100644 --- a/app/controllers/api/avatars.php +++ b/app/controllers/api/avatars.php @@ -730,11 +730,6 @@ App::get('/v1/avatars/screenshots') $config['deviceScaleFactor'] = $scale; } - // Add fullPage to viewport if enabled - if ($fullpage) { - $config['viewport']['fullPage'] = true; - } - // Add optional parameters only if they have meaningful values if (!empty($userAgent)) { $config['userAgent'] = $userAgent; From 5c1f6244c8d3f29ce15dec5c43b61d08f736545d Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 30 Oct 2025 01:32:22 +0000 Subject: [PATCH 16/18] remove dump --- app/controllers/shared/api.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 63987e32f5..78192f0c92 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -840,13 +840,11 @@ App::shutdown() $pattern = $route->getLabel('cache.resource', null); if (!empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); - var_dump($resource); } $pattern = $route->getLabel('cache.resourceType', null); if (!empty($pattern)) { $resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user); - var_dump($resourceType); } $cache = new Cache( From b30890c1d66725f92f575b96f0eb72ae99c13f8e Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 30 Oct 2025 01:37:53 +0000 Subject: [PATCH 17/18] remove duplicate configs --- app/controllers/api/avatars.php | 55 ++------------------------------- 1 file changed, 2 insertions(+), 53 deletions(-) diff --git a/app/controllers/api/avatars.php b/app/controllers/api/avatars.php index ec0314c6d7..d0cb3e554c 100644 --- a/app/controllers/api/avatars.php +++ b/app/controllers/api/avatars.php @@ -730,7 +730,7 @@ App::get('/v1/avatars/screenshots') $config['deviceScaleFactor'] = $scale; } - // Add optional parameters only if they have meaningful values + // Add optional parameters that were set, preserving arrays as arrays if (!empty($userAgent)) { $config['userAgent'] = $userAgent; } @@ -760,62 +760,11 @@ App::get('/v1/avatars/screenshots') $config['hasTouch'] = true; } - // Add permissions if provided - if (!empty($permissions)) { - $config['permissions'] = $permissions; - } - - // Manually handle the config to ensure headers is an object but arrays remain arrays - $finalConfig = [ - 'url' => $config['url'], - 'theme' => $config['theme'], - 'headers' => $config['headers'], // Keep as object - 'sleep' => $config['sleep'], - 'viewport' => $config['viewport'] // Keep as object - ]; - - // Add scale if not default - if ($scale != 1) { - $finalConfig['deviceScaleFactor'] = $scale; - } - - // Add optional parameters that were set, preserving arrays as arrays - if (!empty($userAgent)) { - $finalConfig['userAgent'] = $userAgent; - } - - if ($fullpage) { - $finalConfig['fullPage'] = true; - } - - if (!empty($locale)) { - $finalConfig['locale'] = $locale; - } - - if (!empty($timezone)) { - $finalConfig['timezoneId'] = $timezone; - } - - // Add geolocation if any coordinates are provided - if ($latitude != 0 || $longitude != 0) { - $finalConfig['geolocation'] = [ - 'latitude' => $latitude, - 'longitude' => $longitude, - 'accuracy' => $accuracy - ]; - } - - if ($touch) { - $finalConfig['hasTouch'] = true; - } - // Add permissions if provided (preserve as array) if (!empty($permissions)) { - $finalConfig['permissions'] = $permissions; // Keep as array + $config['permissions'] = $permissions; // Keep as array } - $config = $finalConfig; - try { $browserEndpoint = System::getEnv('_APP_BROWSER_HOST', 'http://appwrite-browser:3000/v1'); From d56bd44f67fa302e2b1da19f3bdaef326e4752d0 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 30 Oct 2025 02:07:15 +0000 Subject: [PATCH 18/18] Fix label parsing --- app/controllers/shared/api.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 78192f0c92..122139d48b 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -57,7 +57,15 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar $replacement = $params[$replace]; // Convert to string if it's not already a string if (!is_string($replacement)) { - $replacement = is_array($replacement) ? json_encode($replacement) : (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); }