Merge branch 'refactor-auth-single-instance' of github.com:appwrite/appwrite into refactor-auth-single-instance

This commit is contained in:
shimon 2025-11-02 18:58:51 +02:00
commit c0be333c0f
41 changed files with 1645 additions and 55 deletions

View file

@ -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' => '11.0.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.1',
'version' => '13.5.0',
'url' => 'https://github.com/appwrite/sdk-for-python',
'package' => 'https://pypi.org/project/appwrite/',
'enabled' => true,

View file

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

View file

@ -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();

View file

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

View file

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

View file

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

View file

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

2
composer.lock generated
View file

@ -8932,5 +8932,5 @@
"platform-overrides": {
"php": "8.3"
},
"plugin-api-version": "2.2.0"
"plugin-api-version": "2.3.0"
}

View file

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

View file

@ -0,0 +1,4 @@
appwrite migrations create-csv-export \
--resource-id <ID1:ID2> \
--bucket-id <BUCKET_ID> \
--filename <FILENAME>

View file

@ -0,0 +1,4 @@
appwrite migrations create-csv-import \
--bucket-id <BUCKET_ID> \
--file-id <FILE_ID> \
--resource-id <ID1:ID2>

View file

@ -0,0 +1,22 @@
import { Client, Migrations } from "@appwrite.io/console";
const client = new Client()
.setEndpoint('https://<REGION>.cloud.appwrite.io/v1') // Your API Endpoint
.setProject('<YOUR_PROJECT_ID>'); // Your project ID
const migrations = new Migrations(client);
const result = await migrations.createCSVExport({
resourceId: '<ID1:ID2>',
bucketId: '<BUCKET_ID>',
filename: '<FILENAME>',
columns: [], // optional
queries: [], // optional
delimiter: '<DELIMITER>', // optional
enclosure: '<ENCLOSURE>', // optional
escape: '<ESCAPE>', // optional
header: false, // optional
notify: false // optional
});
console.log(result);

View file

@ -0,0 +1,16 @@
import { Client, Migrations } from "@appwrite.io/console";
const client = new Client()
.setEndpoint('https://<REGION>.cloud.appwrite.io/v1') // Your API Endpoint
.setProject('<YOUR_PROJECT_ID>'); // Your project ID
const migrations = new Migrations(client);
const result = await migrations.createCSVImport({
bucketId: '<BUCKET_ID>',
fileId: '<FILE_ID>',
resourceId: '<ID1:ID2>',
internalFile: false // optional
});
console.log(result);

View file

@ -11,7 +11,7 @@ const result = await databases.createCollection({
databaseId: '<DATABASE_ID>',
collectionId: '<COLLECTION_ID>',
name: '<NAME>',
permissions: ["read("any")"], // optional
permissions: [sdk.Permission.read(sdk.Role.any())], // optional
documentSecurity: false, // optional
enabled: false // optional
});

View file

@ -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: '<TRANSACTION_ID>' // optional
});

View file

@ -11,7 +11,7 @@ const result = await databases.updateCollection({
databaseId: '<DATABASE_ID>',
collectionId: '<COLLECTION_ID>',
name: '<NAME>',
permissions: ["read("any")"], // optional
permissions: [sdk.Permission.read(sdk.Role.any())], // optional
documentSecurity: false, // optional
enabled: false // optional
});

View file

@ -12,6 +12,6 @@ const result = await databases.updateDocument({
collectionId: '<COLLECTION_ID>',
documentId: '<DOCUMENT_ID>',
data: {}, // optional
permissions: ["read("any")"], // optional
permissions: [sdk.Permission.read(sdk.Role.any())], // optional
transactionId: '<TRANSACTION_ID>' // optional
});

View file

@ -12,6 +12,6 @@ const result = await databases.upsertDocument({
collectionId: '<COLLECTION_ID>',
documentId: '<DOCUMENT_ID>',
data: {},
permissions: ["read("any")"], // optional
permissions: [sdk.Permission.read(sdk.Role.any())], // optional
transactionId: '<TRANSACTION_ID>' // optional
});

View file

@ -10,7 +10,7 @@ const storage = new sdk.Storage(client);
const result = await storage.createBucket({
bucketId: '<BUCKET_ID>',
name: '<NAME>',
permissions: ["read("any")"], // optional
permissions: [sdk.Permission.read(sdk.Role.any())], // optional
fileSecurity: false, // optional
enabled: false, // optional
maximumFileSize: 1, // optional

View file

@ -12,5 +12,5 @@ const result = await storage.createFile({
bucketId: '<BUCKET_ID>',
fileId: '<FILE_ID>',
file: InputFile.fromPath('/path/to/file', 'filename'),
permissions: ["read("any")"] // optional
permissions: [sdk.Permission.read(sdk.Role.any())] // optional
});

View file

@ -10,7 +10,7 @@ const storage = new sdk.Storage(client);
const result = await storage.updateBucket({
bucketId: '<BUCKET_ID>',
name: '<NAME>',
permissions: ["read("any")"], // optional
permissions: [sdk.Permission.read(sdk.Role.any())], // optional
fileSecurity: false, // optional
enabled: false, // optional
maximumFileSize: 1, // optional

View file

@ -11,5 +11,5 @@ const result = await storage.updateFile({
bucketId: '<BUCKET_ID>',
fileId: '<FILE_ID>',
name: '<NAME>', // optional
permissions: ["read("any")"] // optional
permissions: [sdk.Permission.read(sdk.Role.any())] // optional
});

View file

@ -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: '<TRANSACTION_ID>' // optional
});

View file

@ -11,7 +11,7 @@ const result = await tablesDB.createTable({
databaseId: '<DATABASE_ID>',
tableId: '<TABLE_ID>',
name: '<NAME>',
permissions: ["read("any")"], // optional
permissions: [sdk.Permission.read(sdk.Role.any())], // optional
rowSecurity: false, // optional
enabled: false // optional
});

View file

@ -12,6 +12,6 @@ const result = await tablesDB.updateRow({
tableId: '<TABLE_ID>',
rowId: '<ROW_ID>',
data: {}, // optional
permissions: ["read("any")"], // optional
permissions: [sdk.Permission.read(sdk.Role.any())], // optional
transactionId: '<TRANSACTION_ID>' // optional
});

View file

@ -11,7 +11,7 @@ const result = await tablesDB.updateTable({
databaseId: '<DATABASE_ID>',
tableId: '<TABLE_ID>',
name: '<NAME>',
permissions: ["read("any")"], // optional
permissions: [sdk.Permission.read(sdk.Role.any())], // optional
rowSecurity: false, // optional
enabled: false // optional
});

View file

@ -12,6 +12,6 @@ const result = await tablesDB.upsertRow({
tableId: '<TABLE_ID>',
rowId: '<ROW_ID>',
data: {}, // optional
permissions: ["read("any")"], // optional
permissions: [sdk.Permission.read(sdk.Role.any())], // optional
transactionId: '<TRANSACTION_ID>' // optional
});

View file

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

View file

@ -1,5 +1,10 @@
# Change Log
## 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
* Add `onOpen`, `onClose` and `onError` callbacks to `Realtime` service

View file

@ -1,5 +1,15 @@
# Change Log
## 11.0.0
* 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
* Fix attribute changing during push
* Replace pkg with @yao-pkg/pkg in dependencies
## 10.2.3
* Fix `init tables` command not working

View file

@ -1,5 +1,11 @@
# Change Log
## 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
* Add transaction support for Databases and TablesDB

View file

@ -1,5 +1,12 @@
# Change Log
## 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
## 13.4.1
* Add transaction support for Databases and TablesDB

View file

@ -0,0 +1,186 @@
# 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
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
```
Also generate specs for the current stable Appwrite version:
```bash
docker compose exec appwrite specs --version=1.8.x
```
### Running the SDK Release Script
Before running the SDK release script, ensure you update the following for each SDK you plan to release:
1. **Update the changelog** - Add release notes to the SDK's `CHANGELOG.md` file (located in `docs/sdks/<sdk-name>/CHANGELOG.md`)
2. **Bump the version** - Update the version number (patch, minor, or major) in `app/config/platforms.php`
Once you have completed these updates, run the SDK release script:
```bash
docker compose exec appwrite sdks
```
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
#### 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
```
#### 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/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
```
### Creating GitHub Releases
> **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 display what releases would be created:
```text
[DRY RUN] Would create release for Dart SDK:
Repository: appwrite/sdk-for-dart
Version: 13.0.0
Title: 13.0.0
Target Branch: main
Previous Version: 12.0.2
Release Notes:
## What's Changed
- Added support for new Users API endpoints
- Fixed authentication token handling
- Updated dependencies
```
#### Execute Release
After verifying the dry run output, create the actual releases:
```bash
docker compose exec appwrite sdks --release=yes --commit=yes
```
## Reference
### Configuration Files
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/<sdk-name>/CHANGELOG.md`** - Changelog files for each SDK
## Troubleshooting
### Authentication Issues
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
### 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! 🎉

View file

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

View file

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

View file

@ -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);

View file

@ -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']);
}
}

View file

@ -1368,10 +1368,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';
@ -1437,6 +1434,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([

View file

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

View file

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

View file

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