From 5073a865814845acbe5cf93876aacf2434dd4a78 Mon Sep 17 00:00:00 2001 From: Eldad Fux Date: Tue, 21 Oct 2025 15:37:05 +0100 Subject: [PATCH 01/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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 baa7a170d9a2d2edfde5a4e0f859baf3889da4d0 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Oct 2025 18:54:29 +0530 Subject: [PATCH 15/29] update sdks --- app/config/platforms.php | 8 ++++---- composer.lock | 30 +++++++++++++++--------------- docs/sdks/apple/CHANGELOG.md | 4 ++++ docs/sdks/cli/CHANGELOG.md | 7 +++++++ docs/sdks/flutter/CHANGELOG.md | 4 ++++ docs/sdks/python/CHANGELOG.md | 5 +++++ 6 files changed, 39 insertions(+), 19 deletions(-) diff --git a/app/config/platforms.php b/app/config/platforms.php index edb94f1f96..fa753f1871 100644 --- a/app/config/platforms.php +++ b/app/config/platforms.php @@ -60,7 +60,7 @@ return [ [ 'key' => 'flutter', 'name' => 'Flutter', - 'version' => '20.2.1', + 'version' => '20.2.2', 'url' => 'https://github.com/appwrite/sdk-for-flutter', 'package' => 'https://pub.dev/packages/appwrite', 'enabled' => true, @@ -79,7 +79,7 @@ return [ [ 'key' => 'apple', 'name' => 'Apple', - 'version' => '13.3.0', + 'version' => '13.3.1', 'url' => 'https://github.com/appwrite/sdk-for-apple', 'package' => 'https://github.com/appwrite/sdk-for-apple', 'enabled' => true, @@ -226,7 +226,7 @@ return [ [ 'key' => 'cli', 'name' => 'Command Line', - 'version' => '10.2.3', + 'version' => '10.2.4', 'url' => 'https://github.com/appwrite/sdk-for-cli', 'package' => 'https://www.npmjs.com/package/appwrite-cli', 'enabled' => true, @@ -300,7 +300,7 @@ return [ [ 'key' => 'python', 'name' => 'Python', - 'version' => '13.4.1', + 'version' => '13.4.2', 'url' => 'https://github.com/appwrite/sdk-for-python', 'package' => 'https://pypi.org/project/appwrite/', 'enabled' => true, diff --git a/composer.lock b/composer.lock index 9bb2f6a074..f24faa22de 100644 --- a/composer.lock +++ b/composer.lock @@ -4108,16 +4108,16 @@ }, { "name": "utopia-php/emails", - "version": "0.6.2", + "version": "0.6.1", "source": { "type": "git", "url": "https://github.com/utopia-php/emails.git", - "reference": "9c4c40cf7c03c2e9e21364566f9b192d03ea93c9" + "reference": "0de8896f369b6aa1f14df338645048f95a8439e3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/emails/zipball/9c4c40cf7c03c2e9e21364566f9b192d03ea93c9", - "reference": "9c4c40cf7c03c2e9e21364566f9b192d03ea93c9", + "url": "https://api.github.com/repos/utopia-php/emails/zipball/0de8896f369b6aa1f14df338645048f95a8439e3", + "reference": "0de8896f369b6aa1f14df338645048f95a8439e3", "shasum": "" }, "require": { @@ -4125,7 +4125,7 @@ "utopia-php/cli": "^0.15", "utopia-php/domains": "^0.9", "utopia-php/fetch": "^0.4", - "utopia-php/validators": "^0.0.2" + "utopia-php/validators": "^0.0.1" }, "require-dev": { "laravel/pint": "1.25.*", @@ -4162,9 +4162,9 @@ ], "support": { "issues": "https://github.com/utopia-php/emails/issues", - "source": "https://github.com/utopia-php/emails/tree/0.6.2" + "source": "https://github.com/utopia-php/emails/tree/0.6.1" }, - "time": "2025-10-28T16:08:17+00:00" + "time": "2025-10-28T07:29:58+00:00" }, { "name": "utopia-php/fetch", @@ -5109,16 +5109,16 @@ }, { "name": "utopia-php/validators", - "version": "0.0.2", + "version": "0.0.1", "source": { "type": "git", "url": "https://github.com/utopia-php/validators.git", - "reference": "894210695c5d35fa248fb65f7fe7237b6ff4fb0b" + "reference": "69d1afa5df2f052535764520609e91b491708db2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/validators/zipball/894210695c5d35fa248fb65f7fe7237b6ff4fb0b", - "reference": "894210695c5d35fa248fb65f7fe7237b6ff4fb0b", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/69d1afa5df2f052535764520609e91b491708db2", + "reference": "69d1afa5df2f052535764520609e91b491708db2", "shasum": "" }, "require": { @@ -5149,9 +5149,9 @@ ], "support": { "issues": "https://github.com/utopia-php/validators/issues", - "source": "https://github.com/utopia-php/validators/tree/0.0.2" + "source": "https://github.com/utopia-php/validators/tree/0.0.1" }, - "time": "2025-10-20T21:52:28+00:00" + "time": "2025-10-20T15:08:50+00:00" }, { "name": "utopia-php/vcs", @@ -8892,7 +8892,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -8916,5 +8916,5 @@ "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.6.0" } diff --git a/docs/sdks/apple/CHANGELOG.md b/docs/sdks/apple/CHANGELOG.md index 9ffa37cdf8..202dc1cb67 100644 --- a/docs/sdks/apple/CHANGELOG.md +++ b/docs/sdks/apple/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## 13.3.1 + +* Fix `onOpen` callback not being called when the websocket connection is established + ## 13.3.0 * Add `onOpen`, `onClose` and `onError` callbacks to `Realtime` service diff --git a/docs/sdks/cli/CHANGELOG.md b/docs/sdks/cli/CHANGELOG.md index 8a1ea0f360..641e62d73b 100644 --- a/docs/sdks/cli/CHANGELOG.md +++ b/docs/sdks/cli/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log +## 10.2.4 + +* Fix syncing of tables deleted locally during `push tables` command +* Fix added push command support for cli spatial types +* Fix attribute changing during push +* Replace pkg with @yao-pkg/pkg in dependencies + ## 10.2.3 * Fix `init tables` command not working diff --git a/docs/sdks/flutter/CHANGELOG.md b/docs/sdks/flutter/CHANGELOG.md index 7ac74d0c05..77317be7eb 100644 --- a/docs/sdks/flutter/CHANGELOG.md +++ b/docs/sdks/flutter/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## 20.2.2 + +* Widen `device_info_plus` and `package_info_plus` dependencies to allow for newer versions for Android 15+ support + ## 20.2.1 * Add transaction support for Databases and TablesDB diff --git a/docs/sdks/python/CHANGELOG.md b/docs/sdks/python/CHANGELOG.md index 7d8327b919..90dcfc2ade 100644 --- a/docs/sdks/python/CHANGELOG.md +++ b/docs/sdks/python/CHANGELOG.md @@ -1,5 +1,10 @@ # Change Log +## 13.4.2 + +* Fix adding `Optional[]` to optional parameters +* Fix passing of `None` to nullable parameters + ## 13.4.1 * Add transaction support for Databases and TablesDB From 715603612086f5a1f299444fa2fe37bcd3fd79b4 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 30 Oct 2025 01:31:45 +0000 Subject: [PATCH 16/29] 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 17/29] 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 18/29] 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 19/29] 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); } From 07214de37023a592dedbca1346c10a1fcd953981 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 30 Oct 2025 10:03:36 +0530 Subject: [PATCH 20/29] update sdks --- app/config/platforms.php | 4 +- composer.lock | 24 +++--- .../examples/migrations/create-csv-export.md | 4 + .../examples/migrations/create-csv-import.md | 4 + docs/sdks/apple/CHANGELOG.md | 1 + docs/sdks/cli/CHANGELOG.md | 4 +- docs/sdks/flutter/CHANGELOG.md | 2 + docs/sdks/python/CHANGELOG.md | 4 +- src/Appwrite/Platform/Tasks/SDKs.php | 81 ++++++++++++------- 9 files changed, 85 insertions(+), 43 deletions(-) create mode 100644 docs/examples/1.8.x/console-cli/examples/migrations/create-csv-export.md create mode 100644 docs/examples/1.8.x/console-cli/examples/migrations/create-csv-import.md diff --git a/app/config/platforms.php b/app/config/platforms.php index fa753f1871..2956efe1ad 100644 --- a/app/config/platforms.php +++ b/app/config/platforms.php @@ -226,7 +226,7 @@ return [ [ 'key' => 'cli', 'name' => 'Command Line', - 'version' => '10.2.4', + 'version' => '10.3.0', 'url' => 'https://github.com/appwrite/sdk-for-cli', 'package' => 'https://www.npmjs.com/package/appwrite-cli', 'enabled' => true, @@ -300,7 +300,7 @@ return [ [ 'key' => 'python', 'name' => 'Python', - 'version' => '13.4.2', + 'version' => '13.5.0', 'url' => 'https://github.com/appwrite/sdk-for-python', 'package' => 'https://pypi.org/project/appwrite/', 'enabled' => true, diff --git a/composer.lock b/composer.lock index f24faa22de..ddb3b9a6ad 100644 --- a/composer.lock +++ b/composer.lock @@ -5253,16 +5253,16 @@ }, { "name": "webmozart/assert", - "version": "1.12.0", + "version": "1.12.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "541057574806f942c94662b817a50f63f7345360" + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/541057574806f942c94662b817a50f63f7345360", - "reference": "541057574806f942c94662b817a50f63f7345360", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", "shasum": "" }, "require": { @@ -5305,9 +5305,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.0" + "source": "https://github.com/webmozarts/assert/tree/1.12.1" }, - "time": "2025-10-20T12:43:39+00:00" + "time": "2025-10-29T15:56:20+00:00" }, { "name": "webonyx/graphql-php", @@ -5378,16 +5378,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.4.15", + "version": "1.4.16", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "b4a2fd9e92903c2e156f17fc5dafe102e6cfdfda" + "reference": "08f839443f678208eb56a6c5a7456dd632adfc9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/b4a2fd9e92903c2e156f17fc5dafe102e6cfdfda", - "reference": "b4a2fd9e92903c2e156f17fc5dafe102e6cfdfda", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/08f839443f678208eb56a6c5a7456dd632adfc9a", + "reference": "08f839443f678208eb56a6c5a7456dd632adfc9a", "shasum": "" }, "require": { @@ -5423,9 +5423,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.4.15" + "source": "https://github.com/appwrite/sdk-generator/tree/1.4.16" }, - "time": "2025-10-28T04:52:59+00:00" + "time": "2025-10-29T08:39:55+00:00" }, { "name": "doctrine/annotations", diff --git a/docs/examples/1.8.x/console-cli/examples/migrations/create-csv-export.md b/docs/examples/1.8.x/console-cli/examples/migrations/create-csv-export.md new file mode 100644 index 0000000000..e56afae786 --- /dev/null +++ b/docs/examples/1.8.x/console-cli/examples/migrations/create-csv-export.md @@ -0,0 +1,4 @@ +appwrite migrations create-csv-export \ + --resource-id \ + --bucket-id \ + --filename diff --git a/docs/examples/1.8.x/console-cli/examples/migrations/create-csv-import.md b/docs/examples/1.8.x/console-cli/examples/migrations/create-csv-import.md new file mode 100644 index 0000000000..196112bdf8 --- /dev/null +++ b/docs/examples/1.8.x/console-cli/examples/migrations/create-csv-import.md @@ -0,0 +1,4 @@ +appwrite migrations create-csv-import \ + --bucket-id \ + --file-id \ + --resource-id diff --git a/docs/sdks/apple/CHANGELOG.md b/docs/sdks/apple/CHANGELOG.md index 202dc1cb67..df3170cc4d 100644 --- a/docs/sdks/apple/CHANGELOG.md +++ b/docs/sdks/apple/CHANGELOG.md @@ -3,6 +3,7 @@ ## 13.3.1 * Fix `onOpen` callback not being called when the websocket connection is established +* Fix add missing `scheduled` value to `ExecutionStatus` enum ## 13.3.0 diff --git a/docs/sdks/cli/CHANGELOG.md b/docs/sdks/cli/CHANGELOG.md index 641e62d73b..18a3af6387 100644 --- a/docs/sdks/cli/CHANGELOG.md +++ b/docs/sdks/cli/CHANGELOG.md @@ -1,7 +1,9 @@ # Change Log -## 10.2.4 +## 10.3.0 +* Add `create-csv-export` and `create-csv-import` commands to create a CSV export and import of a collection/table +* Add `create-resend-provider` and `update-resend-provider` commands to create and update a Resend Email provider * Fix syncing of tables deleted locally during `push tables` command * Fix added push command support for cli spatial types * Fix attribute changing during push diff --git a/docs/sdks/flutter/CHANGELOG.md b/docs/sdks/flutter/CHANGELOG.md index 77317be7eb..4c723b8017 100644 --- a/docs/sdks/flutter/CHANGELOG.md +++ b/docs/sdks/flutter/CHANGELOG.md @@ -3,6 +3,8 @@ ## 20.2.2 * Widen `device_info_plus` and `package_info_plus` dependencies to allow for newer versions for Android 15+ support +* Fix `CHUNK_SIZE` constant to `chunkSize` +* Fix missing `@override` annotation to `toMap` method in all model classes ## 20.2.1 diff --git a/docs/sdks/python/CHANGELOG.md b/docs/sdks/python/CHANGELOG.md index 90dcfc2ade..cb7f47d379 100644 --- a/docs/sdks/python/CHANGELOG.md +++ b/docs/sdks/python/CHANGELOG.md @@ -1,7 +1,9 @@ # Change Log -## 13.4.2 +## 13.5.0 +* Add `create_resend_provider` and `update_resend_provider` methods to `Messaging` service +* Improve deprecation warnings * Fix adding `Optional[]` to optional parameters * Fix passing of `None` to nullable parameters diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index 2fb15c5f7d..14c4b736e8 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -48,13 +48,18 @@ class SDKs extends Action ->param('message', null, new Nullable(new Text(256)), 'Commit Message', optional: true) ->param('release', null, new Nullable(new WhiteList(['yes', 'no'])), 'Should we create releases?', optional: true) ->param('commit', null, new Nullable(new WhiteList(['yes', 'no'])), 'Actually create releases (yes) or dry-run (no)?', optional: true) + ->param('sdks', null, new Nullable(new Text(256)), 'Selected SDKs', optional: true) ->callback($this->action(...)); } - public function action(?string $selectedPlatform, ?string $selectedSDK, ?string $version, ?string $git, ?string $production, ?string $message, ?string $release, ?string $commit): void + public function action(?string $selectedPlatform, ?string $selectedSDK, ?string $version, ?string $git, ?string $production, ?string $message, ?string $release, ?string $commit, ?string $sdks): void { - $selectedPlatform ??= Console::confirm('Choose Platform ("' . APP_PLATFORM_CLIENT . '", "' . APP_PLATFORM_SERVER . '", "' . APP_PLATFORM_CONSOLE . '" or "*" for all):'); - $selectedSDK ??= \strtolower(Console::confirm('Choose SDK ("*" for all):')); + if (!$sdks){ + $selectedPlatform ??= Console::confirm('Choose Platform ("' . APP_PLATFORM_CLIENT . '", "' . APP_PLATFORM_SERVER . '", "' . APP_PLATFORM_CONSOLE . '" or "*" for all):'); + $selectedSDK ??= \strtolower(Console::confirm('Choose SDK ("*" for all):')); + } else { + $sdks = explode(',', $sdks); + } $version ??= Console::confirm('Choose an Appwrite version'); $createRelease = ($release === 'yes'); @@ -104,12 +109,12 @@ class SDKs extends Action $platforms = Config::getParam('platforms'); foreach ($platforms as $key => $platform) { - if ($selectedPlatform !== $key && $selectedPlatform !== '*') { + if ($selectedPlatform !== $key && $selectedPlatform !== '*' && ($sdks === null)) { continue; } foreach ($platform['sdks'] as $language) { - if ($selectedSDK !== $language['key'] && $selectedSDK !== '*') { + if ($selectedSDK !== $language['key'] && $selectedSDK !== '*' && ($sdks === null || !\in_array($language['key'], $sdks))) { continue; } @@ -472,38 +477,60 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND $errorMessage = implode("\n", $prOutput); if (strpos($errorMessage, 'already exists') !== false) { Console::warning("Pull request already exists for {$language['name']} SDK, updating title and body..."); - - $updateCommand = 'cd ' . $target . ' && \ - gh pr edit "' . $gitBranch . '" \ + $prNumberCommand = 'cd ' . $target . ' && \ + gh pr list \ --repo "' . $repoName . '" \ - --title "' . $prTitle . '" \ - --body "' . $prBody . '" \ + --head "' . $gitBranch . '" \ + --json number \ + --jq ".[0].number" \ 2>&1'; - $updateOutput = []; - $updateReturnCode = 0; - \exec($updateCommand, $updateOutput, $updateReturnCode); + $prNumberOutput = []; + $prNumberReturnCode = 0; + \exec($prNumberCommand, $prNumberOutput, $prNumberReturnCode); - if ($updateReturnCode === 0) { - Console::success("Successfully updated pull request for {$language['name']} SDK"); + if ($prNumberReturnCode === 0 && !empty($prNumberOutput[0])) { + $prNumber = trim($prNumberOutput[0]); - $prUrlCommand = 'cd ' . $target . ' && \ - gh pr view "' . $gitBranch . '" \ - --repo "' . $repoName . '" \ - --json url \ - --jq .url \ + // Use API directly to update PR to avoid deprecated projectCards field + $updateCommand = 'cd ' . $target . ' && \ + gh api \ + --method PATCH \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/' . $repoName . '/pulls/' . $prNumber . ' \ + -f title="' . $prTitle . '" \ + -f body="' . $prBody . '" \ 2>&1'; - $prUrlOutput = []; - $prUrlReturnCode = 0; - \exec($prUrlCommand, $prUrlOutput, $prUrlReturnCode); + $updateOutput = []; + $updateReturnCode = 0; + \exec($updateCommand, $updateOutput, $updateReturnCode); - if ($prUrlReturnCode === 0 && !empty($prUrlOutput)) { - $prUrls[$language['name']] = $prUrlOutput[0]; + if ($updateReturnCode === 0) { + Console::success("Successfully updated pull request for {$language['name']} SDK"); + + $prUrlCommand = 'cd ' . $target . ' && \ + gh pr list \ + --repo "' . $repoName . '" \ + --head "' . $gitBranch . '" \ + --json url \ + --jq ".[0].url" \ + 2>&1'; + + $prUrlOutput = []; + $prUrlReturnCode = 0; + \exec($prUrlCommand, $prUrlOutput, $prUrlReturnCode); + + if ($prUrlReturnCode === 0 && !empty($prUrlOutput)) { + $prUrls[$language['name']] = trim($prUrlOutput[0]); + } + } else { + $updateErrorMessage = implode("\n", $updateOutput); + Console::error("Failed to update pull request for {$language['name']} SDK: " . $updateErrorMessage); } } else { - $updateErrorMessage = implode("\n", $updateOutput); - Console::error("Failed to update pull request for {$language['name']} SDK: " . $updateErrorMessage); + Console::error("Failed to get PR number for {$language['name']} SDK"); } } else { Console::error("Failed to create pull request for {$language['name']} SDK: " . $errorMessage); From 88dffcffbf5c52bb41265ed31c02fe30326d3400 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 30 Oct 2025 11:01:42 +0530 Subject: [PATCH 21/29] format --- src/Appwrite/Platform/Tasks/SDKs.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index 14c4b736e8..f587e0f946 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -54,7 +54,7 @@ class SDKs extends Action public function action(?string $selectedPlatform, ?string $selectedSDK, ?string $version, ?string $git, ?string $production, ?string $message, ?string $release, ?string $commit, ?string $sdks): void { - if (!$sdks){ + if (!$sdks) { $selectedPlatform ??= Console::confirm('Choose Platform ("' . APP_PLATFORM_CLIENT . '", "' . APP_PLATFORM_SERVER . '", "' . APP_PLATFORM_CONSOLE . '" or "*" for all):'); $selectedSDK ??= \strtolower(Console::confirm('Choose SDK ("*" for all):')); } else { From 6bd52875dc4042bf4097534a40f7ea5f33da7144 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 30 Oct 2025 18:13:32 +0530 Subject: [PATCH 22/29] update to major --- app/config/platforms.php | 2 +- docs/sdks/cli/CHANGELOG.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/config/platforms.php b/app/config/platforms.php index 2956efe1ad..34c0290832 100644 --- a/app/config/platforms.php +++ b/app/config/platforms.php @@ -226,7 +226,7 @@ return [ [ 'key' => 'cli', 'name' => 'Command Line', - 'version' => '10.3.0', + 'version' => '11.0.0', 'url' => 'https://github.com/appwrite/sdk-for-cli', 'package' => 'https://www.npmjs.com/package/appwrite-cli', 'enabled' => true, diff --git a/docs/sdks/cli/CHANGELOG.md b/docs/sdks/cli/CHANGELOG.md index 18a3af6387..0ffcb91b80 100644 --- a/docs/sdks/cli/CHANGELOG.md +++ b/docs/sdks/cli/CHANGELOG.md @@ -1,8 +1,9 @@ # Change Log -## 10.3.0 +## 11.0.0 -* Add `create-csv-export` and `create-csv-import` commands to create a CSV export and import of a collection/table +* Rename `create-csv-migration` to `create-csv-import` command to create a CSV import of a collection/table +* Add `create-csv-export` command to create a CSV export of a collection/table * Add `create-resend-provider` and `update-resend-provider` commands to create and update a Resend Email provider * Fix syncing of tables deleted locally during `push tables` command * Fix added push command support for cli spatial types From 68d7ff9c5528986b39756489f551c97617bd7e6e Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 31 Oct 2025 13:05:29 +0530 Subject: [PATCH 23/29] update sdk gen --- composer.lock | 50 +++++++++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/composer.lock b/composer.lock index ddb3b9a6ad..a5e7d2a0a1 100644 --- a/composer.lock +++ b/composer.lock @@ -3840,16 +3840,16 @@ }, { "name": "utopia-php/database", - "version": "3.0.4", + "version": "3.1.2", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "a83997d5555d6cd873b829a2459292211c6ab13f" + "reference": "b6541a9cd9b21786a5020327f582838afdb159aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/a83997d5555d6cd873b829a2459292211c6ab13f", - "reference": "a83997d5555d6cd873b829a2459292211c6ab13f", + "url": "https://api.github.com/repos/utopia-php/database/zipball/b6541a9cd9b21786a5020327f582838afdb159aa", + "reference": "b6541a9cd9b21786a5020327f582838afdb159aa", "shasum": "" }, "require": { @@ -3892,9 +3892,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/3.0.4" + "source": "https://github.com/utopia-php/database/tree/3.1.2" }, - "time": "2025-10-28T08:21:21+00:00" + "time": "2025-10-30T13:10:13+00:00" }, { "name": "utopia-php/detector", @@ -4108,16 +4108,16 @@ }, { "name": "utopia-php/emails", - "version": "0.6.1", + "version": "0.6.2", "source": { "type": "git", "url": "https://github.com/utopia-php/emails.git", - "reference": "0de8896f369b6aa1f14df338645048f95a8439e3" + "reference": "9c4c40cf7c03c2e9e21364566f9b192d03ea93c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/emails/zipball/0de8896f369b6aa1f14df338645048f95a8439e3", - "reference": "0de8896f369b6aa1f14df338645048f95a8439e3", + "url": "https://api.github.com/repos/utopia-php/emails/zipball/9c4c40cf7c03c2e9e21364566f9b192d03ea93c9", + "reference": "9c4c40cf7c03c2e9e21364566f9b192d03ea93c9", "shasum": "" }, "require": { @@ -4125,7 +4125,7 @@ "utopia-php/cli": "^0.15", "utopia-php/domains": "^0.9", "utopia-php/fetch": "^0.4", - "utopia-php/validators": "^0.0.1" + "utopia-php/validators": "^0.0.2" }, "require-dev": { "laravel/pint": "1.25.*", @@ -4162,9 +4162,9 @@ ], "support": { "issues": "https://github.com/utopia-php/emails/issues", - "source": "https://github.com/utopia-php/emails/tree/0.6.1" + "source": "https://github.com/utopia-php/emails/tree/0.6.2" }, - "time": "2025-10-28T07:29:58+00:00" + "time": "2025-10-28T16:08:17+00:00" }, { "name": "utopia-php/fetch", @@ -5109,16 +5109,16 @@ }, { "name": "utopia-php/validators", - "version": "0.0.1", + "version": "0.0.2", "source": { "type": "git", "url": "https://github.com/utopia-php/validators.git", - "reference": "69d1afa5df2f052535764520609e91b491708db2" + "reference": "894210695c5d35fa248fb65f7fe7237b6ff4fb0b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/validators/zipball/69d1afa5df2f052535764520609e91b491708db2", - "reference": "69d1afa5df2f052535764520609e91b491708db2", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/894210695c5d35fa248fb65f7fe7237b6ff4fb0b", + "reference": "894210695c5d35fa248fb65f7fe7237b6ff4fb0b", "shasum": "" }, "require": { @@ -5149,9 +5149,9 @@ ], "support": { "issues": "https://github.com/utopia-php/validators/issues", - "source": "https://github.com/utopia-php/validators/tree/0.0.1" + "source": "https://github.com/utopia-php/validators/tree/0.0.2" }, - "time": "2025-10-20T15:08:50+00:00" + "time": "2025-10-20T21:52:28+00:00" }, { "name": "utopia-php/vcs", @@ -5378,16 +5378,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.4.16", + "version": "1.4.17", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "08f839443f678208eb56a6c5a7456dd632adfc9a" + "reference": "9359beffe25e74b21e4a865101692529617b90d6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/08f839443f678208eb56a6c5a7456dd632adfc9a", - "reference": "08f839443f678208eb56a6c5a7456dd632adfc9a", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/9359beffe25e74b21e4a865101692529617b90d6", + "reference": "9359beffe25e74b21e4a865101692529617b90d6", "shasum": "" }, "require": { @@ -5423,9 +5423,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.4.16" + "source": "https://github.com/appwrite/sdk-generator/tree/1.4.17" }, - "time": "2025-10-29T08:39:55+00:00" + "time": "2025-10-30T12:46:09+00:00" }, { "name": "doctrine/annotations", From ed39fd799977d1648b0b1a80b56d229cd5e02619 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 31 Oct 2025 14:31:16 +0530 Subject: [PATCH 24/29] regen sdks --- .../examples/migrations/create-csv-export.md | 22 +++++++++++++++++++ .../examples/migrations/create-csv-import.md | 16 ++++++++++++++ .../examples/databases/create-collection.md | 2 +- .../examples/databases/create-document.md | 2 +- .../examples/databases/update-collection.md | 2 +- .../examples/databases/update-document.md | 2 +- .../examples/databases/upsert-document.md | 2 +- .../examples/storage/create-bucket.md | 2 +- .../examples/storage/create-file.md | 2 +- .../examples/storage/update-bucket.md | 2 +- .../examples/storage/update-file.md | 2 +- .../examples/tablesdb/create-row.md | 2 +- .../examples/tablesdb/create-table.md | 2 +- .../examples/tablesdb/update-row.md | 2 +- .../examples/tablesdb/update-table.md | 2 +- .../examples/tablesdb/upsert-row.md | 2 +- 16 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 docs/examples/1.8.x/console-web/examples/migrations/create-csv-export.md create mode 100644 docs/examples/1.8.x/console-web/examples/migrations/create-csv-import.md diff --git a/docs/examples/1.8.x/console-web/examples/migrations/create-csv-export.md b/docs/examples/1.8.x/console-web/examples/migrations/create-csv-export.md new file mode 100644 index 0000000000..e1b909a852 --- /dev/null +++ b/docs/examples/1.8.x/console-web/examples/migrations/create-csv-export.md @@ -0,0 +1,22 @@ +import { Client, Migrations } from "@appwrite.io/console"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const migrations = new Migrations(client); + +const result = await migrations.createCSVExport({ + resourceId: '', + bucketId: '', + filename: '', + columns: [], // optional + queries: [], // optional + delimiter: '', // optional + enclosure: '', // optional + escape: '', // optional + header: false, // optional + notify: false // optional +}); + +console.log(result); diff --git a/docs/examples/1.8.x/console-web/examples/migrations/create-csv-import.md b/docs/examples/1.8.x/console-web/examples/migrations/create-csv-import.md new file mode 100644 index 0000000000..9b8b2b2b33 --- /dev/null +++ b/docs/examples/1.8.x/console-web/examples/migrations/create-csv-import.md @@ -0,0 +1,16 @@ +import { Client, Migrations } from "@appwrite.io/console"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const migrations = new Migrations(client); + +const result = await migrations.createCSVImport({ + bucketId: '', + fileId: '', + resourceId: '', + internalFile: false // optional +}); + +console.log(result); diff --git a/docs/examples/1.8.x/server-nodejs/examples/databases/create-collection.md b/docs/examples/1.8.x/server-nodejs/examples/databases/create-collection.md index 8ad770d907..9bc014b59b 100644 --- a/docs/examples/1.8.x/server-nodejs/examples/databases/create-collection.md +++ b/docs/examples/1.8.x/server-nodejs/examples/databases/create-collection.md @@ -11,7 +11,7 @@ const result = await databases.createCollection({ databaseId: '', collectionId: '', name: '', - permissions: ["read("any")"], // optional + permissions: [sdk.Permission.read(sdk.Role.any())], // optional documentSecurity: false, // optional enabled: false // optional }); diff --git a/docs/examples/1.8.x/server-nodejs/examples/databases/create-document.md b/docs/examples/1.8.x/server-nodejs/examples/databases/create-document.md index 6fe77c42be..e6b9b49553 100644 --- a/docs/examples/1.8.x/server-nodejs/examples/databases/create-document.md +++ b/docs/examples/1.8.x/server-nodejs/examples/databases/create-document.md @@ -18,6 +18,6 @@ const result = await databases.createDocument({ "age": 30, "isAdmin": false }, - permissions: ["read("any")"], // optional + permissions: [sdk.Permission.read(sdk.Role.any())], // optional transactionId: '' // optional }); diff --git a/docs/examples/1.8.x/server-nodejs/examples/databases/update-collection.md b/docs/examples/1.8.x/server-nodejs/examples/databases/update-collection.md index d0d25b74d6..4cdc3a203b 100644 --- a/docs/examples/1.8.x/server-nodejs/examples/databases/update-collection.md +++ b/docs/examples/1.8.x/server-nodejs/examples/databases/update-collection.md @@ -11,7 +11,7 @@ const result = await databases.updateCollection({ databaseId: '', collectionId: '', name: '', - permissions: ["read("any")"], // optional + permissions: [sdk.Permission.read(sdk.Role.any())], // optional documentSecurity: false, // optional enabled: false // optional }); diff --git a/docs/examples/1.8.x/server-nodejs/examples/databases/update-document.md b/docs/examples/1.8.x/server-nodejs/examples/databases/update-document.md index 3e953760a1..d33d78d7d3 100644 --- a/docs/examples/1.8.x/server-nodejs/examples/databases/update-document.md +++ b/docs/examples/1.8.x/server-nodejs/examples/databases/update-document.md @@ -12,6 +12,6 @@ const result = await databases.updateDocument({ collectionId: '', documentId: '', data: {}, // optional - permissions: ["read("any")"], // optional + permissions: [sdk.Permission.read(sdk.Role.any())], // optional transactionId: '' // optional }); diff --git a/docs/examples/1.8.x/server-nodejs/examples/databases/upsert-document.md b/docs/examples/1.8.x/server-nodejs/examples/databases/upsert-document.md index 0aaec4e6cb..8fe4b35194 100644 --- a/docs/examples/1.8.x/server-nodejs/examples/databases/upsert-document.md +++ b/docs/examples/1.8.x/server-nodejs/examples/databases/upsert-document.md @@ -12,6 +12,6 @@ const result = await databases.upsertDocument({ collectionId: '', documentId: '', data: {}, - permissions: ["read("any")"], // optional + permissions: [sdk.Permission.read(sdk.Role.any())], // optional transactionId: '' // optional }); diff --git a/docs/examples/1.8.x/server-nodejs/examples/storage/create-bucket.md b/docs/examples/1.8.x/server-nodejs/examples/storage/create-bucket.md index f1f029491a..b47d2c8353 100644 --- a/docs/examples/1.8.x/server-nodejs/examples/storage/create-bucket.md +++ b/docs/examples/1.8.x/server-nodejs/examples/storage/create-bucket.md @@ -10,7 +10,7 @@ const storage = new sdk.Storage(client); const result = await storage.createBucket({ bucketId: '', name: '', - permissions: ["read("any")"], // optional + permissions: [sdk.Permission.read(sdk.Role.any())], // optional fileSecurity: false, // optional enabled: false, // optional maximumFileSize: 1, // optional diff --git a/docs/examples/1.8.x/server-nodejs/examples/storage/create-file.md b/docs/examples/1.8.x/server-nodejs/examples/storage/create-file.md index 628faf7249..8dc1745585 100644 --- a/docs/examples/1.8.x/server-nodejs/examples/storage/create-file.md +++ b/docs/examples/1.8.x/server-nodejs/examples/storage/create-file.md @@ -12,5 +12,5 @@ const result = await storage.createFile({ bucketId: '', fileId: '', file: InputFile.fromPath('/path/to/file', 'filename'), - permissions: ["read("any")"] // optional + permissions: [sdk.Permission.read(sdk.Role.any())] // optional }); diff --git a/docs/examples/1.8.x/server-nodejs/examples/storage/update-bucket.md b/docs/examples/1.8.x/server-nodejs/examples/storage/update-bucket.md index 136ebafe1b..9535914eeb 100644 --- a/docs/examples/1.8.x/server-nodejs/examples/storage/update-bucket.md +++ b/docs/examples/1.8.x/server-nodejs/examples/storage/update-bucket.md @@ -10,7 +10,7 @@ const storage = new sdk.Storage(client); const result = await storage.updateBucket({ bucketId: '', name: '', - permissions: ["read("any")"], // optional + permissions: [sdk.Permission.read(sdk.Role.any())], // optional fileSecurity: false, // optional enabled: false, // optional maximumFileSize: 1, // optional diff --git a/docs/examples/1.8.x/server-nodejs/examples/storage/update-file.md b/docs/examples/1.8.x/server-nodejs/examples/storage/update-file.md index 2d78d5fb91..131682134d 100644 --- a/docs/examples/1.8.x/server-nodejs/examples/storage/update-file.md +++ b/docs/examples/1.8.x/server-nodejs/examples/storage/update-file.md @@ -11,5 +11,5 @@ const result = await storage.updateFile({ bucketId: '', fileId: '', name: '', // optional - permissions: ["read("any")"] // optional + permissions: [sdk.Permission.read(sdk.Role.any())] // optional }); diff --git a/docs/examples/1.8.x/server-nodejs/examples/tablesdb/create-row.md b/docs/examples/1.8.x/server-nodejs/examples/tablesdb/create-row.md index 4468c168e8..d437501ba0 100644 --- a/docs/examples/1.8.x/server-nodejs/examples/tablesdb/create-row.md +++ b/docs/examples/1.8.x/server-nodejs/examples/tablesdb/create-row.md @@ -18,6 +18,6 @@ const result = await tablesDB.createRow({ "age": 30, "isAdmin": false }, - permissions: ["read("any")"], // optional + permissions: [sdk.Permission.read(sdk.Role.any())], // optional transactionId: '' // optional }); diff --git a/docs/examples/1.8.x/server-nodejs/examples/tablesdb/create-table.md b/docs/examples/1.8.x/server-nodejs/examples/tablesdb/create-table.md index 1b252f1484..6a4c12d34d 100644 --- a/docs/examples/1.8.x/server-nodejs/examples/tablesdb/create-table.md +++ b/docs/examples/1.8.x/server-nodejs/examples/tablesdb/create-table.md @@ -11,7 +11,7 @@ const result = await tablesDB.createTable({ databaseId: '', tableId: '', name: '', - permissions: ["read("any")"], // optional + permissions: [sdk.Permission.read(sdk.Role.any())], // optional rowSecurity: false, // optional enabled: false // optional }); diff --git a/docs/examples/1.8.x/server-nodejs/examples/tablesdb/update-row.md b/docs/examples/1.8.x/server-nodejs/examples/tablesdb/update-row.md index 58583af745..d5d2ee3002 100644 --- a/docs/examples/1.8.x/server-nodejs/examples/tablesdb/update-row.md +++ b/docs/examples/1.8.x/server-nodejs/examples/tablesdb/update-row.md @@ -12,6 +12,6 @@ const result = await tablesDB.updateRow({ tableId: '', rowId: '', data: {}, // optional - permissions: ["read("any")"], // optional + permissions: [sdk.Permission.read(sdk.Role.any())], // optional transactionId: '' // optional }); diff --git a/docs/examples/1.8.x/server-nodejs/examples/tablesdb/update-table.md b/docs/examples/1.8.x/server-nodejs/examples/tablesdb/update-table.md index b61fd6ac4e..97483daa03 100644 --- a/docs/examples/1.8.x/server-nodejs/examples/tablesdb/update-table.md +++ b/docs/examples/1.8.x/server-nodejs/examples/tablesdb/update-table.md @@ -11,7 +11,7 @@ const result = await tablesDB.updateTable({ databaseId: '', tableId: '', name: '', - permissions: ["read("any")"], // optional + permissions: [sdk.Permission.read(sdk.Role.any())], // optional rowSecurity: false, // optional enabled: false // optional }); diff --git a/docs/examples/1.8.x/server-nodejs/examples/tablesdb/upsert-row.md b/docs/examples/1.8.x/server-nodejs/examples/tablesdb/upsert-row.md index bfb833356a..f48b0daebd 100644 --- a/docs/examples/1.8.x/server-nodejs/examples/tablesdb/upsert-row.md +++ b/docs/examples/1.8.x/server-nodejs/examples/tablesdb/upsert-row.md @@ -12,6 +12,6 @@ const result = await tablesDB.upsertRow({ tableId: '', rowId: '', data: {}, // optional - permissions: ["read("any")"], // optional + permissions: [sdk.Permission.read(sdk.Role.any())], // optional transactionId: '' // optional }); From fce4f72ca92a09ac78c46b37098197684c70eaf7 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 31 Oct 2025 17:18:54 +0530 Subject: [PATCH 25/29] doc: add tutorial for releasing sdks --- docs/tutorials/release-sdks.md | 151 +++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/tutorials/release-sdks.md diff --git a/docs/tutorials/release-sdks.md b/docs/tutorials/release-sdks.md new file mode 100644 index 0000000000..9b9185ea3b --- /dev/null +++ b/docs/tutorials/release-sdks.md @@ -0,0 +1,151 @@ +# Releasing Appwrite SDKs + +This document is part of the Appwrite contributors' guide. Before you continue reading this document, make sure you have read the [Code of Conduct](https://github.com/appwrite/.github/blob/main/CODE_OF_CONDUCT.md) and the [Contributing Guide](https://github.com/appwrite/appwrite/blob/master/CONTRIBUTING.md). + +## Getting Started + +### Agenda + +This tutorial will cover how to properly release one or multiple Appwrite SDKs. The SDK release process involves updating the SDK generator, configuring Docker secrets, and running the release script. + +### Prerequisites + +Before releasing SDKs, you need to: + +1. **Release a new SDK generator version** - Create a PR in the [sdk-generator](https://github.com/appwrite/sdk-generator) repository with your respective sdk's changes. Wait for the PR to get merged and be released. + +2. **Update the SDK generator dependency** + - Update composer dependencies to use the new SDK generator version: + ```bash + docker run --rm --interactive --tty --volume "$(pwd)":/app composer update --ignore-platform-reqs --optimize-autoloader --no-scripts + ``` + + - Verify that `composer.lock` reflects the new SDK generator version + +### Configure Docker Secrets + +To enable SDK releases via GitHub, you need to mount SSH keys and configure GitHub authentication in your Docker environment. + +#### Update Dockerfile + +Add the following configuration to your `Dockerfile`: + +```dockerfile +ARG GH_TOKEN +ENV GH_TOKEN=your_github_token_here +RUN git config --global user.email "your-email@example.com" +RUN apk add --update --no-cache openssh-client github-cli +``` + +Replace: +- `your_github_token_here` with your GitHub personal access token (with appropriate permissions) +- `your-email@example.com` with your Git email address + +#### Update docker-compose.yml + +Add the SSH key volume mount to the `appwrite` service in `docker-compose.yml`: + +```yaml +services: + appwrite: + volumes: + - ~/.ssh:/root/.ssh + # ... other volumes +``` + +This mounts your SSH keys from the host machine, allowing the container to authenticate with GitHub. + +### Updating specs + +SDK generator script heavily relies on specs. So whenever you are adding a new endpoint, updating parameters or making any sort of API changes, you need to update the specs. You can do this by running the following command: + +```bash +docker compose exec appwrite specs +``` + +Make sure to also run it for the current Appwrite version. + +```bash +docker compose exec appwrite specs --version=1.8.x +``` + +### Running the SDK Release Script + +Before running the SDK release script you need to make sure to update 2 things for the respective SDKs you plan to release: + +1. Update the changelog in the respective SDK's `CHANGELOG.md` file. +2. Bump the version (patch, minor or major) in the `platforms.php` file. + +Once you have done that, you can run the SDK release script using the following command: + +```bash +docker compose exec appwrite sdks +``` + +The script will: +1. Prompt you to select the platform (client, server, console, or `*` for all) +2. Ask which SDK(s) to generate (or `*` for all) +3. Request the Appwrite version for which to generate the SDKs (For example: 1.8.x) +4. Guide you through git push options and PR creation + +If you are releasing multiple SDKs that belong to different platforms, you can pass in the array of SDKs manually like this: + +```bash +docker compose exec appwrite sdks --sdks=dart,flutter,cli,python +``` + +Once you have run the SDK release script, you will get a summary of the PRs made like this: + +```text +Pull Request Summary +Dart: https://github.com/appwrite/dart-sdk/pull/123 +Flutter: https://github.com/appwrite/flutter-sdk/pull/123 +``` + +### Releasing the SDKs + +If you are a maintainer at Appwrite, you can release the SDKs automatically by using the script. Before that make sure the PRs are reviewed and merged by a Lead at Appwrite. + +```bash +docker compose exec appwrite sdks --release=yes +``` + +This will give a DRY RUN for how the releases will look like: + +```text +[DRY RUN] Would create release for Dart SDK: + Repository: appwrite/dart-sdk + Version: 1.8.0 + Title: 1.8.0 + Target Branch: main + Previous Version: 1.7.0 + Release Notes: + ## What's Changed + - Add new endpoint /users/:userId/logs + - Add new endpoint /users/:userId/logs +``` + +After everything looks good, you can release the SDKs by running the following command: + +```bash +docker compose exec appwrite sdks --release=yes --commit=yes +``` + +### Release Configuration Reference + +SDK configurations are defined in: +- **Platform and SDK definitions**: `app/config/collections/platform.php` +- **SDK generation logic**: `src/Appwrite/Platform/Tasks/SDKs.php` + +These files contain the SDK metadata, Git repository URLs, versions, and other configuration needed for the release process. + +## Troubleshooting + +If you encounter authentication issues: +- Verify your GitHub token has the correct permissions (repo access, workflow permissions) +- Ensure your SSH keys are properly configured in `~/.ssh/` +- Check that the Git email in the Dockerfile matches your GitHub account + +If everything went well, you should see the SDKs being generated and pushed to their respective repositories. + +Congrats! You have successfully released Appwrite SDKs. 🎉 From 8c8073d7aa3d96217152c927b6dca548730e1b00 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 31 Oct 2025 17:22:02 +0530 Subject: [PATCH 26/29] grammer fixes --- docs/tutorials/release-sdks.md | 68 +++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/docs/tutorials/release-sdks.md b/docs/tutorials/release-sdks.md index 9b9185ea3b..cd3311e117 100644 --- a/docs/tutorials/release-sdks.md +++ b/docs/tutorials/release-sdks.md @@ -55,15 +55,17 @@ services: This mounts your SSH keys from the host machine, allowing the container to authenticate with GitHub. -### Updating specs +### Updating Specs -SDK generator script heavily relies on specs. So whenever you are adding a new endpoint, updating parameters or making any sort of API changes, you need to update the specs. You can do this by running the following command: +The SDK generator script heavily relies on API specification files (specs). Whenever you are adding a new endpoint, updating parameters, or making any API changes, you need to update the specs. + +Generate specs for the latest version: ```bash docker compose exec appwrite specs ``` -Make sure to also run it for the current Appwrite version. +Also generate specs for the current stable Appwrite version: ```bash docker compose exec appwrite specs --version=1.8.x @@ -71,61 +73,75 @@ docker compose exec appwrite specs --version=1.8.x ### Running the SDK Release Script -Before running the SDK release script you need to make sure to update 2 things for the respective SDKs you plan to release: +Before running the SDK release script, ensure you update the following for each SDK you plan to release: -1. Update the changelog in the respective SDK's `CHANGELOG.md` file. -2. Bump the version (patch, minor or major) in the `platforms.php` file. +1. **Update the changelog** - Add release notes to the SDK's `CHANGELOG.md` file (located in `docs/sdks//CHANGELOG.md`) +2. **Bump the version** - Update the version number (patch, minor, or major) in `app/config/platforms.php` -Once you have done that, you can run the SDK release script using the following command: +Once you have completed these updates, run the SDK release script: ```bash docker compose exec appwrite sdks ``` -The script will: -1. Prompt you to select the platform (client, server, console, or `*` for all) -2. Ask which SDK(s) to generate (or `*` for all) -3. Request the Appwrite version for which to generate the SDKs (For example: 1.8.x) -4. Guide you through git push options and PR creation +The script will prompt you for: +1. **Platform** - Select client, server, console, or `*` for all platforms +2. **SDK(s)** - Choose specific SDK(s) or `*` for all +3. **Appwrite version** - Specify the version (e.g., `1.8.x`) +4. **Git options** - Configure push settings and PR creation -If you are releasing multiple SDKs that belong to different platforms, you can pass in the array of SDKs manually like this: +#### Releasing Multiple SDKs + +If you are releasing multiple SDKs across different platforms, you can specify them directly: ```bash docker compose exec appwrite sdks --sdks=dart,flutter,cli,python ``` -Once you have run the SDK release script, you will get a summary of the PRs made like this: +#### Pull Request Summary + +After the script completes, you'll receive a summary of created pull requests: ```text Pull Request Summary -Dart: https://github.com/appwrite/dart-sdk/pull/123 -Flutter: https://github.com/appwrite/flutter-sdk/pull/123 +Dart: https://github.com/appwrite/sdk-for-dart/pull/123 +Flutter: https://github.com/appwrite/sdk-for-flutter/pull/124 +CLI: https://github.com/appwrite/sdk-for-cli/pull/125 ``` -### Releasing the SDKs +### Creating GitHub Releases -If you are a maintainer at Appwrite, you can release the SDKs automatically by using the script. Before that make sure the PRs are reviewed and merged by a Lead at Appwrite. +> **Note:** This section is for Appwrite maintainers only. + +After the PRs have been reviewed and merged by an Appwrite Lead, you can create GitHub releases automatically. + +#### Dry Run + +First, perform a dry run to preview the releases: ```bash docker compose exec appwrite sdks --release=yes ``` -This will give a DRY RUN for how the releases will look like: +This will display what releases would be created: ```text [DRY RUN] Would create release for Dart SDK: - Repository: appwrite/dart-sdk - Version: 1.8.0 - Title: 1.8.0 + Repository: appwrite/sdk-for-dart + Version: 13.0.0 + Title: 13.0.0 Target Branch: main - Previous Version: 1.7.0 + Previous Version: 12.0.2 Release Notes: ## What's Changed - - Add new endpoint /users/:userId/logs - - Add new endpoint /users/:userId/logs + - Added support for new Users API endpoints + - Fixed authentication token handling + - Updated dependencies ``` -After everything looks good, you can release the SDKs by running the following command: +#### Execute Release + +After verifying the dry run output, create the actual releases: ```bash docker compose exec appwrite sdks --release=yes --commit=yes From ebee3536f59dd56b48a98310d613e6e539e90662 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 31 Oct 2025 17:24:03 +0530 Subject: [PATCH 27/29] final changes --- docs/tutorials/release-sdks.md | 41 +++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/docs/tutorials/release-sdks.md b/docs/tutorials/release-sdks.md index cd3311e117..99c0fa4fd3 100644 --- a/docs/tutorials/release-sdks.md +++ b/docs/tutorials/release-sdks.md @@ -147,21 +147,40 @@ After verifying the dry run output, create the actual releases: docker compose exec appwrite sdks --release=yes --commit=yes ``` -### Release Configuration Reference +## Reference -SDK configurations are defined in: -- **Platform and SDK definitions**: `app/config/collections/platform.php` -- **SDK generation logic**: `src/Appwrite/Platform/Tasks/SDKs.php` +### Configuration Files -These files contain the SDK metadata, Git repository URLs, versions, and other configuration needed for the release process. +SDK configurations are defined in the following files: + +- **`app/config/platforms.php`** - Platform and SDK definitions, including metadata, Git repository URLs, versions, and enabled/disabled status +- **`src/Appwrite/Platform/Tasks/SDKs.php`** - SDK generation and release logic +- **`docs/sdks//CHANGELOG.md`** - Changelog files for each SDK ## Troubleshooting -If you encounter authentication issues: -- Verify your GitHub token has the correct permissions (repo access, workflow permissions) -- Ensure your SSH keys are properly configured in `~/.ssh/` -- Check that the Git email in the Dockerfile matches your GitHub account +### Authentication Issues -If everything went well, you should see the SDKs being generated and pushed to their respective repositories. +If you encounter authentication problems: +- **GitHub token** - Verify your token has the correct permissions (repo access, workflow permissions) +- **SSH keys** - Ensure your SSH keys are properly configured in `~/.ssh/` and added to your GitHub account +- **Git configuration** - Check that the Git email in the Dockerfile matches your GitHub account -Congrats! You have successfully released Appwrite SDKs. 🎉 +### Common Issues + +- **"Release already exists"** - The script automatically skips releases that already exist for the specified version +- **"No changes detected"** - Ensure you've updated the specs and that there are actual API changes to generate +- **Permission denied** - Verify that your GitHub token and SSH keys have write access to the SDK repositories + +## Summary + +Congrats! You've successfully learned how to release Appwrite SDKs. Remember to: + +1. Update SDK generator and run `composer update` +2. Configure Docker secrets (GitHub token and SSH keys) +3. Update specs for both latest and stable versions +4. Update changelogs and bump versions in `platforms.php` +5. Run the SDK script and create PRs +6. (Maintainers only) Create GitHub releases after PR approval + +Happy releasing! 🎉 From f9542e2be2ff8ab99070e24b1aee69087f680e6c Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Fri, 31 Oct 2025 17:52:47 +0530 Subject: [PATCH 28/29] Update base template for session alert email --- app/controllers/api/account.php | 21 ++++ .../Account/AccountConsoleClientTest.php | 108 ++++++++++++++++++ .../Account/AccountCustomClientTest.php | 6 +- 3 files changed, 131 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 5563fc6a59..04210d1523 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -75,6 +75,14 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, Doc $subject = $locale->getText("emails.sessionAlert.subject"); $preview = $locale->getText("emails.sessionAlert.preview"); $customTemplate = $project->getAttribute('templates', [])['email.sessionAlert-' . $locale->default] ?? []; + $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); + + $validator = new FileName(); + if (!$validator->isValid($smtpBaseTemplate)) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid template path'); + } + + $bodyTemplate = __DIR__ . '/../../config/locale/templates/' . $smtpBaseTemplate . '.tpl'; $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-session-alert.tpl'); $message @@ -157,12 +165,25 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, Doc 'country' => $locale->getText('countries.' . $session->getAttribute('countryCode'), $locale->getText('locale.country.unknown')), ]; + if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) { + $emailVariables = array_merge($emailVariables, [ + 'accentColor' => APP_EMAIL_ACCENT_COLOR, + 'logoUrl' => APP_EMAIL_LOGO_URL, + 'twitterUrl' => APP_SOCIAL_TWITTER, + 'discordUrl' => APP_SOCIAL_DISCORD, + 'githubUrl' => APP_SOCIAL_GITHUB_APPWRITE, + 'termsUrl' => APP_EMAIL_TERMS_URL, + 'privacyUrl' => APP_EMAIL_PRIVACY_URL, + ]); + } + $email = $user->getAttribute('email'); $queueForMails ->setSubject($subject) ->setPreview($preview) ->setBody($body) + ->setBodyTemplate($bodyTemplate) ->setVariables($emailVariables) ->setRecipient($email) ->trigger(); diff --git a/tests/e2e/Services/Account/AccountConsoleClientTest.php b/tests/e2e/Services/Account/AccountConsoleClientTest.php index 1df9ef6c18..3e43d443e3 100644 --- a/tests/e2e/Services/Account/AccountConsoleClientTest.php +++ b/tests/e2e/Services/Account/AccountConsoleClientTest.php @@ -88,4 +88,112 @@ class AccountConsoleClientTest extends Scope $this->assertEquals($response['headers']['status-code'], 204); } + + public function testSessionAlert(): void + { + $email = uniqid() . 'session-alert@appwrite.io'; + $password = 'password123'; + $name = 'Session Alert Tester'; + + // Create a new account + $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-dev-key' => $this->getProject()['devKey'] ?? '' + ]), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => $name, + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Create first session for the new account + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'user-agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Create second session for the new account + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'user-agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + + // Check the alert email + $lastEmail = $this->getLastEmail(); + + $this->assertEquals($email, $lastEmail['to'][0]['address']); + $this->assertStringContainsString('Security alert: new session', $lastEmail['subject']); + $this->assertStringContainsString($response['body']['ip'], $lastEmail['text']); // IP Address + $this->assertStringContainsString('Unknown', $lastEmail['text']); // Country + $this->assertStringContainsString($response['body']['clientName'], $lastEmail['text']); // Client name + $this->assertStringContainsStringIgnoringCase('Appwrite logo', $lastEmail['html']); + + // Verify no alert sent in OTP login + $response = $this->client->call(Client::METHOD_POST, '/account/tokens/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => ID::unique(), + 'email' => 'otpuser2@appwrite.io' + ]); + + $this->assertEquals($response['headers']['status-code'], 201); + $this->assertNotEmpty($response['body']['$id']); + $this->assertNotEmpty($response['body']['$createdAt']); + $this->assertNotEmpty($response['body']['userId']); + $this->assertNotEmpty($response['body']['expire']); + $this->assertEmpty($response['body']['secret']); + $this->assertEmpty($response['body']['phrase']); + $this->assertStringContainsStringIgnoringCase('New login detected on '. $this->getProject()['name'], $lastEmail['text']); + + $userId = $response['body']['userId']; + + $lastEmail = $this->getLastEmail(); + + $this->assertEquals('otpuser2@appwrite.io', $lastEmail['to'][0]['address']); + $this->assertEquals('OTP for ' . $this->getProject()['name'] . ' Login', $lastEmail['subject']); + + // Find 6 concurrent digits in email text - OTP + preg_match_all("/\b\d{6}\b/", $lastEmail['text'], $matches); + $code = ($matches[0] ?? [])[0] ?? ''; + + $this->assertNotEmpty($code); + + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/token', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => $userId, + 'secret' => $code + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals($userId, $response['body']['userId']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertNotEmpty($response['body']['expire']); + $this->assertEmpty($response['body']['secret']); + + $lastEmailId = $lastEmail['id']; + $lastEmail = $this->getLastEmail(); + $this->assertEquals($lastEmailId, $lastEmail['id']); + } } diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 0993f68a58..10dc8f8e91 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -1348,10 +1348,7 @@ class AccountCustomClientTest extends Scope return $data; } - /** - * @depends testCreateAccountSession - */ - public function testSessionAlert($data): void + public function testSessionAlert(): void { $email = uniqid() . 'session-alert@appwrite.io'; $password = 'password123'; @@ -1417,6 +1414,7 @@ class AccountCustomClientTest extends Scope $this->assertStringContainsString($response['body']['ip'], $lastEmail['text']); // IP Address $this->assertStringContainsString('Unknown', $lastEmail['text']); // Country $this->assertStringContainsString($response['body']['clientName'], $lastEmail['text']); // Client name + $this->assertStringNotContainsStringIgnoringCase('Appwrite logo', $lastEmail['html']); // Verify no alert sent in OTP login $response = $this->client->call(Client::METHOD_POST, '/account/tokens/email', array_merge([ From af3a0bb34bf52a964011934bba581928e9a6c9e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 31 Oct 2025 13:51:38 +0100 Subject: [PATCH 29/29] Update detection lib --- composer.lock | 50 +++++++++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/composer.lock b/composer.lock index 9bb2f6a074..e493071032 100644 --- a/composer.lock +++ b/composer.lock @@ -3840,16 +3840,16 @@ }, { "name": "utopia-php/database", - "version": "3.0.4", + "version": "3.1.2", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "a83997d5555d6cd873b829a2459292211c6ab13f" + "reference": "b6541a9cd9b21786a5020327f582838afdb159aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/a83997d5555d6cd873b829a2459292211c6ab13f", - "reference": "a83997d5555d6cd873b829a2459292211c6ab13f", + "url": "https://api.github.com/repos/utopia-php/database/zipball/b6541a9cd9b21786a5020327f582838afdb159aa", + "reference": "b6541a9cd9b21786a5020327f582838afdb159aa", "shasum": "" }, "require": { @@ -3892,22 +3892,22 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/3.0.4" + "source": "https://github.com/utopia-php/database/tree/3.1.2" }, - "time": "2025-10-28T08:21:21+00:00" + "time": "2025-10-30T13:10:13+00:00" }, { "name": "utopia-php/detector", - "version": "0.2.1", + "version": "0.2.2", "source": { "type": "git", "url": "https://github.com/utopia-php/detector.git", - "reference": "89f96e864220de13800cf398a1f1686a85401eaa" + "reference": "9a41be5f21efe2d865de79b08dff94fff85ce5e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/detector/zipball/89f96e864220de13800cf398a1f1686a85401eaa", - "reference": "89f96e864220de13800cf398a1f1686a85401eaa", + "url": "https://api.github.com/repos/utopia-php/detector/zipball/9a41be5f21efe2d865de79b08dff94fff85ce5e9", + "reference": "9a41be5f21efe2d865de79b08dff94fff85ce5e9", "shasum": "" }, "require": { @@ -3937,9 +3937,9 @@ ], "support": { "issues": "https://github.com/utopia-php/detector/issues", - "source": "https://github.com/utopia-php/detector/tree/0.2.1" + "source": "https://github.com/utopia-php/detector/tree/0.2.2" }, - "time": "2025-10-27T13:38:33+00:00" + "time": "2025-10-31T12:43:31+00:00" }, { "name": "utopia-php/dns", @@ -5253,16 +5253,16 @@ }, { "name": "webmozart/assert", - "version": "1.12.0", + "version": "1.12.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "541057574806f942c94662b817a50f63f7345360" + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/541057574806f942c94662b817a50f63f7345360", - "reference": "541057574806f942c94662b817a50f63f7345360", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", "shasum": "" }, "require": { @@ -5305,9 +5305,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.0" + "source": "https://github.com/webmozarts/assert/tree/1.12.1" }, - "time": "2025-10-20T12:43:39+00:00" + "time": "2025-10-29T15:56:20+00:00" }, { "name": "webonyx/graphql-php", @@ -5378,16 +5378,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.4.15", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "b4a2fd9e92903c2e156f17fc5dafe102e6cfdfda" + "reference": "42df22195d6457e52e4c819678168470b114a816" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/b4a2fd9e92903c2e156f17fc5dafe102e6cfdfda", - "reference": "b4a2fd9e92903c2e156f17fc5dafe102e6cfdfda", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/42df22195d6457e52e4c819678168470b114a816", + "reference": "42df22195d6457e52e4c819678168470b114a816", "shasum": "" }, "require": { @@ -5423,9 +5423,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.4.15" + "source": "https://github.com/appwrite/sdk-generator/tree/1.5.0" }, - "time": "2025-10-28T04:52:59+00:00" + "time": "2025-10-31T10:10:25+00:00" }, { "name": "doctrine/annotations", @@ -8916,5 +8916,5 @@ "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.3.0" }