Merge remote-tracking branch 'origin/1.9.x' into feat/migrate-di-container

# Conflicts:
#	app/init/resources.php
#	composer.json
#	composer.lock
#	phpstan-baseline.neon
This commit is contained in:
Chirag Aggarwal
2026-04-01 11:46:13 +05:30
131 changed files with 4880 additions and 1219 deletions
+99 -86
View File
@@ -1,107 +1,120 @@
# AGENTS.md
# Appwrite
Appwrite is an end-to-end backend server for web, mobile, native, and backend apps. This guide provides context and instructions for AI coding agents working on the Appwrite codebase.
Self-hosted Backend-as-a-Service platform. Hybrid monolithic-microservice architecture built with PHP 8.3+ on Swoole, delivered as Docker containers.
## Project Overview
## Commands
Appwrite is a self-hosted Backend-as-a-Service (BaaS) platform that provides developers with a set of APIs and tools to build secure, scalable applications. The project uses a hybrid monolithic-microservice architecture built with PHP, running on Swoole for high performance.
| Command | Purpose |
|---------|---------|
| `docker compose up -d --force-recreate --build` | Build and start all services |
| `docker compose exec appwrite test tests/e2e/Services/[Service]` | Run E2E tests for a service |
| `docker compose exec appwrite test tests/e2e/Services/[Service] --filter=[Method]` | Run a single test method |
| `docker compose exec appwrite test tests/unit/` | Run unit tests |
| `composer format` | Auto-format code (Pint, PSR-12) |
| `composer format <file>` | Format a specific file |
| `composer lint <file>` | Check formatting of a file |
| `composer analyze` | Static analysis (PHPStan level 3) |
| `composer check` | Same as `analyze` |
**Key Technologies:**
- **Backend:** PHP 8.3+, Swoole
- **Libraries:** Utopia PHP
- **Database:** MariaDB, Redis
- **Cache:** Redis
- **Queue:** Redis
- **Containers:** Docker
## Stack
## Development Commands
- PHP 8.3+, Swoole 6.x (async runtime, replaces PHP-FPM)
- Utopia PHP framework (HTTP routing, CLI, DI, queue)
- MongoDB (default), MariaDB, MySQL, PostgreSQL (adapters via utopia-php/database)
- Redis (cache, queue, pub/sub)
- Docker + Traefik (reverse proxy)
- PHPUnit 12, Pint (PSR-12), PHPStan level 3
```bash
# Run Appwrite
docker compose up -d --force-recreate --build
## Project layout
# Run specific test
docker compose exec appwrite test /usr/src/code/tests/e2e/Services/[ServiceName] --filter=[FunctionName]
- **src/Appwrite/Platform/Modules/** -- feature modules (Account, Avatars, Compute, Console, Databases, Functions, Health, Project, Projects, Proxy, Sites, Storage, Teams, Tokens, VCS, Webhooks)
- **src/Appwrite/Platform/Workers/** -- background job workers
- **src/Appwrite/Platform/Tasks/** -- CLI tasks
- **app/init.php** -- bootstrap (registers services, resources, listeners)
- **app/init/** -- configs, constants, locales, models, registers, resources, span, database filters/formats
- **bin/** -- CLI entry points: `worker-*` (14 workers), `schedule-*`, `queue-*`, plus `doctor`, `install`, `migrate`, `realtime`, `upgrade`, `ssl`, `vars`, `maintenance`, `interval`, `specs`, `sdks`, etc.
- **tests/e2e/** -- end-to-end tests per service
- **tests/unit/** -- unit tests
- **public/** -- static assets and generated SDKs
# Format code
composer format
## Module structure
Each module under `src/Appwrite/Platform/Modules/{Name}/` contains:
```
Module.php -- registers all services for the module
Services/Http.php -- registers HTTP endpoints
Services/Workers.php -- registers background workers
Services/Tasks.php -- registers CLI tasks
Http/{Service}/ -- endpoint actions (Create.php, Get.php, Update.php, Delete.php, XList.php)
Workers/ -- worker implementations
Tasks/ -- CLI task implementations
```
## Code Style Guidelines
HTTP endpoint nesting reflects the URL path. Sub-resources get subdirectories. For example, within the Functions module:
`Http/Deployments/Template/Create.php` -> `POST /v1/functions/:functionId/deployments/template`
- Follow [PSR-12](https://www.php-fig.org/psr/psr-12/) coding standard
- Use PSR-4 autoloading
- Strict type declarations where applicable
- Comprehensive PHPDoc comments
File names in Http directories must only be `Get.php`, `Create.php`, `Update.php`, `Delete.php`, or `XList.php`. For non-CRUD operations, model the endpoint as a property update. For example, updating a team membership status lives at `Teams/Http/Memberships/Status/Update.php` (`PATCH /v1/teams/:teamId/memberships/:membershipId/status`).
### Naming Conventions
Register new modules in `src/Appwrite/Platform/Appwrite.php`. Detailed module guide: `src/Appwrite/Platform/AGENTS.md`.
#### `resourceType` Naming Rule
## Action pattern (HTTP endpoints)
When a collection has a combination of `resourceType`, `resourceId`, and/or `resourceInternalId`, the value of `resourceType` MUST always be **plural** - for example: `functions`, `sites`, `deployments`.
Examples:
```php
'resourceType' => 'functions'
'resourceType' => 'sites'
'resourceType' => 'deployments'
class Create extends Action
{
public static function getName(): string { return 'createTeam'; }
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/teams')
->desc('Create team')
->groups(['api', 'teams'])
->label('event', 'teams.[teamId].create')
->label('scope', 'teams.write')
->param('teamId', '', new CustomId(), 'Team ID.')
->param('name', null, new Text(128), 'Team name.')
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(
string $teamId,
string $name,
Response $response,
Database $dbForProject,
Event $queueForEvents,
): void {
// implementation
}
}
```
## Performance Patterns
Common injections: `$response`, `$request`, `$dbForProject`, `$dbForPlatform`, `$user`, `$project`, `$queueForEvents`, `$queueForMails`, `$queueForDeletes`.
### Document Update Optimization
## Conventions
When updating documents, always pass only the changed attributes as a sparse `Document` rather than the full document. This is more efficient because `updateDocument()` internally performs `array_merge($old, $new)`.
- PSR-12 formatting enforced by Pint. PSR-4 autoloading.
- `resourceType` values are always **plural**: `'functions'`, `'sites'`, `'deployments'`.
- When updating documents, pass only changed attributes as a sparse Document:
```php
// correct
$dbForProject->updateDocument('users', $user->getId(), new Document([
'name' => $name,
]));
// incorrect -- passing full document is inefficient
$user->setAttribute('name', $name);
$dbForProject->updateDocument('users', $user->getId(), $user);
```
Exceptions: migrations, `array_merge()` with `getArrayCopy()`, updates where nearly all attributes change, complex nested relationship logic requiring full document state.
- Avoid introducing dependencies outside the `utopia-php` ecosystem.
- Never hardcode credentials -- use environment variables.
- Code changes may require container restart. No central log location -- check relevant containers.
**Correct Pattern:**
```php
// Good: Pass only changed attributes directly
$user = $dbForProject->updateDocument('users', $user->getId(), new Document([
'name' => $name,
'email' => $email,
]));
```
## Cross-repo context
**Incorrect Pattern:**
```php
$user->setAttribute('name', $name);
$user->setAttribute('email', $email);
// Bad: Passing full document is inefficient
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
```
**Exceptions:**
- Migration files (need full document updates by design)
- Cases already using `array_merge()` with `getArrayCopy()`
- Updates where almost all attributes of the document change at once (sparse update provides little benefit compared to passing the full document)
- Complex nested relationship logic where full document state is required
## Security Considerations
### Critical Security Practices
- **Never hardcode credentials** - Use environment variables
- **Rate limiting** - Respect abuse prevention mechanisms
## Dependencies
Avoid introducing new dependencies other than utopia-php.
## Adding new endpoints
When adding new endpoints, make sure to use modules and follow its patterns. Find instruction in [Modules AGENTS.md](src/Appwrite/Platform/AGENTS.md) file.
## Pull Request Guidelines
### Before Submitting
- Run `composer format`
- Update documentation if adding features
- Add/update tests for your changes
- Check that Docker build succeeds
`docs/specs/authentication.drawio.svg`
## Known Issues and Gotchas
- **Hot Reload:** Code changes require container restart in some cases
- **Logging:** There is no central place for logs, so when debugging, ensure to check all possibly relevant containers
Appwrite is the base server for `appwrite/cloud`. Changes to the Action pattern, module structure, DI system, or response models affect cloud. The `feat-dedicated-db` feature spans cloud, edge, and console.
+3
View File
@@ -72,6 +72,7 @@ Before running the installation command, make sure you have [Docker](https://www
```bash
docker run -it --rm \
--publish 20080:20080 \
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \
--entrypoint="install" \
@@ -84,6 +85,7 @@ docker run -it --rm \
```cmd
docker run -it --rm ^
--publish 20080:20080 ^
--volume //var/run/docker.sock:/var/run/docker.sock ^
--volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^
--entrypoint="install" ^
@@ -94,6 +96,7 @@ docker run -it --rm ^
```powershell
docker run -it --rm `
--publish 20080:20080 `
--volume /var/run/docker.sock:/var/run/docker.sock `
--volume ${pwd}/appwrite:/usr/src/code/appwrite:rw `
--entrypoint="install" `
+1 -1
View File
@@ -64,7 +64,7 @@ return [
[
'$id' => ID::custom('database'),
'type' => Database::VAR_STRING,
'size' => 128,
'size' => 2000,
'required' => false,
'signed' => true,
'array' => false,
+15 -15
View File
@@ -57,21 +57,21 @@
"emails.recovery.thanks": "Thanks,",
"emails.recovery.buttonText": "Reset password",
"emails.recovery.signature": "{{project}} team",
"emails.csvExport.success.subject": "Your CSV export is ready",
"emails.csvExport.success.preview": "Your data export has been completed successfully.",
"emails.csvExport.success.hello": "Hello {{user}},",
"emails.csvExport.success.body": "Your CSV export is ready to download. Click the button below to download your data export.",
"emails.csvExport.success.footer": "This download link will expire in 1 hour.",
"emails.csvExport.success.thanks": "Thanks,",
"emails.csvExport.success.buttonText": "Download CSV",
"emails.csvExport.success.signature": "Appwrite team",
"emails.csvExport.failure.subject": "Your CSV export failed - file too large",
"emails.csvExport.failure.preview": "Your data export failed because the file size exceeds your plan limit.",
"emails.csvExport.failure.hello": "Hello {{user}},",
"emails.csvExport.failure.body": "Your CSV export could not be completed because the export file size ({{size}}MB) exceeds your plan limit. Please consider upgrading your plan or exporting a smaller dataset.",
"emails.csvExport.failure.footer": "If you have any questions, please contact our support team.",
"emails.csvExport.failure.thanks": "Thanks,",
"emails.csvExport.failure.signature": "{{project}} team",
"emails.dataExport.success.subject": "Your {{type}} export is ready",
"emails.dataExport.success.preview": "Your data export has been completed successfully.",
"emails.dataExport.success.hello": "Hello {{user}},",
"emails.dataExport.success.body": "Your {{type}} export is ready to download. Click the button below to download your data export.",
"emails.dataExport.success.footer": "This download link will expire in 1 hour.",
"emails.dataExport.success.thanks": "Thanks,",
"emails.dataExport.success.buttonText": "Download {{type}}",
"emails.dataExport.success.signature": "Appwrite team",
"emails.dataExport.failure.subject": "Your {{type}} export failed - file too large",
"emails.dataExport.failure.preview": "Your data export failed because the file size exceeds your plan limit.",
"emails.dataExport.failure.hello": "Hello {{user}},",
"emails.dataExport.failure.body": "Your {{type}} export could not be completed because the export file size ({{size}}MB) exceeds your plan limit. Please consider upgrading your plan or exporting a smaller dataset.",
"emails.dataExport.failure.footer": "If you have any questions, please contact our support team.",
"emails.dataExport.failure.thanks": "Thanks,",
"emails.dataExport.failure.signature": "{{project}} team",
"emails.invitation.subject": "Invitation to {{team}} Team at {{project}}",
"emails.invitation.preview": "{{owner}} invited you to join {{team}} at {{project}}",
"emails.invitation.hello": "Hello {{user}},",
+8 -2
View File
@@ -28,12 +28,18 @@ use Utopia\Validator\Text;
Http::init()
->groups(['graphql'])
->inject('project')
->inject('user')
->inject('request')
->inject('response')
->inject('authorization')
->action(function (Document $project, Authorization $authorization) {
->action(function (Document $project, User $user, Request $request, Response $response, Authorization $authorization) {
$response->setUser($user);
$request->setUser($user);
if (
array_key_exists('graphql', $project->getAttribute('apis', []))
&& !$project->getAttribute('apis', [])['graphql']
&& !(User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
&& !($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles()))
) {
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
}
+314 -12
View File
@@ -29,6 +29,7 @@ use Utopia\Migration\Resource;
use Utopia\Migration\Sources\Appwrite;
use Utopia\Migration\Sources\CSV;
use Utopia\Migration\Sources\Firebase;
use Utopia\Migration\Sources\JSON;
use Utopia\Migration\Sources\NHost;
use Utopia\Migration\Sources\Supabase;
use Utopia\Migration\Transfer;
@@ -53,6 +54,15 @@ function getDatabaseTransferResourceServices(string $databaseType)
};
}
function getDatabaseResourceType(string $databaseType): string
{
return match($databaseType) {
DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB,
DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB,
default => Resource::TYPE_DATABASE,
};
}
Http::post('/v1/migrations/appwrite')
->groups(['api', 'migrations'])
->desc('Create Appwrite migration')
@@ -447,6 +457,7 @@ Http::post('/v1/migrations/csv/imports')
}
$fileSize = $deviceForMigrations->getFileSize($newPath);
$resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]);
$resourceType = getDatabaseResourceType($databaseType);
$migration = $dbForProject->createDocument('migrations', new Document([
'$id' => $migrationId,
@@ -456,7 +467,7 @@ Http::post('/v1/migrations/csv/imports')
'destination' => Appwrite::getName(),
'resources' => $resources,
'resourceId' => $resourceId,
'resourceType' => Resource::TYPE_DATABASE,
'resourceType' => $resourceType,
'statusCounters' => '{}',
'resourceData' => '{}',
'errors' => [],
@@ -565,16 +576,6 @@ Http::post('/v1/migrations/csv/exports')
throw new Exception(Exception::COLLECTION_NOT_FOUND);
}
$validator = new Documents(
attributes: $collection->getAttribute('attributes', []),
indexes: $collection->getAttribute('indexes', []),
idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(),
);
if (!$validator->isValid($parsedQueries)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
// getting databasetype
$resources = explode(':', $resourceId);
$databaseId = $resources[0];
@@ -583,7 +584,23 @@ Http::post('/v1/migrations/csv/exports')
if (!in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) {
throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv');
}
// Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields
$isSchemaless = in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]);
$validator = new Documents(
attributes: $collection->getAttribute('attributes', []),
indexes: $collection->getAttribute('indexes', []),
idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(),
supportForAttributes: !$isSchemaless,
);
if (!$validator->isValid($parsedQueries)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]);
$resourceType = getDatabaseResourceType($databaseType);
$migration = $dbForProject->createDocument('migrations', new Document([
'$id' => ID::unique(),
@@ -593,7 +610,7 @@ Http::post('/v1/migrations/csv/exports')
'destination' => CSV::getName(),
'resources' => $resources,
'resourceId' => $resourceId,
'resourceType' => Resource::TYPE_DATABASE,
'resourceType' => $resourceType,
'statusCounters' => '{}',
'resourceData' => '{}',
'errors' => [],
@@ -624,6 +641,291 @@ Http::post('/v1/migrations/csv/exports')
->dynamic($migration, Response::MODEL_MIGRATION);
});
Http::post('/v1/migrations/json/imports')
->groups(['api', 'migrations'])
->desc('Import documents from a JSON')
->label('scope', 'migrations.write')
->label('event', 'migrations.[migrationId].create')
->label('audits.event', 'migration.create')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'createJSONImport',
description: '/docs/references/migrations/migration-json-import.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_ACCEPTED,
model: Response::MODEL_MIGRATION,
)
]
))
->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).')
->param('fileId', '', new UID(), 'File ID.')
->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.')
->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true)
->inject('response')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('authorization')
->inject('project')
->inject('platform')
->inject('deviceForFiles')
->inject('deviceForMigrations')
->inject('queueForEvents')
->inject('queueForMigrations')
->action(function (
string $bucketId,
string $fileId,
string $resourceId,
bool $internalFile,
Response $response,
Database $dbForProject,
Database $dbForPlatform,
Authorization $authorization,
Document $project,
array $platform,
Device $deviceForFiles,
Device $deviceForMigrations,
Event $queueForEvents,
Migration $queueForMigrations
) {
$bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) {
if ($internalFile) {
return $dbForPlatform->getDocument('buckets', 'default');
}
return $dbForProject->getDocument('buckets', $bucketId);
});
if ($bucket->isEmpty()) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
}
$file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId));
if ($file->isEmpty()) {
throw new Exception(Exception::STORAGE_FILE_NOT_FOUND);
}
$path = $file->getAttribute('path', '');
if (!$deviceForFiles->exists($path)) {
throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path);
}
// No encryption or compression on files above 20MB.
$hasEncryption = !empty($file->getAttribute('openSSLCipher'));
$compression = $file->getAttribute('algorithm', Compression::NONE);
$hasCompression = $compression !== Compression::NONE;
$migrationId = ID::unique();
$newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.json');
if ($hasEncryption || $hasCompression) {
$source = $deviceForFiles->read($path);
if ($hasEncryption) {
$source = OpenSSL::decrypt(
$source,
$file->getAttribute('openSSLCipher'),
System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')),
0,
hex2bin($file->getAttribute('openSSLIV')),
hex2bin($file->getAttribute('openSSLTag'))
);
}
if ($hasCompression) {
switch ($compression) {
case Compression::ZSTD:
$source = (new Zstd())->decompress($source);
break;
case Compression::GZIP:
$source = (new GZIP())->decompress($source);
break;
}
}
// Manual write after decryption and/or decompression
if (!$deviceForMigrations->write($newPath, $source, 'application/json')) {
throw new \Exception('Unable to copy file');
}
} elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) {
throw new \Exception('Unable to copy file');
}
$fileSize = $deviceForMigrations->getFileSize($newPath);
[$databaseId] = \explode(':', $resourceId, 2);
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty()) {
throw new Exception(Exception::DATABASE_NOT_FOUND);
}
$databaseType = $database->getAttribute('type');
$resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]);
$resourceType = getDatabaseResourceType($databaseType);
$migration = $dbForProject->createDocument('migrations', new Document([
'$id' => $migrationId,
'status' => 'pending',
'stage' => 'init',
'source' => JSON::getName(),
'destination' => Appwrite::getName(),
'resources' => $resources,
'resourceId' => $resourceId,
'resourceType' => $resourceType,
'statusCounters' => '{}',
'resourceData' => '{}',
'errors' => [],
'options' => [
'path' => $newPath,
'size' => $fileSize,
],
]));
$queueForEvents->setParam('migrationId', $migration->getId());
$queueForMigrations
->setMigration($migration)
->setProject($project)
->setPlatform($platform)
->trigger();
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
->dynamic($migration, Response::MODEL_MIGRATION);
});
Http::post('/v1/migrations/json/exports')
->groups(['api', 'migrations'])
->desc('Export documents to JSON')
->label('scope', 'migrations.write')
->label('event', 'migrations.[migrationId].create')
->label('audits.event', 'migration.create')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'createJSONExport',
description: '/docs/references/migrations/migration-json-export.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_ACCEPTED,
model: Response::MODEL_MIGRATION,
)
]
))
->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.')
->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .json extension.')
->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true)
->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true)
->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true)
->inject('user')
->inject('response')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('authorization')
->inject('project')
->inject('platform')
->inject('queueForEvents')
->inject('queueForMigrations')
->action(function (
string $resourceId,
string $filename,
array $columns,
array $queries,
bool $notify,
Document $user,
Response $response,
Database $dbForProject,
Database $dbForPlatform,
Authorization $authorization,
Document $project,
array $platform,
Event $queueForEvents,
Migration $queueForMigrations
) {
try {
$parsedQueries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
$bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default'));
if ($bucket->isEmpty()) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
}
[$databaseId, $collectionId] = \explode(':', $resourceId, 2);
if (empty($databaseId)) {
throw new Exception(Exception::DATABASE_NOT_FOUND);
}
if (empty($collectionId)) {
throw new Exception(Exception::COLLECTION_NOT_FOUND);
}
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty()) {
throw new Exception(Exception::DATABASE_NOT_FOUND);
}
$collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId));
if ($collection->isEmpty()) {
throw new Exception(Exception::COLLECTION_NOT_FOUND);
}
$databaseType = $database->getAttribute('type');
// Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields
$isSchemaless = in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]);
$validator = new Documents(
attributes: $collection->getAttribute('attributes', []),
indexes: $collection->getAttribute('indexes', []),
idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(),
supportForAttributes: !$isSchemaless,
);
if (!$validator->isValid($parsedQueries)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]);
$resourceType = getDatabaseResourceType($databaseType);
$migration = $dbForProject->createDocument('migrations', new Document([
'$id' => ID::unique(),
'status' => 'pending',
'stage' => 'init',
'source' => Appwrite::getName(),
'destination' => JSON::getName(),
'resources' => $resources,
'resourceId' => $resourceId,
'resourceType' => $resourceType,
'statusCounters' => '{}',
'resourceData' => '{}',
'errors' => [],
'options' => [
'bucketId' => 'default', // Always use internal bucket
'filename' => $filename,
'columns' => $columns,
'queries' => $queries,
'notify' => $notify,
'userInternalId' => $user->getSequence(),
],
]));
$queueForEvents->setParam('migrationId', $migration->getId());
$queueForMigrations
->setMigration($migration)
->setProject($project)
->setPlatform($platform)
->trigger();
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
->dynamic($migration, Response::MODEL_MIGRATION);
});
Http::get('/v1/migrations')
->groups(['api', 'migrations'])
->desc('List migrations')
+10 -1
View File
@@ -1267,7 +1267,16 @@ Http::error()
* If not a publishable error, track usage stats. Publishable errors are >= 500 or those explicitly marked as publish=true in errors.php
*/
if (!$publish && $project->getId() !== 'console') {
if (!DBUser::isPrivileged($authorization->getRoles())) {
$errorUser = new DBUser();
try {
$resolvedUser = $utopia->getResource('user');
if ($resolvedUser instanceof DBUser) {
$errorUser = $resolvedUser;
}
} catch (\Throwable) {
// User resource may not be available in error context
}
if (!$errorUser->isPrivileged($authorization->getRoles())) {
$bus->dispatch(new RequestCompleted(
project: $project->getArrayCopy(),
request: $request,
+14 -11
View File
@@ -96,7 +96,7 @@ Http::init()
->inject('team')
->inject('apiKey')
->inject('authorization')
->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, Audit $queueForAudits, Document $project, Document $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) {
->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, Audit $queueForAudits, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) {
$route = $utopia->getRoute();
/**
@@ -419,7 +419,7 @@ Http::init()
if (
array_key_exists($namespace, $project->getAttribute('services', []))
&& ! $project->getAttribute('services', [])[$namespace]
&& ! (User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
&& ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles()))
) {
throw new Exception(Exception::GENERAL_SERVICE_DISABLED);
}
@@ -483,7 +483,10 @@ Http::init()
->inject('telemetry')
->inject('platform')
->inject('authorization')
->action(function (Http $utopia, Request $request, Response $response, Document $project, Document $user, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) {
->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) {
$response->setUser($user);
$request->setUser($user);
$route = $utopia->getRoute();
$path = $route->getMatchedPath();
@@ -496,7 +499,7 @@ Http::init()
if (
array_key_exists('rest', $project->getAttribute('apis', []))
&& ! $project->getAttribute('apis', [])['rest']
&& ! (User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
&& ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles()))
) {
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
}
@@ -528,8 +531,8 @@ Http::init()
$closestLimit = null;
$roles = $authorization->getRoles();
$isPrivilegedUser = User::isPrivileged($roles);
$isAppUser = User::isApp($roles);
$isPrivilegedUser = $user->isPrivileged($roles);
$isAppUser = $user->isApp($roles);
foreach ($timeLimitArray as $timeLimit) {
foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys
@@ -611,7 +614,7 @@ Http::init()
if ($useCache) {
$route = $utopia->match($request);
$isImageTransformation = $route->getPath() === '/v1/storage/buckets/:bucketId/files/:fileId/preview';
$isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && ! User::isPrivileged($authorization->getRoles());
$isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && ! $user->isPrivileged($authorization->getRoles());
$key = $request->cacheIdentifier();
$cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key));
@@ -630,7 +633,7 @@ Http::init()
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isToken = ! $resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence();
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (! $bucket->getAttribute('enabled') && ! $isAppUser && ! $isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
@@ -663,7 +666,7 @@ Http::init()
throw new Exception(Exception::STORAGE_FILE_NOT_FOUND);
}
// Do not update transformedAt if it's a console user
if (! User::isPrivileged($authorization->getRoles())) {
if (! $user->isPrivileged($authorization->getRoles())) {
$transformedAt = $file->getAttribute('transformedAt', '');
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) {
$file->setAttribute('transformedAt', DateTime::now());
@@ -697,7 +700,7 @@ Http::init()
->groups(['session'])
->inject('user')
->inject('request')
->action(function (Document $user, Request $request) {
->action(function (User $user, Request $request) {
if (\str_contains($request->getURI(), 'oauth2')) {
return;
}
@@ -984,7 +987,7 @@ Http::shutdown()
}
if ($project->getId() !== 'console') {
if (! User::isPrivileged($authorization->getRoles())) {
if (! $user->isPrivileged($authorization->getRoles())) {
$bus->dispatch(new RequestCompleted(
project: $project->getArrayCopy(),
request: $request,
+4 -3
View File
@@ -36,8 +36,9 @@ Http::init()
->inject('request')
->inject('project')
->inject('geodb')
->inject('user')
->inject('authorization')
->action(function (Http $utopia, Request $request, Document $project, Reader $geodb, Authorization $authorization) {
->action(function (Http $utopia, Request $request, Document $project, Reader $geodb, User $user, Authorization $authorization) {
$denylist = System::getEnv('_APP_CONSOLE_COUNTRIES_DENYLIST', '');
if (!empty($denylist && $project->getId() === 'console')) {
$countries = explode(',', $denylist);
@@ -50,8 +51,8 @@ Http::init()
$route = $utopia->match($request);
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAppUser = User::isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$isAppUser = $user->isApp($authorization->getRoles());
if ($isAppUser || $isPrivilegedUser) { // Skip limits for app and console devs
return;
+1 -1
View File
@@ -119,7 +119,7 @@ function dispatch(\Swoole\Http\Server $server, int $fd, int $type, $data = null)
$lines = explode("\n", $data, 3);
$request = $lines[0];
if (count($lines) > 1) {
$domain = trim(explode('Host: ', $lines[1])[1]);
$domain = trim(explode('Host: ', $lines[1])[1] ?? '');
}
// Sync executions are considered risky
+406
View File
@@ -90,6 +90,412 @@ $container->set('platform', function () {
return Config::getParam('platform', []);
}, []);
/**
* List of allowed request hostnames for the request.
*/
Http::setResource('allowedHostnames', function (array $platform, Document $project, Document $rule, Document $devKey, Request $request) {
$allowed = [...($platform['hostnames'] ?? [])];
/* Add platform configured hostnames */
if (! $project->isEmpty() && $project->getId() !== 'console') {
$platforms = $project->getAttribute('platforms', []);
$hostnames = Platform::getHostnames($platforms);
$allowed = [...$allowed, ...$hostnames];
}
/* Add the request hostname if a dev key is found */
if (! $devKey->isEmpty()) {
$allowed[] = $request->getHostname();
}
$originHostname = parse_url($request->getOrigin(), PHP_URL_HOST);
$refererHostname = parse_url($request->getReferer(), PHP_URL_HOST);
$hostname = $originHostname;
if (empty($hostname)) {
$hostname = $refererHostname;
}
/* Add request hostname for preflight requests */
if ($request->getMethod() === 'OPTIONS') {
$allowed[] = $hostname;
}
/* Allow the request origin of rule */
if (! $rule->isEmpty() && ! empty($rule->getAttribute('domain', ''))) {
$allowed[] = $rule->getAttribute('domain', '');
}
/* Allow the request origin if a dev key is found */
if (! $devKey->isEmpty() && ! empty($hostname)) {
$allowed[] = $hostname;
}
return array_unique($allowed);
}, ['platform', 'project', 'rule', 'devKey', 'request']);
/**
* List of allowed request schemes for the request.
*/
Http::setResource('allowedSchemes', function (array $platform, Document $project) {
$allowed = [...($platform['schemas'] ?? [])];
if (! $project->isEmpty() && $project->getId() !== 'console') {
/* Add hardcoded schemes */
$allowed[] = 'exp';
$allowed[] = 'appwrite-callback-' . $project->getId();
/* Add platform configured schemes */
$platforms = $project->getAttribute('platforms', []);
$schemes = Platform::getSchemes($platforms);
$allowed = [...$allowed, ...$schemes];
}
return array_unique($allowed);
}, ['platform', 'project']);
/**
* Rule associated with a request origin.
*/
Http::setResource('rule', function (Request $request, Database $dbForPlatform, Document $project, Authorization $authorization) {
$domain = \parse_url($request->getOrigin(), PHP_URL_HOST);
if (empty($domain)) {
$domain = \parse_url($request->getReferer(), PHP_URL_HOST);
}
if (empty($domain)) {
return new Document();
}
// TODO: (@Meldiron) Remove after 1.7.x migration
$isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5';
$rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) {
if ($isMd5) {
return $dbForPlatform->getDocument('rules', md5($domain));
}
return $dbForPlatform->findOne('rules', [
Query::equal('domain', [$domain]),
]) ?? new Document();
});
$permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence();
// Temporary implementation until custom wildcard domains are an official feature
// Allow trusted projects; Used for Console (website) previews
if (! $permitsCurrentProject && ! $rule->isEmpty() && ! empty($rule->getAttribute('projectId', ''))) {
$trustedProjects = [];
foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) {
if (empty($trustedProject)) {
continue;
}
$trustedProjects[] = $trustedProject;
}
if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects)) {
$permitsCurrentProject = true;
}
}
if (! $permitsCurrentProject) {
return new Document();
}
return $rule;
}, ['request', 'dbForPlatform', 'project', 'authorization']);
/**
* CORS service
*/
Http::setResource('cors', function (array $allowedHostnames) {
$corsConfig = Config::getParam('cors');
return new Cors(
$allowedHostnames,
allowedMethods: $corsConfig['allowedMethods'],
allowedHeaders: $corsConfig['allowedHeaders'],
allowCredentials: true,
exposedHeaders: $corsConfig['exposedHeaders'],
);
}, ['allowedHostnames']);
Http::setResource('originValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) {
if (! $devKey->isEmpty()) {
return new URL();
}
return new Origin($allowedHostnames, $allowedSchemes);
}, ['devKey', 'allowedHostnames', 'allowedSchemes']);
Http::setResource('redirectValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) {
if (! $devKey->isEmpty()) {
return new URL();
}
return new Redirect($allowedHostnames, $allowedSchemes);
}, ['devKey', 'allowedHostnames', 'allowedSchemes']);
Http::setResource('user', function (string $mode, Document $project, Document $console, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Store $store, Token $proofForToken, $authorization) {
/**
* Handles user authentication and session validation.
*
* This function follows a series of steps to determine the appropriate user session
* based on cookies, headers, and JWT tokens.
*
* Process:
* 1. Checks the cookie based on mode:
* - If in admin mode, uses console project id for key.
* - Otherwise, sets the key using the project ID
* 2. If no cookie is found, attempts to retrieve the fallback header `x-fallback-cookies`.
* - If this method is used, returns the header: `X-Debug-Fallback: true`.
* 3. Fetches the user document from the appropriate database based on the mode.
* 4. If the user document is empty or the session key cannot be verified, sets an empty user document.
* 5. Regardless of the results from steps 1-4, attempts to fetch the JWT token.
* 6. If the JWT user has a valid session ID, updates the user variable with the user from `projectDB`,
* overwriting the previous value.
* 7. If account API key is passed, use user of the account API key as long as user ID header matches too
*/
$authorization->setDefaultStatus(true);
$store->setKey('a_session_' . $project->getId());
if ($mode === APP_MODE_ADMIN) {
$store->setKey('a_session_' . $console->getId());
}
$store->decode(
$request->getCookie(
$store->getKey(), // Get sessions
$request->getCookie($store->getKey() . '_legacy', '')
)
);
// Get session from header for SSR clients
if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
$sessionHeader = $request->getHeader('x-appwrite-session', '');
if (! empty($sessionHeader)) {
$store->decode($sessionHeader);
}
}
// Get fallback session from old clients (no SameSite support) or clients who block 3rd-party cookies
if ($response) { // if in http context - add debug header
$response->addHeader('X-Debug-Fallback', 'false');
}
if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
if ($response) {
$response->addHeader('X-Debug-Fallback', 'true');
}
$fallback = $request->getHeader('x-fallback-cookies', '');
$fallback = \json_decode($fallback, true);
$store->decode(((is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : ''));
}
$user = null;
if ($mode === APP_MODE_ADMIN) {
/** @var User $user */
$user = $dbForPlatform->getDocument('users', $store->getProperty('id', ''));
} else {
if ($project->isEmpty()) {
$user = new User([]);
} else {
if (! empty($store->getProperty('id', ''))) {
if ($project->getId() === 'console') {
/** @var User $user */
$user = $dbForPlatform->getDocument('users', $store->getProperty('id', ''));
} else {
/** @var User $user */
$user = $dbForProject->getDocument('users', $store->getProperty('id', ''));
}
}
}
}
if (
! $user ||
$user->isEmpty() // Check a document has been found in the DB
|| ! $user->sessionVerify($store->getProperty('secret', ''), $proofForToken)
) { // Validate user has valid login token
$user = new User([]);
}
$authJWT = $request->getHeader('x-appwrite-jwt', '');
if (! empty($authJWT) && ! $project->isEmpty()) { // JWT authentication
if (! $user->isEmpty()) {
throw new Exception(Exception::USER_JWT_AND_COOKIE_SET);
}
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0);
try {
$payload = $jwt->decode($authJWT);
} catch (JWTException $error) {
throw new Exception(Exception::USER_JWT_INVALID, 'Failed to verify JWT. ' . $error->getMessage());
}
$jwtUserId = $payload['userId'] ?? '';
if (! empty($jwtUserId)) {
if ($mode === APP_MODE_ADMIN) {
/** @var User $user */
$user = $dbForPlatform->getDocument('users', $jwtUserId);
} else {
/** @var User $user */
$user = $dbForProject->getDocument('users', $jwtUserId);
}
}
$jwtSessionId = $payload['sessionId'] ?? '';
if (! empty($jwtSessionId)) {
if (empty($user->find('$id', $jwtSessionId, 'sessions'))) { // Match JWT to active token
$user = new User([]);
}
}
}
// Account based on account API key
$accountKey = $request->getHeader('x-appwrite-key', '');
$accountKeyUserId = $request->getHeader('x-appwrite-user', '');
if (! empty($accountKeyUserId) && ! empty($accountKey)) {
if (! $user->isEmpty()) {
throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET);
}
/** @var User $accountKeyUser */
$accountKeyUser = $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId));
if (! $accountKeyUser->isEmpty()) {
$key = $accountKeyUser->find(
key: 'secret',
find: $accountKey,
subject: 'keys'
);
if (! empty($key)) {
$expire = $key->getAttribute('expire');
if (! empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
throw new Exception(Exception::ACCOUNT_KEY_EXPIRED);
}
$user = $accountKeyUser;
}
}
}
// Impersonation: if current user has impersonator capability and headers are set, act as another user
$impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', '');
$impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', '');
$impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', '');
if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) {
$userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject;
$targetUser = null;
if (!empty($impersonateUserId)) {
$targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->getDocument('users', $impersonateUserId));
} elseif (!empty($impersonateEmail)) {
$targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->findOne('users', [Query::equal('email', [\strtolower($impersonateEmail)])]));
} elseif (!empty($impersonatePhone)) {
$targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->findOne('users', [Query::equal('phone', [$impersonatePhone])]));
}
if ($targetUser !== null && !$targetUser->isEmpty()) {
$impersonator = clone $user;
$user = clone $targetUser;
$user->setAttribute('impersonatorUserId', $impersonator->getId());
$user->setAttribute('impersonatorUserInternalId', $impersonator->getSequence());
$user->setAttribute('impersonatorUserName', $impersonator->getAttribute('name', ''));
$user->setAttribute('impersonatorUserEmail', $impersonator->getAttribute('email', ''));
$user->setAttribute('impersonatorAccessedAt', $impersonator->getAttribute('accessedAt', 0));
}
}
$dbForProject->setMetadata('user', $user->getId());
$dbForPlatform->setMetadata('user', $user->getId());
return $user;
}, ['mode', 'project', 'console', 'request', 'response', 'dbForProject', 'dbForPlatform', 'store', 'proofForToken', 'authorization']);
Http::setResource('project', function ($dbForPlatform, $request, $console, $authorization, Http $utopia) {
/** @var Appwrite\Utopia\Request $request */
/** @var Utopia\Database\Database $dbForPlatform */
/** @var Utopia\Database\Document $console */
$projectId = $request->getParam('project', $request->getHeader('x-appwrite-project', ''));
// Realtime channel "project" can send project=Query array
if (! \is_string($projectId)) {
$projectId = $request->getHeader('x-appwrite-project', '');
}
// Backwards compatibility for new services, originally project resources
// These endpoints moved from /v1/projects/:projectId/<resource> to /v1/<resource>
// When accessed via the old alias path, extract projectId from the URI
$deprecatedProjectPathPrefix = '/v1/projects/';
$route = $utopia->match($request);
if (!empty($route)) {
$isDeprecatedAlias = \str_starts_with($request->getURI(), $deprecatedProjectPathPrefix) &&
!\str_starts_with($route->getPath(), $deprecatedProjectPathPrefix);
if ($isDeprecatedAlias) {
$projectId = \explode('/', $request->getURI(), 5)[3] ?? '';
}
}
if (empty($projectId) || $projectId === 'console') {
return $console;
}
$project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
return $project;
}, ['dbForPlatform', 'request', 'console', 'authorization', 'utopia']);
Http::setResource('session', function (User $user, Store $store, Token $proofForToken) {
if ($user->isEmpty()) {
return;
}
$sessions = $user->getAttribute('sessions', []);
$sessionId = $user->sessionVerify($store->getProperty('secret', ''), $proofForToken);
if (! $sessionId) {
return;
}
foreach ($sessions as $session) {
/** @var Document $session */
if ($sessionId === $session->getId()) {
return $session;
}
}
}, ['user', 'store', 'proofForToken']);
Http::setResource('store', function (): Store {
return new Store();
});
Http::setResource('proofForPassword', function (): Password {
$hash = new Argon2();
$hash
->setMemoryCost(7168)
->setTimeCost(5)
->setThreads(1);
$password = new Password();
$password
->setHash($hash);
return $password;
});
Http::setResource('proofForToken', function (): Token {
$token = new Token();
$token->setHash(new Sha());
return $token;
});
Http::setResource('proofForCode', function (): Code {
$code = new Code();
$code->setHash(new Sha());
return $code;
});
$container->set('console', function () {
return new Document(Config::getParam('console'));
}, []);
+6 -6
View File
@@ -527,7 +527,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
$project = $consoleDatabase->getAuthorization()->skip(fn () => $consoleDatabase->getDocument('projects', $projectId));
$database = getProjectDB($project);
/** @var Appwrite\Utopia\Database\Documents\User $user */
/** @var User $user */
$user = $database->getDocument('users', $userId);
$roles = $user->getRoles($database->getAuthorization());
@@ -657,10 +657,14 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing or unknown project ID');
}
$timelimit = $app->getResource('timelimit');
$user = $app->getResource('user'); /** @var User $user */
$logUser = $user;
if (
array_key_exists('realtime', $project->getAttribute('apis', []))
&& !$project->getAttribute('apis', [])['realtime']
&& !(User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
&& !($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles()))
) {
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
}
@@ -671,10 +675,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Project is not accessible in this region. Please make sure you are using the correct endpoint');
}
$timelimit = $app->getResource('timelimit');
$user = $app->getResource('user'); /** @var User $user */
$logUser = $user;
/*
* Abuse Check
*
+1 -1
View File
@@ -13,7 +13,7 @@ $enabledDatabases = $enabledDatabases ?? ['mongodb', 'mariadb', 'postgresql'];
$isLocalInstall = $isLocalInstall ?? false;
$cardStep = min(4, $step);
$cardStep = ($step === 5) ? 4 : $step;
$stepFile = __DIR__ . "/installer/templates/steps/step-{$cardStep}.phtml";
if (!is_file($stepFile)) {
$stepFile = __DIR__ . "/installer/templates/steps/step-1.phtml";
@@ -478,6 +478,10 @@ body {
overflow: hidden;
}
.installer-page[data-upgrade='true'] .installer-step {
min-height: 0;
}
.action-shell {
display: flex;
flex-direction: column;
@@ -1808,3 +1812,92 @@ body {
gap: var(--gap-s);
}
}
.migration-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap-l);
padding: var(--space-6);
background: var(--bgcolor-neutral-default);
border-radius: var(--border-radius-m);
outline: var(--border-width-s) solid var(--border-neutral);
outline-offset: calc(var(--border-width-s) * -1);
cursor: pointer;
transition: outline-color 0.15s ease-in-out;
}
.migration-option:hover {
outline-color: var(--border-neutral-stronger);
}
.migration-option-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.migration-switch {
flex-shrink: 0;
}
.migration-switch-track {
position: relative;
display: block;
width: 32px;
height: 20px;
border-radius: 10px;
background: var(--bgcolor-neutral-invert-weaker);
transition: background 0.15s ease-in-out;
}
.migration-switch-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--bgcolor-neutral-primary);
transition: transform 0.15s ease-in-out;
}
#run-migration:checked ~ .migration-switch-track {
background: var(--bgcolor-neutral-invert-weak);
}
#run-migration:checked ~ .migration-switch-track .migration-switch-thumb {
transform: translateX(12px);
}
#run-migration:focus-visible ~ .migration-switch-track {
box-shadow: 0 0 0 var(--border-width-l) var(--border-focus);
}
.migration-hint {
display: flex;
align-items: flex-start;
gap: var(--gap-s);
padding: 0 var(--space-2);
}
.migration-hint-icon {
flex-shrink: 0;
width: 16px;
height: 16px;
color: var(--fgcolor-neutral-tertiary);
margin-top: 1px;
}
.migration-hint-icon svg {
width: 100%;
height: 100%;
}
.migration-code {
padding: 1px 4px;
border-radius: var(--border-radius-xs, 4px);
background: var(--bgcolor-neutral-secondary);
font-family: monospace;
font-size: inherit;
}
+13 -6
View File
@@ -12,7 +12,7 @@
const { validateInstallRequest } = window.InstallerStepsProgress || {};
const isUpgrade = document.body?.dataset.upgrade === 'true';
const stepFlow = isUpgrade ? [1, 4, 5] : [1, 2, 3, 4, 5];
const stepFlow = isUpgrade ? [1, 6, 4, 5] : [1, 2, 3, 4, 5];
const cardSteps = stepFlow.filter((step) => step !== 5);
const normalizeStep = (step) => {
@@ -53,7 +53,7 @@
let pendingStep = null;
let pendingPushState = false;
const clampStep = (step) => Math.max(1, Math.min(5, step));
const clampStep = (step) => Math.max(1, Math.min(6, step));
const isInstallLocked = () => Boolean(window.InstallerSteps?.isInstallLocked?.());
const scrollToFirstError = (panel) => {
@@ -399,11 +399,18 @@
}
}
}
if (action === 'next' && String(target) === '5' && typeof validateInstallRequest === 'function') {
const isValid = await validateInstallRequest();
if (!isValid) {
return;
if (action === 'next' && String(target) === '5') {
if (typeof validateInstallRequest === 'function') {
const isValid = await validateInstallRequest();
if (!isValid) {
return;
}
}
// Clear stale install data from previous runs so initStep5
// starts a fresh install instead of trying to resume.
const { clearInstallLock, clearInstallId } = window.InstallerStepsState || {};
clearInstallLock?.();
clearInstallId?.();
}
if (isInstallLocked() && Number(target) !== 5) {
requestStep(5, true);
@@ -14,6 +14,7 @@
ENV_VARS: 'env-vars',
DOCKER_CONTAINERS: 'docker-containers',
ACCOUNT_SETUP: 'account-setup',
MIGRATION: 'migration',
SSL_CERTIFICATE: 'ssl-certificate',
REDIRECT: 'redirect'
});
@@ -52,6 +53,11 @@
id: STEP_IDS.DOCKER_CONTAINERS,
inProgress: 'Restarting Docker containers...',
done: 'Docker containers restarted'
},
{
id: STEP_IDS.MIGRATION,
inProgress: 'Running database migration...',
done: 'Database migration completed'
}
] : [
{
@@ -95,7 +101,7 @@
const clampStep = (step) => {
const numeric = Number(step);
if (Number.isNaN(numeric)) return 1;
return Math.max(1, Math.min(5, numeric));
return Math.max(1, Math.min(6, numeric));
};
window.InstallerStepsContext = Object.freeze({
@@ -373,7 +373,8 @@
opensslKey: (formState?.opensslKey || '').trim(),
assistantOpenAIKey: normalizedAssistantKey,
accountEmail: normalizedAccountEmail,
accountPassword: normalizedAccountPassword
accountPassword: normalizedAccountPassword,
migrate: formState?.migrate ?? false
};
};
@@ -721,7 +722,8 @@
});
startSyncedSpinnerRotation(list);
notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => {
const completeId = activeInstall?.installId || getStoredInstallId?.();
notifyInstallComplete(completeId, sessionDetails).finally(() => {
setTimeout(() => redirectToApp(protocol), TIMINGS?.redirectDelay ?? 0);
});
};
@@ -911,21 +913,28 @@
};
const isSnapshotTerminal = (snapshot) => {
if (!snapshot?.steps) return true;
if (!snapshot?.steps) return 'empty';
const stepEntries = Object.values(snapshot.steps);
if (stepEntries.length === 0) return true;
if (stepEntries.length === 0) return 'empty';
const hasError = stepEntries.some((s) => s.status === STATUS.ERROR);
if (hasError) return true;
if (hasError) return 'error';
const allCompleted = INSTALLATION_STEPS.every((step) => {
const detail = snapshot.steps[step.id];
return detail && detail.status === STATUS.COMPLETED;
});
return allCompleted;
if (allCompleted) return 'completed';
return false;
};
const resumeInstall = async (installId) => {
const snapshot = await fetchInstallStatus(installId);
if (!snapshot || isSnapshotTerminal(snapshot)) return false;
const terminal = isSnapshotTerminal(snapshot);
if (!snapshot || terminal) {
if (terminal === 'completed') {
return 'completed';
}
return false;
}
activeInstall = {
installId,
controller: new AbortController(),
@@ -1069,14 +1078,33 @@
startInstallStream(newInstallId);
};
const recoverToLastStep = () => {
clearInstallId?.();
clearInstallLock?.();
const url = new URL(window.location.href);
const lastStep = url.searchParams.get('step');
// Stay on the current URL so the user keeps their place;
// only navigate away if we're already on step 5 (the
// progress screen) since there's nothing to show.
if (!lastStep || String(lastStep) === '5') {
window.location.href = '/?step=1';
}
};
const lock = getInstallLock?.();
const existingInstallId = lock?.installId || getStoredInstallId?.();
if (existingInstallId) {
resumeInstall(existingInstallId).then((resumed) => {
if (!resumed) {
clearInstallId?.();
resumeInstall(existingInstallId).then((result) => {
if (result === 'completed') {
// Install already finished — redirect to console
// instead of bouncing back to step 1.
stopSyncedSpinnerRotation();
setUnloadGuard(false);
clearInstallLock?.();
window.location.href = '/?step=1';
clearInstallId?.();
startSslCheck(null);
} else if (!result) {
recoverToLastStep();
}
});
} else {
+25
View File
@@ -329,6 +329,30 @@
}
};
const initStep6 = (root) => {
if (!root) return;
syncInstallLockFlag?.();
applyLockPayload?.();
applyBodyDefaults?.();
const checkbox = root.querySelector('#run-migration');
if (checkbox) {
if (formState.migrate !== undefined) {
checkbox.checked = formState.migrate;
} else {
formState.migrate = checkbox.checked;
}
checkbox.addEventListener('change', () => {
formState.migrate = checkbox.checked;
dispatchStateChange?.('migrate');
});
}
if (isInstallLocked?.()) {
disableControls?.(root);
}
};
const initStep = (step, container) => {
if (!container) return;
const root = container.querySelector('.step-layout') || container;
@@ -346,6 +370,7 @@
if (normalized === 3) initStep3(root);
if (normalized === 4) initStep4(root);
if (normalized === 5) Progress.initStep5?.(root);
if (normalized === 6) initStep6(root);
};
window.InstallerSteps = {
@@ -62,12 +62,14 @@ $badgeClass = $defaultSecretKey !== '' ? 'badge-success' : 'badge-warning';
<span class="badge badge-neutral typography-text-xs-400" data-review-assistant-badge>Disabled</span>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Appwrite Assistant</div>
</div>
<?php if (!$isUpgrade) { ?>
<div class="review-row">
<span class="badge <?php echo $badgeClass; ?> typography-text-xs-400" data-review-badge>
<?php echo htmlspecialchars((string) $badgeLabel, ENT_QUOTES, 'UTF-8'); ?>
</span>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Secret API key</div>
</div>
<?php } ?>
</div>
</div>
</div>
@@ -6,7 +6,7 @@ $isUpgrade = $isUpgrade ?? false;
<div class="install-panel">
<div class="install-header">
<div class="typography-text-m-400 text-neutral-primary">
<?php echo $isUpgrade ? 'Updating your app…' : 'Installing your app…'; ?>
<?php echo $isUpgrade ? 'Updating Appwrite…' : 'Installing Appwrite…'; ?>
</div>
</div>
<div class="install-list" data-install-list></div>
@@ -0,0 +1,37 @@
<?php
$isUpgrade = $isUpgrade ?? false;
?>
<div class="step-layout" data-step="6">
<div class="stack-xl">
<div class="stack-xxxs">
<h1 class="typography-title-s text-neutral-primary">Database migration</h1>
<p class="typography-text-m-400 text-neutral-secondary">
Run database migration after the update to apply schema changes.
</p>
</div>
<div class="stack-xl">
<label class="migration-option" for="run-migration">
<span class="migration-option-content">
<span class="typography-text-m-500 text-neutral-primary">Run migration automatically</span>
<span class="typography-text-xs-400 text-neutral-tertiary">Recommended when upgrading to a new version</span>
</span>
<span class="migration-switch">
<input type="checkbox" id="run-migration" name="migrate" class="sr-only" checked>
<span class="migration-switch-track" aria-hidden="true">
<span class="migration-switch-thumb"></span>
</span>
</span>
</label>
<div class="migration-hint">
<span class="migration-hint-icon">
<?php include __DIR__ . '/../../icons/info.svg'; ?>
</span>
<span class="typography-text-xs-400 text-neutral-tertiary">
To run manually later: <code class="migration-code">docker compose exec appwrite migrate</code>
</span>
</div>
</div>
</div>
</div>
+1 -1
View File
@@ -73,7 +73,7 @@
"utopia-php/locale": "0.8.*",
"utopia-php/logger": "0.6.*",
"utopia-php/messaging": "0.20.*",
"utopia-php/migration": "1.8.*",
"utopia-php/migration": "1.9.*",
"utopia-php/platform": "0.11.*",
"utopia-php/pools": "1.*",
"utopia-php/span": "1.1.*",
Generated
+12 -12
View File
@@ -161,16 +161,16 @@
},
{
"name": "appwrite/php-runtimes",
"version": "0.19.4",
"version": "0.19.5",
"source": {
"type": "git",
"url": "https://github.com/appwrite/runtimes.git",
"reference": "eea9d1b3ca2540eab623b419c8afde09ef406c0b"
"reference": "aa2f7760cd0493c0880209b92df812c9386b3546"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/appwrite/runtimes/zipball/eea9d1b3ca2540eab623b419c8afde09ef406c0b",
"reference": "eea9d1b3ca2540eab623b419c8afde09ef406c0b",
"url": "https://api.github.com/repos/appwrite/runtimes/zipball/aa2f7760cd0493c0880209b92df812c9386b3546",
"reference": "aa2f7760cd0493c0880209b92df812c9386b3546",
"shasum": ""
},
"require": {
@@ -210,9 +210,9 @@
],
"support": {
"issues": "https://github.com/appwrite/runtimes/issues",
"source": "https://github.com/appwrite/runtimes/tree/0.19.4"
"source": "https://github.com/appwrite/runtimes/tree/0.19.5"
},
"time": "2026-02-17T10:04:39+00:00"
"time": "2026-04-01T01:39:23+00:00"
},
{
"name": "brick/math",
@@ -4580,16 +4580,16 @@
},
{
"name": "utopia-php/migration",
"version": "1.8.3",
"version": "1.9.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
"reference": "8633523b3343d492427331b6eec53f020f6ab7a7"
"reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/8633523b3343d492427331b6eec53f020f6ab7a7",
"reference": "8633523b3343d492427331b6eec53f020f6ab7a7",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/7a86aeadf182b63a9f4ceba7e137588b31c5d2e2",
"reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2",
"shasum": ""
},
"require": {
@@ -4629,9 +4629,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/migration/issues",
"source": "https://github.com/utopia-php/migration/tree/1.8.3"
"source": "https://github.com/utopia-php/migration/tree/1.9.1"
},
"time": "2026-03-19T09:18:47+00:00"
"time": "2026-03-25T07:05:27+00:00"
},
{
"name": "utopia-php/mongo",
+1 -1
View File
@@ -1 +1 @@
Get the number of audit logs that are waiting to be processed in the Appwrite internal queue server.
Get the number of audit logs that are waiting to be processed in the Appwrite internal queue server.
+14 -421
View File
@@ -120,6 +120,12 @@ parameters:
count: 1
path: app/controllers/shared/api.php
-
message: '#^Variable \$register might not be defined\.$#'
identifier: variable.undefined
count: 3
path: app/http.php
-
message: '#^Variable \$tag on left side of \?\? always exists and is always null\.$#'
identifier: nullCoalesce.variable
@@ -276,6 +282,12 @@ parameters:
count: 1
path: src/Appwrite/Functions/EventProcessor.php
-
message: '#^Binary operation "\*" between \-1 and string results in an error\.$#'
identifier: binaryOp.invalid
count: 3
path: app/worker.php
-
message: '#^Anonymous function has an unused use \$context\.$#'
identifier: closure.unusedUse
@@ -348,30 +360,6 @@ parameters:
count: 1
path: src/Appwrite/GraphQL/Types/Mapper.php
-
message: '#^Unsafe access to private property Appwrite\\GraphQL\\Types\\Mapper\:\:\$models through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 3
path: src/Appwrite/GraphQL/Types/Mapper.php
-
message: '#^Unsafe call to private method Appwrite\\GraphQL\\Types\\Mapper\:\:getColumnImplementation\(\) through static\:\:\.$#'
identifier: staticClassAccess.privateMethod
count: 2
path: src/Appwrite/GraphQL/Types/Mapper.php
-
message: '#^Unsafe call to private method Appwrite\\GraphQL\\Types\\Mapper\:\:getHashOptionsImplementation\(\) through static\:\:\.$#'
identifier: staticClassAccess.privateMethod
count: 1
path: src/Appwrite/GraphQL/Types/Mapper.php
-
message: '#^Unsafe call to private method Appwrite\\GraphQL\\Types\\Mapper\:\:getUnionImplementation\(\) through static\:\:\.$#'
identifier: staticClassAccess.privateMethod
count: 1
path: src/Appwrite/GraphQL/Types/Mapper.php
-
message: '#^Call to an undefined method Appwrite\\Migration\\Version\\V15\:\:documentsIterator\(\)\.$#'
identifier: method.notFound
@@ -438,12 +426,6 @@ parameters:
count: 1
path: src/Appwrite/OpenSSL/OpenSSL.php
-
message: '#^PHPDoc tag @param references unknown parameter\: \$projectId$#'
identifier: parameter.notFound
count: 1
path: src/Appwrite/Platform/Action.php
-
message: '#^Variable \$output in empty\(\) always exists and is not falsy\.$#'
identifier: empty.variable
@@ -1128,12 +1110,6 @@ parameters:
count: 1
path: src/Appwrite/SDK/Specification/Format/Swagger2.php
-
message: '#^PHPDoc tag @var above a method has no effect\.$#'
identifier: varTag.misplaced
count: 2
path: src/Appwrite/Template/Template.php
-
message: '#^PHPDoc tag @param has invalid value \(Document \$this\)\: Unexpected token "\$this", expected variable at offset 69 on line 4$#'
identifier: phpDoc.parseError
@@ -1146,35 +1122,6 @@ parameters:
count: 1
path: src/Appwrite/Utopia/Database/Documents/User.php
-
message: '#^Unsafe call to private method Appwrite\\Utopia\\Request\\Filters\\V17\:\:appendSymbol\(\) through static\:\:\.$#'
identifier: staticClassAccess.privateMethod
count: 4
path: src/Appwrite/Utopia/Request/Filters/V17.php
-
message: '#^Unsafe call to private method Appwrite\\Utopia\\Request\\Filters\\V17\:\:isSpecialChar\(\) through static\:\:\.$#'
identifier: staticClassAccess.privateMethod
count: 1
path: src/Appwrite/Utopia/Request/Filters/V17.php
-
message: '#^PHPDoc tag @param has invalid value \(callable The callback to show sensitive information for\)\: Unexpected token "The", expected variable at offset 91 on line 4$#'
identifier: phpDoc.parseError
count: 1
path: src/Appwrite/Utopia/Response.php
-
message: '#^PHPDoc tag @return with type Appwrite\\Utopia\\Response\\Filter is incompatible with native type array\.$#'
identifier: return.phpDocType
count: 1
path: src/Appwrite/Utopia/Response.php
-
message: '#^PHPDoc tag @return with type string is incompatible with native type Utopia\\Database\\Document\.$#'
identifier: return.phpDocType
count: 1
path: src/Appwrite/Utopia/Response/Model/User.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\Databases\\Legacy\\DatabasesStringTypesTest\:\:\$setupCache through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
@@ -1210,108 +1157,12 @@ parameters:
count: 8
path: tests/e2e/Services/Functions/FunctionsCustomServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsClientTest\:\:\$cachedDeployment through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 3
path: tests/e2e/Services/GraphQL/FunctionsClientTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsClientTest\:\:\$cachedExecution through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/GraphQL/FunctionsClientTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsClientTest\:\:\$cachedFunction through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 3
path: tests/e2e/Services/GraphQL/FunctionsClientTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsServerTest\:\:\$cachedDeployment through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/GraphQL/FunctionsServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsServerTest\:\:\$cachedExecution through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 5
path: tests/e2e/Services/GraphQL/FunctionsServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsServerTest\:\:\$cachedFunction through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/GraphQL/FunctionsServerTest.php
-
message: '#^Binary operation "\+" between string and 1 results in an error\.$#'
identifier: binaryOp.invalid
count: 1
path: tests/e2e/Services/GraphQL/Legacy/AbuseTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$bulkData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$collection through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$database through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$document through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedEmail through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 2
path: tests/e2e/Services/GraphQL/MessagingTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedProviders through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 8
path: tests/e2e/Services/GraphQL/MessagingTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedPush through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/GraphQL/MessagingTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedSms through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/GraphQL/MessagingTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedSubscriber through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 5
path: tests/e2e/Services/GraphQL/MessagingTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedTopic through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 9
path: tests/e2e/Services/GraphQL/MessagingTest.php
-
message: '#^Variable \$from in empty\(\) is never defined\.$#'
identifier: empty.variable
@@ -1324,174 +1175,18 @@ parameters:
count: 1
path: tests/e2e/Services/GraphQL/StorageClientTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageClientTest\:\:\$cachedBucket through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 3
path: tests/e2e/Services/GraphQL/StorageClientTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageClientTest\:\:\$cachedFile through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 5
path: tests/e2e/Services/GraphQL/StorageClientTest.php
-
message: '#^Method Tests\\E2E\\Services\\GraphQL\\StorageServerTest\:\:testGetFileDownload\(\) should return array but return statement is missing\.$#'
identifier: return.missing
count: 1
path: tests/e2e/Services/GraphQL/StorageServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageServerTest\:\:\$cachedBucket through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/GraphQL/StorageServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageServerTest\:\:\$cachedFile through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 6
path: tests/e2e/Services/GraphQL/StorageServerTest.php
-
message: '#^Binary operation "\+" between string and 1 results in an error\.$#'
identifier: binaryOp.invalid
count: 1
path: tests/e2e/Services/GraphQL/TablesDB/AbuseTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedBooleanColumnData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 5
path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedBulkData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedDatabase through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedDatetimeColumnData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 5
path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedEmailColumnData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 5
path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedEnumColumnData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 5
path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedFloatColumnData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 5
path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedIPColumnData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 5
path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedIndexData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 7
path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedIntegerColumnData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 5
path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedRelationshipColumnData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 5
path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedRowData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 5
path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedStringColumnData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 5
path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedTableData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedURLColumnData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 5
path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsClientTest\:\:\$cachedMembership through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/GraphQL/TeamsClientTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsClientTest\:\:\$cachedTeam through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 3
path: tests/e2e/Services/GraphQL/TeamsClientTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsServerTest\:\:\$cachedMembership through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/GraphQL/TeamsServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsServerTest\:\:\$cachedTeam through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 3
path: tests/e2e/Services/GraphQL/TeamsServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsServerTest\:\:\$cachedTeamWithPrefs through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 3
path: tests/e2e/Services/GraphQL/TeamsServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\UsersTest\:\:\$cachedUser through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 3
path: tests/e2e/Services/GraphQL/UsersTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\UsersTest\:\:\$cachedUserTarget through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 5
path: tests/e2e/Services/GraphQL/UsersTest.php
-
message: '#^Variable \$from in empty\(\) is never defined\.$#'
identifier: empty.variable
@@ -1513,13 +1208,13 @@ parameters:
-
message: '#^Anonymous function has an unused use \$databaseId\.$#'
identifier: closure.unusedUse
count: 5
count: 6
path: tests/e2e/Services/Migrations/MigrationsConsoleClientTest.php
-
message: '#^Anonymous function has an unused use \$tableId\.$#'
identifier: closure.unusedUse
count: 5
count: 6
path: tests/e2e/Services/Migrations/MigrationsConsoleClientTest.php
-
@@ -1563,36 +1258,6 @@ parameters:
identifier: method.notFound
count: 1
path: tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsTeamTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensConsoleClientTest\:\:\$bucketAndFileData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/Tokens/TokensConsoleClientTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensConsoleClientTest\:\:\$tokenData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/Tokens/TokensConsoleClientTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensCustomClientTest\:\:\$bucketAndFileData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/Tokens/TokensCustomClientTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensCustomServerTest\:\:\$bucketAndFileData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/Tokens/TokensCustomServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensCustomServerTest\:\:\$tokenData through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
count: 4
path: tests/e2e/Services/Tokens/TokensCustomServerTest.php
-
message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$cachedHashedPasswordUsers through static\:\:\.$#'
identifier: staticClassAccess.privateProperty
@@ -1652,75 +1317,3 @@ parameters:
identifier: method.notFound
count: 1
path: tests/unit/Event/EventTest.php
-
message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#'
identifier: class.notFound
count: 6
path: tests/unit/Utopia/Response/Filters/V16Test.php
-
message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V16Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V16\.$#'
identifier: assign.propertyType
count: 1
path: tests/unit/Utopia/Response/Filters/V16Test.php
-
message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V16Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#'
identifier: class.notFound
count: 1
path: tests/unit/Utopia/Response/Filters/V16Test.php
-
message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#'
identifier: class.notFound
count: 5
path: tests/unit/Utopia/Response/Filters/V17Test.php
-
message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V17Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V17\.$#'
identifier: assign.propertyType
count: 1
path: tests/unit/Utopia/Response/Filters/V17Test.php
-
message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V17Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#'
identifier: class.notFound
count: 1
path: tests/unit/Utopia/Response/Filters/V17Test.php
-
message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#'
identifier: class.notFound
count: 4
path: tests/unit/Utopia/Response/Filters/V18Test.php
-
message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V18Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V18\.$#'
identifier: assign.propertyType
count: 1
path: tests/unit/Utopia/Response/Filters/V18Test.php
-
message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V18Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#'
identifier: class.notFound
count: 1
path: tests/unit/Utopia/Response/Filters/V18Test.php
-
message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#'
identifier: class.notFound
count: 11
path: tests/unit/Utopia/Response/Filters/V19Test.php
-
message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V19Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V19\.$#'
identifier: assign.propertyType
count: 1
path: tests/unit/Utopia/Response/Filters/V19Test.php
-
message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V19Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#'
identifier: class.notFound
count: 1
path: tests/unit/Utopia/Response/Filters/V19Test.php
+1 -1
View File
@@ -155,7 +155,7 @@ abstract class OAuth2
/**
* @param string $code
*
* @return string
* @return int
*/
public function getAccessTokenExpiry(string $code): int
{
+1 -1
View File
@@ -108,7 +108,7 @@ class Disqus extends OAuth2
}
/**
* @param string $token
* @param string $accessToken
*
* @return string
*/
+1 -1
View File
@@ -33,7 +33,7 @@ class PersonalData extends Password
/**
* Is valid.
*
* @param mixed $value
* @param mixed $password
*
* @return bool
*/
-6
View File
@@ -6,14 +6,8 @@ use DeviceDetector\DeviceDetector;
class Detector
{
/**
* @param string
*/
protected $userAgent = '';
/**
* @param DeviceDetector
*/
protected $detctor;
/**
-3
View File
@@ -12,9 +12,6 @@ class Compose
*/
protected $compose = [];
/**
* @var string $data
*/
public function __construct(string $data)
{
$this->compose = yaml_parse($data);
-3
View File
@@ -11,9 +11,6 @@ class Service
*/
protected $service = [];
/**
* @var string $path
*/
public function __construct(array $service)
{
$this->service = $service;
-3
View File
@@ -9,9 +9,6 @@ class Env
*/
protected $vars = [];
/**
* @var string $data
*/
public function __construct(string $data)
{
$data = explode("\n", $data);
+5 -4
View File
@@ -101,7 +101,8 @@ class Mail extends Event
/**
* Sets preview for the mail event.
*
* @return string
* @param string $preview
* @return self
*/
public function setPreview(string $preview): self
{
@@ -115,7 +116,7 @@ class Mail extends Event
*
* @return string
*/
public function getPreview(string $preview): string
public function getPreview(): string
{
return $this->preview;
}
@@ -181,7 +182,7 @@ class Mail extends Event
/**
* Set SMTP port
*
* @param int port
* @param int $port
* @return self
*/
public function setSmtpPort(int $port): self
@@ -217,7 +218,7 @@ class Mail extends Event
/**
* Set SMTP secure
*
* @param string $password
* @param string $secure
* @return self
*/
public function setSmtpSecure(string $secure): self
+1 -1
View File
@@ -40,7 +40,7 @@ class Usage extends Base
*/
public static function fromArray(array $data): static
{
return new self(
return new static(
project: new Document($data['project'] ?? []),
metrics: $data['metrics'] ?? [],
reduce: array_map(fn (array $doc) => new Document($doc), $data['reduce'] ?? []),
+2 -2
View File
@@ -86,7 +86,7 @@ class Messaging extends Event
/**
* Returns message document for the messaging event.
*
* @return string
* @return Document
*/
public function getMessage(): Document
{
@@ -96,7 +96,7 @@ class Messaging extends Event
/**
* Sets message ID for the messaging event.
*
* @param string $message
* @param string $messageId
* @return self
*/
public function setMessageId(string $messageId): self
+18 -4
View File
@@ -8,6 +8,18 @@ use Utopia\Database\Query;
class EventProcessor
{
/**
* @param array<mixed> $events
* @return array<string, bool>
*/
private function getEventMap(array $events): array
{
return \array_fill_keys(
\array_map('strval', \array_unique($events)),
true
);
}
/**
* Get function events for a project, using Redis cache
* @param Document|null $project
@@ -26,7 +38,7 @@ class EventProcessor
$cacheKey = \sprintf(
'%s-cache-%s:%s:%s:project:%s:functions:events',
$dbForProject->getCacheName(),
$hostname ?? '',
$hostname,
$dbForProject->getNamespace(),
$dbForProject->getTenant(),
$project->getId()
@@ -36,7 +48,9 @@ class EventProcessor
$cachedFunctionEvents = $dbForProject->getCache()->load($cacheKey, $ttl);
if ($cachedFunctionEvents !== false) {
return \json_decode($cachedFunctionEvents, true) ?? [];
$decoded = \json_decode($cachedFunctionEvents, true);
return \is_array($decoded) ? $this->getEventMap(\array_keys($decoded)) : [];
}
$events = [];
@@ -63,7 +77,7 @@ class EventProcessor
}
}
$uniqueEvents = \array_flip(\array_unique($events));
$uniqueEvents = $this->getEventMap($events);
$dbForProject->getCache()->save($cacheKey, \json_encode($uniqueEvents));
return $uniqueEvents;
@@ -97,6 +111,6 @@ class EventProcessor
}
}
return \array_flip(\array_unique($events));
return $this->getEventMap($events);
}
}
+7 -7
View File
@@ -101,16 +101,16 @@ class Mapper
if (\is_array($modelName)) {
foreach ($modelName as $name) {
$models[] = static::$models[$name];
$models[] = self::$models[$name];
}
} else {
$models[] = static::$models[$modelName];
$models[] = self::$models[$modelName];
}
}
} else {
// If single response, get its model and wrap in array
$modelName = $responses->getModel();
$models = [static::$models[$modelName]];
$models = [self::$models[$modelName]];
}
foreach ($models as $model) {
@@ -425,7 +425,7 @@ class Mapper
'name' => $unionName,
'types' => $types,
'resolveType' => static function ($object) use ($unionName) {
return static::getUnionImplementation($unionName, $object);
return self::getUnionImplementation($unionName, $object);
},
]);
@@ -440,11 +440,11 @@ class Mapper
switch ($name) {
case 'Attributes':
return static::getColumnImplementation($object);
return self::getColumnImplementation($object);
case 'Columns':
return static::getColumnImplementation($object, true);
return self::getColumnImplementation($object, true);
case 'HashOptions':
return static::getHashOptionsImplementation($object);
return self::getHashOptionsImplementation($object);
}
throw new Exception('Unknown union type: ' . $name);
+14
View File
@@ -187,6 +187,20 @@ class V24 extends Migration
$this->dbForProject->purgeCachedCollection($id);
break;
case 'users':
try {
$this->createAttributeFromCollection($this->dbForProject, $id, 'impersonator');
} catch (Throwable $th) {
Console::warning("Failed to create attribute \"impersonator\" in collection {$id}: {$th->getMessage()}");
}
try {
$this->createIndexFromCollection($this->dbForProject, $id, 'impersonator');
} catch (Throwable $th) {
Console::warning("Failed to create index \"impersonator\" from {$id}: {$th->getMessage()}");
}
$this->dbForProject->purgeCachedCollection($id);
break;
case 'teams':
try {
$this->createAttributeFromCollection($this->dbForProject, $id, 'labels');
+1 -1
View File
@@ -37,7 +37,7 @@ class Action extends UtopiaAction
* Foreach Document
* Call provided callback for each document in the collection
*
* @param string $projectId
* @param Database $database
* @param string $collection
* @param array $queries
* @param callable $callback
@@ -43,6 +43,7 @@ class Install extends Action
->param('database', '', new WhiteList(['mongodb', 'mariadb', 'postgresql']), 'Database adapter', true)
->param('installId', '', new Text(64, 0), 'Installation ID', true)
->param('retryStep', null, new Nullable(new WhiteList([Server::STEP_DOCKER_COMPOSE, Server::STEP_ENV_VARS, Server::STEP_DOCKER_CONTAINERS], true)), 'Retry from step', true)
->param('migrate', false, new \Utopia\Validator\Boolean(true), 'Run database migration after upgrade', true)
->inject('request')
->inject('response')
->inject('swooleResponse')
@@ -64,6 +65,7 @@ class Install extends Action
string $database,
string $installId,
?string $retryStep,
bool $migrate,
Request $request,
Response $response,
SwooleResponse $swooleResponse,
@@ -321,6 +323,28 @@ class Install extends Action
}
};
$responseSent = false;
$onComplete = function () use ($wantsStream, $swooleResponse, $response, $installId, $state, &$responseSent) {
if ($responseSent) {
return;
}
$responseSent = true;
$state->updateGlobalLock($installId, Server::STATUS_COMPLETED);
if ($wantsStream) {
$this->writeSseEvent($swooleResponse, 'done', ['installId' => $installId, 'success' => true]);
usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS);
$swooleResponse->write(": keepalive\n\n");
usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS);
$swooleResponse->end();
} else {
$response->json([
'success' => true,
'installId' => $installId,
'message' => 'Installation completed successfully',
]);
}
};
$installer->performInstallation(
$httpPort ?: $config->getDefaultHttpPort(),
$httpsPort ?: $config->getDefaultHttpsPort(),
@@ -331,23 +355,12 @@ class Install extends Action
$progress,
$retryStep,
$config->isUpgrade(),
$account
$account,
$onComplete,
$migrate,
);
if ($wantsStream) {
$this->writeSseEvent($swooleResponse, 'done', ['installId' => $installId, 'success' => true]);
usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS);
$swooleResponse->write(": keepalive\n\n");
usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS);
$swooleResponse->end();
} else {
$response->json([
'success' => true,
'installId' => $installId,
'message' => 'Installation completed successfully',
]);
}
$state->updateGlobalLock($installId, Server::STATUS_COMPLETED);
$onComplete();
} catch (\Throwable $e) {
$this->handleInstallationError($e, $installId, $wantsStream, $response, $swooleResponse, $state);
}
@@ -24,7 +24,7 @@ class View extends Action
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/')
->desc('Serve installer UI')
->param('step', 1, new Integer(true), 'Step number (1-5)', true)
->param('step', 1, new Integer(true), 'Step number (1-6)', true)
->param('partial', null, new Nullable(new Text(1, 0)), 'Render partial step only', true)
->inject('request')
->inject('response')
@@ -52,10 +52,13 @@ class View extends Action
$defaultEmailCertificates = 'walterobrien@example.com';
}
$step = max(1, min(5, $step));
$step = max(1, min(6, $step));
if ($isUpgrade && ($step === 2 || $step === 3)) {
$step = 4;
}
if (!$isUpgrade && $step === 6) {
$step = 4;
}
$partialFile = $paths['views'] . "/installer/templates/steps/step-{$step}.phtml";
if (!is_file($partialFile)) {
@@ -6,6 +6,7 @@ use Appwrite\Platform\Installer\Http\Installer\Error;
use Appwrite\Platform\Installer\Runtime\Config;
use Appwrite\Platform\Installer\Runtime\State;
use Swoole\Http\Server as SwooleServer;
use Swoole\Runtime;
use Utopia\Http\Adapter\Swoole\Request;
use Utopia\Http\Adapter\Swoole\Response;
use Utopia\Http\Adapter\Swoole\Server as SwooleAdapter;
@@ -28,6 +29,7 @@ class Server
public const string STEP_DOCKER_COMPOSE = 'docker-compose';
public const string STEP_DOCKER_CONTAINERS = 'docker-containers';
public const string STEP_ACCOUNT_SETUP = 'account-setup';
public const string STEP_MIGRATION = 'migration';
public const string STEP_SSL_CERTIFICATE = 'ssl-certificate';
public const string STATUS_IN_PROGRESS = 'in-progress';
@@ -129,6 +131,8 @@ class Server
private function startSwooleServer(string $host, int $port, ?string $readyFile = null): void
{
Runtime::enableCoroutine(SWOOLE_HOOK_ALL);
$this->state->clearStaleLock();
// Preload static files into memory
@@ -87,13 +87,14 @@ class Decrement extends Action
->inject('usage')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $min, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization): void
public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $min, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization, User $user): void
{
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty()) {
@@ -87,13 +87,14 @@ class Increment extends Action
->inject('usage')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $max, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization): void
public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $max, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization, User $user): void
{
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty()) {
@@ -139,7 +139,7 @@ class Create extends Action
->inject('eventProcessor')
->callback($this->action(...));
}
public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Document $user, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, Authorization $authorization, EventProcessor $eventProcessor): void
public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, User $user, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, Authorization $authorization, EventProcessor $eventProcessor): void
{
$data = \is_string($data)
? \json_decode($data, true)
@@ -183,8 +183,8 @@ class Create extends Action
$documents = [$data];
}
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($isBulk && !$isAPIKey && !$isPrivilegedUser) {
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE);
@@ -85,6 +85,7 @@ class Delete extends Action
->inject('transactionState')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
@@ -101,12 +102,13 @@ class Delete extends Action
Context $usage,
TransactionState $transactionState,
array $plan,
Authorization $authorization
Authorization $authorization,
User $user
): void {
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]);
@@ -72,13 +72,14 @@ class Get extends Action
->inject('usage')
->inject('transactionState')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
public function action(string $databaseId, string $collectionId, string $documentId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization): void
public function action(string $databaseId, string $collectionId, string $documentId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, User $user): void
{
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
@@ -89,10 +89,11 @@ class Update extends Action
->inject('transactionState')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, TransactionState $transactionState, array $plan, Authorization $authorization): void
public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, TransactionState $transactionState, array $plan, Authorization $authorization, User $user): void
{
$data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array
@@ -102,8 +103,8 @@ class Update extends Action
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]);
@@ -96,7 +96,7 @@ class Upsert extends Action
->callback($this->action(...));
}
public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Document $user, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, TransactionState $transactionState, array $plan, Authorization $authorization): void
public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, User $user, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, TransactionState $transactionState, array $plan, Authorization $authorization): void
{
$data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array
@@ -108,8 +108,8 @@ class Upsert extends Action
throw new Exception($this->getMissingPayloadException());
}
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
@@ -83,10 +83,10 @@ class XList extends Action
->callback($this->action(...));
}
public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, Document $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization): void
public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization): void
{
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
@@ -65,17 +65,18 @@ class Create extends Action
->inject('transactionState')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
public function action(string $transactionId, array $operations, UtopiaResponse $response, Database $dbForProject, TransactionState $transactionState, array $plan, Authorization $authorization): void
public function action(string $transactionId, array $operations, UtopiaResponse $response, Database $dbForProject, TransactionState $transactionState, array $plan, Authorization $authorization, User $user): void
{
if (empty($operations)) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Operations array cannot be empty');
}
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
// API keys and admins can read any transaction, regular users need permissions
$transaction = ($isAPIKey || $isPrivilegedUser)
@@ -91,7 +91,7 @@ class Update extends Action
* @param UtopiaResponse $response
* @param Database $dbForProject
* @param callable $getDatabasesDB
* @param Document $user
* @param User $user
* @param TransactionState $transactionState
* @param Delete $queueForDeletes
* @param Event $queueForEvents
@@ -109,7 +109,7 @@ class Update extends Action
* @throws Structure
* @throws \Utopia\Http\Exception
*/
public function action(string $transactionId, bool $commit, bool $rollback, Document $project, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Document $user, TransactionState $transactionState, Delete $queueForDeletes, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, Authorization $authorization, EventProcessor $eventProcessor): void
public function action(string $transactionId, bool $commit, bool $rollback, Document $project, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, User $user, TransactionState $transactionState, Delete $queueForDeletes, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, Authorization $authorization, EventProcessor $eventProcessor): void
{
if (!$commit && !$rollback) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Either commit or rollback must be true');
@@ -118,8 +118,8 @@ class Update extends Action
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Cannot commit and rollback at the same time');
}
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$transaction = ($isAPIKey || $isPrivilegedUser)
? $authorization->skip(fn () => $dbForProject->getDocument('transactions', $transactionId))
@@ -68,6 +68,7 @@ class Decrement extends DecrementDocumentAttribute
->inject('usage')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}
@@ -68,6 +68,7 @@ class Increment extends IncrementDocumentAttribute
->inject('usage')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}
@@ -71,6 +71,7 @@ class Delete extends DocumentDelete
->inject('transactionState')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}
@@ -59,6 +59,7 @@ class Get extends DocumentGet
->inject('usage')
->inject('transactionState')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}
@@ -70,6 +70,7 @@ class Update extends DocumentUpdate
->inject('transactionState')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}
@@ -58,7 +58,7 @@ class Create extends IndexCreate
->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).', false, ['dbForProject'])
->param('key', null, fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Index Key.', false, ['dbForProject'])
->param('type', null, new WhiteList([Database::INDEX_KEY, Database::INDEX_FULLTEXT, Database::INDEX_UNIQUE, Database::INDEX_SPATIAL]), 'Index type.')
->param('type', null, new WhiteList([Database::INDEX_KEY, Database::INDEX_FULLTEXT, Database::INDEX_UNIQUE]), 'Index type.')
->param('attributes', null, fn (Database $dbForProject) => new ArrayList(new Key(true, $dbForProject->getAdapter()->getMaxUIDLength()), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of attributes to index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' attributes are allowed, each 32 characters long.', false, ['dbForProject'])
->param('orders', [], new ArrayList(new WhiteList(['ASC', 'DESC'], false, Database::VAR_STRING), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of index orders. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' orders are allowed.', true)
->param('lengths', [], new ArrayList(new Nullable(new Integer()), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Length of index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE, optional: true)
@@ -55,6 +55,7 @@ class Create extends OperationsCreate
->inject('transactionState')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}
@@ -70,6 +70,7 @@ class Decrement extends DecrementDocumentAttribute
->inject('usage')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}
@@ -70,6 +70,7 @@ class Increment extends IncrementDocumentAttribute
->inject('usage')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}
@@ -73,6 +73,7 @@ class Delete extends DocumentDelete
->inject('transactionState')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}
@@ -61,6 +61,7 @@ class Get extends DocumentGet
->inject('usage')
->inject('transactionState')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}
@@ -71,6 +71,7 @@ class Update extends DocumentUpdate
->inject('transactionState')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}
@@ -56,6 +56,7 @@ class Create extends OperationsCreate
->inject('transactionState')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}
@@ -71,6 +71,7 @@ class Delete extends DocumentDelete
->inject('transactionState')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}
@@ -59,6 +59,7 @@ class Get extends DocumentGet
->inject('usage')
->inject('transactionState')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}
@@ -70,6 +70,7 @@ class Update extends DocumentUpdate
->inject('transactionState')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}
@@ -55,6 +55,7 @@ class Create extends OperationsCreate
->inject('transactionState')
->inject('plan')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
}
@@ -119,7 +119,7 @@ class Create extends Base
Document $project,
Database $dbForProject,
Database $dbForPlatform,
Document $user,
User $user,
Event $queueForEvents,
Context $usage,
Func $queueForFunctions,
@@ -171,8 +171,8 @@ class Create extends Base
/* @var Document $function */
$function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId));
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::FUNCTION_NOT_FOUND);
@@ -53,6 +53,7 @@ class Get extends Base
->inject('response')
->inject('dbForProject')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
@@ -61,12 +62,13 @@ class Get extends Base
string $executionId,
Response $response,
Database $dbForProject,
Authorization $authorization
Authorization $authorization,
User $user
) {
$function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId));
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::FUNCTION_NOT_FOUND);
@@ -62,6 +62,7 @@ class XList extends Base
->inject('response')
->inject('dbForProject')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
@@ -71,12 +72,13 @@ class XList extends Base
bool $includeTotal,
Response $response,
Database $dbForProject,
Authorization $authorization
Authorization $authorization,
User $user
) {
$function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId));
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::FUNCTION_NOT_FOUND);
@@ -103,7 +103,7 @@ class Create extends Action
Request $request,
Response $response,
Database $dbForProject,
Document $user,
User $user,
Event $queueForEvents,
string $mode,
Device $deviceForFiles,
@@ -112,8 +112,8 @@ class Create extends Action
) {
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
@@ -66,6 +66,7 @@ class Delete extends Action
->inject('deviceForFiles')
->inject('queueForDeletes')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
@@ -77,12 +78,13 @@ class Delete extends Action
Event $queueForEvents,
Device $deviceForFiles,
DeleteEvent $queueForDeletes,
Authorization $authorization
Authorization $authorization,
User $user
) {
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
@@ -70,6 +70,7 @@ class Get extends Action
->inject('resourceToken')
->inject('deviceForFiles')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
@@ -84,12 +85,13 @@ class Get extends Action
Document $resourceToken,
Device $deviceForFiles,
Authorization $authorization,
User $user,
) {
/* @type Document $bucket */
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
@@ -51,6 +51,7 @@ class Get extends Action
->inject('response')
->inject('dbForProject')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
@@ -59,12 +60,13 @@ class Get extends Action
string $fileId,
Response $response,
Database $dbForProject,
Authorization $authorization
Authorization $authorization,
User $user
) {
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
@@ -92,6 +92,7 @@ class Get extends Action
->inject('deviceForLocal')
->inject('project')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
@@ -117,7 +118,8 @@ class Get extends Action
Device $deviceForFiles,
Device $deviceForLocal,
Document $project,
Authorization $authorization
Authorization $authorization,
User $user
) {
if (!\extension_loaded('imagick')) {
@@ -127,8 +129,8 @@ class Get extends Action
/* @type Document $bucket */
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
@@ -271,7 +273,7 @@ class Get extends Action
$contentType = (\array_key_exists($output, $outputs)) ? $outputs[$output] : $outputs['jpg'];
//Do not update transformedAt if it's a console user
if (!User::isPrivileged($authorization->getRoles())) {
if (!$user->isPrivileged($authorization->getRoles())) {
$transformedAt = $file->getAttribute('transformedAt', '');
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) {
$file->setAttribute('transformedAt', DateTime::now());
@@ -52,6 +52,7 @@ class Get extends Action
->inject('mode')
->inject('deviceForFiles')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
@@ -66,7 +67,8 @@ class Get extends Action
Document $project,
string $mode,
Device $deviceForFiles,
Authorization $authorization
Authorization $authorization,
User $user
) {
$decoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0);
@@ -88,8 +90,8 @@ class Get extends Action
$disposition = $decoded['disposition'] ?? 'inline';
$dbForProject = $isInternal ? $dbForPlatform : $dbForProject;
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
@@ -64,6 +64,7 @@ class Update extends Action
->inject('dbForProject')
->inject('queueForEvents')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
@@ -75,12 +76,13 @@ class Update extends Action
Response $response,
Database $dbForProject,
Event $queueForEvents,
Authorization $authorization
Authorization $authorization,
User $user
) {
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
@@ -108,7 +110,7 @@ class Update extends Action
// Users can only manage their own roles, API keys and Admin users can manage any
$roles = $authorization->getRoles();
if (!User::isApp($roles) && !User::isPrivileged($roles) && !\is_null($permissions)) {
if (!$user->isApp($roles) && !$user->isPrivileged($roles) && !\is_null($permissions)) {
foreach (Database::PERMISSIONS as $type) {
foreach ($permissions as $permission) {
$permission = Permission::parse($permission);
@@ -71,6 +71,7 @@ class Get extends Action
->inject('resourceToken')
->inject('deviceForFiles')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
@@ -84,13 +85,14 @@ class Get extends Action
string $mode,
Document $resourceToken,
Device $deviceForFiles,
Authorization $authorization
Authorization $authorization,
User $user
) {
/* @type Document $bucket */
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
@@ -63,6 +63,7 @@ class XList extends Action
->inject('dbForProject')
->inject('mode')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
@@ -74,12 +75,13 @@ class XList extends Action
Response $response,
Database $dbForProject,
string $mode,
Authorization $authorization
Authorization $authorization,
User $user
) {
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
@@ -98,10 +98,10 @@ class Create extends Action
->callback($this->action(...));
}
public function action(string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, Document $user, Database $dbForProject, Authorization $authorization, Locale $locale, Mail $queueForMails, Messaging $queueForMessaging, Event $queueForEvents, callable $timelimit, Context $usage, array $plan, Password $proofForPassword, Token $proofForToken)
public function action(string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, User $user, Database $dbForProject, Authorization $authorization, Locale $locale, Mail $queueForMails, Messaging $queueForMessaging, Event $queueForEvents, callable $timelimit, Context $usage, array $plan, Password $proofForPassword, Token $proofForToken)
{
$isAppUser = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAppUser = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if (empty($url)) {
if (! $isAppUser && ! $isPrivilegedUser) {
@@ -52,10 +52,11 @@ class Get extends Action
->inject('project')
->inject('dbForProject')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
public function action(string $teamId, string $membershipId, Response $response, Document $project, Database $dbForProject, Authorization $authorization)
public function action(string $teamId, string $membershipId, Response $response, Document $project, Database $dbForProject, Authorization $authorization, User $user)
{
$team = $dbForProject->getDocument('teams', $teamId);
@@ -76,25 +77,25 @@ class Get extends Action
];
$roles = $authorization->getRoles();
$isPrivilegedUser = User::isPrivileged($roles);
$isAppUser = User::isApp($roles);
$isPrivilegedUser = $user->isPrivileged($roles);
$isAppUser = $user->isApp($roles);
$membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) {
return $privacy || $isPrivilegedUser || $isAppUser;
}, $membershipsPrivacy);
$user = !empty(array_filter($membershipsPrivacy))
$memberUser = !empty(array_filter($membershipsPrivacy))
? $dbForProject->getDocument('users', $membership->getAttribute('userId'))
: new Document();
if ($membershipsPrivacy['mfa']) {
$mfa = $user->getAttribute('mfa', false);
$mfa = $memberUser->getAttribute('mfa', false);
if ($mfa) {
$totp = TOTP::getAuthenticatorFromUser($user);
$totp = TOTP::getAuthenticatorFromUser($memberUser);
$totpEnabled = $totp && $totp->getAttribute('verified', false);
$emailEnabled = $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false);
$phoneEnabled = $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false);
$emailEnabled = $memberUser->getAttribute('email', false) && $memberUser->getAttribute('emailVerification', false);
$phoneEnabled = $memberUser->getAttribute('phone', false) && $memberUser->getAttribute('phoneVerification', false);
if (!$totpEnabled && !$emailEnabled && !$phoneEnabled) {
$mfa = false;
@@ -105,11 +106,11 @@ class Get extends Action
}
if ($membershipsPrivacy['userName']) {
$membership->setAttribute('userName', $user->getAttribute('name'));
$membership->setAttribute('userName', $memberUser->getAttribute('name'));
}
if ($membershipsPrivacy['userEmail']) {
$membership->setAttribute('userEmail', $user->getAttribute('email'));
$membership->setAttribute('userEmail', $memberUser->getAttribute('email'));
}
$membership->setAttribute('teamName', $team->getAttribute('name'));
@@ -66,7 +66,7 @@ class Update extends Action
->callback($this->action(...));
}
public function action(string $teamId, string $membershipId, array $roles, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Authorization $authorization, Event $queueForEvents)
public function action(string $teamId, string $membershipId, array $roles, Request $request, Response $response, User $user, Document $project, Database $dbForProject, Authorization $authorization, Event $queueForEvents)
{
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
@@ -83,8 +83,8 @@ class Update extends Action
throw new Exception(Exception::USER_NOT_FOUND);
}
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAppUser = User::isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$isAppUser = $user->isApp($authorization->getRoles());
$isOwner = $authorization->hasRole('team:' . $team->getId() . '/owner');
if ($project->getId() === 'console') {
@@ -61,10 +61,11 @@ class XList extends Action
->inject('project')
->inject('dbForProject')
->inject('authorization')
->inject('user')
->callback($this->action(...));
}
public function action(string $teamId, array $queries, string $search, bool $includeTotal, Response $response, Document $project, Database $dbForProject, Authorization $authorization)
public function action(string $teamId, array $queries, string $search, bool $includeTotal, Response $response, Document $project, Database $dbForProject, Authorization $authorization, User $user)
{
$team = $dbForProject->getDocument('teams', $teamId);
@@ -129,26 +130,26 @@ class XList extends Action
];
$roles = $authorization->getRoles();
$isPrivilegedUser = User::isPrivileged($roles);
$isAppUser = User::isApp($roles);
$isPrivilegedUser = $user->isPrivileged($roles);
$isAppUser = $user->isApp($roles);
$membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) {
return $privacy || $isPrivilegedUser || $isAppUser;
}, $membershipsPrivacy);
$memberships = array_map(function ($membership) use ($dbForProject, $team, $membershipsPrivacy) {
$user = !empty(array_filter($membershipsPrivacy))
$memberUser = !empty(array_filter($membershipsPrivacy))
? $dbForProject->getDocument('users', $membership->getAttribute('userId'))
: new Document();
if ($membershipsPrivacy['mfa']) {
$mfa = $user->getAttribute('mfa', false);
$mfa = $memberUser->getAttribute('mfa', false);
if ($mfa) {
$totp = TOTP::getAuthenticatorFromUser($user);
$totp = TOTP::getAuthenticatorFromUser($memberUser);
$totpEnabled = $totp && $totp->getAttribute('verified', false);
$emailEnabled = $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false);
$phoneEnabled = $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false);
$emailEnabled = $memberUser->getAttribute('email', false) && $memberUser->getAttribute('emailVerification', false);
$phoneEnabled = $memberUser->getAttribute('phone', false) && $memberUser->getAttribute('phoneVerification', false);
if (!$totpEnabled && !$emailEnabled && !$phoneEnabled) {
$mfa = false;
@@ -159,11 +160,11 @@ class XList extends Action
}
if ($membershipsPrivacy['userName']) {
$membership->setAttribute('userName', $user->getAttribute('name'));
$membership->setAttribute('userName', $memberUser->getAttribute('name'));
}
if ($membershipsPrivacy['userEmail']) {
$membership->setAttribute('userEmail', $user->getAttribute('email'));
$membership->setAttribute('userEmail', $memberUser->getAttribute('email'));
}
$membership->setAttribute('teamName', $team->getAttribute('name'));
@@ -68,10 +68,10 @@ class Create extends Action
->callback($this->action(...));
}
public function action(string $teamId, string $name, array $roles, Response $response, Document $user, Database $dbForProject, Authorization $authorization, Event $queueForEvents)
public function action(string $teamId, string $name, array $roles, Response $response, User $user, Database $dbForProject, Authorization $authorization, Event $queueForEvents)
{
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAppUser = User::isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$isAppUser = $user->isApp($authorization->getRoles());
$teamId = $teamId == 'unique()' ? ID::unique() : $teamId;
@@ -11,12 +11,12 @@ use Utopia\Platform\Action as UtopiaAction;
class Action extends UtopiaAction
{
protected function getFileAndBucket(Database $dbForProject, Authorization $authorization, string $bucketId, string $fileId): array
protected function getFileAndBucket(Database $dbForProject, Authorization $authorization, User $user, string $bucketId, string $fileId): array
{
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$isAPIKey = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
@@ -8,6 +8,7 @@ use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Response;
use Utopia\Auth\Proofs\Token;
use Utopia\Database\Database;
@@ -64,19 +65,20 @@ class Create extends Action
->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File unique ID.', false, ['dbForProject'])
->param('expire', null, new Nullable(new DatetimeValidator(requireDateInFuture: true)), 'Token expiry date', true)
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
public function action(string $bucketId, string $fileId, ?string $expire, Response $response, Database $dbForProject, Event $queueForEvents, Authorization $authorization): void
public function action(string $bucketId, string $fileId, ?string $expire, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Authorization $authorization): void
{
/**
* @var Document $bucket
* @var Document $file
*/
['bucket' => $bucket, 'file' => $file] = $this->getFileAndBucket($dbForProject, $authorization, $bucketId, $fileId);
['bucket' => $bucket, 'file' => $file] = $this->getFileAndBucket($dbForProject, $authorization, $user, $bucketId, $fileId);
$fileSecurity = $bucket->getAttribute('fileSecurity', false);
$bucketPermission = $authorization->isValid(new Input(Database::PERMISSION_UPDATE, $bucket->getUpdate()));
@@ -7,6 +7,7 @@ use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Database\Validator\Queries\FileTokens;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
@@ -57,14 +58,15 @@ class XList extends Action
->param('queries', [], new FileTokens(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', FileTokens::ALLOWED_ATTRIBUTES), true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('authorization')
->callback($this->action(...));
}
public function action(string $bucketId, string $fileId, array $queries, bool $includeTotal, Response $response, Database $dbForProject, Authorization $authorization)
public function action(string $bucketId, string $fileId, array $queries, bool $includeTotal, Response $response, User $user, Database $dbForProject, Authorization $authorization)
{
['bucket' => $bucket, 'file' => $file] = $this->getFileAndBucket($dbForProject, $authorization, $bucketId, $fileId);
['bucket' => $bucket, 'file' => $file] = $this->getFileAndBucket($dbForProject, $authorization, $user, $bucketId, $fileId);
$queries = Query::parseQueries($queries);
$queries[] = Query::equal('resourceType', [TOKENS_RESOURCE_TYPE_FILES]);
@@ -70,6 +70,8 @@ trait Deployment
throw new Exception(Exception::PROJECT_NOT_FOUND, 'Repository references non-existent project');
}
$this->beforeCreateGitDeployment($project, $repository, $dbForPlatform, $authorization);
try {
$dsn = new DSN($project->getAttribute('database'));
$databaseName = $dsn->getHost();
@@ -561,6 +563,10 @@ trait Deployment
}
}
protected function beforeCreateGitDeployment(Document $project, Document $repository, Database $dbForPlatform, Authorization $authorization): void
{
}
protected function getBuildQueueName(Document $project, Database $dbForPlatform, Authorization $authorization): string
{
return System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME);
@@ -82,12 +82,11 @@ class Create extends Action
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_DETECTION_RUNTIME,
model: [
Response::MODEL_DETECTION_RUNTIME,
Response::MODEL_DETECTION_FRAMEWORK,
],
),
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_DETECTION_FRAMEWORK,
)
]
))
->param('installationId', '', new Text(256), 'Installation Id')
@@ -86,12 +86,11 @@ class XList extends Action
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST,
model: [
Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST,
Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST,
],
),
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST,
)
]
))
->param('installationId', '', new Text(256), 'Installation Id')
+97 -8
View File
@@ -7,6 +7,7 @@ use Appwrite\Docker\Env;
use Appwrite\Platform\Installer\Runtime\State;
use Appwrite\Platform\Installer\Server as InstallerServer;
use Appwrite\Utopia\View;
use Swoole\Coroutine;
use Utopia\Auth\Proofs\Password;
use Utopia\Auth\Proofs\Token;
use Utopia\Config\Config;
@@ -35,6 +36,7 @@ class Install extends Action
private const string GROWTH_API_URL = 'https://growth.appwrite.io/v1';
protected bool $isUpgrade = false;
protected bool $migrate = false;
protected string $hostPath = '';
protected ?bool $isLocalInstall = null;
protected ?array $installerConfig = null;
@@ -211,13 +213,14 @@ class Install extends Action
}
// If interactive and web mode enabled, start web server
if ($interactive === 'Y' && Console::isInteractive()) {
// Skip the web installer when explicit CLI params are provided
if ($interactive === 'Y' && Console::isInteractive() && !$this->hasExplicitCliParams()) {
Console::success('Starting web installer...');
Console::info('Open your browser at: http://localhost:' . InstallerServer::INSTALLER_WEB_PORT);
Console::info('Press Ctrl+C to cancel installation');
$detectedDb = ($existingInstallation && isset($existingDatabase)) ? $existingDatabase : null;
$this->startWebServer($defaultHttpPort, $defaultHttpsPort, $organization, $image, $noStart, $vars, $isUpgrade, $detectedDb);
$this->startWebServer($defaultHttpPort, $defaultHttpsPort, $organization, $image, $noStart, $vars, $isUpgrade || $existingInstallation, $detectedDb);
return;
}
@@ -321,7 +324,7 @@ class Install extends Action
$shouldGenerateSecrets = !$existingInstallation && !$isUpgrade;
$input = $this->prepareEnvironmentVariables($userInput, $vars, $shouldGenerateSecrets);
$this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart, null, null, $isUpgrade);
$this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart, null, null, $isUpgrade, migrate: $this->migrate);
}
@@ -510,7 +513,9 @@ class Install extends Action
?callable $progress = null,
?string $resumeFromStep = null,
bool $isUpgrade = false,
array $account = []
array $account = [],
?callable $onComplete = null,
bool $migrate = false,
): void {
$isLocalInstall = $this->isLocalInstall();
$this->applyLocalPaths($isLocalInstall, false);
@@ -633,8 +638,34 @@ class Install extends Action
$this->createInitialAdminAccount($account, $progress, $apiUrl, $domain);
}
// Track installs
$this->trackSelfHostedInstall($input, $isUpgrade, $version, $account);
if ($isUpgrade && $migrate) {
// Allow the containers-completed SSE event to flush
// before blocking on migration exec
usleep(200_000);
$currentStep = InstallerServer::STEP_MIGRATION;
$this->runDatabaseMigration($progress, $isLocalInstall);
} elseif ($isUpgrade) {
$this->updateProgress($progress, InstallerServer::STEP_MIGRATION, InstallerServer::STATUS_COMPLETED, messageOverride: 'Migration skipped');
}
// Signal completion before tracking so the SSE stream
// finishes and the frontend can redirect immediately.
if ($onComplete) {
try {
$onComplete();
} catch (\Throwable) {
}
}
// Run tracking in a coroutine when inside a Swoole
// request so it doesn't block the worker.
if (Coroutine::getCid() !== -1) {
go(function () use ($input, $isUpgrade, $version, $account) {
$this->trackSelfHostedInstall($input, $isUpgrade, $version, $account);
});
} else {
$this->trackSelfHostedInstall($input, $isUpgrade, $version, $account);
}
if ($isCLI) {
Console::success('Appwrite installed successfully');
@@ -726,6 +757,46 @@ class Install extends Action
}
}
private function runDatabaseMigration(?callable $progress, bool $isLocalInstall): void
{
$this->updateProgress(
$progress,
InstallerServer::STEP_MIGRATION,
InstallerServer::STATUS_IN_PROGRESS,
messageOverride: 'Running database migration...'
);
// Allow the SSE chunk to flush before the blocking exec
usleep(100_000);
// Static command — no user input involved
$command = $isLocalInstall
? 'docker compose exec appwrite migrate 2>&1'
: 'docker exec appwrite migrate 2>&1';
$output = [];
\exec($command, $output, $exit);
if ($exit !== 0) {
$message = trim(implode("\n", $output));
$this->updateProgress(
$progress,
InstallerServer::STEP_MIGRATION,
InstallerServer::STATUS_ERROR,
details: ['output' => $message],
messageOverride: 'Migration failed: ' . ($message ?: 'exit code ' . $exit)
);
throw new \RuntimeException('Database migration failed', 0, $message !== '' ? new \RuntimeException($message) : null);
}
$this->updateProgress(
$progress,
InstallerServer::STEP_MIGRATION,
InstallerServer::STATUS_COMPLETED,
messageOverride: 'Database migration completed'
);
}
private function trackSelfHostedInstall(array $input, bool $isUpgrade, string $version, array $account): void
{
if ($this->isLocalInstall()) {
@@ -753,7 +824,7 @@ class Install extends Action
$name = $account['name'] ?? 'Admin';
$email = $account['email'] ?? 'admin@selfhosted.local';
$hostIp = gethostbyname($domain);
$hostIp = @gethostbyname($domain);
$payload = [
'action' => $type,
@@ -767,7 +838,7 @@ class Install extends Action
'email' => $email,
'domain' => $domain,
'database' => $database,
'hostIp' => $hostIp !== $domain ? $hostIp : null,
'ip' => ($hostIp !== false && $hostIp !== $domain) ? $hostIp : null,
'os' => php_uname('s') . ' ' . php_uname('r'),
'arch' => php_uname('m'),
'cpus' => ((int) trim((string) \shell_exec('nproc'))) ?: null,
@@ -778,6 +849,8 @@ class Install extends Action
try {
$client = new Client();
$client
->setConnectTimeout(5000)
->setTimeout(5000)
->addHeader('Content-Type', 'application/json')
->fetch(self::GROWTH_API_URL . '/analytics', Client::METHOD_POST, $payload);
} catch (\Throwable) {
@@ -1261,6 +1334,22 @@ class Install extends Action
$this->hostPath = $this->getInstallerHostPath();
}
/**
* Check if any installer-specific CLI params were explicitly passed.
* When params like --database or --http-port are provided, the user
* intends to run in CLI mode rather than launching the web installer.
*/
private function hasExplicitCliParams(): bool
{
$argv = $_SERVER['argv'] ?? [];
foreach ($argv as $arg) {
if (\str_starts_with($arg, '--')) {
return true;
}
}
return false;
}
/**
* Detect the database adapter from a pre-1.9.0 compose file by
* checking which DB service exists or reading _APP_DB_HOST.
+150 -138
View File
@@ -102,11 +102,14 @@ class SDKs extends Action
} else {
$sdks = explode(',', $sdks);
}
$version ??= Console::confirm('Choose an Appwrite version');
$createRelease = ($release === 'yes');
$commitRelease = ($commit === 'yes');
if ($createRelease && $examplesOnly) {
throw new \Exception('Cannot use --release=yes with --mode=examples');
}
if (! $createRelease && ! $examplesOnly) {
$git ??= Console::confirm('Should we use git push? (yes/no)');
$git = ($git === 'yes');
@@ -118,30 +121,34 @@ class SDKs extends Action
$prUrls = [];
}
if (! \in_array($version, [
'0.6.x',
'0.7.x',
'0.8.x',
'0.9.x',
'0.10.x',
'0.11.x',
'0.12.x',
'0.13.x',
'0.14.x',
'0.15.x',
'1.0.x',
'1.1.x',
'1.2.x',
'1.3.x',
'1.4.x',
'1.5.x',
'1.6.x',
'1.7.x',
'1.8.x',
'1.9.x',
'latest',
])) {
throw new \Exception('Unknown version given');
if (! $createRelease) {
$version ??= Console::confirm('Choose an Appwrite version');
if (! \in_array($version, [
'0.6.x',
'0.7.x',
'0.8.x',
'0.9.x',
'0.10.x',
'0.11.x',
'0.12.x',
'0.13.x',
'0.14.x',
'0.15.x',
'1.0.x',
'1.1.x',
'1.2.x',
'1.3.x',
'1.4.x',
'1.5.x',
'1.6.x',
'1.7.x',
'1.8.x',
'1.9.x',
'latest',
])) {
throw new \Exception('Unknown version given');
}
}
$selectedPlatforms = ($selectedPlatform === '*' || $selectedPlatform === null) ? null : \array_map('trim', \explode(',', $selectedPlatform));
@@ -173,6 +180,124 @@ class SDKs extends Action
}
Console::log('');
if ($createRelease && ! $examplesOnly) {
Console::info("━━━ {$language['name']} SDK ({$platform['name']}, {$language['version']}) ━━━");
$changelog = $language['changelog'] ?? '';
$changelog = ($changelog) ? \file_get_contents($changelog) : '# Change Log';
$repoName = $language['gitUserName'] . '/' . $language['gitRepoName'];
$releaseVersion = $language['version'];
$releaseNotes = $this->extractReleaseNotes($changelog, $releaseVersion);
if (empty($releaseNotes)) {
$releaseNotes = "Release version {$releaseVersion}";
}
$releaseTitle = $releaseVersion;
$releaseTarget = $language['repoBranch'] ?? 'main';
if ($repoName === '/') {
Console::warning(' Not a releasable SDK, skipping');
continue;
}
// Check if release already exists
$checkReleaseCommand = 'gh release view ' . \escapeshellarg($releaseVersion) . ' --repo ' . \escapeshellarg($repoName) . ' --json url --jq ".url" 2>/dev/null';
$existingReleaseUrl = trim(\shell_exec($checkReleaseCommand) ?? '');
if (! empty($existingReleaseUrl)) {
Console::warning(" Release {$releaseVersion} already exists, skipping");
Console::log(" {$existingReleaseUrl}");
continue;
}
// Check if the latest commit on the target branch already has a release
$latestCommitCommand = 'gh api repos/' . $repoName . '/commits/' . $releaseTarget . ' --jq ".sha" 2>/dev/null';
$latestCommitSha = trim(\shell_exec($latestCommitCommand) ?? '');
if (! empty($latestCommitSha)) {
$latestReleaseTagCommand = 'gh api repos/' . $repoName . '/releases --jq ".[0] | .tag_name" 2>/dev/null';
$latestReleaseTag = trim(\shell_exec($latestReleaseTagCommand) ?? '');
if (! empty($latestReleaseTag)) {
$tagCommitCommand = 'gh api repos/' . $repoName . '/git/ref/tags/' . $latestReleaseTag . ' --jq ".object.sha" 2>/dev/null';
$tagCommitSha = trim(\shell_exec($tagCommitCommand) ?? '');
if (! empty($tagCommitSha) && $latestCommitSha === $tagCommitSha) {
Console::warning(" Latest commit already released ({$latestReleaseTag}), skipping");
continue;
}
}
}
$previousVersion = '';
$tagListCommand = 'gh release list --repo ' . \escapeshellarg($repoName) . ' --limit 1 --json tagName --jq ".[0].tagName" 2>&1';
$previousVersion = trim(\shell_exec($tagListCommand) ?? '');
$formattedNotes = "## What's Changed\n\n";
$formattedNotes .= $releaseNotes . "\n\n";
if (! empty($previousVersion)) {
$formattedNotes .= '**Full Changelog**: https://github.com/' . $repoName . '/compare/' . $previousVersion . '...' . $releaseVersion;
} else {
$formattedNotes .= '**Full Changelog**: https://github.com/' . $repoName . '/releases/tag/' . $releaseVersion;
}
if (! $commitRelease) {
Console::info(' [DRY RUN] Would create release:');
Console::log(" Repository: {$repoName}");
Console::log(" Version: {$releaseVersion}");
Console::log(" Title: {$releaseTitle}");
Console::log(" Target Branch: {$releaseTarget}");
Console::log(' Previous Version: ' . ($previousVersion ?: 'N/A'));
Console::log(' Release Notes:');
Console::log(' ' . str_replace("\n", "\n ", $formattedNotes));
} else {
Console::log(" Creating release {$releaseVersion}...");
$tempNotesFile = \tempnam(\sys_get_temp_dir(), 'release_notes_');
\file_put_contents($tempNotesFile, $formattedNotes);
$releaseCommand = 'gh release create ' . \escapeshellarg($releaseVersion) . ' \
--repo ' . \escapeshellarg($repoName) . ' \
--title ' . \escapeshellarg($releaseTitle) . ' \
--notes-file ' . \escapeshellarg($tempNotesFile) . ' \
--target ' . \escapeshellarg($releaseTarget) . ' \
2>&1';
$releaseOutput = [];
$releaseReturnCode = 0;
\exec($releaseCommand, $releaseOutput, $releaseReturnCode);
\unlink($tempNotesFile);
if ($releaseReturnCode === 0) {
// Extract release URL from output
$releaseUrl = '';
foreach ($releaseOutput as $line) {
if (strpos($line, 'https://github.com/') !== false) {
$releaseUrl = trim($line);
break;
}
}
Console::success(" Release {$releaseVersion} created");
if (! empty($releaseUrl)) {
Console::log(" {$releaseUrl}");
}
} else {
$errorMessage = implode("\n", $releaseOutput);
Console::error(" Failed to create release: " . $errorMessage);
}
}
continue;
}
Console::info("━━━ {$language['name']} SDK ({$platform['name']}, {$version}) ━━━");
$specFormat = $language['spec'] ?? 'swagger2';
$spec = null;
@@ -330,119 +455,6 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
throw new \Exception('Language "' . $language['key'] . '" not supported');
}
if ($createRelease && ! $examplesOnly) {
$repoName = $language['gitUserName'] . '/' . $language['gitRepoName'];
$releaseVersion = $language['version'];
$releaseNotes = $this->extractReleaseNotes($changelog, $releaseVersion);
if (empty($releaseNotes)) {
$releaseNotes = "Release version {$releaseVersion}";
}
$releaseTitle = $releaseVersion;
$releaseTarget = $language['repoBranch'] ?? 'main';
if ($repoName === '/') {
Console::warning(' Not a releasable SDK, skipping');
continue;
}
// Check if release already exists
$checkReleaseCommand = 'gh release view ' . \escapeshellarg($releaseVersion) . ' --repo ' . \escapeshellarg($repoName) . ' --json url --jq ".url" 2>/dev/null';
$existingReleaseUrl = trim(\shell_exec($checkReleaseCommand) ?? '');
if (! empty($existingReleaseUrl)) {
Console::warning(" Release {$releaseVersion} already exists, skipping");
Console::log(" {$existingReleaseUrl}");
continue;
}
// Check if the latest commit on the target branch already has a release
$latestCommitCommand = 'gh api repos/' . $repoName . '/commits/' . $releaseTarget . ' --jq ".sha" 2>/dev/null';
$latestCommitSha = trim(\shell_exec($latestCommitCommand) ?? '');
if (! empty($latestCommitSha)) {
$latestReleaseTagCommand = 'gh api repos/' . $repoName . '/releases --jq ".[0] | .tag_name" 2>/dev/null';
$latestReleaseTag = trim(\shell_exec($latestReleaseTagCommand) ?? '');
if (! empty($latestReleaseTag)) {
$tagCommitCommand = 'gh api repos/' . $repoName . '/git/ref/tags/' . $latestReleaseTag . ' --jq ".object.sha" 2>/dev/null';
$tagCommitSha = trim(\shell_exec($tagCommitCommand) ?? '');
if (! empty($tagCommitSha) && $latestCommitSha === $tagCommitSha) {
Console::warning(" Latest commit already released ({$latestReleaseTag}), skipping");
continue;
}
}
}
$previousVersion = '';
$tagListCommand = 'gh release list --repo ' . \escapeshellarg($repoName) . ' --limit 1 --json tagName --jq ".[0].tagName" 2>&1';
$previousVersion = trim(\shell_exec($tagListCommand) ?? '');
$formattedNotes = "## What's Changed\n\n";
$formattedNotes .= $releaseNotes . "\n\n";
if (! empty($previousVersion)) {
$formattedNotes .= '**Full Changelog**: https://github.com/' . $repoName . '/compare/' . $previousVersion . '...' . $releaseVersion;
} else {
$formattedNotes .= '**Full Changelog**: https://github.com/' . $repoName . '/releases/tag/' . $releaseVersion;
}
if (! $commitRelease) {
Console::info(' [DRY RUN] Would create release:');
Console::log(" Repository: {$repoName}");
Console::log(" Version: {$releaseVersion}");
Console::log(" Title: {$releaseTitle}");
Console::log(" Target Branch: {$releaseTarget}");
Console::log(' Previous Version: ' . ($previousVersion ?: 'N/A'));
Console::log(' Release Notes:');
Console::log(' ' . str_replace("\n", "\n ", $formattedNotes));
} else {
Console::log(" Creating release {$releaseVersion}...");
$tempNotesFile = \tempnam(\sys_get_temp_dir(), 'release_notes_');
\file_put_contents($tempNotesFile, $formattedNotes);
$releaseCommand = 'gh release create ' . \escapeshellarg($releaseVersion) . ' \
--repo ' . \escapeshellarg($repoName) . ' \
--title ' . \escapeshellarg($releaseTitle) . ' \
--notes-file ' . \escapeshellarg($tempNotesFile) . ' \
--target ' . \escapeshellarg($releaseTarget) . ' \
2>&1';
$releaseOutput = [];
$releaseReturnCode = 0;
\exec($releaseCommand, $releaseOutput, $releaseReturnCode);
\unlink($tempNotesFile);
if ($releaseReturnCode === 0) {
// Extract release URL from output
$releaseUrl = '';
foreach ($releaseOutput as $line) {
if (strpos($line, 'https://github.com/') !== false) {
$releaseUrl = trim($line);
break;
}
}
Console::success(" Release {$releaseVersion} created");
if (! empty($releaseUrl)) {
Console::log(" {$releaseUrl}");
}
} else {
$errorMessage = implode("\n", $releaseOutput);
Console::error(" Failed to create release: " . $errorMessage);
}
}
continue;
}
Console::log($examplesOnly
? ' Generating examples...'
: ' Generating SDK...');
+24 -25
View File
@@ -357,6 +357,13 @@ class Specs extends Action
$keys = $this->getKeys();
$generatedFiles = [];
$endpoint = System::getEnv('_APP_HOME', 'https://appwrite.io');
$email = System::getEnv('_APP_SYSTEM_TEAM_EMAIL', 'team@appwrite.io');
$specsDir = __DIR__ . '/../../../../app/config/specs';
if (!is_dir($specsDir) && !@mkdir($specsDir, 0755, true) && !is_dir($specsDir)) {
throw new Exception('Failed to create specs directory: ' . $specsDir);
}
foreach ($platforms as $platform) {
$routes = [];
@@ -453,8 +460,6 @@ class Specs extends Action
foreach (['swagger2', 'open-api3'] as $format) {
$formatInstance = $this->getFormatInstance($format, $arguments);
$specs = new Specification($formatInstance);
$endpoint = System::getEnv('_APP_HOME', '[HOSTNAME]');
$email = System::getEnv('_APP_SYSTEM_TEAM_EMAIL', APP_EMAIL_TEAM);
$formatInstance
->setParam('name', APP_NAME)
@@ -473,36 +478,30 @@ class Specs extends Action
->setParam('docs.description', 'Full API docs, specs and tutorials')
->setParam('docs.url', $endpoint . '/docs');
$specsDir = __DIR__ . '/../../../../app/config/specs';
$path = $mocks
? $specsDir . '/' . $format . '-mocks-' . $platform . '.json'
: $specsDir . '/' . $format . '-' . $version . '-' . $platform . '.json';
if (!is_dir($specsDir)) {
if (!mkdir($specsDir, 0755, true)) {
throw new Exception('Failed to create specs directory: ' . $specsDir);
}
$parsedSpecs = $specs->parse();
$encodedSpecs = \json_encode($parsedSpecs, JSON_PRETTY_PRINT);
unset($parsedSpecs);
if ($encodedSpecs === false) {
throw new Exception('Failed to encode ' . ($mocks ? 'mocks ' : '') . 'spec file: ' . \json_last_error_msg());
}
if ($mocks) {
$path = $specsDir . '/' . $format . '-mocks-' . $platform . '.json';
if (!file_put_contents($path, json_encode($specs->parse(), JSON_PRETTY_PRINT))) {
throw new Exception('Failed to save mocks spec file: ' . $path);
}
$generatedFiles[] = realpath($path);
Console::success('Saved mocks spec file: ' . realpath($path));
continue;
}
$path = $specsDir . '/' . $format . '-' . $version . '-' . $platform . '.json';
if (!file_put_contents($path, json_encode($specs->parse(), JSON_PRETTY_PRINT))) {
throw new Exception('Failed to save spec file: ' . $path);
if (\file_put_contents($path, $encodedSpecs) === false) {
throw new Exception('Failed to save ' . ($mocks ? 'mocks ' : '') . 'spec file: ' . $path);
}
$generatedFiles[] = realpath($path);
Console::success('Saved spec file: ' . realpath($path));
Console::success('Saved ' . ($mocks ? 'mocks ' : '') . 'spec file: ' . realpath($path));
unset($encodedSpecs, $specs, $formatInstance);
}
unset($arguments, $models, $routes, $services);
}
if ($git === 'yes') {
+4 -1
View File
@@ -30,6 +30,7 @@ class Upgrade extends Install
->param('interactive', 'Y', new Text(1), 'Run an interactive session', true)
->param('no-start', false, new Boolean(true), 'Run an interactive session', true)
->param('database', 'mongodb', new Text(length: 0), 'Database to use (mongodb|mariadb|postgresql)', true)
->param('migrate', false, new Boolean(true), 'Run database migration after upgrade', true)
->callback($this->action(...));
}
@@ -40,9 +41,11 @@ class Upgrade extends Install
string $image,
string $interactive,
bool $noStart,
string $database
string $database,
bool $migrate = false,
): void {
$this->isUpgrade = true;
$this->migrate = $migrate;
$isLocalInstall = $this->isLocalInstall();
$this->applyLocalPaths($isLocalInstall, true);
+40 -18
View File
@@ -28,6 +28,7 @@ use Utopia\Locale\Locale;
use Utopia\Migration\Destination;
use Utopia\Migration\Destinations\Appwrite as DestinationAppwrite;
use Utopia\Migration\Destinations\CSV as DestinationCSV;
use Utopia\Migration\Destinations\JSON as DestinationJSON;
use Utopia\Migration\Exception as MigrationException;
use Utopia\Migration\Resource;
use Utopia\Migration\Resources\Database\Database as ResourceDatabase;
@@ -37,6 +38,7 @@ use Utopia\Migration\Source;
use Utopia\Migration\Sources\Appwrite as SourceAppwrite;
use Utopia\Migration\Sources\CSV;
use Utopia\Migration\Sources\Firebase;
use Utopia\Migration\Sources\JSON;
use Utopia\Migration\Sources\NHost;
use Utopia\Migration\Sources\Supabase;
use Utopia\Migration\Transfer;
@@ -208,8 +210,8 @@ class Migrations extends Action
$getDatabasesDB = fn (Document $database): Database =>
$this->getDatabasesDBForProject($database);
$queries = [];
if ($source === SourceAppwrite::getName() && $destination === DestinationCSV::getName()) {
$queries = Query::parseQueries($migrationOptions['queries']);
if ($source === SourceAppwrite::getName() && in_array($destination, [DestinationCSV::getName(), DestinationJSON::getName()])) {
$queries = Query::parseQueries($migrationOptions['queries'] ?? []);
}
$migrationSource = match ($source) {
@@ -250,6 +252,12 @@ class Migrations extends Action
$this->dbForProject,
$getDatabasesDB
),
JSON::getName() => new JSON(
$resourceId,
$migrationOptions['path'],
$this->deviceForMigrations,
$this->dbForProject,
),
default => throw new \Exception('Invalid source type'),
};
@@ -288,6 +296,13 @@ class Migrations extends Action
$options['escape'],
$options['header'],
),
DestinationJSON::getName() => new DestinationJSON(
$this->deviceForFiles,
$migration->getAttribute('resourceId'),
$options['bucketId'] ?? 'default',
$options['filename'],
$options['columns'] ?? [],
),
default => throw new \Exception('Invalid destination type'),
};
}
@@ -550,8 +565,9 @@ class Migrations extends Action
$destination?->success();
$source?->success();
}
if ($migration->getAttribute('destination') === DestinationCSV::getName()) {
$this->handleCSVExportComplete($project, $migration, $queueForMails, $queueForRealtime, $platform, $authorization);
$destination_type = $migration->getAttribute('destination');
if ($destination_type === DestinationCSV::getName() || $destination_type === DestinationJSON::getName()) {
$this->handleDataExportComplete($project, $migration, $queueForMails, $queueForRealtime, $platform, $authorization);
}
} finally {
$source?->cleanup();
@@ -583,7 +599,7 @@ class Migrations extends Action
* @param Authorization $authorization
* @return void
*/
protected function handleCSVExportComplete(
protected function handleDataExportComplete(
Document $project,
Document $migration,
Mail $queueForMails,
@@ -608,7 +624,8 @@ class Migrations extends Action
throw new \Exception('Bucket not found');
}
$path = $this->deviceForFiles->getPath($bucketId . '/' . $this->sanitizeFilename($filename) . '.csv');
$extension = $migration->getAttribute('destination') === DestinationJSON::getName() ? '.json' : '.csv';
$path = $this->deviceForFiles->getPath($bucketId . '/' . $this->sanitizeFilename($filename) . $extension);
$size = $this->deviceForFiles->getFileSize($path);
$mime = $this->deviceForFiles->getFileMimeType($path);
$hash = $this->deviceForFiles->getFileHash($path);
@@ -632,13 +649,14 @@ class Migrations extends Action
$migration->setAttribute('errors', $errors);
$migration = $this->updateMigrationDocument($migration, $project, $queueForRealtime);
$this->sendCSVEmail(
$this->sendExportEmail(
success: false,
project: $project,
user: $user,
options: $options,
queueForMails: $queueForMails,
platform: $platform,
exportType: $migration->getAttribute('destination') === DestinationJSON::getName() ? 'JSON' : 'CSV',
sizeMB: $sizeMB
);
@@ -694,13 +712,14 @@ class Migrations extends Action
$migration->setAttribute('options', $options);
$this->updateMigrationDocument($migration, $project, $queueForRealtime);
$this->sendCSVEmail(
$this->sendExportEmail(
success: true,
project: $project,
user: $user,
options: $options,
queueForMails: $queueForMails,
platform: $platform,
exportType: $migration->getAttribute('destination') === DestinationJSON::getName() ? 'JSON' : 'CSV',
downloadUrl: $downloadUrl
);
}
@@ -719,13 +738,14 @@ class Migrations extends Action
* @return void
* @throws \Exception
*/
protected function sendCSVEmail(
protected function sendExportEmail(
bool $success,
Document $project,
Document $user,
array $options,
Mail $queueForMails,
array $platform,
string $exportType = 'CSV',
string $downloadUrl = '',
float $sizeMB = 0.0,
): void {
@@ -745,15 +765,15 @@ class Migrations extends Action
? 'success'
: 'failure';
// Get localized email content
$subject = $locale->getText("emails.csvExport.{$emailType}.subject");
$preview = $locale->getText("emails.csvExport.{$emailType}.preview");
$hello = $locale->getText("emails.csvExport.{$emailType}.hello");
$body = $locale->getText("emails.csvExport.{$emailType}.body");
$footer = $locale->getText("emails.csvExport.{$emailType}.footer");
$thanks = $locale->getText("emails.csvExport.{$emailType}.thanks");
$signature = $locale->getText("emails.csvExport.{$emailType}.signature");
$buttonText = $success ? $locale->getText("emails.csvExport.{$emailType}.buttonText") : '';
// Get localized email content — replace {{type}} with export format (CSV/JSON)
$subject = \str_replace('{{type}}', $exportType, $locale->getText("emails.dataExport.{$emailType}.subject"));
$preview = \str_replace('{{type}}', $exportType, $locale->getText("emails.dataExport.{$emailType}.preview"));
$hello = $locale->getText("emails.dataExport.{$emailType}.hello");
$body = $locale->getText("emails.dataExport.{$emailType}.body");
$footer = $locale->getText("emails.dataExport.{$emailType}.footer");
$thanks = $locale->getText("emails.dataExport.{$emailType}.thanks");
$signature = $locale->getText("emails.dataExport.{$emailType}.signature");
$buttonText = $success ? $locale->getText("emails.dataExport.{$emailType}.buttonText") : '';
// Build email body using appropriate template
$templatePath = $success
@@ -769,6 +789,7 @@ class Migrations extends Action
->setParam('{{direction}}', $locale->getText('settings.direction'))
->setParam('{{project}}', $project->getAttribute('name'))
->setParam('{{user}}', $user->getAttribute('name', $user->getAttribute('email')))
->setParam('{{type}}', $exportType)
->setParam('{{size}}', $success ? '' : (string)$sizeMB);
if ($success) {
@@ -789,6 +810,7 @@ class Migrations extends Action
'terms' => $platform['termsUrl'],
'privacy' => $platform['privacyUrl'],
'platform' => $platform['platformName'],
'type' => $exportType,
];
$queueForMails
+2 -2
View File
@@ -149,7 +149,7 @@ class Template extends View
/**
* From Camel Case
*
* @var string $input
* @param string $input
*
* @return string
*/
@@ -167,7 +167,7 @@ class Template extends View
/**
* From Camel Case to Dash Case
*
* @var string $input
* @param string $input
*
* @return string
*/
@@ -102,7 +102,7 @@ class User extends Document
*
* @return bool
*/
public static function isPrivileged(array $roles): bool
public function isPrivileged(array $roles): bool
{
if (
in_array(self::ROLE_OWNER, $roles) ||
@@ -122,7 +122,7 @@ class User extends Document
*
* @return bool
*/
public static function isApp(array $roles): bool
public function isApp(array $roles): bool
{
if (in_array(self::ROLE_APPS, $roles)) {
return true;
@@ -175,7 +175,5 @@ class User extends Document
}
return false;
return false;
}
}
+7 -1
View File
@@ -215,7 +215,7 @@ class Request extends UtopiaRequest
$forwardedUserAgent = $this->getHeader('x-forwarded-user-agent');
if (!empty($forwardedUserAgent)) {
$roles = $this->authorization->getRoles();
$isAppUser = User::isApp($roles);
$isAppUser = $this->user?->isApp($roles) ?? false;
if ($isAppUser) {
return $forwardedUserAgent;
@@ -239,9 +239,15 @@ class Request extends UtopiaRequest
}
private ?Authorization $authorization = null;
private ?User $user = null;
public function setAuthorization(Authorization $authorization): void
{
$this->authorization = $authorization;
}
public function setUser(User $user): void
{
$this->user = $user;
}
}

Some files were not shown because too many files have changed in this diff Show More