From 61ca4e3969827ddcd6d5a90d6e47ae244a57b258 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 4 May 2026 14:15:01 +0400 Subject: [PATCH 01/47] Fix parallel storage chunk upload state --- composer.json | 7 + composer.lock | 79 +++- .../Storage/Http/Buckets/Files/Create.php | 423 ++++++++++-------- tests/e2e/Services/Storage/StorageBase.php | 151 +++++++ 4 files changed, 471 insertions(+), 189 deletions(-) diff --git a/composer.json b/composer.json index 735955d980..a5b00a8b8b 100644 --- a/composer.json +++ b/composer.json @@ -72,6 +72,7 @@ "utopia-php/validators": "0.2.*", "utopia-php/image": "0.8.*", "utopia-php/locale": "0.8.*", + "utopia-php/lock": "dev-main", "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.22.*", "utopia-php/migration": "1.9.*", @@ -111,6 +112,12 @@ "provide": { "ext-phpiredis": "*" }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/utopia-php/lock" + } + ], "config": { "platform": { }, diff --git a/composer.lock b/composer.lock index 9f34d2dbe1..4d920f1eee 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bd45829c252971301370d62300be106d", + "content-hash": "fab1d5b01931f0e2545c36c50b3963b7", "packages": [ { "name": "adhocore/jwt", @@ -4423,6 +4423,79 @@ }, "time": "2025-08-12T12:58:26+00:00" }, + { + "name": "utopia-php/lock", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/lock.git", + "reference": "22eda789528da4cc71e767c9ad9e9fd4fa2c9467" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/lock/zipball/22eda789528da4cc71e767c9ad9e9fd4fa2c9467", + "reference": "22eda789528da4cc71e767c9ad9e9fd4fa2c9467", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "laravel/pint": "1.*", + "phpstan/phpstan": "2.*", + "phpunit/phpunit": "11.*", + "swoole/ide-helper": "*" + }, + "suggest": { + "ext-pcntl": "Required to run the File lock tests", + "ext-redis": "Required for the Distributed lock", + "ext-swoole": "Required for the Mutex and Semaphore locks (>=6.0)" + }, + "default-branch": true, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Lock\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Utopia\\Lock\\Tests\\": "tests/" + } + }, + "scripts": { + "test": [ + "vendor/bin/phpunit" + ], + "lint": [ + "vendor/bin/pint --test" + ], + "format": [ + "vendor/bin/pint" + ], + "format:check": [ + "vendor/bin/pint --test" + ], + "analyze": [ + "vendor/bin/phpstan analyse --memory-limit=512M" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Appwrite Team", + "email": "team@appwrite.io" + } + ], + "description": "A simple lock library to coordinate access to shared resources across coroutines, processes and hosts", + "support": { + "source": "https://github.com/utopia-php/lock/tree/main", + "issues": "https://github.com/utopia-php/lock/issues" + }, + "time": "2026-04-28T10:07:10+00:00" + }, { "name": "utopia-php/logger", "version": "0.6.2", @@ -8444,7 +8517,9 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": { + "utopia-php/lock": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php index 2ce5ef97f5..f9854a17a4 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php @@ -29,6 +29,8 @@ use Utopia\Database\Validator\Authorization\Input; use Utopia\Database\Validator\Permissions; use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Request; +use Utopia\Lock\Distributed; +use Utopia\Lock\Exception\Contention as LockContention; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Storage\Device; @@ -86,12 +88,14 @@ class Create extends Action ->inject('request') ->inject('response') ->inject('dbForProject') + ->inject('project') ->inject('user') ->inject('queueForEvents') ->inject('mode') ->inject('deviceForFiles') ->inject('deviceForLocal') ->inject('authorization') + ->inject('redis') ->callback($this->action(...)); } @@ -103,12 +107,14 @@ class Create extends Action Request $request, Response $response, Database $dbForProject, + Document $project, User $user, Event $queueForEvents, string $mode, Device $deviceForFiles, Device $deviceForLocal, - Authorization $authorization + Authorization $authorization, + \Redis $redis ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); @@ -234,24 +240,43 @@ class Create extends Action $path = $deviceForFiles->getPath($fileId . '.' . \pathinfo($fileName, PATHINFO_EXTENSION)); $path = str_ireplace($deviceForFiles->getRoot(), $deviceForFiles->getRoot() . DIRECTORY_SEPARATOR . $bucket->getId(), $path); // Add bucket id to path after root - $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); + $lock = new Distributed( + $redis, + 'storage:file:' . $project->getId() . ':' . $bucket->getId() . ':' . $fileId, + ttl: 600, + ); $metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)]; - if (!$file->isEmpty()) { - $chunks = $file->getAttribute('chunksTotal', 1); - $uploaded = $file->getAttribute('chunksUploaded', 0); - $metadata = $file->getAttribute('metadata', []); + $completed = false; - if ($uploaded === $chunks) { - if (empty($contentRange)) { - throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); + try { + $lock->withLock(function () use ($bucket, &$chunks, $contentRange, $dbForProject, $fileId, &$metadata, &$completed, $response): void { + $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); + if (!$file->isEmpty()) { + $chunks = $file->getAttribute('chunksTotal', 1); + $uploaded = $file->getAttribute('chunksUploaded', 0); + $metadata = $file->getAttribute('metadata', []); + + if ($uploaded === $chunks) { + if (empty($contentRange)) { + throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic($file, Response::MODEL_FILE); + + $completed = true; + return; + } } + }, timeout: 120.0); + } catch (LockContention) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'File upload is busy. Try again.'); + } - $response - ->setStatusCode(Response::STATUS_CODE_OK) - ->dynamic($file, Response::MODEL_FILE); - return; - } + if ($completed) { + return; } $chunksUploaded = $deviceForFiles->upload($fileTmpName, $path, $chunk, $chunks, $metadata); @@ -260,187 +285,211 @@ class Create extends Action throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed uploading file'); } - if ($chunksUploaded === $chunks) { - if (System::getEnv('_APP_STORAGE_ANTIVIRUS') === 'enabled' && $bucket->getAttribute('antivirus', true) && $fileSize <= APP_LIMIT_ANTIVIRUS && $deviceForFiles->getType() === Storage::DEVICE_LOCAL) { - $antivirus = new Network( - System::getEnv('_APP_STORAGE_ANTIVIRUS_HOST', 'clamav'), - (int) System::getEnv('_APP_STORAGE_ANTIVIRUS_PORT', 3310) - ); + try { + $lock->withLock(function () use ($authorization, $bucket, &$chunks, $chunksUploaded, $contentRange, $dbForProject, $deviceForFiles, $fileId, $fileName, $fileSize, &$metadata, $path, $permissions, $queueForEvents, $response): void { + $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); - if (!$antivirus->fileScan($path)) { - $deviceForFiles->delete($path); - throw new Exception(Exception::STORAGE_INVALID_FILE); + if (!$file->isEmpty()) { + $chunks = $file->getAttribute('chunksTotal', 1); + $uploaded = $file->getAttribute('chunksUploaded', 0); + $metadata = \array_merge($file->getAttribute('metadata', []), $metadata); + + if ($uploaded === $chunks) { + if (empty($contentRange)) { + throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic($file, Response::MODEL_FILE); + + return; + } } - } - $mimeType = $deviceForFiles->getFileMimeType($path); // Get mime-type before compression and encryption - $fileHash = $deviceForFiles->getFileHash($path); // Get file hash before compression and encryption - $data = ''; - $iv = ''; - $tag = null; - // Compression - $algorithm = $bucket->getAttribute('compression', Compression::NONE); - if ($fileSize <= APP_STORAGE_READ_BUFFER && $algorithm != Compression::NONE) { - $data = $deviceForFiles->read($path); - switch ($algorithm) { - case Compression::ZSTD: - $compressor = new Zstd(); - break; - case Compression::GZIP: - default: - $compressor = new GZIP(); - break; + if ($chunksUploaded === $chunks) { + if (System::getEnv('_APP_STORAGE_ANTIVIRUS') === 'enabled' && $bucket->getAttribute('antivirus', true) && $fileSize <= APP_LIMIT_ANTIVIRUS && $deviceForFiles->getType() === Storage::DEVICE_LOCAL) { + $antivirus = new Network( + System::getEnv('_APP_STORAGE_ANTIVIRUS_HOST', 'clamav'), + (int) System::getEnv('_APP_STORAGE_ANTIVIRUS_PORT', 3310) + ); + + if (!$antivirus->fileScan($path)) { + $deviceForFiles->delete($path); + throw new Exception(Exception::STORAGE_INVALID_FILE); + } + } + + $mimeType = $deviceForFiles->getFileMimeType($path); // Get mime-type before compression and encryption + $fileHash = $deviceForFiles->getFileHash($path); // Get file hash before compression and encryption + $data = ''; + $iv = ''; + $tag = null; + // Compression + $algorithm = $bucket->getAttribute('compression', Compression::NONE); + if ($fileSize <= APP_STORAGE_READ_BUFFER && $algorithm != Compression::NONE) { + $data = $deviceForFiles->read($path); + switch ($algorithm) { + case Compression::ZSTD: + $compressor = new Zstd(); + break; + case Compression::GZIP: + default: + $compressor = new GZIP(); + break; + } + $data = $compressor->compress($data); + } else { + // reset the algorithm to none as we do not compress the file + // if file size exceedes the APP_STORAGE_READ_BUFFER + // regardless the bucket compression algoorithm + $algorithm = Compression::NONE; + } + + if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) { + if (empty($data)) { + $data = $deviceForFiles->read($path); + } + $key = System::getEnv('_APP_OPENSSL_KEY_V1'); + $iv = OpenSSL::randomPseudoBytes(OpenSSL::cipherIVLength(OpenSSL::CIPHER_AES_128_GCM)); + $data = OpenSSL::encrypt($data, OpenSSL::CIPHER_AES_128_GCM, $key, 0, $iv, $tag); + } + + if (!empty($data)) { + if (!$deviceForFiles->write($path, $data, $mimeType)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to save file'); + } + } + + $sizeActual = $deviceForFiles->getFileSize($path); + + $openSSLVersion = null; + $openSSLCipher = null; + $openSSLTag = null; + $openSSLIV = null; + + if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) { + $openSSLVersion = '1'; + $openSSLCipher = OpenSSL::CIPHER_AES_128_GCM; + $openSSLTag = \bin2hex($tag); + $openSSLIV = \bin2hex($iv); + } + + if ($file->isEmpty()) { + $doc = new Document([ + '$id' => $fileId, + '$permissions' => $permissions, + 'bucketId' => $bucket->getId(), + 'bucketInternalId' => $bucket->getSequence(), + 'name' => $fileName, + 'path' => $path, + 'signature' => $fileHash, + 'mimeType' => $mimeType, + 'sizeOriginal' => $fileSize, + 'sizeActual' => $sizeActual, + 'algorithm' => $algorithm, + 'comment' => '', + 'chunksTotal' => $chunks, + 'chunksUploaded' => $chunksUploaded, + 'openSSLVersion' => $openSSLVersion, + 'openSSLCipher' => $openSSLCipher, + 'openSSLTag' => $openSSLTag, + 'openSSLIV' => $openSSLIV, + 'search' => implode(' ', [$fileId, $fileName]), + 'metadata' => $metadata, + ]); + + try { + $file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc); + } catch (DuplicateException) { + throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); + } catch (NotFoundException) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + } else { + /** + * Skip authorization in updateDocument. + * Without this, the file creation will fail when user doesn't have update permission. + * However as with chunk upload even if we are updating, we are essentially creating a file + * adding it's new chunk so we rely on the create-permission check performed earlier. + */ + $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, new Document([ + '$permissions' => $permissions, + 'signature' => $fileHash, + 'mimeType' => $mimeType, + 'sizeActual' => $sizeActual, + 'algorithm' => $algorithm, + 'openSSLVersion' => $openSSLVersion, + 'openSSLCipher' => $openSSLCipher, + 'openSSLTag' => $openSSLTag, + 'openSSLIV' => $openSSLIV, + 'metadata' => $metadata, + 'chunksUploaded' => $chunksUploaded, + ]))); + } + + // Trigger after create success hook + $this->afterCreateSuccess($file); + } else { + if ($file->isEmpty()) { + $doc = new Document([ + '$id' => ID::custom($fileId), + '$permissions' => $permissions, + 'bucketId' => $bucket->getId(), + 'bucketInternalId' => $bucket->getSequence(), + 'name' => $fileName, + 'path' => $path, + 'signature' => '', + 'mimeType' => '', + 'sizeOriginal' => $fileSize, + 'sizeActual' => 0, + 'algorithm' => '', + 'comment' => '', + 'chunksTotal' => $chunks, + 'chunksUploaded' => $chunksUploaded, + 'search' => implode(' ', [$fileId, $fileName]), + 'metadata' => $metadata, + ]); + + try { + $file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc); + } catch (DuplicateException) { + throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); + } catch (NotFoundException) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + } else { + /** + * Skip authorization in updateDocument. + * Without this, the file creation will fail when user doesn't have update permission. + * However as with chunk upload even if we are updating, we are essentially creating a file + * adding it's new chunk so we rely on the create-permission check performed earlier. + */ + try { + $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, new Document([ + 'chunksUploaded' => $chunksUploaded, + 'metadata' => $metadata, + ]))); + } catch (NotFoundException) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + } } - $data = $compressor->compress($data); - } else { - // reset the algorithm to none as we do not compress the file - // if file size exceedes the APP_STORAGE_READ_BUFFER - // regardless the bucket compression algoorithm - $algorithm = Compression::NONE; - } - if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) { - if (empty($data)) { - $data = $deviceForFiles->read($path); - } - $key = System::getEnv('_APP_OPENSSL_KEY_V1'); - $iv = OpenSSL::randomPseudoBytes(OpenSSL::cipherIVLength(OpenSSL::CIPHER_AES_128_GCM)); - $data = OpenSSL::encrypt($data, OpenSSL::CIPHER_AES_128_GCM, $key, 0, $iv, $tag); - } + $queueForEvents + ->setParam('bucketId', $bucket->getId()) + ->setParam('fileId', $file->getId()) + ->setContext('bucket', $bucket); - if (!empty($data)) { - if (!$deviceForFiles->write($path, $data, $mimeType)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to save file'); - } - } + $metadata = null; // was causing leaks as it was passed by reference - $sizeActual = $deviceForFiles->getFileSize($path); - - $openSSLVersion = null; - $openSSLCipher = null; - $openSSLTag = null; - $openSSLIV = null; - - if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) { - $openSSLVersion = '1'; - $openSSLCipher = OpenSSL::CIPHER_AES_128_GCM; - $openSSLTag = \bin2hex($tag); - $openSSLIV = \bin2hex($iv); - } - - if ($file->isEmpty()) { - $doc = new Document([ - '$id' => $fileId, - '$permissions' => $permissions, - 'bucketId' => $bucket->getId(), - 'bucketInternalId' => $bucket->getSequence(), - 'name' => $fileName, - 'path' => $path, - 'signature' => $fileHash, - 'mimeType' => $mimeType, - 'sizeOriginal' => $fileSize, - 'sizeActual' => $sizeActual, - 'algorithm' => $algorithm, - 'comment' => '', - 'chunksTotal' => $chunks, - 'chunksUploaded' => $chunksUploaded, - 'openSSLVersion' => $openSSLVersion, - 'openSSLCipher' => $openSSLCipher, - 'openSSLTag' => $openSSLTag, - 'openSSLIV' => $openSSLIV, - 'search' => implode(' ', [$fileId, $fileName]), - 'metadata' => $metadata, - ]); - - try { - $file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc); - } catch (DuplicateException) { - throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); - } catch (NotFoundException) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - } else { - $file = $file - ->setAttribute('$permissions', $permissions) - ->setAttribute('signature', $fileHash) - ->setAttribute('mimeType', $mimeType) - ->setAttribute('sizeActual', $sizeActual) - ->setAttribute('algorithm', $algorithm) - ->setAttribute('openSSLVersion', $openSSLVersion) - ->setAttribute('openSSLCipher', $openSSLCipher) - ->setAttribute('openSSLTag', $openSSLTag) - ->setAttribute('openSSLIV', $openSSLIV) - ->setAttribute('metadata', $metadata) - ->setAttribute('chunksUploaded', $chunksUploaded); - - /** - * Skip authorization in updateDocument. - * Without this, the file creation will fail when user doesn't have update permission. - * However as with chunk upload even if we are updating, we are essentially creating a file - * adding it's new chunk so we rely on the create-permission check performed earlier. - */ - $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); - } - - // Trigger after create success hook - $this->afterCreateSuccess($file); - } else { - if ($file->isEmpty()) { - $doc = new Document([ - '$id' => ID::custom($fileId), - '$permissions' => $permissions, - 'bucketId' => $bucket->getId(), - 'bucketInternalId' => $bucket->getSequence(), - 'name' => $fileName, - 'path' => $path, - 'signature' => '', - 'mimeType' => '', - 'sizeOriginal' => $fileSize, - 'sizeActual' => 0, - 'algorithm' => '', - 'comment' => '', - 'chunksTotal' => $chunks, - 'chunksUploaded' => $chunksUploaded, - 'search' => implode(' ', [$fileId, $fileName]), - 'metadata' => $metadata, - ]); - - try { - $file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc); - } catch (DuplicateException) { - throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); - } catch (NotFoundException) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - } else { - $file = $file - ->setAttribute('chunksUploaded', $chunksUploaded) - ->setAttribute('metadata', $metadata); - - /** - * Skip authorization in updateDocument. - * Without this, the file creation will fail when user doesn't have update permission. - * However as with chunk upload even if we are updating, we are essentially creating a file - * adding it's new chunk so we rely on the create-permission check performed earlier. - */ - try { - $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); - } catch (NotFoundException) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - } + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($file, Response::MODEL_FILE); + }, timeout: 120.0); + } catch (LockContention) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'File upload is busy. Try again.'); } - - $queueForEvents - ->setParam('bucketId', $bucket->getId()) - ->setParam('fileId', $file->getId()) - ->setContext('bucket', $bucket); - - $metadata = null; // was causing leaks as it was passed by reference - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($file, Response::MODEL_FILE); } /** diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index 29f7d70435..a7f1693214 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -1374,6 +1374,157 @@ trait StorageBase ]); } + public function testCreateBucketFileParallelChunksLargeFile(): void + { + $totalSize = (int) ($_ENV['APPWRITE_TEST_PARALLEL_UPLOAD_SIZE'] ?? 20 * 1024 * 1024); + $chunkSize = 5 * 1024 * 1024; + $chunksTotal = (int) ceil($totalSize / $chunkSize); + + $this->assertGreaterThanOrEqual(4, $chunksTotal, 'Test file must span at least 4 chunks'); + + $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'bucketId' => ID::unique(), + 'name' => 'Test Bucket Parallel Chunk Upload', + 'fileSecurity' => true, + 'maximumFileSize' => $totalSize, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $bucket['headers']['status-code']); + + $bucketId = $bucket['body']['$id']; + $fileId = ID::unique(); + $tmpDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'appwrite-parallel-upload-' . $fileId; + $source = $tmpDirectory . DIRECTORY_SEPARATOR . 'large-parallel-upload.bin'; + + mkdir($tmpDirectory); + + try { + $handle = fopen($source, 'wb'); + $this->assertNotFalse($handle, 'Could not create test file'); + + $remaining = $totalSize; + $block = str_repeat(hash('sha256', $fileId, binary: true), 1024); + while ($remaining > 0) { + $bytes = substr($block, 0, min(strlen($block), $remaining)); + fwrite($handle, $bytes); + $remaining -= strlen($bytes); + } + fclose($handle); + + $multi = curl_multi_init(); + $handles = []; + + $sourceHandle = fopen($source, 'rb'); + $this->assertNotFalse($sourceHandle, 'Could not open test file'); + + for ($i = 0; $i < $chunksTotal; $i++) { + $start = $i * $chunkSize; + $end = min($start + $chunkSize, $totalSize) - 1; + $length = $end - $start + 1; + $chunkPath = $tmpDirectory . DIRECTORY_SEPARATOR . 'chunk-' . $i . '.part'; + + fseek($sourceHandle, $start); + file_put_contents($chunkPath, fread($sourceHandle, $length)); + + $headers = array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + 'content-range' => 'bytes ' . $start . '-' . $end . '/' . $totalSize, + ]); + + $formattedHeaders = []; + foreach ($headers as $key => $value) { + $formattedHeaders[] = $key . ': ' . $value; + } + + $ch = curl_init($this->client->getEndpoint() . '/storage/buckets/' . $bucketId . '/files'); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, Client::METHOD_POST); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($ch, CURLOPT_HTTPHEADER, $formattedHeaders); + curl_setopt($ch, CURLOPT_POSTFIELDS, [ + 'fileId' => $fileId, + 'file' => new CURLFile($chunkPath, 'application/octet-stream', 'large-parallel-upload.bin'), + 'permissions[0]' => Permission::read(Role::any()), + 'permissions[1]' => Permission::delete(Role::any()), + ]); + curl_setopt($ch, CURLOPT_TIMEOUT, 300); + $handles[] = $ch; + curl_multi_add_handle($multi, $ch); + } + fclose($sourceHandle); + + do { + $status = curl_multi_exec($multi, $running); + if ($running > 0) { + curl_multi_select($multi, 1.0); + } + } while ($running > 0 && $status === CURLM_OK); + + foreach ($handles as $ch) { + $body = curl_multi_getcontent($ch); + $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_multi_remove_handle($multi, $ch); + + $this->assertSame('', $error); + $this->assertContains($statusCode, [200, 201], $body); + } + curl_multi_close($multi); + + $uploadedFile = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + + $this->assertEquals(200, $uploadedFile['headers']['status-code']); + $this->assertEquals($chunksTotal, $uploadedFile['body']['chunksTotal']); + $this->assertEquals($chunksTotal, $uploadedFile['body']['chunksUploaded']); + + $download = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/download', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + + $this->assertEquals(200, $download['headers']['status-code']); + $this->assertEquals($totalSize, strlen($download['body'])); + $this->assertEquals(hash_file('sha256', $source), hash('sha256', $download['body'])); + } finally { + if (isset($bucketId)) { + $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId . '/files/' . $fileId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + + $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + } + + foreach (glob($tmpDirectory . DIRECTORY_SEPARATOR . '*') ?: [] as $file) { + unlink($file); + } + + if (is_dir($tmpDirectory)) { + rmdir($tmpDirectory); + } + } + } + public function testDeleteBucketFile(): void { // Create a fresh file just for deletion testing (not using cache since we delete it) From 3db776e2e5a43f59e1f237ae9c7112645bbfcc61 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 4 May 2026 14:24:01 +0400 Subject: [PATCH 02/47] Update parallel upload test concurrency --- tests/e2e/Services/Storage/StorageBase.php | 96 +++++++++++++--------- 1 file changed, 56 insertions(+), 40 deletions(-) diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index a7f1693214..3e2e8b4dea 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -1420,8 +1420,7 @@ trait StorageBase } fclose($handle); - $multi = curl_multi_init(); - $handles = []; + $requests = []; $sourceHandle = fopen($source, 'rb'); $this->assertNotFalse($sourceHandle, 'Could not open test file'); @@ -1435,51 +1434,68 @@ trait StorageBase fseek($sourceHandle, $start); file_put_contents($chunkPath, fread($sourceHandle, $length)); - $headers = array_merge([ - 'content-type' => 'multipart/form-data', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - 'content-range' => 'bytes ' . $start . '-' . $end . '/' . $totalSize, - ]); - - $formattedHeaders = []; - foreach ($headers as $key => $value) { - $formattedHeaders[] = $key . ': ' . $value; - } - - $ch = curl_init($this->client->getEndpoint() . '/storage/buckets/' . $bucketId . '/files'); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, Client::METHOD_POST); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - curl_setopt($ch, CURLOPT_HTTPHEADER, $formattedHeaders); - curl_setopt($ch, CURLOPT_POSTFIELDS, [ - 'fileId' => $fileId, - 'file' => new CURLFile($chunkPath, 'application/octet-stream', 'large-parallel-upload.bin'), - 'permissions[0]' => Permission::read(Role::any()), - 'permissions[1]' => Permission::delete(Role::any()), - ]); - curl_setopt($ch, CURLOPT_TIMEOUT, 300); - $handles[] = $ch; - curl_multi_add_handle($multi, $ch); + $requests[] = [ + 'headers' => [ + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + 'content-range' => 'bytes ' . $start . '-' . $end . '/' . $totalSize, + ], + 'chunkPath' => $chunkPath, + ]; } fclose($sourceHandle); - do { - $status = curl_multi_exec($multi, $running); - if ($running > 0) { - curl_multi_select($multi, 1.0); + $responses = []; + $endpoint = parse_url($this->client->getEndpoint()); + $scheme = $endpoint['scheme'] ?? 'http'; + $host = $endpoint['host'] ?? 'appwrite'; + $port = $endpoint['port'] ?? ($scheme === 'https' ? 443 : 80); + $basePath = rtrim($endpoint['path'] ?? '', '/'); + + \Swoole\Coroutine\run(function () use ($basePath, $bucketId, $fileId, $host, $port, $requests, $scheme, &$responses): void { + $wg = new \Swoole\Coroutine\WaitGroup(); + + foreach ($requests as $index => $request) { + $wg->add(); + \Swoole\Coroutine::create(function () use ($basePath, $bucketId, $fileId, $host, $index, $port, $request, &$responses, $scheme, $wg): void { + $client = new \Swoole\Coroutine\Http\Client($host, (int) $port, $scheme === 'https'); + $client->set([ + 'timeout' => 300, + 'ssl_verify_peer' => false, + 'ssl_verify_host' => false, + ]); + $client->setHeaders($request['headers']); + $client->setMethod(Client::METHOD_POST); + $client->setData([ + 'fileId' => $fileId, + 'permissions[0]' => Permission::read(Role::any()), + 'permissions[1]' => Permission::delete(Role::any()), + ]); + $client->addFile($request['chunkPath'], 'file', 'application/octet-stream', 'large-parallel-upload.bin'); + $client->execute($basePath . '/storage/buckets/' . $bucketId . '/files'); + + try { + $responses[$index] = [ + 'body' => $client->body, + 'error' => $client->errMsg, + 'statusCode' => $client->statusCode, + ]; + } finally { + $client->close(); + $wg->done(); + } + }); } - } while ($running > 0 && $status === CURLM_OK); - foreach ($handles as $ch) { - $body = curl_multi_getcontent($ch); - $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error = curl_error($ch); - curl_multi_remove_handle($multi, $ch); + $wg->wait(); + }); - $this->assertSame('', $error); - $this->assertContains($statusCode, [200, 201], $body); + ksort($responses); + + foreach ($responses as $response) { + $this->assertSame('', $response['error']); + $this->assertContains($response['statusCode'], [200, 201], (string) $response['body']); } - curl_multi_close($multi); $uploadedFile = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId, array_merge([ 'content-type' => 'application/json', From 9927e4bd8e878bbe60e8f75131b7f6efa139aeef Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 4 May 2026 14:34:42 +0400 Subject: [PATCH 03/47] Fix storage upload lock contention handling --- .../Modules/Storage/Http/Buckets/Files/Create.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php index f9854a17a4..4e98c4c8d5 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php @@ -243,7 +243,7 @@ class Create extends Action $lock = new Distributed( $redis, 'storage:file:' . $project->getId() . ':' . $bucket->getId() . ':' . $fileId, - ttl: 600, + ttl: 120, ); $metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)]; @@ -272,7 +272,8 @@ class Create extends Action } }, timeout: 120.0); } catch (LockContention) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'File upload is busy. Try again.'); + $response->addHeader('Retry-After', '5'); + throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'File upload is busy. Try again.'); } if ($completed) { @@ -288,6 +289,7 @@ class Create extends Action try { $lock->withLock(function () use ($authorization, $bucket, &$chunks, $chunksUploaded, $contentRange, $dbForProject, $deviceForFiles, $fileId, $fileName, $fileSize, &$metadata, $path, $permissions, $queueForEvents, $response): void { $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); + $uploaded = 0; if (!$file->isEmpty()) { $chunks = $file->getAttribute('chunksTotal', 1); @@ -307,7 +309,9 @@ class Create extends Action } } - if ($chunksUploaded === $chunks) { + $chunksUploaded = max($uploaded, $chunksUploaded); + + if ($chunksUploaded === $chunks && $uploaded < $chunks) { if (System::getEnv('_APP_STORAGE_ANTIVIRUS') === 'enabled' && $bucket->getAttribute('antivirus', true) && $fileSize <= APP_LIMIT_ANTIVIRUS && $deviceForFiles->getType() === Storage::DEVICE_LOCAL) { $antivirus = new Network( System::getEnv('_APP_STORAGE_ANTIVIRUS_HOST', 'clamav'), @@ -488,7 +492,8 @@ class Create extends Action ->dynamic($file, Response::MODEL_FILE); }, timeout: 120.0); } catch (LockContention) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'File upload is busy. Try again.'); + $response->addHeader('Retry-After', '5'); + throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'File upload is busy. Try again.'); } } From d58929b621b4c227c0595c4d3b42696ee3290624 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 4 May 2026 15:00:16 +0400 Subject: [PATCH 04/47] Restore storage upload lock TTL --- .../Platform/Modules/Storage/Http/Buckets/Files/Create.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php index 4e98c4c8d5..a85fb72a23 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php @@ -91,7 +91,6 @@ class Create extends Action ->inject('project') ->inject('user') ->inject('queueForEvents') - ->inject('mode') ->inject('deviceForFiles') ->inject('deviceForLocal') ->inject('authorization') @@ -110,7 +109,6 @@ class Create extends Action Document $project, User $user, Event $queueForEvents, - string $mode, Device $deviceForFiles, Device $deviceForLocal, Authorization $authorization, @@ -243,7 +241,7 @@ class Create extends Action $lock = new Distributed( $redis, 'storage:file:' . $project->getId() . ':' . $bucket->getId() . ':' . $fileId, - ttl: 120, + ttl: 600, ); $metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)]; From 6e19db130e03e09dcdfc68eb3af572162247ff5e Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 4 May 2026 15:09:52 +0400 Subject: [PATCH 05/47] Fix storage chunk upload events --- .../Storage/Http/Buckets/Files/Create.php | 10 ++-- tests/e2e/Services/Storage/StorageBase.php | 55 +++++++++++-------- 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php index a85fb72a23..c26e30474f 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php @@ -478,10 +478,12 @@ class Create extends Action } } - $queueForEvents - ->setParam('bucketId', $bucket->getId()) - ->setParam('fileId', $file->getId()) - ->setContext('bucket', $bucket); + if ($chunksUploaded === $chunks) { + $queueForEvents + ->setParam('bucketId', $bucket->getId()) + ->setParam('fileId', $file->getId()) + ->setContext('bucket', $bucket); + } $metadata = null; // was causing leaks as it was passed by reference diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index 3e2e8b4dea..009910fb38 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -1458,30 +1458,41 @@ trait StorageBase foreach ($requests as $index => $request) { $wg->add(); \Swoole\Coroutine::create(function () use ($basePath, $bucketId, $fileId, $host, $index, $port, $request, &$responses, $scheme, $wg): void { - $client = new \Swoole\Coroutine\Http\Client($host, (int) $port, $scheme === 'https'); - $client->set([ - 'timeout' => 300, - 'ssl_verify_peer' => false, - 'ssl_verify_host' => false, - ]); - $client->setHeaders($request['headers']); - $client->setMethod(Client::METHOD_POST); - $client->setData([ - 'fileId' => $fileId, - 'permissions[0]' => Permission::read(Role::any()), - 'permissions[1]' => Permission::delete(Role::any()), - ]); - $client->addFile($request['chunkPath'], 'file', 'application/octet-stream', 'large-parallel-upload.bin'); - $client->execute($basePath . '/storage/buckets/' . $bucketId . '/files'); - try { - $responses[$index] = [ - 'body' => $client->body, - 'error' => $client->errMsg, - 'statusCode' => $client->statusCode, - ]; + for ($attempt = 0; $attempt < 3; $attempt++) { + $client = new \Swoole\Coroutine\Http\Client($host, (int) $port, $scheme === 'https'); + $client->set([ + 'timeout' => 300, + 'ssl_verify_peer' => false, + 'ssl_verify_host' => false, + ]); + $client->setHeaders($request['headers']); + $client->setMethod(Client::METHOD_POST); + $client->setData([ + 'fileId' => $fileId, + 'permissions[0]' => Permission::read(Role::any()), + 'permissions[1]' => Permission::delete(Role::any()), + ]); + $client->addFile($request['chunkPath'], 'file', 'application/octet-stream', 'large-parallel-upload.bin'); + $client->execute($basePath . '/storage/buckets/' . $bucketId . '/files'); + + $responses[$index] = [ + 'body' => $client->body, + 'error' => $client->errMsg, + 'headers' => $client->headers ?? [], + 'statusCode' => $client->statusCode, + ]; + + $client->close(); + + if ($responses[$index]['statusCode'] !== 429) { + break; + } + + $retryAfter = (float) ($responses[$index]['headers']['retry-after'] ?? 0.1); + \Swoole\Coroutine::sleep(max($retryAfter, 0.1)); + } } finally { - $client->close(); $wg->done(); } }); From fd830902157b19c6d178b8a4fd09c6f765eebdec Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 4 May 2026 17:34:53 +0400 Subject: [PATCH 06/47] Fix parallel deployment chunk uploads --- .../Functions/Http/Deployments/Create.php | 278 +++++++----- .../Modules/Sites/Http/Deployments/Create.php | 398 ++++++++++-------- .../Functions/FunctionsCustomServerTest.php | 138 ++++++ .../Services/Sites/SitesCustomServerTest.php | 139 ++++++ tests/e2e/Services/Storage/StorageBase.php | 2 +- 5 files changed, 665 insertions(+), 290 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php index 757edc0484..ac735a8b0a 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php @@ -20,6 +20,8 @@ use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Request; +use Utopia\Lock\Distributed; +use Utopia\Lock\Exception\Contention as LockContention; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Storage\Device; @@ -90,6 +92,7 @@ class Create extends Action ->inject('queueForBuilds') ->inject('plan') ->inject('authorization') + ->inject('redis') ->callback($this->action(...)); } @@ -108,7 +111,8 @@ class Create extends Action Device $deviceForLocal, Build $queueForBuilds, array $plan, - Authorization $authorization + Authorization $authorization, + \Redis $redis ) { $activate = \strval($activate) === 'true' || \strval($activate) === '1'; @@ -190,20 +194,40 @@ class Create extends Action // Save to storage $fileSize ??= $deviceForLocal->getFileSize($fileTmpName); $path = $deviceForFunctions->getPath($deploymentId . '.' . \pathinfo($fileName, PATHINFO_EXTENSION)); - $deployment = $dbForProject->getDocument('deployments', $deploymentId); + + $lockKey = 'functions:deployment:' . $project->getId() . ':' . $functionId . ':' . $deploymentId; + $checkLock = new Distributed($redis, $lockKey, ttl: 120); + $stateLock = new Distributed($redis, $lockKey, ttl: 600); $metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)]; - if (!$deployment->isEmpty()) { - $chunks = $deployment->getAttribute('sourceChunksTotal', 1); - $uploaded = $deployment->getAttribute('sourceChunksUploaded', 0); - $metadata = $deployment->getAttribute('sourceMetadata', []); + $completed = false; - if ($uploaded === $chunks) { - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($deployment, Response::MODEL_DEPLOYMENT); - return; - } + try { + $checkLock->withLock(function () use (&$chunks, $contentRange, $dbForProject, $deploymentId, &$metadata, &$completed, $response): void { + $deployment = $dbForProject->getDocument('deployments', $deploymentId); + + if (!$deployment->isEmpty()) { + $chunks = $deployment->getAttribute('sourceChunksTotal', 1); + $uploaded = $deployment->getAttribute('sourceChunksUploaded', 0); + $metadata = $deployment->getAttribute('sourceMetadata', []); + + if ($uploaded === $chunks) { + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($deployment, Response::MODEL_DEPLOYMENT); + + $completed = true; + return; + } + } + }, timeout: 120.0); + } catch (LockContention) { + $response->addHeader('Retry-After', '5'); + throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Deployment upload is busy. Try again.'); + } + + if ($completed) { + return; } $chunksUploaded = $deviceForFunctions->upload($fileTmpName, $path, $chunk, $chunks, $metadata); @@ -214,115 +238,141 @@ class Create extends Action $type = $request->getHeader('x-sdk-language') === 'cli' ? 'cli' : 'manual'; - if ($chunksUploaded === $chunks) { - if ($activate) { - // Remove deploy for all other deployments. - $activeDeployments = $dbForProject->find('deployments', [ - Query::equal('activate', [true]), - Query::equal('resourceId', [$functionId]), - Query::equal('resourceType', ['functions']) - ]); + try { + $stateLock->withLock(function () use ($activate, &$chunks, $chunksUploaded, $commands, $dbForProject, $deploymentId, $deviceForFunctions, $entrypoint, $fileSize, &$function, $functionId, $path, &$metadata, $queueForBuilds, $queueForEvents, $response, $type): void { + $deployment = $dbForProject->getDocument('deployments', $deploymentId); + $uploaded = 0; - foreach ($activeDeployments as $activeDeployment) { - $activeDeployment->setAttribute('activate', false); - $dbForProject->updateDocument('deployments', $activeDeployment->getId(), new Document([ - 'activate' => false, - ])); + if (!$deployment->isEmpty()) { + $chunks = $deployment->getAttribute('sourceChunksTotal', 1); + $uploaded = $deployment->getAttribute('sourceChunksUploaded', 0); + $metadata = \array_merge($deployment->getAttribute('sourceMetadata', []), $metadata); + + if ($uploaded === $chunks) { + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($deployment, Response::MODEL_DEPLOYMENT); + return; + } } - } - $fileSize = $deviceForFunctions->getFileSize($path); + $chunksUploaded = max($uploaded, $chunksUploaded); - if ($deployment->isEmpty()) { - $deployment = $dbForProject->createDocument('deployments', new Document([ - '$id' => $deploymentId, - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'resourceInternalId' => $function->getSequence(), - 'resourceId' => $function->getId(), - 'resourceType' => 'functions', - 'entrypoint' => $entrypoint, - 'buildCommands' => $commands, - 'startCommand' => $function->getAttribute('startCommand', ''), - 'sourcePath' => $path, - 'sourceSize' => $fileSize, - 'totalSize' => $fileSize, - 'sourceChunksTotal' => $chunks, - 'sourceChunksUploaded' => $chunksUploaded, - 'activate' => $activate, - 'sourceMetadata' => $metadata, - 'type' => $type - ])); + if ($chunksUploaded === $chunks && $uploaded < $chunks) { + if ($activate) { + // Remove deploy for all other deployments. + $activeDeployments = $dbForProject->find('deployments', [ + Query::equal('activate', [true]), + Query::equal('resourceId', [$functionId]), + Query::equal('resourceType', ['functions']) + ]); - $function = $dbForProject->updateDocument('functions', $function->getId(), new Document([ - 'latestDeploymentId' => $deployment->getId(), - 'latestDeploymentInternalId' => $deployment->getSequence(), - 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(), - 'latestDeploymentStatus' => $deployment->getAttribute('status', ''), - ])); - } else { - $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ - 'sourceSize' => $fileSize, - 'sourceChunksUploaded' => $chunksUploaded, - 'sourceMetadata' => $metadata, - ])); - } + foreach ($activeDeployments as $activeDeployment) { + $dbForProject->updateDocument('deployments', $activeDeployment->getId(), new Document([ + 'activate' => false, + ])); + } + } - // Start the build - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($function) - ->setDeployment($deployment); - } else { - if ($deployment->isEmpty()) { - $deployment = $dbForProject->createDocument('deployments', new Document([ - '$id' => $deploymentId, - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'resourceInternalId' => $function->getSequence(), - 'resourceId' => $function->getId(), - 'resourceType' => 'functions', - 'entrypoint' => $entrypoint, - 'buildCommands' => $commands, - 'startCommand' => $function->getAttribute('startCommand', ''), - 'sourcePath' => $path, - 'sourceSize' => $fileSize, - 'totalSize' => $fileSize, - 'sourceChunksTotal' => $chunks, - 'sourceChunksUploaded' => $chunksUploaded, - 'activate' => $activate, - 'sourceMetadata' => $metadata, - 'type' => $type - ])); + $fileSize = $deviceForFunctions->getFileSize($path); - $function = $dbForProject->updateDocument('functions', $function->getId(), new Document([ - 'latestDeploymentId' => $deployment->getId(), - 'latestDeploymentInternalId' => $deployment->getSequence(), - 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(), - 'latestDeploymentStatus' => $deployment->getAttribute('status', ''), - ])); - } else { - $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ - 'sourceChunksUploaded' => $chunksUploaded, - 'sourceMetadata' => $metadata, - ])); - } + if ($deployment->isEmpty()) { + $deployment = $dbForProject->createDocument('deployments', new Document([ + '$id' => $deploymentId, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'resourceInternalId' => $function->getSequence(), + 'resourceId' => $function->getId(), + 'resourceType' => 'functions', + 'entrypoint' => $entrypoint, + 'buildCommands' => $commands, + 'startCommand' => $function->getAttribute('startCommand', ''), + 'sourcePath' => $path, + 'sourceSize' => $fileSize, + 'totalSize' => $fileSize, + 'sourceChunksTotal' => $chunks, + 'sourceChunksUploaded' => $chunksUploaded, + 'activate' => $activate, + 'sourceMetadata' => $metadata, + 'type' => $type + ])); + + $function = $dbForProject->updateDocument('functions', $function->getId(), new Document([ + 'latestDeploymentId' => $deployment->getId(), + 'latestDeploymentInternalId' => $deployment->getSequence(), + 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(), + 'latestDeploymentStatus' => $deployment->getAttribute('status', ''), + ])); + } else { + $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ + 'sourceSize' => $fileSize, + 'sourceChunksUploaded' => $chunksUploaded, + 'sourceMetadata' => $metadata, + ])); + } + + // Start the build + $queueForBuilds + ->setType(BUILD_TYPE_DEPLOYMENT) + ->setResource($function) + ->setDeployment($deployment); + } else { + if ($deployment->isEmpty()) { + $deployment = $dbForProject->createDocument('deployments', new Document([ + '$id' => $deploymentId, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'resourceInternalId' => $function->getSequence(), + 'resourceId' => $function->getId(), + 'resourceType' => 'functions', + 'entrypoint' => $entrypoint, + 'buildCommands' => $commands, + 'startCommand' => $function->getAttribute('startCommand', ''), + 'sourcePath' => $path, + 'sourceSize' => $fileSize, + 'totalSize' => $fileSize, + 'sourceChunksTotal' => $chunks, + 'sourceChunksUploaded' => $chunksUploaded, + 'activate' => $activate, + 'sourceMetadata' => $metadata, + 'type' => $type + ])); + + $function = $dbForProject->updateDocument('functions', $function->getId(), new Document([ + 'latestDeploymentId' => $deployment->getId(), + 'latestDeploymentInternalId' => $deployment->getSequence(), + 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(), + 'latestDeploymentStatus' => $deployment->getAttribute('status', ''), + ])); + } else { + $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ + 'sourceChunksUploaded' => $chunksUploaded, + 'sourceMetadata' => $metadata, + ])); + } + } + + $metadata = null; + + if ($chunksUploaded === $chunks) { + $queueForEvents + ->setParam('functionId', $function->getId()) + ->setParam('deploymentId', $deployment->getId()); + } + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($deployment, Response::MODEL_DEPLOYMENT); + }, timeout: 120.0); + } catch (LockContention) { + $response->addHeader('Retry-After', '5'); + throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Deployment upload is busy. Try again.'); } - - $metadata = null; - - $queueForEvents - ->setParam('functionId', $function->getId()) - ->setParam('deploymentId', $deployment->getId()); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($deployment, Response::MODEL_DEPLOYMENT); } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index 71ea5ceb2f..531962d33a 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -20,6 +20,8 @@ use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Request; +use Utopia\Lock\Distributed; +use Utopia\Lock\Exception\Contention as LockContention; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Storage\Device; @@ -89,6 +91,7 @@ class Create extends Action ->inject('plan') ->inject('authorization') ->inject('platform') + ->inject('redis') ->callback($this->action(...)); } @@ -111,6 +114,7 @@ class Create extends Action array $plan, Authorization $authorization, array $platform, + \Redis $redis, ) { $activate = \strval($activate) === 'true' || \strval($activate) === '1'; @@ -192,20 +196,40 @@ class Create extends Action // Save to storage $fileSize ??= $deviceForLocal->getFileSize($fileTmpName); $path = $deviceForSites->getPath($deploymentId . '.' . \pathinfo($fileName, PATHINFO_EXTENSION)); - $deployment = $dbForProject->getDocument('deployments', $deploymentId); + + $lockKey = 'sites:deployment:' . $project->getId() . ':' . $siteId . ':' . $deploymentId; + $checkLock = new Distributed($redis, $lockKey, ttl: 120); + $stateLock = new Distributed($redis, $lockKey, ttl: 600); $metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)]; - if (!$deployment->isEmpty()) { - $chunks = $deployment->getAttribute('sourceChunksTotal', 1); - $uploaded = $deployment->getAttribute('sourceChunksUploaded', 0); - $metadata = $deployment->getAttribute('sourceMetadata', []); + $completed = false; - if ($uploaded === $chunks) { - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($deployment, Response::MODEL_DEPLOYMENT); - return; - } + try { + $checkLock->withLock(function () use (&$chunks, $dbForProject, $deploymentId, &$metadata, &$completed, $response): void { + $deployment = $dbForProject->getDocument('deployments', $deploymentId); + + if (!$deployment->isEmpty()) { + $chunks = $deployment->getAttribute('sourceChunksTotal', 1); + $uploaded = $deployment->getAttribute('sourceChunksUploaded', 0); + $metadata = $deployment->getAttribute('sourceMetadata', []); + + if ($uploaded === $chunks) { + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($deployment, Response::MODEL_DEPLOYMENT); + + $completed = true; + return; + } + } + }, timeout: 120.0); + } catch (LockContention) { + $response->addHeader('Retry-After', '5'); + throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Deployment upload is busy. Try again.'); + } + + if ($completed) { + return; } $chunksUploaded = $deviceForSites->upload($fileTmpName, $path, $chunk, $chunks, $metadata); @@ -224,181 +248,205 @@ class Create extends Action $commands[] = $buildCommand; } - if ($chunksUploaded === $chunks) { - if ($activate) { - // Remove deploy for all other deployments. - $activeDeployments = $dbForProject->find('deployments', [ - Query::equal('activate', [true]), - Query::equal('resourceId', [$siteId]), - Query::equal('resourceType', ['sites']) - ]); + try { + $stateLock->withLock(function () use ($activate, $authorization, $commands, &$chunks, $chunksUploaded, $dbForPlatform, $dbForProject, $deploymentId, $deviceForSites, $fileSize, &$metadata, $outputDirectory, $path, $platform, $project, $queueForBuilds, $queueForEvents, $response, &$site, $siteId, $type): void { + $deployment = $dbForProject->getDocument('deployments', $deploymentId); + $uploaded = 0; - foreach ($activeDeployments as $activeDeployment) { - $activeDeployment->setAttribute('activate', false); - $dbForProject->updateDocument('deployments', $activeDeployment->getId(), new Document(['activate' => false])); + if (!$deployment->isEmpty()) { + $chunks = $deployment->getAttribute('sourceChunksTotal', 1); + $uploaded = $deployment->getAttribute('sourceChunksUploaded', 0); + $metadata = \array_merge($deployment->getAttribute('sourceMetadata', []), $metadata); + + if ($uploaded === $chunks) { + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($deployment, Response::MODEL_DEPLOYMENT); + return; + } } - } - $fileSize = $deviceForSites->getFileSize($path); + $chunksUploaded = max($uploaded, $chunksUploaded); - if ($deployment->isEmpty()) { - $deployment = $dbForProject->createDocument('deployments', new Document([ - '$id' => $deploymentId, - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'resourceInternalId' => $site->getSequence(), - 'resourceId' => $site->getId(), - 'resourceType' => 'sites', - 'buildCommands' => \implode(' && ', $commands), - 'startCommand' => $site->getAttribute('startCommand', ''), - 'buildOutput' => $outputDirectory, - 'adapter' => $site->getAttribute('adapter', ''), - 'fallbackFile' => $site->getAttribute('fallbackFile', ''), - 'sourcePath' => $path, - 'sourceSize' => $fileSize, - 'totalSize' => $fileSize, - 'sourceChunksTotal' => $chunks, - 'sourceChunksUploaded' => $chunksUploaded, - 'activate' => $activate, - 'sourceMetadata' => $metadata, - 'type' => $type, - ])); + if ($chunksUploaded === $chunks && $uploaded < $chunks) { + if ($activate) { + // Remove deploy for all other deployments. + $activeDeployments = $dbForProject->find('deployments', [ + Query::equal('activate', [true]), + Query::equal('resourceId', [$siteId]), + Query::equal('resourceType', ['sites']) + ]); - $site = $site - ->setAttribute('latestDeploymentId', $deployment->getId()) - ->setAttribute('latestDeploymentInternalId', $deployment->getSequence()) - ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt()) - ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); - $dbForProject->updateDocument('sites', $site->getId(), new Document([ - 'latestDeploymentId' => $deployment->getId(), - 'latestDeploymentInternalId' => $deployment->getSequence(), - 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(), - 'latestDeploymentStatus' => $deployment->getAttribute('status', ''), - ])); + foreach ($activeDeployments as $activeDeployment) { + $dbForProject->updateDocument('deployments', $activeDeployment->getId(), new Document(['activate' => false])); + } + } - $sitesDomain = $platform['sitesDomain']; - $domain = ID::unique() . "." . $sitesDomain; + $fileSize = $deviceForSites->getFileSize($path); - // TODO: (@Meldiron) Remove after 1.7.x migration - $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; - $ruleId = $isMd5 ? md5($domain) : ID::unique(); + if ($deployment->isEmpty()) { + $deployment = $dbForProject->createDocument('deployments', new Document([ + '$id' => $deploymentId, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'resourceInternalId' => $site->getSequence(), + 'resourceId' => $site->getId(), + 'resourceType' => 'sites', + 'buildCommands' => \implode(' && ', $commands), + 'startCommand' => $site->getAttribute('startCommand', ''), + 'buildOutput' => $outputDirectory, + 'adapter' => $site->getAttribute('adapter', ''), + 'fallbackFile' => $site->getAttribute('fallbackFile', ''), + 'sourcePath' => $path, + 'sourceSize' => $fileSize, + 'totalSize' => $fileSize, + 'sourceChunksTotal' => $chunks, + 'sourceChunksUploaded' => $chunksUploaded, + 'activate' => $activate, + 'sourceMetadata' => $metadata, + 'type' => $type, + ])); - $authorization->skip( - fn () => $dbForPlatform->createDocument('rules', new Document([ - '$id' => $ruleId, - 'projectId' => $project->getId(), - 'projectInternalId' => $project->getSequence(), - 'domain' => $domain, - 'type' => 'deployment', - 'trigger' => 'deployment', - 'deploymentId' => $deployment->isEmpty() ? '' : $deployment->getId(), - 'deploymentInternalId' => $deployment->isEmpty() ? '' : $deployment->getSequence(), - 'deploymentResourceType' => 'site', - 'deploymentResourceId' => $site->getId(), - 'deploymentResourceInternalId' => $site->getSequence(), - 'status' => 'verified', - 'certificateId' => '', - 'search' => implode(' ', [$ruleId, $domain]), - 'owner' => 'Appwrite', - 'region' => $project->getAttribute('region') - ])) - ); - } else { - $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ - 'sourceSize' => $fileSize, - 'sourceChunksUploaded' => $chunksUploaded, - 'sourceMetadata' => $metadata, - ])); - } + $site = $site + ->setAttribute('latestDeploymentId', $deployment->getId()) + ->setAttribute('latestDeploymentInternalId', $deployment->getSequence()) + ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt()) + ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); + $dbForProject->updateDocument('sites', $site->getId(), new Document([ + 'latestDeploymentId' => $deployment->getId(), + 'latestDeploymentInternalId' => $deployment->getSequence(), + 'latestDeploymentCreatedAt' => $deployment->getCreatedAt(), + 'latestDeploymentStatus' => $deployment->getAttribute('status', ''), + ])); - // Start the build - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($site) - ->setDeployment($deployment); - } else { - if ($deployment->isEmpty()) { - $deployment = $dbForProject->createDocument('deployments', new Document([ - '$id' => $deploymentId, - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'resourceInternalId' => $site->getSequence(), - 'resourceId' => $site->getId(), - 'resourceType' => 'sites', - 'buildCommands' => \implode(' && ', $commands), - 'startCommand' => $site->getAttribute('startCommand', ''), - 'buildOutput' => $outputDirectory, - 'adapter' => $site->getAttribute('adapter', ''), - 'fallbackFile' => $site->getAttribute('fallbackFile', ''), - 'sourcePath' => $path, - 'sourceSize' => $fileSize, - 'totalSize' => $fileSize, - 'sourceChunksTotal' => $chunks, - 'sourceChunksUploaded' => $chunksUploaded, - 'activate' => $activate, - 'sourceMetadata' => $metadata, - 'type' => $type, - ])); + $sitesDomain = $platform['sitesDomain']; + $domain = ID::unique() . "." . $sitesDomain; - $site = $site - ->setAttribute('latestDeploymentId', $deployment->getId()) - ->setAttribute('latestDeploymentInternalId', $deployment->getSequence()) - ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt()) - ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); - $dbForProject->updateDocument('sites', $site->getId(), new Document([ - 'latestDeploymentId' => $site->getAttribute('latestDeploymentId'), - 'latestDeploymentInternalId' => $site->getAttribute('latestDeploymentInternalId'), - 'latestDeploymentCreatedAt' => $site->getAttribute('latestDeploymentCreatedAt'), - 'latestDeploymentStatus' => $site->getAttribute('latestDeploymentStatus'), - ])); + // TODO: (@Meldiron) Remove after 1.7.x migration + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + $ruleId = $isMd5 ? md5($domain) : ID::unique(); - $sitesDomain = $platform['sitesDomain']; - $domain = ID::unique() . "." . $sitesDomain; - $ruleId = md5($domain); - $authorization->skip( - fn () => $dbForPlatform->createDocument('rules', new Document([ - '$id' => $ruleId, - 'projectId' => $project->getId(), - 'projectInternalId' => $project->getSequence(), - 'domain' => $domain, - 'type' => 'deployment', - 'trigger' => 'deployment', - 'deploymentId' => $deployment->isEmpty() ? '' : $deployment->getId(), - 'deploymentInternalId' => $deployment->isEmpty() ? '' : $deployment->getSequence(), - 'deploymentResourceType' => 'site', - 'deploymentResourceId' => $site->getId(), - 'deploymentResourceInternalId' => $site->getSequence(), - 'status' => 'verified', - 'certificateId' => '', - 'search' => implode(' ', [$ruleId, $domain]), - 'owner' => 'Appwrite', - 'region' => $project->getAttribute('region') - ])) - ); - } else { - $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ - 'sourceChunksUploaded' => $chunksUploaded, - 'sourceMetadata' => $metadata, - ])); - } + $authorization->skip( + fn () => $dbForPlatform->createDocument('rules', new Document([ + '$id' => $ruleId, + 'projectId' => $project->getId(), + 'projectInternalId' => $project->getSequence(), + 'domain' => $domain, + 'type' => 'deployment', + 'trigger' => 'deployment', + 'deploymentId' => $deployment->isEmpty() ? '' : $deployment->getId(), + 'deploymentInternalId' => $deployment->isEmpty() ? '' : $deployment->getSequence(), + 'deploymentResourceType' => 'site', + 'deploymentResourceId' => $site->getId(), + 'deploymentResourceInternalId' => $site->getSequence(), + 'status' => 'verified', + 'certificateId' => '', + 'search' => implode(' ', [$ruleId, $domain]), + 'owner' => 'Appwrite', + 'region' => $project->getAttribute('region') + ])) + ); + } else { + $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ + 'sourceSize' => $fileSize, + 'sourceChunksUploaded' => $chunksUploaded, + 'sourceMetadata' => $metadata, + ])); + } + + // Start the build + $queueForBuilds + ->setType(BUILD_TYPE_DEPLOYMENT) + ->setResource($site) + ->setDeployment($deployment); + } else { + if ($deployment->isEmpty()) { + $deployment = $dbForProject->createDocument('deployments', new Document([ + '$id' => $deploymentId, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'resourceInternalId' => $site->getSequence(), + 'resourceId' => $site->getId(), + 'resourceType' => 'sites', + 'buildCommands' => \implode(' && ', $commands), + 'startCommand' => $site->getAttribute('startCommand', ''), + 'buildOutput' => $outputDirectory, + 'adapter' => $site->getAttribute('adapter', ''), + 'fallbackFile' => $site->getAttribute('fallbackFile', ''), + 'sourcePath' => $path, + 'sourceSize' => $fileSize, + 'totalSize' => $fileSize, + 'sourceChunksTotal' => $chunks, + 'sourceChunksUploaded' => $chunksUploaded, + 'activate' => $activate, + 'sourceMetadata' => $metadata, + 'type' => $type, + ])); + + $site = $site + ->setAttribute('latestDeploymentId', $deployment->getId()) + ->setAttribute('latestDeploymentInternalId', $deployment->getSequence()) + ->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt()) + ->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', '')); + $dbForProject->updateDocument('sites', $site->getId(), new Document([ + 'latestDeploymentId' => $site->getAttribute('latestDeploymentId'), + 'latestDeploymentInternalId' => $site->getAttribute('latestDeploymentInternalId'), + 'latestDeploymentCreatedAt' => $site->getAttribute('latestDeploymentCreatedAt'), + 'latestDeploymentStatus' => $site->getAttribute('latestDeploymentStatus'), + ])); + + $sitesDomain = $platform['sitesDomain']; + $domain = ID::unique() . "." . $sitesDomain; + $ruleId = md5($domain); + $authorization->skip( + fn () => $dbForPlatform->createDocument('rules', new Document([ + '$id' => $ruleId, + 'projectId' => $project->getId(), + 'projectInternalId' => $project->getSequence(), + 'domain' => $domain, + 'type' => 'deployment', + 'trigger' => 'deployment', + 'deploymentId' => $deployment->isEmpty() ? '' : $deployment->getId(), + 'deploymentInternalId' => $deployment->isEmpty() ? '' : $deployment->getSequence(), + 'deploymentResourceType' => 'site', + 'deploymentResourceId' => $site->getId(), + 'deploymentResourceInternalId' => $site->getSequence(), + 'status' => 'verified', + 'certificateId' => '', + 'search' => implode(' ', [$ruleId, $domain]), + 'owner' => 'Appwrite', + 'region' => $project->getAttribute('region') + ])) + ); + } else { + $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ + 'sourceChunksUploaded' => $chunksUploaded, + 'sourceMetadata' => $metadata, + ])); + } + } + + $metadata = null; + + if ($chunksUploaded === $chunks) { + $queueForEvents + ->setParam('siteId', $site->getId()) + ->setParam('deploymentId', $deployment->getId()); + } + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($deployment, Response::MODEL_DEPLOYMENT); + }, timeout: 120.0); + } catch (LockContention) { + $response->addHeader('Retry-After', '5'); + throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Deployment upload is busy. Try again.'); } - - - - $metadata = null; - - $queueForEvents - ->setParam('siteId', $site->getId()) - ->setParam('deploymentId', $deployment->getId()); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($deployment, Response::MODEL_DEPLOYMENT); } } diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 899c0ff71f..8e784b3d5b 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -1191,6 +1191,144 @@ class FunctionsCustomServerTest extends Scope }, 120000, 500); } + public function testCreateDeploymentParallelChunksLargeFile(): void + { + $functionId = $this->setupFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test Parallel Chunk Deployment', + 'execute' => [Role::user($this->getUser()['$id'])->toString()], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 10, + ]); + + $deploymentId = ID::unique(); + $tmpDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'appwrite-parallel-function-deployment-' . $deploymentId; + + mkdir($tmpDirectory); + + try { + copy(__DIR__ . '/../../../resources/functions/basic/index.js', $tmpDirectory . DIRECTORY_SEPARATOR . 'index.js'); + file_put_contents($tmpDirectory . DIRECTORY_SEPARATOR . 'large.bin', random_bytes(20 * 1024 * 1024)); + + $source = $tmpDirectory . DIRECTORY_SEPARATOR . 'code.tar.gz'; + Console::execute('cd ' . $tmpDirectory . ' && tar --exclude code.tar.gz -czf code.tar.gz .', '', $this->stdout, $this->stderr); + + $totalSize = filesize($source); + $chunkSize = 5 * 1024 * 1024; + $chunksTotal = (int) ceil($totalSize / $chunkSize); + + $this->assertGreaterThanOrEqual(4, $chunksTotal, 'Test deployment must span at least 4 chunks'); + + $requests = []; + $sourceHandle = fopen($source, 'rb'); + $this->assertNotFalse($sourceHandle, 'Could not open deployment package'); + + try { + for ($i = 0; $i < $chunksTotal; $i++) { + $start = $i * $chunkSize; + $end = min($start + $chunkSize, $totalSize) - 1; + $length = $end - $start + 1; + $chunkPath = $tmpDirectory . DIRECTORY_SEPARATOR . 'chunk-' . $i . '.part'; + + fseek($sourceHandle, $start); + file_put_contents($chunkPath, fread($sourceHandle, $length)); + + $requests[] = [ + 'headers' => [ + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + 'x-appwrite-id' => $deploymentId, + 'content-range' => 'bytes ' . $start . '-' . $end . '/' . $totalSize, + ], + 'chunkPath' => $chunkPath, + ]; + } + } finally { + fclose($sourceHandle); + } + + $responses = []; + $endpoint = parse_url($this->client->getEndpoint()); + $scheme = $endpoint['scheme'] ?? 'http'; + $host = $endpoint['host'] ?? 'appwrite'; + $port = $endpoint['port'] ?? ($scheme === 'https' ? 443 : 80); + $basePath = rtrim($endpoint['path'] ?? '', '/'); + + \Swoole\Coroutine\run(function () use ($basePath, $functionId, $host, $port, $requests, $scheme, &$responses): void { + $wg = new \Swoole\Coroutine\WaitGroup(); + + foreach ($requests as $index => $request) { + $wg->add(); + \Swoole\Coroutine::create(function () use ($basePath, $functionId, $host, $index, $port, $request, &$responses, $scheme, $wg): void { + try { + for ($attempt = 0; $attempt < 3; $attempt++) { + $client = new \Swoole\Coroutine\Http\Client($host, (int) $port, $scheme === 'https'); + $client->set([ + 'timeout' => 300, + 'ssl_verify_peer' => false, + 'ssl_verify_host' => false, + ]); + $client->setHeaders($request['headers']); + $client->setMethod(Client::METHOD_POST); + $client->setData([ + 'entrypoint' => 'index.js', + 'activate' => true, + ]); + $client->addFile($request['chunkPath'], 'code', 'application/x-gzip', 'code.tar.gz'); + $client->execute($basePath . '/functions/' . $functionId . '/deployments'); + + $responses[$index] = [ + 'body' => $client->body, + 'error' => $client->errMsg, + 'headers' => $client->headers ?? [], + 'statusCode' => $client->statusCode, + ]; + + $client->close(); + + if ($responses[$index]['statusCode'] !== 429) { + break; + } + + $retryAfter = (float) ($responses[$index]['headers']['retry-after'] ?? 0.1); + \Swoole\Coroutine::sleep(max($retryAfter, 0.1)); + } + } finally { + $wg->done(); + } + }); + } + + $wg->wait(); + }); + + ksort($responses); + + foreach ($responses as $response) { + $this->assertSame('', $response['error']); + $this->assertContains($response['statusCode'], [202], (string) $response['body']); + } + + $this->assertEventually(function () use ($functionId, $deploymentId) { + $deployment = $this->getDeployment($functionId, $deploymentId); + + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertEquals('ready', $deployment['body']['status']); + $this->assertEquals($deploymentId, $deployment['body']['$id']); + }, 120000, 500); + } finally { + $this->cleanupFunction($functionId); + + if (is_dir($tmpDirectory)) { + foreach (glob($tmpDirectory . DIRECTORY_SEPARATOR . '*') ?: [] as $file) { + unlink($file); + } + rmdir($tmpDirectory); + } + } + } + public function testUpdateDeployment(): void { $data = $this->setupTestDeployment(); diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 7d9257c699..653b5919eb 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -1034,6 +1034,145 @@ class SitesCustomServerTest extends Scope $this->cleanupSite($siteId); } + public function testCreateDeploymentParallelChunksLargeFile(): void + { + $siteId = $this->setupSite([ + 'buildRuntime' => 'node-22', + 'fallbackFile' => '', + 'framework' => 'other', + 'name' => 'Test Site Parallel Chunk Deployment', + 'outputDirectory' => './', + 'providerBranch' => 'main', + 'providerRootDirectory' => './', + 'siteId' => ID::unique() + ]); + + $deploymentId = ID::unique(); + $tmpDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'appwrite-parallel-site-deployment-' . $deploymentId; + + mkdir($tmpDirectory); + + try { + file_put_contents($tmpDirectory . DIRECTORY_SEPARATOR . 'index.html', 'Hello World'); + file_put_contents($tmpDirectory . DIRECTORY_SEPARATOR . 'large.bin', random_bytes(20 * 1024 * 1024)); + + $source = $tmpDirectory . DIRECTORY_SEPARATOR . 'code.tar.gz'; + Console::execute('cd ' . $tmpDirectory . ' && tar --exclude code.tar.gz -czf code.tar.gz .', '', $this->stdout, $this->stderr); + + $totalSize = filesize($source); + $chunkSize = 5 * 1024 * 1024; + $chunksTotal = (int) ceil($totalSize / $chunkSize); + + $this->assertGreaterThanOrEqual(4, $chunksTotal, 'Test deployment must span at least 4 chunks'); + + $requests = []; + $sourceHandle = fopen($source, 'rb'); + $this->assertNotFalse($sourceHandle, 'Could not open deployment package'); + + try { + for ($i = 0; $i < $chunksTotal; $i++) { + $start = $i * $chunkSize; + $end = min($start + $chunkSize, $totalSize) - 1; + $length = $end - $start + 1; + $chunkPath = $tmpDirectory . DIRECTORY_SEPARATOR . 'chunk-' . $i . '.part'; + + fseek($sourceHandle, $start); + file_put_contents($chunkPath, fread($sourceHandle, $length)); + + $requests[] = [ + 'headers' => [ + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + 'x-appwrite-id' => $deploymentId, + 'content-range' => 'bytes ' . $start . '-' . $end . '/' . $totalSize, + ], + 'chunkPath' => $chunkPath, + ]; + } + } finally { + fclose($sourceHandle); + } + + $responses = []; + $endpoint = parse_url($this->client->getEndpoint()); + $scheme = $endpoint['scheme'] ?? 'http'; + $host = $endpoint['host'] ?? 'appwrite'; + $port = $endpoint['port'] ?? ($scheme === 'https' ? 443 : 80); + $basePath = rtrim($endpoint['path'] ?? '', '/'); + + \Swoole\Coroutine\run(function () use ($basePath, $host, $port, $requests, $scheme, $siteId, &$responses): void { + $wg = new \Swoole\Coroutine\WaitGroup(); + + foreach ($requests as $index => $request) { + $wg->add(); + \Swoole\Coroutine::create(function () use ($basePath, $host, $index, $port, $request, &$responses, $scheme, $siteId, $wg): void { + try { + for ($attempt = 0; $attempt < 3; $attempt++) { + $client = new \Swoole\Coroutine\Http\Client($host, (int) $port, $scheme === 'https'); + $client->set([ + 'timeout' => 300, + 'ssl_verify_peer' => false, + 'ssl_verify_host' => false, + ]); + $client->setHeaders($request['headers']); + $client->setMethod(Client::METHOD_POST); + $client->setData([ + 'activate' => true, + ]); + $client->addFile($request['chunkPath'], 'code', 'application/x-gzip', 'code.tar.gz'); + $client->execute($basePath . '/sites/' . $siteId . '/deployments'); + + $responses[$index] = [ + 'body' => $client->body, + 'error' => $client->errMsg, + 'headers' => $client->headers ?? [], + 'statusCode' => $client->statusCode, + ]; + + $client->close(); + + if ($responses[$index]['statusCode'] !== 429) { + break; + } + + $retryAfter = (float) ($responses[$index]['headers']['retry-after'] ?? 0.1); + \Swoole\Coroutine::sleep(max($retryAfter, 0.1)); + } + } finally { + $wg->done(); + } + }); + } + + $wg->wait(); + }); + + ksort($responses); + + foreach ($responses as $response) { + $this->assertSame('', $response['error']); + $this->assertContains($response['statusCode'], [202], (string) $response['body']); + } + + $this->assertEventually(function () use ($siteId, $deploymentId) { + $deployment = $this->getDeployment($siteId, $deploymentId); + + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertEquals('ready', $deployment['body']['status']); + $this->assertEquals($deploymentId, $deployment['body']['$id']); + }, 120000, 500); + } finally { + $this->cleanupSite($siteId); + + if (is_dir($tmpDirectory)) { + foreach (glob($tmpDirectory . DIRECTORY_SEPARATOR . '*') ?: [] as $file) { + unlink($file); + } + rmdir($tmpDirectory); + } + } + } + public function testCreateDeployment() { $siteId = $this->setupSite([ diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index 009910fb38..af6aa62564 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -1376,7 +1376,7 @@ trait StorageBase public function testCreateBucketFileParallelChunksLargeFile(): void { - $totalSize = (int) ($_ENV['APPWRITE_TEST_PARALLEL_UPLOAD_SIZE'] ?? 20 * 1024 * 1024); + $totalSize = 20 * 1024 * 1024; $chunkSize = 5 * 1024 * 1024; $chunksTotal = (int) ceil($totalSize / $chunkSize); From 5c7e08fdff44bc7b5cf957fbf7c86c5695f46727 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 4 May 2026 17:59:27 +0400 Subject: [PATCH 07/47] Fix deployment upload analyze warning --- .../Platform/Modules/Functions/Http/Deployments/Create.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php index ac735a8b0a..de468e1bb0 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php @@ -203,7 +203,7 @@ class Create extends Action $completed = false; try { - $checkLock->withLock(function () use (&$chunks, $contentRange, $dbForProject, $deploymentId, &$metadata, &$completed, $response): void { + $checkLock->withLock(function () use (&$chunks, $dbForProject, $deploymentId, &$metadata, &$completed, $response): void { $deployment = $dbForProject->getDocument('deployments', $deploymentId); if (!$deployment->isEmpty()) { From 2836f89b3440505ed14214707e71e0118f66d6f9 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 4 May 2026 13:09:38 +0530 Subject: [PATCH 08/47] feat: skip deployment on commit message pattern match Add providerCommitSkipPatterns array field to functions and sites. Any commit message containing one of the patterns (case-insensitive substring) skips the VCS-triggered deployment. Co-Authored-By: Claude Sonnet 4.6 --- app/config/collections/projects.php | 22 +++ .../Modules/VCS/Http/GitHub/Deployment.php | 17 +++ .../Vcs/Validator/CommitSkipPatterns.php | 50 +++++++ .../Vcs/Validator/CommitSkipPatternsTest.php | 141 ++++++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 src/Appwrite/Vcs/Validator/CommitSkipPatterns.php create mode 100644 tests/unit/Vcs/Validator/CommitSkipPatternsTest.php diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php index 9568c59369..0f1bfe12f5 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -841,6 +841,17 @@ return [ 'array' => true, 'filters' => [], ], + [ + '$id' => ID::custom('providerCommitSkipPatterns'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => false, + 'default' => [], + 'array' => true, + 'filters' => [], + ], ], 'indexes' => [ [ @@ -1320,6 +1331,17 @@ return [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('providerCommitSkipPatterns'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => false, + 'default' => [], + 'array' => true, + 'filters' => [], + ], ], 'indexes' => [ [ diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index 8bc090bb03..a6124412bb 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -8,6 +8,7 @@ use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Filter\BranchDomain as BranchDomainFilter; use Appwrite\Vcs\Comment; +use Appwrite\Vcs\Validator\CommitSkipPatterns; use Utopia\Config\Config; use Utopia\Console; use Utopia\Database\Database; @@ -95,6 +96,11 @@ trait Deployment $resource = $authorization->skip(fn () => $dbForProject->getDocument($resourceCollection, $resourceId)); $resourceInternalId = $resource->getSequence(); + if (!$this->isResourceBuildable($resource, $logBase, $providerCommitMessage)) { + Span::add("{$logBase}.build.skipped", 'true'); + continue; + } + $deploymentId = ID::unique(); $repositoryId = $repository->getId(); $repositoryInternalId = $repository->getSequence(); @@ -561,4 +567,15 @@ trait Deployment { return System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME); } + + private function isResourceBuildable(Document $resource, string $logBase, string $providerCommitMessage = ''): bool + { + $commitSkip = new CommitSkipPatterns($resource->getAttribute('providerCommitSkipPatterns', [])); + if (!$commitSkip->isValid($providerCommitMessage)) { + Span::add("{$logBase}.build.skipped.reason", 'commitMessage'); + return false; + } + + return true; + } } diff --git a/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php b/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php new file mode 100644 index 0000000000..76c04e5417 --- /dev/null +++ b/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php @@ -0,0 +1,50 @@ +patterns as $pattern) { + if (!is_string($pattern) || $pattern === '') { + continue; + } + if (stripos($value, $pattern) !== false) { + return false; + } + } + + return true; + } + + public function getDescription(): string + { + return 'Commit message must not contain any of the configured skip patterns.'; + } + + public function isArray(): bool + { + return false; + } + + public function getType(): string + { + return self::TYPE_STRING; + } +} diff --git a/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php b/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php new file mode 100644 index 0000000000..3725c643bc --- /dev/null +++ b/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php @@ -0,0 +1,141 @@ +assertTrue($validator->isValid('fix: update readme')); + $this->assertTrue($validator->isValid('[skip deploy] docs only')); + $this->assertTrue($validator->isValid('')); + } + + // ------------------------------------------------------------------------- + // Single pattern — exact substring + // ------------------------------------------------------------------------- + + public function testSinglePatternMatchSkips(): void + { + $validator = new CommitSkipPatterns(['[skip deploy]']); + $this->assertFalse($validator->isValid('[skip deploy] docs only')); + $this->assertFalse($validator->isValid('chore: update deps [skip deploy]')); + $this->assertFalse($validator->isValid('prefix [skip deploy] suffix')); + } + + public function testSinglePatternNoMatchProceeds(): void + { + $validator = new CommitSkipPatterns(['[skip deploy]']); + $this->assertTrue($validator->isValid('fix: real bug fix')); + $this->assertTrue($validator->isValid('feat: add new feature')); + $this->assertTrue($validator->isValid('skip deploy without brackets')); + } + + // ------------------------------------------------------------------------- + // Case insensitivity + // ------------------------------------------------------------------------- + + public function testCaseInsensitiveMatch(): void + { + $validator = new CommitSkipPatterns(['[skip deploy]']); + $this->assertFalse($validator->isValid('[SKIP DEPLOY] uppercase')); + $this->assertFalse($validator->isValid('[Skip Deploy] mixed case')); + $this->assertFalse($validator->isValid('[skip DEPLOY] partial upper')); + } + + public function testPatternItselfCaseInsensitive(): void + { + $validator = new CommitSkipPatterns(['[SKIP DEPLOY]']); + $this->assertFalse($validator->isValid('[skip deploy] lowercase message')); + $this->assertFalse($validator->isValid('[Skip Deploy] mixed message')); + } + + // ------------------------------------------------------------------------- + // Array of patterns — any match skips (OR semantics) + // ------------------------------------------------------------------------- + + public function testMultiplePatternsFirstMatches(): void + { + $validator = new CommitSkipPatterns(['[skip deploy]', '[skip ci]', '[no deploy]']); + $this->assertFalse($validator->isValid('[skip deploy] docs only')); + } + + public function testMultiplePatternsSecondMatches(): void + { + $validator = new CommitSkipPatterns(['[skip deploy]', '[skip ci]', '[no deploy]']); + $this->assertFalse($validator->isValid('chore: update readme [skip ci]')); + } + + public function testMultiplePatternsThirdMatches(): void + { + $validator = new CommitSkipPatterns(['[skip deploy]', '[skip ci]', '[no deploy]']); + $this->assertFalse($validator->isValid('[no deploy] just docs')); + } + + public function testMultiplePatternsNoneMatchProceeds(): void + { + $validator = new CommitSkipPatterns(['[skip deploy]', '[skip ci]', '[no deploy]']); + $this->assertTrue($validator->isValid('feat: completely new feature')); + $this->assertTrue($validator->isValid('fix: important bug fix')); + } + + // ------------------------------------------------------------------------- + // Common real-world skip conventions + // ------------------------------------------------------------------------- + + public function testCommonSkipCiPattern(): void + { + $validator = new CommitSkipPatterns(['[skip ci]']); + $this->assertFalse($validator->isValid('[skip ci] update changelog')); + $this->assertFalse($validator->isValid('[SKIP CI]')); + $this->assertTrue($validator->isValid('feat: something real')); + } + + public function testNoDeployPattern(): void + { + $validator = new CommitSkipPatterns(['[no deploy]']); + $this->assertFalse($validator->isValid('[no deploy] tweak docs')); + $this->assertTrue($validator->isValid('deploy this please')); + } + + // ------------------------------------------------------------------------- + // Edge cases + // ------------------------------------------------------------------------- + + public function testEmptyCommitMessageNeverSkipsWithPatterns(): void + { + $validator = new CommitSkipPatterns(['[skip deploy]']); + $this->assertTrue($validator->isValid('')); + } + + public function testBlankPatternsInArrayAreIgnored(): void + { + $validator = new CommitSkipPatterns(['', ' ', '[skip deploy]']); + // empty/whitespace-only patterns must not cause a false positive on empty messages + $this->assertTrue($validator->isValid('normal commit message')); + // but the real pattern still works + $this->assertFalse($validator->isValid('[skip deploy] docs')); + } + + public function testPatternAsSubstringOfLongerWord(): void + { + // "skip" is a substring of "skippy" — should NOT accidentally skip + $validator = new CommitSkipPatterns(['[skip deploy]']); + $this->assertTrue($validator->isValid('skippy the kangaroo')); + } + + public function testMultilineCommitMessage(): void + { + $validator = new CommitSkipPatterns(['[skip deploy]']); + $msg = "feat: add new stuff\n\nMore detail here.\n\n[skip deploy]"; + $this->assertFalse($validator->isValid($msg)); + } +} From 4a4f51622d24b0f96a538225200b45fb640ceb29 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 5 May 2026 18:23:14 +0530 Subject: [PATCH 09/47] fix: tighten commit skip directive matching --- .../Vcs/Validator/CommitSkipPatterns.php | 74 +++++++++++++++++-- .../Vcs/Validator/CommitSkipPatternsTest.php | 23 ++++-- 2 files changed, 86 insertions(+), 11 deletions(-) diff --git a/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php b/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php index 76c04e5417..7243808bf8 100644 --- a/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php +++ b/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php @@ -12,7 +12,7 @@ class CommitSkipPatterns extends Validator /** * Returns false (skip deployment) when the commit message contains any of the - * configured patterns (case-insensitive substring match). + * configured skip directives. * Returns true (proceed) when no patterns are configured or none match. */ public function isValid($value): bool @@ -21,11 +21,13 @@ class CommitSkipPatterns extends Validator return false; } - foreach ($this->patterns as $pattern) { - if (!is_string($pattern) || $pattern === '') { - continue; - } - if (stripos($value, $pattern) !== false) { + $patterns = $this->normalizePatterns($this->patterns); + if (empty($patterns)) { + return true; + } + + foreach ($this->extractDirectives($value) as $directive) { + if (isset($patterns[$directive])) { return false; } } @@ -47,4 +49,64 @@ class CommitSkipPatterns extends Validator { return self::TYPE_STRING; } + + /** + * @param array $patterns + * @return array + */ + private function normalizePatterns(array $patterns): array + { + $normalized = []; + + foreach ($patterns as $pattern) { + if (!\is_string($pattern)) { + continue; + } + + $pattern = $this->normalizeDirective($pattern); + if ($pattern === '') { + continue; + } + + $normalized[$pattern] = true; + } + + return $normalized; + } + + /** + * @return array + */ + private function extractDirectives(string $message): array + { + $directives = []; + + if (\preg_match_all('/\[[^\]\r\n]+\]/u', $message, $matches) > 0) { + foreach ($matches[0] as $match) { + $directives[] = $this->normalizeDirective($match); + } + } + + foreach (\preg_split("/\r\n|\n|\r/", $message) ?: [] as $line) { + $line = \trim($line); + if ($line === '' || !\str_contains($line, ':')) { + continue; + } + + $directives[] = $this->normalizeDirective($line); + } + + return \array_values(\array_filter(\array_unique($directives))); + } + + private function normalizeDirective(string $value): string + { + $value = \trim($value); + if ($value === '') { + return ''; + } + + $value = (string) \preg_replace('/\s+/u', ' ', $value); + return \mb_strtolower($value); + } } diff --git a/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php b/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php index 3725c643bc..efc88bbf6a 100644 --- a/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php +++ b/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php @@ -20,7 +20,7 @@ class CommitSkipPatternsTest extends TestCase } // ------------------------------------------------------------------------- - // Single pattern — exact substring + // Single pattern — directive match // ------------------------------------------------------------------------- public function testSinglePatternMatchSkips(): void @@ -37,6 +37,7 @@ class CommitSkipPatternsTest extends TestCase $this->assertTrue($validator->isValid('fix: real bug fix')); $this->assertTrue($validator->isValid('feat: add new feature')); $this->assertTrue($validator->isValid('skip deploy without brackets')); + $this->assertTrue($validator->isValid('prefix[skip deploy]suffix')); } // ------------------------------------------------------------------------- @@ -119,17 +120,15 @@ class CommitSkipPatternsTest extends TestCase public function testBlankPatternsInArrayAreIgnored(): void { $validator = new CommitSkipPatterns(['', ' ', '[skip deploy]']); - // empty/whitespace-only patterns must not cause a false positive on empty messages $this->assertTrue($validator->isValid('normal commit message')); - // but the real pattern still works $this->assertFalse($validator->isValid('[skip deploy] docs')); } - public function testPatternAsSubstringOfLongerWord(): void + public function testPatternMustBeStandaloneDirective(): void { - // "skip" is a substring of "skippy" — should NOT accidentally skip $validator = new CommitSkipPatterns(['[skip deploy]']); $this->assertTrue($validator->isValid('skippy the kangaroo')); + $this->assertTrue($validator->isValid('prefix[skip deploy]suffix')); } public function testMultilineCommitMessage(): void @@ -138,4 +137,18 @@ class CommitSkipPatternsTest extends TestCase $msg = "feat: add new stuff\n\nMore detail here.\n\n[skip deploy]"; $this->assertFalse($validator->isValid($msg)); } + + public function testWhitespaceInsideDirectiveIsNormalized(): void + { + $validator = new CommitSkipPatterns([' [skip deploy] ']); + $this->assertFalse($validator->isValid('[skip deploy] docs only')); + $this->assertFalse($validator->isValid('[SKIP DEPLOY] docs only')); + } + + public function testTrailerDirectiveCanSkip(): void + { + $validator = new CommitSkipPatterns(['skip-checks: true']); + $msg = "feat: add new stuff\n\nMore detail here.\n\nskip-checks:true"; + $this->assertFalse($validator->isValid($msg)); + } } From 444d4b4e66928ec2ca0d52528dbf3eb4add7730c Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 7 May 2026 18:25:39 +0530 Subject: [PATCH 10/47] refactor: standalone regex matching for commit skip patterns Replace directive-extraction approach with word-boundary regex matching so plain-word patterns like "skip appwrite" and "appwrite skip" work alongside bracket directives. Use \s+ between word tokens (required space) and \s* only after ":" tokens (git trailer flexibility). Add tests for "skip appwrite" and "appwrite skip" with case insensitivity. Co-Authored-By: Claude Sonnet 4.6 --- .../Vcs/Validator/CommitSkipPatterns.php | 107 +++++++----------- .../Vcs/Validator/CommitSkipPatternsTest.php | 61 ++++++++++ 2 files changed, 101 insertions(+), 67 deletions(-) diff --git a/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php b/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php index 7243808bf8..9dbe45ba83 100644 --- a/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php +++ b/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php @@ -12,8 +12,16 @@ class CommitSkipPatterns extends Validator /** * Returns false (skip deployment) when the commit message contains any of the - * configured skip directives. + * configured patterns as a standalone directive (case-insensitive). * Returns true (proceed) when no patterns are configured or none match. + * + * Matching rules: + * - Case-insensitive + * - The directive must be surrounded by whitespace or string boundaries, so + * "prefix[skip deploy]suffix" does NOT accidentally skip + * - Internal whitespace in the pattern is normalised: tokens are split on \s+ + * and rejoined with \s* in the regex, so "[skip deploy]" matches + * "[skip deploy]" and "skip-checks: true" matches "skip-checks:true" */ public function isValid($value): bool { @@ -21,13 +29,38 @@ class CommitSkipPatterns extends Validator return false; } - $patterns = $this->normalizePatterns($this->patterns); - if (empty($patterns)) { - return true; - } + foreach ($this->patterns as $pattern) { + if (!is_string($pattern)) { + continue; + } - foreach ($this->extractDirectives($value) as $directive) { - if (isset($patterns[$directive])) { + $pattern = trim($pattern); + if ($pattern === '') { + continue; + } + + // Split on whitespace; each token is regex-quoted. Tokens are rejoined + // with \s+ (required space) so that "skipappwrite" does NOT match the + // pattern "skip appwrite". The only exception: when the preceding token + // ends with ":" (git trailer style), \s* is used so that + // "skip-checks:true" still matches the pattern "skip-checks: true". + $tokens = preg_split('/\s+/', $pattern); + $regexParts = []; + $count = count($tokens); + for ($i = 0; $i < $count; $i++) { + $regexParts[] = preg_quote($tokens[$i], '~'); + if ($i < $count - 1) { + $regexParts[] = str_ends_with($tokens[$i], ':') ? '\s*' : '\s+'; + } + } + $regexBody = implode('', $regexParts); + + // (? $patterns - * @return array - */ - private function normalizePatterns(array $patterns): array - { - $normalized = []; - - foreach ($patterns as $pattern) { - if (!\is_string($pattern)) { - continue; - } - - $pattern = $this->normalizeDirective($pattern); - if ($pattern === '') { - continue; - } - - $normalized[$pattern] = true; - } - - return $normalized; - } - - /** - * @return array - */ - private function extractDirectives(string $message): array - { - $directives = []; - - if (\preg_match_all('/\[[^\]\r\n]+\]/u', $message, $matches) > 0) { - foreach ($matches[0] as $match) { - $directives[] = $this->normalizeDirective($match); - } - } - - foreach (\preg_split("/\r\n|\n|\r/", $message) ?: [] as $line) { - $line = \trim($line); - if ($line === '' || !\str_contains($line, ':')) { - continue; - } - - $directives[] = $this->normalizeDirective($line); - } - - return \array_values(\array_filter(\array_unique($directives))); - } - - private function normalizeDirective(string $value): string - { - $value = \trim($value); - if ($value === '') { - return ''; - } - - $value = (string) \preg_replace('/\s+/u', ' ', $value); - return \mb_strtolower($value); - } } diff --git a/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php b/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php index efc88bbf6a..b546199d22 100644 --- a/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php +++ b/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php @@ -151,4 +151,65 @@ class CommitSkipPatternsTest extends TestCase $msg = "feat: add new stuff\n\nMore detail here.\n\nskip-checks:true"; $this->assertFalse($validator->isValid($msg)); } + + // ------------------------------------------------------------------------- + // Plain-word patterns: "skip appwrite" and "appwrite skip" + // ------------------------------------------------------------------------- + + public function testSkipAppwritePatternSkips(): void + { + $validator = new CommitSkipPatterns(['skip appwrite']); + $this->assertFalse($validator->isValid('docs: update readme skip appwrite')); + $this->assertFalse($validator->isValid('skip appwrite')); + } + + public function testSkipAppwritePatternCaseInsensitive(): void + { + $validator = new CommitSkipPatterns(['skip appwrite']); + $this->assertFalse($validator->isValid('SKIP APPWRITE')); + $this->assertFalse($validator->isValid('Skip Appwrite')); + $this->assertFalse($validator->isValid('SKIP appwrite')); + } + + public function testSkipAppwritePatternNoMatchProceeds(): void + { + $validator = new CommitSkipPatterns(['skip appwrite']); + $this->assertTrue($validator->isValid('feat: real feature')); + $this->assertTrue($validator->isValid('skipappwrite')); // no space — not standalone + $this->assertTrue($validator->isValid('appwrite is great')); + } + + public function testAppwriteSkipPatternSkips(): void + { + $validator = new CommitSkipPatterns(['appwrite skip']); + $this->assertFalse($validator->isValid('appwrite skip ci')); + $this->assertFalse($validator->isValid('docs appwrite skip')); + $this->assertFalse($validator->isValid('appwrite skip')); + } + + public function testAppwriteSkipPatternCaseInsensitive(): void + { + $validator = new CommitSkipPatterns(['appwrite skip']); + $this->assertFalse($validator->isValid('APPWRITE SKIP')); + $this->assertFalse($validator->isValid('Appwrite Skip')); + $this->assertFalse($validator->isValid('appwrite SKIP')); + } + + public function testAppwriteSkipPatternNoMatchProceeds(): void + { + $validator = new CommitSkipPatterns(['appwrite skip']); + $this->assertTrue($validator->isValid('feat: deploy appwrite changes')); + $this->assertTrue($validator->isValid('appwriteskip')); // no space — not standalone + $this->assertTrue($validator->isValid('skip the appwrite stuff')); + } + + public function testBothAppwritePatternsInArray(): void + { + $validator = new CommitSkipPatterns(['skip appwrite', 'appwrite skip']); + $this->assertFalse($validator->isValid('skip appwrite')); + $this->assertFalse($validator->isValid('appwrite skip')); + $this->assertFalse($validator->isValid('SKIP APPWRITE')); + $this->assertFalse($validator->isValid('APPWRITE SKIP')); + $this->assertTrue($validator->isValid('feat: deploy appwrite changes')); + } } From 4684688fc7e5b5568d12dd253e796b25168bb690 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 11 May 2026 17:16:03 +0530 Subject: [PATCH 11/47] feat: expose commit skip patterns --- .../Platform/Modules/Functions/Http/Functions/Create.php | 3 +++ .../Platform/Modules/Functions/Http/Functions/Update.php | 3 +++ src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php | 4 ++++ src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php | 5 +++++ src/Appwrite/Utopia/Response/Model/Func.php | 7 +++++++ src/Appwrite/Utopia/Response/Model/Site.php | 7 +++++++ 6 files changed, 29 insertions(+) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index 00a91141fb..b9bd67b1e0 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -94,6 +94,7 @@ class Create extends Base ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function.', true) ->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the function? In silent mode, comments will not be made on commits and pull requests.', true) ->param('providerRootDirectory', '', new Text(128, 0), 'Path to function code in the linked repo.', true) + ->param('providerCommitSkipPatterns', [], new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of commit message patterns to skip automatic deployments. Leave empty to deploy on all commits.', true) ->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( $plan, Config::getParam('specifications', []), @@ -146,6 +147,7 @@ class Create extends Base string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, + array $providerCommitSkipPatterns, string $buildSpecification, string $runtimeSpecification, string $templateRepository, @@ -247,6 +249,7 @@ class Create extends Base 'providerBranch' => $providerBranch, 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, + 'providerCommitSkipPatterns' => $providerCommitSkipPatterns, 'buildSpecification' => $buildSpecification, 'runtimeSpecification' => $runtimeSpecification, ])); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index b3fcb2c021..39b56bb39e 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -87,6 +87,7 @@ class Update extends Base ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function', true) ->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the function? In silent mode, comments will not be made on commits and pull requests.', true) ->param('providerRootDirectory', '', new Text(128, 0), 'Path to function code in the linked repo.', true) + ->param('providerCommitSkipPatterns', null, new Nullable(new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'List of commit message patterns to skip automatic deployments. Leave empty to deploy on all commits.', true) ->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( $plan, Config::getParam('specifications', []), @@ -132,6 +133,7 @@ class Update extends Base string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, + ?array $providerCommitSkipPatterns, string $buildSpecification, string $runtimeSpecification, int $deploymentRetention, @@ -276,6 +278,7 @@ class Update extends Base 'providerBranch' => $providerBranch, 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, + 'providerCommitSkipPatterns' => $providerCommitSkipPatterns ?? $function->getAttribute('providerCommitSkipPatterns', []), 'buildSpecification' => $buildSpecification, 'runtimeSpecification' => $runtimeSpecification, 'search' => implode(' ', [$functionId, $name, $runtime]), diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php index d01d0d8ca7..95c34ce686 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php @@ -19,6 +19,7 @@ use Utopia\Database\Helpers\ID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; +use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Range; use Utopia\Validator\Text; @@ -78,6 +79,7 @@ class Create extends Base ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the site.', true) ->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the site? In silent mode, comments will not be made on commits and pull requests.', true) ->param('providerRootDirectory', '', new Text(128, 0), 'Path to site code in the linked repo.', true) + ->param('providerCommitSkipPatterns', [], new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of commit message patterns to skip automatic deployments. Leave empty to deploy on all commits.', true) ->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( $plan, Config::getParam('specifications', []), @@ -118,6 +120,7 @@ class Create extends Base string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, + array $providerCommitSkipPatterns, string $buildSpecification, string $runtimeSpecification, int $deploymentRetention, @@ -173,6 +176,7 @@ class Create extends Base 'providerBranch' => $providerBranch, 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, + 'providerCommitSkipPatterns' => $providerCommitSkipPatterns, 'buildSpecification' => $buildSpecification, 'runtimeSpecification' => $runtimeSpecification, 'buildRuntime' => $buildRuntime, diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php index 2aee03265e..9e5184abd0 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -22,7 +22,9 @@ use Utopia\Http\Adapter\Swoole\Request; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; +use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; +use Utopia\Validator\Nullable; use Utopia\Validator\Range; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; @@ -81,6 +83,7 @@ class Update extends Base ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the site.', true) ->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the site? In silent mode, comments will not be made on commits and pull requests.', true) ->param('providerRootDirectory', '', new Text(128, 0), 'Path to site code in the linked repo.', true) + ->param('providerCommitSkipPatterns', null, new Nullable(new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'List of commit message patterns to skip automatic deployments. Leave empty to deploy on all commits.', true) ->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( $plan, Config::getParam('specifications', []), @@ -126,6 +129,7 @@ class Update extends Base string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, + ?array $providerCommitSkipPatterns, string $buildSpecification, string $runtimeSpecification, int $deploymentRetention, @@ -271,6 +275,7 @@ class Update extends Base 'providerBranch' => $providerBranch, 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, + 'providerCommitSkipPatterns' => $providerCommitSkipPatterns ?? $site->getAttribute('providerCommitSkipPatterns', []), 'buildSpecification' => $buildSpecification, 'runtimeSpecification' => $runtimeSpecification, 'search' => implode(' ', [$siteId, $name, $framework]), diff --git a/src/Appwrite/Utopia/Response/Model/Func.php b/src/Appwrite/Utopia/Response/Model/Func.php index 3aea364fe5..a85014b9d8 100644 --- a/src/Appwrite/Utopia/Response/Model/Func.php +++ b/src/Appwrite/Utopia/Response/Model/Func.php @@ -182,6 +182,13 @@ class Func extends Model 'default' => false, 'example' => false, ]) + ->addRule('providerCommitSkipPatterns', [ + 'type' => self::TYPE_STRING, + 'description' => 'Commit message patterns that skip automatic deployments', + 'default' => [], + 'example' => ['[skip deploy]'], + 'array' => true, + ]) ->addRule('buildSpecification', [ 'type' => self::TYPE_STRING, 'description' => 'Machine specification for deployment builds.', diff --git a/src/Appwrite/Utopia/Response/Model/Site.php b/src/Appwrite/Utopia/Response/Model/Site.php index 941b6104df..8bb33f6000 100644 --- a/src/Appwrite/Utopia/Response/Model/Site.php +++ b/src/Appwrite/Utopia/Response/Model/Site.php @@ -173,6 +173,13 @@ class Site extends Model 'default' => false, 'example' => false, ]) + ->addRule('providerCommitSkipPatterns', [ + 'type' => self::TYPE_STRING, + 'description' => 'Commit message patterns that skip automatic deployments', + 'default' => [], + 'example' => ['[skip deploy]'], + 'array' => true, + ]) ->addRule('buildSpecification', [ 'type' => self::TYPE_STRING, 'description' => 'Machine specification for deployment builds.', From 73eb14d4bb724b84c6c545741243ce612b64c1ee Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 11 May 2026 17:19:55 +0530 Subject: [PATCH 12/47] fix: fail open for non-string commit messages --- src/Appwrite/Vcs/Validator/CommitSkipPatterns.php | 2 +- tests/unit/Vcs/Validator/CommitSkipPatternsTest.php | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php b/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php index 9dbe45ba83..fc5dec2de3 100644 --- a/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php +++ b/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php @@ -26,7 +26,7 @@ class CommitSkipPatterns extends Validator public function isValid($value): bool { if (!is_string($value)) { - return false; + return true; } foreach ($this->patterns as $pattern) { diff --git a/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php b/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php index b546199d22..6f486872bd 100644 --- a/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php +++ b/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php @@ -117,6 +117,13 @@ class CommitSkipPatternsTest extends TestCase $this->assertTrue($validator->isValid('')); } + public function testNonStringCommitMessageNeverSkips(): void + { + $validator = new CommitSkipPatterns(['[skip deploy]']); + $this->assertTrue($validator->isValid(null)); + $this->assertTrue($validator->isValid([])); + } + public function testBlankPatternsInArrayAreIgnored(): void { $validator = new CommitSkipPatterns(['', ' ', '[skip deploy]']); From 9a6d2a6d2112f63eec3e7313e594d3c2b8af6039 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 11 May 2026 18:02:51 +0530 Subject: [PATCH 13/47] refactor: use fixed commit skip directives --- app/config/collections/projects.php | 22 -- .../Functions/Http/Functions/Create.php | 3 - .../Functions/Http/Functions/Update.php | 3 - .../Modules/Sites/Http/Sites/Create.php | 4 - .../Modules/Sites/Http/Sites/Update.php | 5 - .../Modules/VCS/Http/GitHub/Deployment.php | 6 +- src/Appwrite/Utopia/Response/Model/Func.php | 7 - src/Appwrite/Utopia/Response/Model/Site.php | 7 - .../Vcs/Validator/CommitSkipPatterns.php | 29 ++- .../Vcs/Validator/CommitSkipPatternsTest.php | 227 ++++-------------- 10 files changed, 62 insertions(+), 251 deletions(-) diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php index 0f1bfe12f5..9568c59369 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -841,17 +841,6 @@ return [ 'array' => true, 'filters' => [], ], - [ - '$id' => ID::custom('providerCommitSkipPatterns'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 128, - 'signed' => true, - 'required' => false, - 'default' => [], - 'array' => true, - 'filters' => [], - ], ], 'indexes' => [ [ @@ -1331,17 +1320,6 @@ return [ 'array' => false, 'filters' => [], ], - [ - '$id' => ID::custom('providerCommitSkipPatterns'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 128, - 'signed' => true, - 'required' => false, - 'default' => [], - 'array' => true, - 'filters' => [], - ], ], 'indexes' => [ [ diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index b9bd67b1e0..00a91141fb 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -94,7 +94,6 @@ class Create extends Base ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function.', true) ->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the function? In silent mode, comments will not be made on commits and pull requests.', true) ->param('providerRootDirectory', '', new Text(128, 0), 'Path to function code in the linked repo.', true) - ->param('providerCommitSkipPatterns', [], new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of commit message patterns to skip automatic deployments. Leave empty to deploy on all commits.', true) ->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( $plan, Config::getParam('specifications', []), @@ -147,7 +146,6 @@ class Create extends Base string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, - array $providerCommitSkipPatterns, string $buildSpecification, string $runtimeSpecification, string $templateRepository, @@ -249,7 +247,6 @@ class Create extends Base 'providerBranch' => $providerBranch, 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, - 'providerCommitSkipPatterns' => $providerCommitSkipPatterns, 'buildSpecification' => $buildSpecification, 'runtimeSpecification' => $runtimeSpecification, ])); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index 39b56bb39e..b3fcb2c021 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -87,7 +87,6 @@ class Update extends Base ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function', true) ->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the function? In silent mode, comments will not be made on commits and pull requests.', true) ->param('providerRootDirectory', '', new Text(128, 0), 'Path to function code in the linked repo.', true) - ->param('providerCommitSkipPatterns', null, new Nullable(new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'List of commit message patterns to skip automatic deployments. Leave empty to deploy on all commits.', true) ->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( $plan, Config::getParam('specifications', []), @@ -133,7 +132,6 @@ class Update extends Base string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, - ?array $providerCommitSkipPatterns, string $buildSpecification, string $runtimeSpecification, int $deploymentRetention, @@ -278,7 +276,6 @@ class Update extends Base 'providerBranch' => $providerBranch, 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, - 'providerCommitSkipPatterns' => $providerCommitSkipPatterns ?? $function->getAttribute('providerCommitSkipPatterns', []), 'buildSpecification' => $buildSpecification, 'runtimeSpecification' => $runtimeSpecification, 'search' => implode(' ', [$functionId, $name, $runtime]), diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php index 95c34ce686..d01d0d8ca7 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php @@ -19,7 +19,6 @@ use Utopia\Database\Helpers\ID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; -use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Range; use Utopia\Validator\Text; @@ -79,7 +78,6 @@ class Create extends Base ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the site.', true) ->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the site? In silent mode, comments will not be made on commits and pull requests.', true) ->param('providerRootDirectory', '', new Text(128, 0), 'Path to site code in the linked repo.', true) - ->param('providerCommitSkipPatterns', [], new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of commit message patterns to skip automatic deployments. Leave empty to deploy on all commits.', true) ->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( $plan, Config::getParam('specifications', []), @@ -120,7 +118,6 @@ class Create extends Base string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, - array $providerCommitSkipPatterns, string $buildSpecification, string $runtimeSpecification, int $deploymentRetention, @@ -176,7 +173,6 @@ class Create extends Base 'providerBranch' => $providerBranch, 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, - 'providerCommitSkipPatterns' => $providerCommitSkipPatterns, 'buildSpecification' => $buildSpecification, 'runtimeSpecification' => $runtimeSpecification, 'buildRuntime' => $buildRuntime, diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php index 9e5184abd0..2aee03265e 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -22,9 +22,7 @@ use Utopia\Http\Adapter\Swoole\Request; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; -use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; -use Utopia\Validator\Nullable; use Utopia\Validator\Range; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; @@ -83,7 +81,6 @@ class Update extends Base ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the site.', true) ->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the site? In silent mode, comments will not be made on commits and pull requests.', true) ->param('providerRootDirectory', '', new Text(128, 0), 'Path to site code in the linked repo.', true) - ->param('providerCommitSkipPatterns', null, new Nullable(new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'List of commit message patterns to skip automatic deployments. Leave empty to deploy on all commits.', true) ->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification( $plan, Config::getParam('specifications', []), @@ -129,7 +126,6 @@ class Update extends Base string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, - ?array $providerCommitSkipPatterns, string $buildSpecification, string $runtimeSpecification, int $deploymentRetention, @@ -275,7 +271,6 @@ class Update extends Base 'providerBranch' => $providerBranch, 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, - 'providerCommitSkipPatterns' => $providerCommitSkipPatterns ?? $site->getAttribute('providerCommitSkipPatterns', []), 'buildSpecification' => $buildSpecification, 'runtimeSpecification' => $runtimeSpecification, 'search' => implode(' ', [$siteId, $name, $framework]), diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index a6124412bb..f244dd1829 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -96,7 +96,7 @@ trait Deployment $resource = $authorization->skip(fn () => $dbForProject->getDocument($resourceCollection, $resourceId)); $resourceInternalId = $resource->getSequence(); - if (!$this->isResourceBuildable($resource, $logBase, $providerCommitMessage)) { + if (!$this->isResourceBuildable($logBase, $providerCommitMessage)) { Span::add("{$logBase}.build.skipped", 'true'); continue; } @@ -568,9 +568,9 @@ trait Deployment return System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME); } - private function isResourceBuildable(Document $resource, string $logBase, string $providerCommitMessage = ''): bool + private function isResourceBuildable(string $logBase, string $providerCommitMessage = ''): bool { - $commitSkip = new CommitSkipPatterns($resource->getAttribute('providerCommitSkipPatterns', [])); + $commitSkip = new CommitSkipPatterns(); if (!$commitSkip->isValid($providerCommitMessage)) { Span::add("{$logBase}.build.skipped.reason", 'commitMessage'); return false; diff --git a/src/Appwrite/Utopia/Response/Model/Func.php b/src/Appwrite/Utopia/Response/Model/Func.php index a85014b9d8..3aea364fe5 100644 --- a/src/Appwrite/Utopia/Response/Model/Func.php +++ b/src/Appwrite/Utopia/Response/Model/Func.php @@ -182,13 +182,6 @@ class Func extends Model 'default' => false, 'example' => false, ]) - ->addRule('providerCommitSkipPatterns', [ - 'type' => self::TYPE_STRING, - 'description' => 'Commit message patterns that skip automatic deployments', - 'default' => [], - 'example' => ['[skip deploy]'], - 'array' => true, - ]) ->addRule('buildSpecification', [ 'type' => self::TYPE_STRING, 'description' => 'Machine specification for deployment builds.', diff --git a/src/Appwrite/Utopia/Response/Model/Site.php b/src/Appwrite/Utopia/Response/Model/Site.php index 8bb33f6000..941b6104df 100644 --- a/src/Appwrite/Utopia/Response/Model/Site.php +++ b/src/Appwrite/Utopia/Response/Model/Site.php @@ -173,13 +173,6 @@ class Site extends Model 'default' => false, 'example' => false, ]) - ->addRule('providerCommitSkipPatterns', [ - 'type' => self::TYPE_STRING, - 'description' => 'Commit message patterns that skip automatic deployments', - 'default' => [], - 'example' => ['[skip deploy]'], - 'array' => true, - ]) ->addRule('buildSpecification', [ 'type' => self::TYPE_STRING, 'description' => 'Machine specification for deployment builds.', diff --git a/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php b/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php index fc5dec2de3..8439be4dd5 100644 --- a/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php +++ b/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php @@ -6,14 +6,24 @@ use Utopia\Validator; class CommitSkipPatterns extends Validator { - public function __construct(private readonly array $patterns) - { - } + private const PATTERNS = [ + '[skip ci]', + '[ci skip]', + '[no ci]', + '[skip actions]', + '[actions skip]', + '[skip deploy]', + '[deploy skip]', + '[no deploy]', + 'skip-checks: true', + 'skip appwrite', + 'appwrite skip', + ]; /** * Returns false (skip deployment) when the commit message contains any of the - * configured patterns as a standalone directive (case-insensitive). - * Returns true (proceed) when no patterns are configured or none match. + * known skip directives as a standalone directive (case-insensitive). + * Returns true (proceed) when none match. * * Matching rules: * - Case-insensitive @@ -29,15 +39,8 @@ class CommitSkipPatterns extends Validator return true; } - foreach ($this->patterns as $pattern) { - if (!is_string($pattern)) { - continue; - } - + foreach (self::PATTERNS as $pattern) { $pattern = trim($pattern); - if ($pattern === '') { - continue; - } // Split on whitespace; each token is regex-quoted. Tokens are rejoined // with \s+ (required space) so that "skipappwrite" does NOT match the diff --git a/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php b/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php index 6f486872bd..3be02e93c9 100644 --- a/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php +++ b/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php @@ -7,216 +7,75 @@ use PHPUnit\Framework\TestCase; class CommitSkipPatternsTest extends TestCase { - // ------------------------------------------------------------------------- - // Empty patterns — never skip - // ------------------------------------------------------------------------- - - public function testEmptyPatternsNeverSkip(): void + public function testKnownSkipDirectivesSkip(): void { - $validator = new CommitSkipPatterns([]); - $this->assertTrue($validator->isValid('fix: update readme')); - $this->assertTrue($validator->isValid('[skip deploy] docs only')); - $this->assertTrue($validator->isValid('')); + $validator = new CommitSkipPatterns(); + + $this->assertFalse($validator->isValid('[skip ci] update changelog')); + $this->assertFalse($validator->isValid('[ci skip] update changelog')); + $this->assertFalse($validator->isValid('[no ci] update changelog')); + $this->assertFalse($validator->isValid('[skip actions] update changelog')); + $this->assertFalse($validator->isValid('[actions skip] update changelog')); + $this->assertFalse($validator->isValid('[skip deploy] update changelog')); + $this->assertFalse($validator->isValid('[deploy skip] update changelog')); + $this->assertFalse($validator->isValid('[no deploy] update changelog')); + $this->assertFalse($validator->isValid('skip-checks:true')); + $this->assertFalse($validator->isValid('skip appwrite')); + $this->assertFalse($validator->isValid('appwrite skip')); } - // ------------------------------------------------------------------------- - // Single pattern — directive match - // ------------------------------------------------------------------------- - - public function testSinglePatternMatchSkips(): void + public function testKnownSkipDirectivesAreCaseInsensitive(): void { - $validator = new CommitSkipPatterns(['[skip deploy]']); - $this->assertFalse($validator->isValid('[skip deploy] docs only')); - $this->assertFalse($validator->isValid('chore: update deps [skip deploy]')); - $this->assertFalse($validator->isValid('prefix [skip deploy] suffix')); + $validator = new CommitSkipPatterns(); + + $this->assertFalse($validator->isValid('[SKIP CI] update changelog')); + $this->assertFalse($validator->isValid('[Skip Deploy] update changelog')); + $this->assertFalse($validator->isValid('SKIP APPWRITE')); + $this->assertFalse($validator->isValid('Appwrite Skip')); } - public function testSinglePatternNoMatchProceeds(): void + public function testMessageWithoutKnownDirectiveProceeds(): void { - $validator = new CommitSkipPatterns(['[skip deploy]']); + $validator = new CommitSkipPatterns(); + $this->assertTrue($validator->isValid('fix: real bug fix')); $this->assertTrue($validator->isValid('feat: add new feature')); $this->assertTrue($validator->isValid('skip deploy without brackets')); - $this->assertTrue($validator->isValid('prefix[skip deploy]suffix')); - } - - // ------------------------------------------------------------------------- - // Case insensitivity - // ------------------------------------------------------------------------- - - public function testCaseInsensitiveMatch(): void - { - $validator = new CommitSkipPatterns(['[skip deploy]']); - $this->assertFalse($validator->isValid('[SKIP DEPLOY] uppercase')); - $this->assertFalse($validator->isValid('[Skip Deploy] mixed case')); - $this->assertFalse($validator->isValid('[skip DEPLOY] partial upper')); - } - - public function testPatternItselfCaseInsensitive(): void - { - $validator = new CommitSkipPatterns(['[SKIP DEPLOY]']); - $this->assertFalse($validator->isValid('[skip deploy] lowercase message')); - $this->assertFalse($validator->isValid('[Skip Deploy] mixed message')); - } - - // ------------------------------------------------------------------------- - // Array of patterns — any match skips (OR semantics) - // ------------------------------------------------------------------------- - - public function testMultiplePatternsFirstMatches(): void - { - $validator = new CommitSkipPatterns(['[skip deploy]', '[skip ci]', '[no deploy]']); - $this->assertFalse($validator->isValid('[skip deploy] docs only')); - } - - public function testMultiplePatternsSecondMatches(): void - { - $validator = new CommitSkipPatterns(['[skip deploy]', '[skip ci]', '[no deploy]']); - $this->assertFalse($validator->isValid('chore: update readme [skip ci]')); - } - - public function testMultiplePatternsThirdMatches(): void - { - $validator = new CommitSkipPatterns(['[skip deploy]', '[skip ci]', '[no deploy]']); - $this->assertFalse($validator->isValid('[no deploy] just docs')); - } - - public function testMultiplePatternsNoneMatchProceeds(): void - { - $validator = new CommitSkipPatterns(['[skip deploy]', '[skip ci]', '[no deploy]']); - $this->assertTrue($validator->isValid('feat: completely new feature')); - $this->assertTrue($validator->isValid('fix: important bug fix')); - } - - // ------------------------------------------------------------------------- - // Common real-world skip conventions - // ------------------------------------------------------------------------- - - public function testCommonSkipCiPattern(): void - { - $validator = new CommitSkipPatterns(['[skip ci]']); - $this->assertFalse($validator->isValid('[skip ci] update changelog')); - $this->assertFalse($validator->isValid('[SKIP CI]')); - $this->assertTrue($validator->isValid('feat: something real')); - } - - public function testNoDeployPattern(): void - { - $validator = new CommitSkipPatterns(['[no deploy]']); - $this->assertFalse($validator->isValid('[no deploy] tweak docs')); $this->assertTrue($validator->isValid('deploy this please')); } - // ------------------------------------------------------------------------- - // Edge cases - // ------------------------------------------------------------------------- - - public function testEmptyCommitMessageNeverSkipsWithPatterns(): void + public function testDirectiveMustBeStandalone(): void { - $validator = new CommitSkipPatterns(['[skip deploy]']); - $this->assertTrue($validator->isValid('')); - } + $validator = new CommitSkipPatterns(); - public function testNonStringCommitMessageNeverSkips(): void - { - $validator = new CommitSkipPatterns(['[skip deploy]']); - $this->assertTrue($validator->isValid(null)); - $this->assertTrue($validator->isValid([])); - } - - public function testBlankPatternsInArrayAreIgnored(): void - { - $validator = new CommitSkipPatterns(['', ' ', '[skip deploy]']); - $this->assertTrue($validator->isValid('normal commit message')); - $this->assertFalse($validator->isValid('[skip deploy] docs')); - } - - public function testPatternMustBeStandaloneDirective(): void - { - $validator = new CommitSkipPatterns(['[skip deploy]']); - $this->assertTrue($validator->isValid('skippy the kangaroo')); + $this->assertFalse($validator->isValid('docs: update readme [skip deploy]')); + $this->assertTrue($validator->isValid('docs: update readme[skip deploy]')); $this->assertTrue($validator->isValid('prefix[skip deploy]suffix')); + $this->assertTrue($validator->isValid('skipappwrite')); + $this->assertTrue($validator->isValid('appwriteskip')); } - public function testMultilineCommitMessage(): void + public function testMultilineCommitMessageSkips(): void { - $validator = new CommitSkipPatterns(['[skip deploy]']); - $msg = "feat: add new stuff\n\nMore detail here.\n\n[skip deploy]"; - $this->assertFalse($validator->isValid($msg)); + $validator = new CommitSkipPatterns(); + $message = "feat: add new stuff\n\nMore detail here.\n\n[skip deploy]"; + + $this->assertFalse($validator->isValid($message)); } public function testWhitespaceInsideDirectiveIsNormalized(): void { - $validator = new CommitSkipPatterns([' [skip deploy] ']); - $this->assertFalse($validator->isValid('[skip deploy] docs only')); - $this->assertFalse($validator->isValid('[SKIP DEPLOY] docs only')); + $validator = new CommitSkipPatterns(); + + $this->assertFalse($validator->isValid('[skip deploy] docs only')); + $this->assertFalse($validator->isValid('skip-checks: true')); } - public function testTrailerDirectiveCanSkip(): void + public function testNonStringCommitMessageProceeds(): void { - $validator = new CommitSkipPatterns(['skip-checks: true']); - $msg = "feat: add new stuff\n\nMore detail here.\n\nskip-checks:true"; - $this->assertFalse($validator->isValid($msg)); - } + $validator = new CommitSkipPatterns(); - // ------------------------------------------------------------------------- - // Plain-word patterns: "skip appwrite" and "appwrite skip" - // ------------------------------------------------------------------------- - - public function testSkipAppwritePatternSkips(): void - { - $validator = new CommitSkipPatterns(['skip appwrite']); - $this->assertFalse($validator->isValid('docs: update readme skip appwrite')); - $this->assertFalse($validator->isValid('skip appwrite')); - } - - public function testSkipAppwritePatternCaseInsensitive(): void - { - $validator = new CommitSkipPatterns(['skip appwrite']); - $this->assertFalse($validator->isValid('SKIP APPWRITE')); - $this->assertFalse($validator->isValid('Skip Appwrite')); - $this->assertFalse($validator->isValid('SKIP appwrite')); - } - - public function testSkipAppwritePatternNoMatchProceeds(): void - { - $validator = new CommitSkipPatterns(['skip appwrite']); - $this->assertTrue($validator->isValid('feat: real feature')); - $this->assertTrue($validator->isValid('skipappwrite')); // no space — not standalone - $this->assertTrue($validator->isValid('appwrite is great')); - } - - public function testAppwriteSkipPatternSkips(): void - { - $validator = new CommitSkipPatterns(['appwrite skip']); - $this->assertFalse($validator->isValid('appwrite skip ci')); - $this->assertFalse($validator->isValid('docs appwrite skip')); - $this->assertFalse($validator->isValid('appwrite skip')); - } - - public function testAppwriteSkipPatternCaseInsensitive(): void - { - $validator = new CommitSkipPatterns(['appwrite skip']); - $this->assertFalse($validator->isValid('APPWRITE SKIP')); - $this->assertFalse($validator->isValid('Appwrite Skip')); - $this->assertFalse($validator->isValid('appwrite SKIP')); - } - - public function testAppwriteSkipPatternNoMatchProceeds(): void - { - $validator = new CommitSkipPatterns(['appwrite skip']); - $this->assertTrue($validator->isValid('feat: deploy appwrite changes')); - $this->assertTrue($validator->isValid('appwriteskip')); // no space — not standalone - $this->assertTrue($validator->isValid('skip the appwrite stuff')); - } - - public function testBothAppwritePatternsInArray(): void - { - $validator = new CommitSkipPatterns(['skip appwrite', 'appwrite skip']); - $this->assertFalse($validator->isValid('skip appwrite')); - $this->assertFalse($validator->isValid('appwrite skip')); - $this->assertFalse($validator->isValid('SKIP APPWRITE')); - $this->assertFalse($validator->isValid('APPWRITE SKIP')); - $this->assertTrue($validator->isValid('feat: deploy appwrite changes')); + $this->assertTrue($validator->isValid(null)); + $this->assertTrue($validator->isValid([])); } } From fa63983fd7695783f0d371ad31cb564278a01886 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 11 May 2026 18:11:48 +0530 Subject: [PATCH 14/47] fix: bracket appwrite skip directives --- src/Appwrite/Vcs/Validator/CommitSkipPatterns.php | 4 ++-- tests/unit/Vcs/Validator/CommitSkipPatternsTest.php | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php b/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php index 8439be4dd5..e5988555eb 100644 --- a/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php +++ b/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php @@ -16,8 +16,8 @@ class CommitSkipPatterns extends Validator '[deploy skip]', '[no deploy]', 'skip-checks: true', - 'skip appwrite', - 'appwrite skip', + '[skip appwrite]', + '[appwrite skip]', ]; /** diff --git a/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php b/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php index 3be02e93c9..74c401230e 100644 --- a/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php +++ b/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php @@ -20,8 +20,8 @@ class CommitSkipPatternsTest extends TestCase $this->assertFalse($validator->isValid('[deploy skip] update changelog')); $this->assertFalse($validator->isValid('[no deploy] update changelog')); $this->assertFalse($validator->isValid('skip-checks:true')); - $this->assertFalse($validator->isValid('skip appwrite')); - $this->assertFalse($validator->isValid('appwrite skip')); + $this->assertFalse($validator->isValid('[skip appwrite] update changelog')); + $this->assertFalse($validator->isValid('[appwrite skip] update changelog')); } public function testKnownSkipDirectivesAreCaseInsensitive(): void @@ -30,8 +30,8 @@ class CommitSkipPatternsTest extends TestCase $this->assertFalse($validator->isValid('[SKIP CI] update changelog')); $this->assertFalse($validator->isValid('[Skip Deploy] update changelog')); - $this->assertFalse($validator->isValid('SKIP APPWRITE')); - $this->assertFalse($validator->isValid('Appwrite Skip')); + $this->assertFalse($validator->isValid('[SKIP APPWRITE] update changelog')); + $this->assertFalse($validator->isValid('[Appwrite Skip] update changelog')); } public function testMessageWithoutKnownDirectiveProceeds(): void @@ -51,8 +51,8 @@ class CommitSkipPatternsTest extends TestCase $this->assertFalse($validator->isValid('docs: update readme [skip deploy]')); $this->assertTrue($validator->isValid('docs: update readme[skip deploy]')); $this->assertTrue($validator->isValid('prefix[skip deploy]suffix')); - $this->assertTrue($validator->isValid('skipappwrite')); - $this->assertTrue($validator->isValid('appwriteskip')); + $this->assertTrue($validator->isValid('refactor: skip appwrite cache seeding')); + $this->assertTrue($validator->isValid('fix: appwrite skip quota check in tests')); } public function testMultilineCommitMessageSkips(): void From cc0207cfe4c5e8d43f0277e74efa8c21a431c571 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 11 May 2026 18:32:14 +0530 Subject: [PATCH 15/47] fix: align appwrite skip directives --- .../Vcs/Validator/CommitSkipPatterns.php | 16 +++++++++------- .../Vcs/Validator/CommitSkipPatternsTest.php | 10 ++++++++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php b/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php index e5988555eb..75f0bdd9c0 100644 --- a/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php +++ b/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php @@ -10,14 +10,18 @@ class CommitSkipPatterns extends Validator '[skip ci]', '[ci skip]', '[no ci]', + '[skip action]', + '[action skip]', + '[no action]', '[skip actions]', '[actions skip]', + '[no actions]', '[skip deploy]', '[deploy skip]', '[no deploy]', - 'skip-checks: true', '[skip appwrite]', '[appwrite skip]', + '[no appwrite]', ]; /** @@ -30,8 +34,8 @@ class CommitSkipPatterns extends Validator * - The directive must be surrounded by whitespace or string boundaries, so * "prefix[skip deploy]suffix" does NOT accidentally skip * - Internal whitespace in the pattern is normalised: tokens are split on \s+ - * and rejoined with \s* in the regex, so "[skip deploy]" matches - * "[skip deploy]" and "skip-checks: true" matches "skip-checks:true" + * and rejoined with \s+ in the regex, so "[skip deploy]" matches + * "[skip deploy]". */ public function isValid($value): bool { @@ -44,16 +48,14 @@ class CommitSkipPatterns extends Validator // Split on whitespace; each token is regex-quoted. Tokens are rejoined // with \s+ (required space) so that "skipappwrite" does NOT match the - // pattern "skip appwrite". The only exception: when the preceding token - // ends with ":" (git trailer style), \s* is used so that - // "skip-checks:true" still matches the pattern "skip-checks: true". + // pattern "skip appwrite". $tokens = preg_split('/\s+/', $pattern); $regexParts = []; $count = count($tokens); for ($i = 0; $i < $count; $i++) { $regexParts[] = preg_quote($tokens[$i], '~'); if ($i < $count - 1) { - $regexParts[] = str_ends_with($tokens[$i], ':') ? '\s*' : '\s+'; + $regexParts[] = '\s+'; } } $regexBody = implode('', $regexParts); diff --git a/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php b/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php index 74c401230e..64f5140db7 100644 --- a/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php +++ b/tests/unit/Vcs/Validator/CommitSkipPatternsTest.php @@ -14,14 +14,18 @@ class CommitSkipPatternsTest extends TestCase $this->assertFalse($validator->isValid('[skip ci] update changelog')); $this->assertFalse($validator->isValid('[ci skip] update changelog')); $this->assertFalse($validator->isValid('[no ci] update changelog')); + $this->assertFalse($validator->isValid('[skip action] update changelog')); + $this->assertFalse($validator->isValid('[action skip] update changelog')); + $this->assertFalse($validator->isValid('[no action] update changelog')); $this->assertFalse($validator->isValid('[skip actions] update changelog')); $this->assertFalse($validator->isValid('[actions skip] update changelog')); + $this->assertFalse($validator->isValid('[no actions] update changelog')); $this->assertFalse($validator->isValid('[skip deploy] update changelog')); $this->assertFalse($validator->isValid('[deploy skip] update changelog')); $this->assertFalse($validator->isValid('[no deploy] update changelog')); - $this->assertFalse($validator->isValid('skip-checks:true')); $this->assertFalse($validator->isValid('[skip appwrite] update changelog')); $this->assertFalse($validator->isValid('[appwrite skip] update changelog')); + $this->assertFalse($validator->isValid('[no appwrite] update changelog')); } public function testKnownSkipDirectivesAreCaseInsensitive(): void @@ -32,6 +36,7 @@ class CommitSkipPatternsTest extends TestCase $this->assertFalse($validator->isValid('[Skip Deploy] update changelog')); $this->assertFalse($validator->isValid('[SKIP APPWRITE] update changelog')); $this->assertFalse($validator->isValid('[Appwrite Skip] update changelog')); + $this->assertFalse($validator->isValid('[No Actions] update changelog')); } public function testMessageWithoutKnownDirectiveProceeds(): void @@ -42,6 +47,7 @@ class CommitSkipPatternsTest extends TestCase $this->assertTrue($validator->isValid('feat: add new feature')); $this->assertTrue($validator->isValid('skip deploy without brackets')); $this->assertTrue($validator->isValid('deploy this please')); + $this->assertTrue($validator->isValid('skip-checks:true')); } public function testDirectiveMustBeStandalone(): void @@ -68,7 +74,7 @@ class CommitSkipPatternsTest extends TestCase $validator = new CommitSkipPatterns(); $this->assertFalse($validator->isValid('[skip deploy] docs only')); - $this->assertFalse($validator->isValid('skip-checks: true')); + $this->assertFalse($validator->isValid('[no actions] docs only')); } public function testNonStringCommitMessageProceeds(): void From dda38442ad76f60dde355fdec56ded980c79fa41 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 11 May 2026 18:56:31 +0530 Subject: [PATCH 16/47] refactor: simplify deployment skip patterns --- .../Modules/VCS/Http/GitHub/Deployment.php | 16 +--- .../Vcs/Validator/CommitSkipPatterns.php | 90 ------------------- .../Vcs/Validator/DeploymentSkipPatterns.php | 66 ++++++++++++++ ...est.php => DeploymentSkipPatternsTest.php} | 30 +++---- 4 files changed, 81 insertions(+), 121 deletions(-) delete mode 100644 src/Appwrite/Vcs/Validator/CommitSkipPatterns.php create mode 100644 src/Appwrite/Vcs/Validator/DeploymentSkipPatterns.php rename tests/unit/Vcs/Validator/{CommitSkipPatternsTest.php => DeploymentSkipPatternsTest.php} (77%) diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index f244dd1829..22920e7679 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -8,7 +8,7 @@ use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Filter\BranchDomain as BranchDomainFilter; use Appwrite\Vcs\Comment; -use Appwrite\Vcs\Validator\CommitSkipPatterns; +use Appwrite\Vcs\Validator\DeploymentSkipPatterns; use Utopia\Config\Config; use Utopia\Console; use Utopia\Database\Database; @@ -96,7 +96,9 @@ trait Deployment $resource = $authorization->skip(fn () => $dbForProject->getDocument($resourceCollection, $resourceId)); $resourceInternalId = $resource->getSequence(); - if (!$this->isResourceBuildable($logBase, $providerCommitMessage)) { + $commitSkip = new DeploymentSkipPatterns(); + if (!$commitSkip->isValid($providerCommitMessage)) { + Span::add("{$logBase}.build.skipped.reason", 'commitMessage'); Span::add("{$logBase}.build.skipped", 'true'); continue; } @@ -568,14 +570,4 @@ trait Deployment return System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME); } - private function isResourceBuildable(string $logBase, string $providerCommitMessage = ''): bool - { - $commitSkip = new CommitSkipPatterns(); - if (!$commitSkip->isValid($providerCommitMessage)) { - Span::add("{$logBase}.build.skipped.reason", 'commitMessage'); - return false; - } - - return true; - } } diff --git a/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php b/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php deleted file mode 100644 index 75f0bdd9c0..0000000000 --- a/src/Appwrite/Vcs/Validator/CommitSkipPatterns.php +++ /dev/null @@ -1,90 +0,0 @@ -assertFalse($validator->isValid('[skip ci] update changelog')); $this->assertFalse($validator->isValid('[ci skip] update changelog')); @@ -30,7 +30,7 @@ class CommitSkipPatternsTest extends TestCase public function testKnownSkipDirectivesAreCaseInsensitive(): void { - $validator = new CommitSkipPatterns(); + $validator = new DeploymentSkipPatterns(); $this->assertFalse($validator->isValid('[SKIP CI] update changelog')); $this->assertFalse($validator->isValid('[Skip Deploy] update changelog')); @@ -41,7 +41,7 @@ class CommitSkipPatternsTest extends TestCase public function testMessageWithoutKnownDirectiveProceeds(): void { - $validator = new CommitSkipPatterns(); + $validator = new DeploymentSkipPatterns(); $this->assertTrue($validator->isValid('fix: real bug fix')); $this->assertTrue($validator->isValid('feat: add new feature')); @@ -50,36 +50,28 @@ class CommitSkipPatternsTest extends TestCase $this->assertTrue($validator->isValid('skip-checks:true')); } - public function testDirectiveMustBeStandalone(): void + public function testDirectiveCanAppearAnywhere(): void { - $validator = new CommitSkipPatterns(); + $validator = new DeploymentSkipPatterns(); $this->assertFalse($validator->isValid('docs: update readme [skip deploy]')); - $this->assertTrue($validator->isValid('docs: update readme[skip deploy]')); - $this->assertTrue($validator->isValid('prefix[skip deploy]suffix')); + $this->assertFalse($validator->isValid('docs: update readme[skip deploy]')); + $this->assertFalse($validator->isValid('prefix[skip deploy]suffix')); $this->assertTrue($validator->isValid('refactor: skip appwrite cache seeding')); $this->assertTrue($validator->isValid('fix: appwrite skip quota check in tests')); } public function testMultilineCommitMessageSkips(): void { - $validator = new CommitSkipPatterns(); + $validator = new DeploymentSkipPatterns(); $message = "feat: add new stuff\n\nMore detail here.\n\n[skip deploy]"; $this->assertFalse($validator->isValid($message)); } - public function testWhitespaceInsideDirectiveIsNormalized(): void - { - $validator = new CommitSkipPatterns(); - - $this->assertFalse($validator->isValid('[skip deploy] docs only')); - $this->assertFalse($validator->isValid('[no actions] docs only')); - } - public function testNonStringCommitMessageProceeds(): void { - $validator = new CommitSkipPatterns(); + $validator = new DeploymentSkipPatterns(); $this->assertTrue($validator->isValid(null)); $this->assertTrue($validator->isValid([])); From e3755679c519ed61c37ae14651313d226e73a94e Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 14 May 2026 17:26:32 +0530 Subject: [PATCH 17/47] refactor: extend Contains validator, keep only [skip ci] pattern --- composer.json | 2 +- composer.lock | 14 ++-- .../Modules/VCS/Http/GitHub/Deployment.php | 2 +- .../Vcs/Validator/DeploymentSkipPatterns.php | 57 ++-------------- .../Validator/DeploymentSkipPatternsTest.php | 67 ++++++------------- 5 files changed, 35 insertions(+), 107 deletions(-) diff --git a/composer.json b/composer.json index 7d68a838f8..5eb68b6199 100644 --- a/composer.json +++ b/composer.json @@ -69,7 +69,7 @@ "utopia-php/dsn": "0.2.1", "utopia-php/http": "0.34.*", "utopia-php/fetch": "^1.1", - "utopia-php/validators": "0.2.*", + "utopia-php/validators": "0.2.3", "utopia-php/image": "0.8.*", "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.8.*", diff --git a/composer.lock b/composer.lock index 0aa58ca03e..d06d9ce12b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d58736fec3028d1f9aedd055e6d82684", + "content-hash": "52bfeef1fecc435484005e472d15d21a", "packages": [ { "name": "adhocore/jwt", @@ -5193,16 +5193,16 @@ }, { "name": "utopia-php/validators", - "version": "0.2.2", + "version": "0.2.3", "source": { "type": "git", "url": "https://github.com/utopia-php/validators.git", - "reference": "5d7d494e64457cd4eb67fdcfd9481f2c89796aa6" + "reference": "9770269c8ed8e6909934965fa8722103c7434c23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/validators/zipball/5d7d494e64457cd4eb67fdcfd9481f2c89796aa6", - "reference": "5d7d494e64457cd4eb67fdcfd9481f2c89796aa6", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/9770269c8ed8e6909934965fa8722103c7434c23", + "reference": "9770269c8ed8e6909934965fa8722103c7434c23", "shasum": "" }, "require": { @@ -5232,9 +5232,9 @@ ], "support": { "issues": "https://github.com/utopia-php/validators/issues", - "source": "https://github.com/utopia-php/validators/tree/0.2.2" + "source": "https://github.com/utopia-php/validators/tree/0.2.3" }, - "time": "2026-04-27T16:30:24+00:00" + "time": "2026-05-14T08:05:44+00:00" }, { "name": "utopia-php/vcs", diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index 22920e7679..6428708a75 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -97,7 +97,7 @@ trait Deployment $resourceInternalId = $resource->getSequence(); $commitSkip = new DeploymentSkipPatterns(); - if (!$commitSkip->isValid($providerCommitMessage)) { + if ($commitSkip->isValid($providerCommitMessage)) { Span::add("{$logBase}.build.skipped.reason", 'commitMessage'); Span::add("{$logBase}.build.skipped", 'true'); continue; diff --git a/src/Appwrite/Vcs/Validator/DeploymentSkipPatterns.php b/src/Appwrite/Vcs/Validator/DeploymentSkipPatterns.php index 8810ed3912..8e1d0623d9 100644 --- a/src/Appwrite/Vcs/Validator/DeploymentSkipPatterns.php +++ b/src/Appwrite/Vcs/Validator/DeploymentSkipPatterns.php @@ -2,65 +2,16 @@ namespace Appwrite\Vcs\Validator; -use Utopia\Validator; +use Utopia\Validator\Contains; -class DeploymentSkipPatterns extends Validator +class DeploymentSkipPatterns extends Contains { private const PATTERNS = [ '[skip ci]', - '[ci skip]', - '[no ci]', - '[skip action]', - '[action skip]', - '[no action]', - '[skip actions]', - '[actions skip]', - '[no actions]', - '[skip deploy]', - '[deploy skip]', - '[no deploy]', - '[skip appwrite]', - '[appwrite skip]', - '[no appwrite]', ]; - /** - * Returns false (skip deployment) when the commit message contains any of the - * known skip directives as a standalone directive (case-insensitive). - * Returns true (proceed) when none match. - * - * Matching rules: - * - Case-insensitive - */ - public function isValid($value): bool + public function __construct() { - if (!is_string($value)) { - return true; - } - - $value = strtolower($value); - - foreach (self::PATTERNS as $pattern) { - if (str_contains($value, $pattern)) { - return false; - } - } - - return true; - } - - public function getDescription(): string - { - return 'Commit message must not contain any of the configured skip patterns.'; - } - - public function isArray(): bool - { - return false; - } - - public function getType(): string - { - return self::TYPE_STRING; + parent::__construct(self::PATTERNS); } } diff --git a/tests/unit/Vcs/Validator/DeploymentSkipPatternsTest.php b/tests/unit/Vcs/Validator/DeploymentSkipPatternsTest.php index 0882d781c0..473ba4c6f4 100644 --- a/tests/unit/Vcs/Validator/DeploymentSkipPatternsTest.php +++ b/tests/unit/Vcs/Validator/DeploymentSkipPatternsTest.php @@ -7,73 +7,50 @@ use PHPUnit\Framework\TestCase; class DeploymentSkipPatternsTest extends TestCase { + private DeploymentSkipPatterns $validator; + + protected function setUp(): void + { + $this->validator = new DeploymentSkipPatterns(); + } + public function testKnownSkipDirectivesSkip(): void { - $validator = new DeploymentSkipPatterns(); - - $this->assertFalse($validator->isValid('[skip ci] update changelog')); - $this->assertFalse($validator->isValid('[ci skip] update changelog')); - $this->assertFalse($validator->isValid('[no ci] update changelog')); - $this->assertFalse($validator->isValid('[skip action] update changelog')); - $this->assertFalse($validator->isValid('[action skip] update changelog')); - $this->assertFalse($validator->isValid('[no action] update changelog')); - $this->assertFalse($validator->isValid('[skip actions] update changelog')); - $this->assertFalse($validator->isValid('[actions skip] update changelog')); - $this->assertFalse($validator->isValid('[no actions] update changelog')); - $this->assertFalse($validator->isValid('[skip deploy] update changelog')); - $this->assertFalse($validator->isValid('[deploy skip] update changelog')); - $this->assertFalse($validator->isValid('[no deploy] update changelog')); - $this->assertFalse($validator->isValid('[skip appwrite] update changelog')); - $this->assertFalse($validator->isValid('[appwrite skip] update changelog')); - $this->assertFalse($validator->isValid('[no appwrite] update changelog')); + $this->assertTrue($this->validator->isValid('[skip ci] update changelog')); } public function testKnownSkipDirectivesAreCaseInsensitive(): void { - $validator = new DeploymentSkipPatterns(); - - $this->assertFalse($validator->isValid('[SKIP CI] update changelog')); - $this->assertFalse($validator->isValid('[Skip Deploy] update changelog')); - $this->assertFalse($validator->isValid('[SKIP APPWRITE] update changelog')); - $this->assertFalse($validator->isValid('[Appwrite Skip] update changelog')); - $this->assertFalse($validator->isValid('[No Actions] update changelog')); + $this->assertTrue($this->validator->isValid('[SKIP CI] update changelog')); } public function testMessageWithoutKnownDirectiveProceeds(): void { - $validator = new DeploymentSkipPatterns(); - - $this->assertTrue($validator->isValid('fix: real bug fix')); - $this->assertTrue($validator->isValid('feat: add new feature')); - $this->assertTrue($validator->isValid('skip deploy without brackets')); - $this->assertTrue($validator->isValid('deploy this please')); - $this->assertTrue($validator->isValid('skip-checks:true')); + $this->assertFalse($this->validator->isValid('fix: real bug fix')); + $this->assertFalse($this->validator->isValid('feat: add new feature')); + $this->assertFalse($this->validator->isValid('skip deploy without brackets')); + $this->assertFalse($this->validator->isValid('deploy this please')); + $this->assertFalse($this->validator->isValid('skip-checks:true')); } public function testDirectiveCanAppearAnywhere(): void { - $validator = new DeploymentSkipPatterns(); - - $this->assertFalse($validator->isValid('docs: update readme [skip deploy]')); - $this->assertFalse($validator->isValid('docs: update readme[skip deploy]')); - $this->assertFalse($validator->isValid('prefix[skip deploy]suffix')); - $this->assertTrue($validator->isValid('refactor: skip appwrite cache seeding')); - $this->assertTrue($validator->isValid('fix: appwrite skip quota check in tests')); + $this->assertTrue($this->validator->isValid('docs: update readme [skip ci]')); + $this->assertTrue($this->validator->isValid('docs: update readme[skip ci]')); + $this->assertTrue($this->validator->isValid('prefix[skip ci]suffix')); + $this->assertFalse($this->validator->isValid('refactor: skip ci cache seeding')); } public function testMultilineCommitMessageSkips(): void { - $validator = new DeploymentSkipPatterns(); - $message = "feat: add new stuff\n\nMore detail here.\n\n[skip deploy]"; + $message = "feat: add new stuff\n\nMore detail here.\n\n[skip ci]"; - $this->assertFalse($validator->isValid($message)); + $this->assertTrue($this->validator->isValid($message)); } public function testNonStringCommitMessageProceeds(): void { - $validator = new DeploymentSkipPatterns(); - - $this->assertTrue($validator->isValid(null)); - $this->assertTrue($validator->isValid([])); + $this->assertFalse($this->validator->isValid(null)); + $this->assertFalse($this->validator->isValid([])); } } From 25772f4585217df2db035a7adb4a2eb18ff18a52 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 14 May 2026 17:28:52 +0530 Subject: [PATCH 18/47] chore: composer update all dependencies --- composer.lock | 108 +++++++++++++++++++++++++------------------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/composer.lock b/composer.lock index d06d9ce12b..ccd69e927a 100644 --- a/composer.lock +++ b/composer.lock @@ -69,16 +69,16 @@ }, { "name": "appwrite/appwrite", - "version": "23.1.0", + "version": "23.1.1", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-for-php.git", - "reference": "2f275921f10ceb7cff99f2d463f7328b296234fa" + "reference": "fd7c0f0bf5ddf334533534b20ed967cfb400f6ea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/2f275921f10ceb7cff99f2d463f7328b296234fa", - "reference": "2f275921f10ceb7cff99f2d463f7328b296234fa", + "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/fd7c0f0bf5ddf334533534b20ed967cfb400f6ea", + "reference": "fd7c0f0bf5ddf334533534b20ed967cfb400f6ea", "shasum": "" }, "require": { @@ -104,10 +104,10 @@ "support": { "email": "team@appwrite.io", "issues": "https://github.com/appwrite/sdk-for-php/issues", - "source": "https://github.com/appwrite/sdk-for-php/tree/23.1.0", + "source": "https://github.com/appwrite/sdk-for-php/tree/23.1.1", "url": "https://appwrite.io/support" }, - "time": "2026-05-08T13:44:58+00:00" + "time": "2026-05-12T11:03:36+00:00" }, { "name": "appwrite/php-clamav", @@ -3614,16 +3614,16 @@ }, { "name": "utopia-php/cache", - "version": "1.0.2", + "version": "1.0.3", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "d36f9050c39c02e09a7763389c9e71258e74af1f" + "reference": "ef52a04e8bfa314c621e3d3326ffcf50db3dfdfa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/d36f9050c39c02e09a7763389c9e71258e74af1f", - "reference": "d36f9050c39c02e09a7763389c9e71258e74af1f", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/ef52a04e8bfa314c621e3d3326ffcf50db3dfdfa", + "reference": "ef52a04e8bfa314c621e3d3326ffcf50db3dfdfa", "shasum": "" }, "require": { @@ -3660,9 +3660,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/1.0.2" + "source": "https://github.com/utopia-php/cache/tree/1.0.3" }, - "time": "2026-05-08T11:40:20+00:00" + "time": "2026-05-11T11:02:13+00:00" }, { "name": "utopia-php/cli", @@ -4490,16 +4490,16 @@ }, { "name": "utopia-php/messaging", - "version": "0.22.0", + "version": "0.22.2", "source": { "type": "git", "url": "https://github.com/utopia-php/messaging.git", - "reference": "a6ac04fd204fb6a16bf8c75a84d0b9fc10aa5030" + "reference": "f99feceab575243f3a86ee2e90cd1a6407805def" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/messaging/zipball/a6ac04fd204fb6a16bf8c75a84d0b9fc10aa5030", - "reference": "a6ac04fd204fb6a16bf8c75a84d0b9fc10aa5030", + "url": "https://api.github.com/repos/utopia-php/messaging/zipball/f99feceab575243f3a86ee2e90cd1a6407805def", + "reference": "f99feceab575243f3a86ee2e90cd1a6407805def", "shasum": "" }, "require": { @@ -4535,22 +4535,22 @@ ], "support": { "issues": "https://github.com/utopia-php/messaging/issues", - "source": "https://github.com/utopia-php/messaging/tree/0.22.0" + "source": "https://github.com/utopia-php/messaging/tree/0.22.2" }, - "time": "2026-04-02T04:09:19+00:00" + "time": "2026-05-14T08:51:26+00:00" }, { "name": "utopia-php/migration", - "version": "1.11.0", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "0fca44f40ad07bf2d56e9396afa6fa6d9b098ef1" + "reference": "3ee6e12af256726bddc3a0402c94535132abecc6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/0fca44f40ad07bf2d56e9396afa6fa6d9b098ef1", - "reference": "0fca44f40ad07bf2d56e9396afa6fa6d9b098ef1", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/3ee6e12af256726bddc3a0402c94535132abecc6", + "reference": "3ee6e12af256726bddc3a0402c94535132abecc6", "shasum": "" }, "require": { @@ -4590,9 +4590,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.11.0" + "source": "https://github.com/utopia-php/migration/tree/1.12.0" }, - "time": "2026-05-11T08:13:06+00:00" + "time": "2026-05-14T07:30:09+00:00" }, { "name": "utopia-php/mongo", @@ -5476,16 +5476,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.28.1", + "version": "1.29.2", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "009118ccda8ccece2b9fc043c158cb1dd3efaa88" + "reference": "31248a984a4d478d20a780dda8f5897984ee4e8f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/009118ccda8ccece2b9fc043c158cb1dd3efaa88", - "reference": "009118ccda8ccece2b9fc043c158cb1dd3efaa88", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/31248a984a4d478d20a780dda8f5897984ee4e8f", + "reference": "31248a984a4d478d20a780dda8f5897984ee4e8f", "shasum": "" }, "require": { @@ -5521,9 +5521,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.28.1" + "source": "https://github.com/appwrite/sdk-generator/tree/1.29.2" }, - "time": "2026-05-08T13:24:33+00:00" + "time": "2026-05-13T04:47:38+00:00" }, { "name": "brianium/paratest", @@ -6630,16 +6630,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.24", + "version": "12.5.25", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046" + "reference": "792c2980442dfce319226b88fa845b8b6de3b333" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d75dd30597caa80e72fad2ef7904601a30ef1046", - "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/792c2980442dfce319226b88fa845b8b6de3b333", + "reference": "792c2980442dfce319226b88fa845b8b6de3b333", "shasum": "" }, "require": { @@ -6708,7 +6708,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.24" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.25" }, "funding": [ { @@ -6716,7 +6716,7 @@ "type": "other" } ], - "time": "2026-05-01T04:21:04+00:00" + "time": "2026-05-13T03:56:57+00:00" }, { "name": "sebastian/cli-parser", @@ -7701,16 +7701,16 @@ }, { "name": "symfony/console", - "version": "v8.0.9", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d" + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/7113778e2e91f4709cb3194a75dfa9c0d028d94d", - "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d", + "url": "https://api.github.com/repos/symfony/console/zipball/3156577f46a38aa1b9323aad223de7a9cd426782", + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782", "shasum": "" }, "require": { @@ -7767,7 +7767,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.9" + "source": "https://github.com/symfony/console/tree/v8.0.11" }, "funding": [ { @@ -7787,7 +7787,7 @@ "type": "tidelift" } ], - "time": "2026-04-29T15:02:55+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/polyfill-ctype", @@ -8121,16 +8121,16 @@ }, { "name": "symfony/process", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", - "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "url": "https://api.github.com/repos/symfony/process/zipball/26d89e459f037d2873300605d0a07e7a8ef84db0", + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0", "shasum": "" }, "require": { @@ -8162,7 +8162,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.8" + "source": "https://github.com/symfony/process/tree/v8.0.11" }, "funding": [ { @@ -8182,20 +8182,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-11T16:56:32+00:00" }, { "name": "symfony/string", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", - "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", + "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff", + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff", "shasum": "" }, "require": { @@ -8252,7 +8252,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.8" + "source": "https://github.com/symfony/string/tree/v8.0.11" }, "funding": [ { @@ -8272,7 +8272,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "textalk/websocket", From 3cc600d29a21591996679657320a63d99d7f429f Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 14 May 2026 17:56:09 +0530 Subject: [PATCH 19/47] fix: revert validators to 0.2.* for auto updates --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a3086822b3..7cd048726c 100644 --- a/composer.json +++ b/composer.json @@ -69,7 +69,7 @@ "utopia-php/dsn": "0.2.1", "utopia-php/http": "^2.0@RC", "utopia-php/fetch": "^1.1", - "utopia-php/validators": "0.2.3", + "utopia-php/validators": "0.2.*", "utopia-php/image": "0.8.*", "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.8.*", From b5f0ebb37b9cd29137695b4b0bf8533f46d30df7 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 14 May 2026 17:59:34 +0530 Subject: [PATCH 20/47] refactor: replace DeploymentSkipPatterns validator with VCS_DEPLOYMENT_SKIP_PATTERNS constant --- app/init/constants.php | 5 ++ .../Modules/VCS/Http/GitHub/Deployment.php | 4 +- .../Vcs/Validator/DeploymentSkipPatterns.php | 17 ------ .../Validator/DeploymentSkipPatternsTest.php | 56 ------------------- 4 files changed, 7 insertions(+), 75 deletions(-) delete mode 100644 src/Appwrite/Vcs/Validator/DeploymentSkipPatterns.php delete mode 100644 tests/unit/Vcs/Validator/DeploymentSkipPatternsTest.php diff --git a/app/init/constants.php b/app/init/constants.php index abbe8a535e..5f3672bd45 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -514,3 +514,8 @@ const CSV_ALLOWED_DATABASE_TYPES = [ DATABASE_TYPE_TABLESDB, DATABASE_TYPE_VECTORSDB ]; + +// VCS deployment skip patterns +const VCS_DEPLOYMENT_SKIP_PATTERNS = [ + '[skip ci]', +]; diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index 811b83f722..bc6f4db7e9 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -8,8 +8,8 @@ use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Filter\BranchDomain as BranchDomainFilter; use Appwrite\Vcs\Comment; -use Appwrite\Vcs\Validator\DeploymentSkipPatterns; use Utopia\Config\Config; +use Utopia\Validator\Contains; use Utopia\Console; use Utopia\Database\Database; use Utopia\Database\Document; @@ -96,7 +96,7 @@ trait Deployment $resource = $authorization->skip(fn () => $dbForProject->getDocument($resourceCollection, $resourceId)); $resourceInternalId = $resource->getSequence(); - $commitSkip = new DeploymentSkipPatterns(); + $commitSkip = new Contains(VCS_DEPLOYMENT_SKIP_PATTERNS); if ($commitSkip->isValid($providerCommitMessage)) { Span::add("{$logBase}.build.skipped.reason", 'commitMessage'); Span::add("{$logBase}.build.skipped", 'true'); diff --git a/src/Appwrite/Vcs/Validator/DeploymentSkipPatterns.php b/src/Appwrite/Vcs/Validator/DeploymentSkipPatterns.php deleted file mode 100644 index 8e1d0623d9..0000000000 --- a/src/Appwrite/Vcs/Validator/DeploymentSkipPatterns.php +++ /dev/null @@ -1,17 +0,0 @@ -validator = new DeploymentSkipPatterns(); - } - - public function testKnownSkipDirectivesSkip(): void - { - $this->assertTrue($this->validator->isValid('[skip ci] update changelog')); - } - - public function testKnownSkipDirectivesAreCaseInsensitive(): void - { - $this->assertTrue($this->validator->isValid('[SKIP CI] update changelog')); - } - - public function testMessageWithoutKnownDirectiveProceeds(): void - { - $this->assertFalse($this->validator->isValid('fix: real bug fix')); - $this->assertFalse($this->validator->isValid('feat: add new feature')); - $this->assertFalse($this->validator->isValid('skip deploy without brackets')); - $this->assertFalse($this->validator->isValid('deploy this please')); - $this->assertFalse($this->validator->isValid('skip-checks:true')); - } - - public function testDirectiveCanAppearAnywhere(): void - { - $this->assertTrue($this->validator->isValid('docs: update readme [skip ci]')); - $this->assertTrue($this->validator->isValid('docs: update readme[skip ci]')); - $this->assertTrue($this->validator->isValid('prefix[skip ci]suffix')); - $this->assertFalse($this->validator->isValid('refactor: skip ci cache seeding')); - } - - public function testMultilineCommitMessageSkips(): void - { - $message = "feat: add new stuff\n\nMore detail here.\n\n[skip ci]"; - - $this->assertTrue($this->validator->isValid($message)); - } - - public function testNonStringCommitMessageProceeds(): void - { - $this->assertFalse($this->validator->isValid(null)); - $this->assertFalse($this->validator->isValid([])); - } -} From 0b073057758a351c7bde86e035ba719d3dc91b7a Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 14 May 2026 18:05:04 +0530 Subject: [PATCH 21/47] fix: remove redundant comment, rename commitSkip to validator, use getDescription in span --- app/init/constants.php | 1 - .../Platform/Modules/VCS/Http/GitHub/Deployment.php | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/init/constants.php b/app/init/constants.php index 5f3672bd45..bdc8e67fae 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -515,7 +515,6 @@ const CSV_ALLOWED_DATABASE_TYPES = [ DATABASE_TYPE_VECTORSDB ]; -// VCS deployment skip patterns const VCS_DEPLOYMENT_SKIP_PATTERNS = [ '[skip ci]', ]; diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index bc6f4db7e9..38475b4df0 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -96,9 +96,9 @@ trait Deployment $resource = $authorization->skip(fn () => $dbForProject->getDocument($resourceCollection, $resourceId)); $resourceInternalId = $resource->getSequence(); - $commitSkip = new Contains(VCS_DEPLOYMENT_SKIP_PATTERNS); - if ($commitSkip->isValid($providerCommitMessage)) { - Span::add("{$logBase}.build.skipped.reason", 'commitMessage'); + $validator = new Contains(VCS_DEPLOYMENT_SKIP_PATTERNS); + if ($validator->isValid($providerCommitMessage)) { + Span::add("{$logBase}.build.skipped.reason", $validator->getDescription()); Span::add("{$logBase}.build.skipped", 'true'); continue; } From 437b3bfa10b7829872c9a26c6a013ba674891aa4 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 14 May 2026 18:08:59 +0530 Subject: [PATCH 22/47] fix: lint --- src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index 38475b4df0..a6f0e7fd6d 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -9,7 +9,6 @@ use Appwrite\Extend\Exception; use Appwrite\Filter\BranchDomain as BranchDomainFilter; use Appwrite\Vcs\Comment; use Utopia\Config\Config; -use Utopia\Validator\Contains; use Utopia\Console; use Utopia\Database\Database; use Utopia\Database\Document; @@ -22,6 +21,7 @@ use Utopia\Database\Validator\Authorization; use Utopia\DSN\DSN; use Utopia\Span\Span; use Utopia\System\System; +use Utopia\Validator\Contains; use Utopia\VCS\Adapter\Git\GitHub; use Utopia\VCS\Exception\RepositoryNotFound; From 4d1dabac66405b0c8de8d6333d0921408de37e94 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 15 May 2026 13:21:35 +0530 Subject: [PATCH 23/47] Update Utopia platform to rc2 --- composer.json | 2 +- composer.lock | 59 +++++++++++++++++++++++++-------------------------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/composer.json b/composer.json index 7cd048726c..c04bc28f14 100644 --- a/composer.json +++ b/composer.json @@ -75,7 +75,7 @@ "utopia-php/logger": "0.8.*", "utopia-php/messaging": "0.22.*", "utopia-php/migration": "1.*", - "utopia-php/platform": "^1.0@RC", + "utopia-php/platform": "1.0.0-rc2", "utopia-php/pools": "1.*", "utopia-php/span": "1.1.*", "utopia-php/preloader": "0.2.*", diff --git a/composer.lock b/composer.lock index 3033d762e2..3e2e5e764b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9377e1b56bca8dbaf213ee3572ca15c0", + "content-hash": "6404f075ed03ef6651ce9cea63518fa0", "packages": [ { "name": "adhocore/jwt", @@ -4722,26 +4722,26 @@ }, { "name": "utopia-php/platform", - "version": "1.0.0-rc1", + "version": "1.0.0-rc2", "source": { "type": "git", "url": "https://github.com/utopia-php/platform.git", - "reference": "36c0a8b2f3d96ca056d724701a302a127111e933" + "reference": "a67e5037007ee7fdca5359ab4577b82917e55452" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/platform/zipball/36c0a8b2f3d96ca056d724701a302a127111e933", - "reference": "36c0a8b2f3d96ca056d724701a302a127111e933", + "url": "https://api.github.com/repos/utopia-php/platform/zipball/a67e5037007ee7fdca5359ab4577b82917e55452", + "reference": "a67e5037007ee7fdca5359ab4577b82917e55452", "shasum": "" }, "require": { "ext-json": "*", "ext-redis": "*", "php": ">=8.3", - "utopia-php/cli": "0.23.3", + "utopia-php/cli": "0.23.*", "utopia-php/http": "^2.0@RC", - "utopia-php/queue": "0.18.2", - "utopia-php/servers": "0.4.0" + "utopia-php/queue": "0.18.*", + "utopia-php/servers": "0.4.*" }, "require-dev": { "laravel/pint": "1.2.*", @@ -4767,9 +4767,9 @@ ], "support": { "issues": "https://github.com/utopia-php/platform/issues", - "source": "https://github.com/utopia-php/platform/tree/1.0.0-rc1" + "source": "https://github.com/utopia-php/platform/tree/1.0.0-rc2" }, - "time": "2026-05-05T15:09:27+00:00" + "time": "2026-05-15T06:19:20+00:00" }, { "name": "utopia-php/pools", @@ -4925,16 +4925,16 @@ }, { "name": "utopia-php/queue", - "version": "0.18.2", + "version": "0.18.3", "source": { "type": "git", "url": "https://github.com/utopia-php/queue.git", - "reference": "f85ca003c99ff475708c05466643d067403c0c22" + "reference": "141aad162b90728353f3aa834684b1f2affed045" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/queue/zipball/f85ca003c99ff475708c05466643d067403c0c22", - "reference": "f85ca003c99ff475708c05466643d067403c0c22", + "url": "https://api.github.com/repos/utopia-php/queue/zipball/141aad162b90728353f3aa834684b1f2affed045", + "reference": "141aad162b90728353f3aa834684b1f2affed045", "shasum": "" }, "require": { @@ -4985,9 +4985,9 @@ ], "support": { "issues": "https://github.com/utopia-php/queue/issues", - "source": "https://github.com/utopia-php/queue/tree/0.18.2" + "source": "https://github.com/utopia-php/queue/tree/0.18.3" }, - "time": "2026-05-05T04:38:59+00:00" + "time": "2026-05-14T08:53:35+00:00" }, { "name": "utopia-php/registry", @@ -5349,16 +5349,16 @@ }, { "name": "utopia-php/vcs", - "version": "4.0.0", + "version": "4.1.0", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "c14ec4d1188e6cc2e8f5256a4b26e531e4f9ac4e" + "reference": "2850dbe975ee69b9466ee6df385fe1679394ce78" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/c14ec4d1188e6cc2e8f5256a4b26e531e4f9ac4e", - "reference": "c14ec4d1188e6cc2e8f5256a4b26e531e4f9ac4e", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/2850dbe975ee69b9466ee6df385fe1679394ce78", + "reference": "2850dbe975ee69b9466ee6df385fe1679394ce78", "shasum": "" }, "require": { @@ -5392,9 +5392,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/4.0.0" + "source": "https://github.com/utopia-php/vcs/tree/4.1.0" }, - "time": "2026-05-13T04:20:45+00:00" + "time": "2026-05-14T10:04:10+00:00" }, { "name": "utopia-php/websocket", @@ -5587,16 +5587,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.29.2", + "version": "1.29.5", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "31248a984a4d478d20a780dda8f5897984ee4e8f" + "reference": "e670edcdfb9ffcec36125b1eb3e4473dce30b620" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/31248a984a4d478d20a780dda8f5897984ee4e8f", - "reference": "31248a984a4d478d20a780dda8f5897984ee4e8f", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/e670edcdfb9ffcec36125b1eb3e4473dce30b620", + "reference": "e670edcdfb9ffcec36125b1eb3e4473dce30b620", "shasum": "" }, "require": { @@ -5632,9 +5632,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.29.2" + "source": "https://github.com/appwrite/sdk-generator/tree/1.29.5" }, - "time": "2026-05-13T04:47:38+00:00" + "time": "2026-05-15T06:49:05+00:00" }, { "name": "brianium/paratest", @@ -8567,8 +8567,7 @@ "aliases": [], "minimum-stability": "dev", "stability-flags": { - "utopia-php/http": 5, - "utopia-php/platform": 5 + "utopia-php/http": 5 }, "prefer-stable": true, "prefer-lowest": false, From ad88b827e60f0f813e4000299d3213b3b7b661cd Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 15 May 2026 13:22:35 +0530 Subject: [PATCH 24/47] Refactor database queue publisher --- app/cli.php | 5 ++ app/controllers/shared/api.php | 14 +---- app/init/resources.php | 5 ++ app/init/resources/request.php | 2 - app/init/worker/message.php | 5 -- src/Appwrite/Event/Message/Database.php | 51 +++++++++++++++++++ src/Appwrite/Event/Publisher/Database.php | 45 ++++++++++++++++ .../Collections/Attributes/Action.php | 31 ++++++----- .../Collections/Attributes/BigInt/Create.php | 8 +-- .../Collections/Attributes/Boolean/Create.php | 8 +-- .../Attributes/Datetime/Create.php | 8 +-- .../Collections/Attributes/Delete.php | 33 ++++++------ .../Collections/Attributes/Email/Create.php | 8 +-- .../Collections/Attributes/Enum/Create.php | 8 +-- .../Collections/Attributes/Float/Create.php | 8 +-- .../Collections/Attributes/IP/Create.php | 8 +-- .../Collections/Attributes/Integer/Create.php | 8 +-- .../Collections/Attributes/Line/Create.php | 8 +-- .../Attributes/Longtext/Create.php | 8 +-- .../Attributes/Mediumtext/Create.php | 8 +-- .../Collections/Attributes/Point/Create.php | 8 +-- .../Collections/Attributes/Polygon/Create.php | 8 +-- .../Attributes/Relationship/Create.php | 8 +-- .../Collections/Attributes/String/Create.php | 8 +-- .../Collections/Attributes/Text/Create.php | 8 +-- .../Collections/Attributes/URL/Create.php | 8 +-- .../Collections/Attributes/Varchar/Create.php | 8 +-- .../Http/Databases/Collections/Delete.php | 27 +++++----- .../Databases/Collections/Indexes/Create.php | 33 ++++++------ .../Databases/Collections/Indexes/Delete.php | 33 ++++++------ .../Databases/Http/Databases/Delete.php | 19 ++++--- .../Http/DocumentsDB/Collections/Delete.php | 2 +- .../Collections/Indexes/Create.php | 2 +- .../Collections/Indexes/Delete.php | 2 +- .../Databases/Http/DocumentsDB/Delete.php | 2 +- .../Databases/Http/TablesDB/Delete.php | 2 +- .../TablesDB/Tables/Columns/BigInt/Create.php | 2 +- .../Tables/Columns/Boolean/Create.php | 2 +- .../Tables/Columns/Datetime/Create.php | 2 +- .../Http/TablesDB/Tables/Columns/Delete.php | 2 +- .../TablesDB/Tables/Columns/Email/Create.php | 2 +- .../TablesDB/Tables/Columns/Enum/Create.php | 2 +- .../TablesDB/Tables/Columns/Float/Create.php | 2 +- .../TablesDB/Tables/Columns/IP/Create.php | 2 +- .../Tables/Columns/Integer/Create.php | 2 +- .../TablesDB/Tables/Columns/Line/Create.php | 2 +- .../Tables/Columns/Longtext/Create.php | 2 +- .../Tables/Columns/Mediumtext/Create.php | 2 +- .../TablesDB/Tables/Columns/Point/Create.php | 2 +- .../Tables/Columns/Polygon/Create.php | 2 +- .../Tables/Columns/Relationship/Create.php | 2 +- .../TablesDB/Tables/Columns/String/Create.php | 2 +- .../TablesDB/Tables/Columns/Text/Create.php | 2 +- .../TablesDB/Tables/Columns/URL/Create.php | 2 +- .../Tables/Columns/Varchar/Create.php | 2 +- .../Databases/Http/TablesDB/Tables/Delete.php | 2 +- .../Http/TablesDB/Tables/Indexes/Create.php | 2 +- .../Http/TablesDB/Tables/Indexes/Delete.php | 2 +- .../Http/VectorsDB/Collections/Delete.php | 2 +- .../VectorsDB/Collections/Indexes/Create.php | 2 +- .../VectorsDB/Collections/Indexes/Delete.php | 2 +- .../Databases/Http/VectorsDB/Delete.php | 2 +- .../Modules/Databases/Workers/Databases.php | 10 ++-- .../Http/Health/Queue/Databases/Get.php | 9 ++-- .../Health/Http/Health/Queue/Failed/Get.php | 8 +-- 65 files changed, 315 insertions(+), 221 deletions(-) create mode 100644 src/Appwrite/Event/Message/Database.php create mode 100644 src/Appwrite/Event/Publisher/Database.php diff --git a/app/cli.php b/app/cli.php index c5f436215e..9ad223a3ff 100644 --- a/app/cli.php +++ b/app/cli.php @@ -4,6 +4,7 @@ require_once __DIR__ . '/init.php'; use Appwrite\Event\Event; use Appwrite\Event\Publisher\Certificate as CertificatePublisher; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Event\Publisher\Delete as DeletePublisher; use Appwrite\Event\Publisher\Func as FunctionPublisher; use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; @@ -285,6 +286,10 @@ $container->set('publisherForFunctions', fn (Publisher $publisher) => new Functi $publisher, new Queue(System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', Event::FUNCTIONS_QUEUE_TTL) ), ['publisher']); +$container->set('publisherForDatabase', fn (Publisher $publisherDatabases) => new DatabasePublisher( + $publisherDatabases, + new Queue(System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME)) +), ['publisherDatabases']); $container->set('publisherForDeletes', fn (Publisher $publisher) => new DeletePublisher( $publisher, new Queue(System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME)) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index eb99a656c0..6e5167660a 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -4,7 +4,6 @@ use Appwrite\Auth\Key; use Appwrite\Auth\MFA\Type\TOTP; use Appwrite\Bus\Events\RequestCompleted; use Appwrite\Event\Context\Audit as AuditContext; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; use Appwrite\Event\Message\Audit as AuditMessage; use Appwrite\Event\Message\Func as FunctionMessage; @@ -564,7 +563,6 @@ Http::init() ->inject('user') ->inject('queueForEvents') ->inject('auditContext') - ->inject('queueForDatabase') ->inject('usage') ->inject('publisherForFunctions') ->inject('dbForProject') @@ -576,7 +574,7 @@ Http::init() ->inject('platform') ->inject('authorization') ->inject('cacheControlForStorage') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, EventDatabase $queueForDatabase, Context $usage, FunctionPublisher $publisherForFunctions, Database $dbForProject, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Telemetry $telemetry, array $platform, Authorization $authorization, callable $cacheControlForStorage) { + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Context $usage, FunctionPublisher $publisherForFunctions, Database $dbForProject, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Telemetry $telemetry, array $platform, Authorization $authorization, callable $cacheControlForStorage) { $response->setUser($user); $request->setUser($user); @@ -622,9 +620,6 @@ Http::init() $auditContext->user = $userClone; } - /* Auto-set projects */ - $queueForDatabase->setProject($project); - $useCache = $route->getLabel('cache', false); $storageCacheOperationsCounter = $telemetry->createCounter('storage.cache.operations.load'); if ($useCache) { @@ -815,7 +810,6 @@ Http::shutdown() ->inject('publisherForAudits') ->inject('usage') ->inject('publisherForUsage') - ->inject('queueForDatabase') ->inject('publisherForFunctions') ->inject('queueForWebhooks') ->inject('queueForRealtime') @@ -826,7 +820,7 @@ Http::shutdown() ->inject('bus') ->inject('apiKey') ->inject('mode') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, EventDatabase $queueForDatabase, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { $responsePayload = $response->getPayload(); @@ -973,10 +967,6 @@ Http::shutdown() $publisherForAudits->enqueue(AuditMessage::fromContext($auditContext)); } - if (! empty($queueForDatabase->getType())) { - $queueForDatabase->trigger(); - } - // Cache label $useCache = $route->getLabel('cache', false); if ($useCache) { diff --git a/app/init/resources.php b/app/init/resources.php index 64f16b4c05..92b581157f 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -4,6 +4,7 @@ use Appwrite\Event\Event; use Appwrite\Event\Publisher\Audit as AuditPublisher; use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Event\Publisher\Certificate as CertificatePublisher; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Event\Publisher\Delete as DeletePublisher; use Appwrite\Event\Publisher\Execution as ExecutionPublisher; use Appwrite\Event\Publisher\Func as FunctionPublisher; @@ -126,6 +127,10 @@ $container->set('publisherForBuilds', fn (Publisher $publisher) => new BuildPubl $publisher, new Queue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME)) ), ['publisher']); +$container->set('publisherForDatabase', fn (Publisher $publisherDatabases) => new DatabasePublisher( + $publisherDatabases, + new Queue(System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME)) +), ['publisherDatabases']); $container->set('publisherForDeletes', fn (Publisher $publisher) => new DeletePublisher( $publisher, new Queue(System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME)) diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 2e303dcfaa..85d8db3698 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -5,7 +5,6 @@ use Ahc\Jwt\JWTException; use Appwrite\Auth\Key; use Appwrite\Databases\TransactionState; use Appwrite\Event\Context\Audit as AuditContext; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; use Appwrite\Event\Message\Func as FunctionMessage; use Appwrite\Event\Publisher\Func as FunctionPublisher; @@ -107,7 +106,6 @@ return function (Container $context): void { }); // Per-request queue resources (stateful, accumulate event data during request) - $context->set('queueForDatabase', fn (Publisher $publisher) => new EventDatabase($publisher), ['publisher']); $context->set('queueForEvents', fn (Publisher $publisher) => new Event($publisher), ['publisher']); $context->set('queueForWebhooks', fn (Publisher $publisher) => new Webhook($publisher), ['publisher']); $context->set('queueForRealtime', fn () => new Realtime(), []); diff --git a/app/init/worker/message.php b/app/init/worker/message.php index 2862d3ccdf..3585421a28 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -1,6 +1,5 @@ set('queueForDatabase', function (Publisher $publisher) { - return new EventDatabase($publisher); - }, ['publisher']); - $container->set('queueForEvents', function (Publisher $publisher) { return new Event($publisher); }, ['publisher']); diff --git a/src/Appwrite/Event/Message/Database.php b/src/Appwrite/Event/Message/Database.php new file mode 100644 index 0000000000..1178dcf5c7 --- /dev/null +++ b/src/Appwrite/Event/Message/Database.php @@ -0,0 +1,51 @@ + $this->project?->getArrayCopy(), + 'user' => $this->user?->getArrayCopy(), + 'type' => $this->type, + 'table' => $this->table?->getArrayCopy(), + 'row' => $this->row?->getArrayCopy(), + 'collection' => $this->collection?->getArrayCopy(), + 'document' => $this->document?->getArrayCopy(), + 'database' => $this->database?->getArrayCopy(), + 'events' => $this->events, + ]; + } + + public static function fromArray(array $data): static + { + return new self( + project: !empty($data['project']) ? new Document($data['project']) : null, + user: !empty($data['user']) ? new Document($data['user']) : null, + type: $data['type'] ?? '', + table: !empty($data['table']) ? new Document($data['table']) : null, + row: !empty($data['row']) ? new Document($data['row']) : null, + collection: !empty($data['collection']) ? new Document($data['collection']) : null, + document: !empty($data['document']) ? new Document($data['document']) : null, + database: !empty($data['database']) ? new Document($data['database']) : null, + events: $data['events'] ?? [], + ); + } +} diff --git a/src/Appwrite/Event/Publisher/Database.php b/src/Appwrite/Event/Publisher/Database.php new file mode 100644 index 0000000000..09d5c33f03 --- /dev/null +++ b/src/Appwrite/Event/Publisher/Database.php @@ -0,0 +1,45 @@ +publish($queue ?? $this->getQueueFromProject($message->project), $message); + } + + public function getSize(bool $failed = false, ?Queue $queue = null): int + { + return $this->getQueueSize($queue ?? $this->queue, $failed); + } + + private function getQueueFromProject(?Document $project): Queue + { + $database = $project?->getAttribute('database', ''); + if (empty($database)) { + return $this->queue; + } + + try { + $dsn = new DSN($database); + } catch (\InvalidArgumentException) { + $dsn = new DSN('mysql://' . $database); + } + + return new Queue($dsn->getHost()); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php index 4e5203b13f..a07a4be561 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Message\Database as DatabaseMessage; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response as UtopiaResponse; @@ -312,7 +313,7 @@ abstract class Action extends UtopiaAction }; } - protected function createAttribute(string $databaseId, string $collectionId, Document $attribute, Response $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): Document + protected function createAttribute(string $databaseId, string $collectionId, Document $attribute, Response $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): Document { $key = $attribute->getAttribute('key'); $type = $attribute->getAttribute('type', ''); @@ -464,20 +465,6 @@ abstract class Action extends UtopiaAction $dbForProject->purgeCachedCollection('database_' . $db->getSequence() . '_collection_' . $relatedCollection->getSequence()); } - $queueForDatabase - ->setType(DATABASE_TYPE_CREATE_ATTRIBUTE) - ->setDatabase($db); - - if ($this->isCollectionsAPI()) { - $queueForDatabase - ->setDocument($attribute) - ->setCollection($collection); - } else { - $queueForDatabase - ->setRow($attribute) - ->setTable($collection); - } - $queueForEvents ->setContext('database', $db) ->setParam('databaseId', $databaseId) @@ -487,6 +474,18 @@ abstract class Action extends UtopiaAction ->setParam('columnId', $attribute->getId()) ->setContext($this->getCollectionsEventsContext(), $collection); + $publisherForDatabase->enqueue(new DatabaseMessage( + project: $queueForEvents->getProject(), + user: $queueForEvents->getUser(), + type: DATABASE_TYPE_CREATE_ATTRIBUTE, + database: $db, + collection: $this->isCollectionsAPI() ? $collection : null, + document: $this->isCollectionsAPI() ? $attribute : null, + table: $this->isCollectionsAPI() ? null : $collection, + row: $this->isCollectionsAPI() ? null : $attribute, + events: Event::generateEvents($queueForEvents->getEvent(), $queueForEvents->getParams()), + )); + $response->setStatusCode(SwooleResponse::STATUS_CODE_CREATED); return $attribute; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/BigInt/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/BigInt/Create.php index 4ea85b71e6..11d3ada810 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/BigInt/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/BigInt/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\BigInt; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; @@ -73,13 +73,13 @@ class Create extends Action ->param('array', false, new Boolean(), 'Is attribute an array?', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?int $min, ?int $max, ?int $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?int $min, ?int $max, ?int $default, bool $array, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void { $min ??= \PHP_INT_MIN; $max ??= \PHP_INT_MAX; @@ -102,7 +102,7 @@ class Create extends Action 'array' => $array, 'format' => APP_DATABASE_ATTRIBUTE_BIGINT_RANGE, 'formatOptions' => ['min' => $min, 'max' => $max], - ]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization); + ]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization); $formatOptions = $attribute->getAttribute('formatOptions', []); if (!empty($formatOptions)) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Boolean/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Boolean/Create.php index a19b1626c9..475b43f569 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Boolean/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Boolean/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Boolean; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Deprecated; @@ -68,13 +68,13 @@ class Create extends Action ->param('array', false, new Boolean(), 'Is attribute an array?', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?bool $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?bool $default, bool $array, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void { $attribute = $this->createAttribute($databaseId, $collectionId, new Document([ 'key' => $key, @@ -83,7 +83,7 @@ class Create extends Action 'required' => $required, 'default' => $default, 'array' => $array, - ]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization); + ]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization); $response ->setStatusCode(SwooleResponse::STATUS_CODE_ACCEPTED) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Datetime/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Datetime/Create.php index 4162b50daf..7a0776751b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Datetime/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Datetime/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Datetime; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Deprecated; @@ -69,13 +69,13 @@ class Create extends Action ->param('array', false, new Boolean(), 'Is attribute an array?', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?string $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?string $default, bool $array, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void { $attribute = $this->createAttribute( $databaseId, @@ -91,7 +91,7 @@ class Create extends Action ]), $response, $dbForProject, - $queueForDatabase, + $publisherForDatabase, $queueForEvents, $authorization ); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Delete.php index 38b96e67bc..ff1636ae60 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Delete.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Message\Database as DatabaseMessage; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -66,13 +67,13 @@ class Delete extends Action ->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Attribute Key.', false, ['dbForProject']) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $key, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $key, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void { $db = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($db->isEmpty()) { @@ -129,20 +130,6 @@ class Delete extends Action } } - $queueForDatabase - ->setDatabase($db) - ->setType(DATABASE_TYPE_DELETE_ATTRIBUTE); - - if ($this->isCollectionsAPI()) { - $queueForDatabase - ->setRow($attribute) - ->setTable($collection); - } else { - $queueForDatabase - ->setDocument($attribute) - ->setCollection($collection); - } - $type = $attribute->getAttribute('type'); $format = $attribute->getAttribute('format'); @@ -158,6 +145,18 @@ class Delete extends Action ->setPayload($response->output($attribute, $model)) ->setContext($this->getCollectionsEventsContext(), $collection); + $publisherForDatabase->enqueue(new DatabaseMessage( + project: $queueForEvents->getProject(), + user: $queueForEvents->getUser(), + type: DATABASE_TYPE_DELETE_ATTRIBUTE, + database: $db, + collection: $this->isCollectionsAPI() ? null : $collection, + document: $this->isCollectionsAPI() ? null : $attribute, + table: $this->isCollectionsAPI() ? $collection : null, + row: $this->isCollectionsAPI() ? $attribute : null, + events: Event::generateEvents($queueForEvents->getEvent(), $queueForEvents->getParams()), + )); + $response->noContent(); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Email/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Email/Create.php index 6530cdb1dd..098083bea6 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Email/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Email/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Email; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Deprecated; @@ -69,13 +69,13 @@ class Create extends Action ->param('array', false, new Boolean(), 'Is attribute an array?', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?string $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?string $default, bool $array, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void { $attribute = $this->createAttribute( $databaseId, @@ -91,7 +91,7 @@ class Create extends Action ]), $response, $dbForProject, - $queueForDatabase, + $publisherForDatabase, $queueForEvents, $authorization ); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Enum/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Enum/Create.php index fbc2d08cd1..602189e881 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Enum/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Enum/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Enum; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; @@ -72,13 +72,13 @@ class Create extends Action ->param('array', false, new Boolean(), 'Is attribute an array?', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $key, array $elements, ?bool $required, ?string $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $key, array $elements, ?bool $required, ?string $default, bool $array, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void { if (!is_null($default) && !\in_array($default, $elements, true)) { throw new Exception($this->getInvalidValueException(), 'Default value not found in elements'); @@ -99,7 +99,7 @@ class Create extends Action ]), $response, $dbForProject, - $queueForDatabase, + $publisherForDatabase, $queueForEvents, $authorization ); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Float/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Float/Create.php index e1585be169..a715b51b5a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Float/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Float/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Float; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; @@ -73,13 +73,13 @@ class Create extends Action ->param('array', false, new Boolean(), 'Is attribute an array?', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?float $min, ?float $max, ?float $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?float $min, ?float $max, ?float $default, bool $array, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void { $min ??= -PHP_FLOAT_MAX; $max ??= PHP_FLOAT_MAX; @@ -102,7 +102,7 @@ class Create extends Action 'array' => $array, 'format' => APP_DATABASE_ATTRIBUTE_FLOAT_RANGE, 'formatOptions' => ['min' => $min, 'max' => $max], - ]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization); + ]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization); $formatOptions = $attribute->getAttribute('formatOptions', []); if (!empty($formatOptions)) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/IP/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/IP/Create.php index 8b02339252..9a142b1a86 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/IP/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/IP/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\IP; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Deprecated; @@ -69,13 +69,13 @@ class Create extends Action ->param('array', false, new Boolean(), 'Is attribute an array?', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?string $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?string $default, bool $array, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void { $attribute = $this->createAttribute( $databaseId, @@ -91,7 +91,7 @@ class Create extends Action ]), $response, $dbForProject, - $queueForDatabase, + $publisherForDatabase, $queueForEvents, $authorization ); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Integer/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Integer/Create.php index 3d2fa68797..89aefb87e6 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Integer/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Integer/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Integer; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; @@ -73,13 +73,13 @@ class Create extends Action ->param('array', false, new Boolean(), 'Is attribute an array?', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?int $min, ?int $max, ?int $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?int $min, ?int $max, ?int $default, bool $array, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void { $min ??= \PHP_INT_MIN; $max ??= \PHP_INT_MAX; @@ -104,7 +104,7 @@ class Create extends Action 'array' => $array, 'format' => APP_DATABASE_ATTRIBUTE_INT_RANGE, 'formatOptions' => ['min' => $min, 'max' => $max], - ]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization); + ]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization); $formatOptions = $attribute->getAttribute('formatOptions', []); if (!empty($formatOptions)) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Line/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Line/Create.php index d2578a963f..d3f82cd109 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Line/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Line/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Line; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; @@ -69,13 +69,13 @@ class Create extends Action ->param('default', null, new Nullable(new Spatial(Database::VAR_LINESTRING)), 'Default value for attribute when not provided, two-dimensional array of coordinate pairs, [[longitude, latitude], [longitude, latitude], …], listing the vertices of the line in order. Cannot be set when attribute is required.', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?array $default, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?array $default, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void { if (!$dbForProject->getAdapter()->getSupportForSpatialAttributes()) { throw new Exception(Exception::GENERAL_FEATURE_UNSUPPORTED, 'Spatial columns are not supported by this database.'); @@ -86,7 +86,7 @@ class Create extends Action 'type' => Database::VAR_LINESTRING, 'required' => $required, 'default' => $default - ]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization); + ]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization); $response ->setStatusCode(SwooleResponse::STATUS_CODE_ACCEPTED) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Longtext/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Longtext/Create.php index 2fc9de8699..90591b43fb 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Longtext/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Longtext/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Longtext; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; @@ -67,7 +67,7 @@ class Create extends Action ->param('encrypt', false, new Boolean(), 'Toggle encryption for the attribute. Encryption enhances security by not storing any plain text values in the database. However, encrypted attributes cannot be queried.', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('plan') ->inject('authorization') @@ -84,7 +84,7 @@ class Create extends Action bool $encrypt, UtopiaResponse $response, Database $dbForProject, - EventDatabase $queueForDatabase, + DatabasePublisher $publisherForDatabase, Event $queueForEvents, array $plan, Authorization $authorization @@ -112,7 +112,7 @@ class Create extends Action ]), $response, $dbForProject, - $queueForDatabase, + $publisherForDatabase, $queueForEvents, $authorization ); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Mediumtext/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Mediumtext/Create.php index 5776e51917..0f7b386fd5 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Mediumtext/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Mediumtext/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Mediumtext; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; @@ -67,7 +67,7 @@ class Create extends Action ->param('encrypt', false, new Boolean(), 'Toggle encryption for the attribute. Encryption enhances security by not storing any plain text values in the database. However, encrypted attributes cannot be queried.', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('plan') ->inject('authorization') @@ -84,7 +84,7 @@ class Create extends Action bool $encrypt, UtopiaResponse $response, Database $dbForProject, - EventDatabase $queueForDatabase, + DatabasePublisher $publisherForDatabase, Event $queueForEvents, array $plan, Authorization $authorization @@ -112,7 +112,7 @@ class Create extends Action ]), $response, $dbForProject, - $queueForDatabase, + $publisherForDatabase, $queueForEvents, $authorization ); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Point/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Point/Create.php index 527b4330b9..38082b46da 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Point/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Point/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Point; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; @@ -69,13 +69,13 @@ class Create extends Action ->param('default', null, new Nullable(new Spatial(Database::VAR_POINT)), 'Default value for attribute when not provided, array of two numbers [longitude, latitude], representing a single coordinate. Cannot be set when attribute is required.', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?array $default, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?array $default, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void { if (!$dbForProject->getAdapter()->getSupportForSpatialAttributes()) { throw new Exception(Exception::GENERAL_FEATURE_UNSUPPORTED, 'Spatial columns are not supported by this database.'); @@ -86,7 +86,7 @@ class Create extends Action 'type' => Database::VAR_POINT, 'required' => $required, 'default' => $default, - ]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization); + ]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization); $response ->setStatusCode(SwooleResponse::STATUS_CODE_ACCEPTED) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Polygon/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Polygon/Create.php index 4c3e725f3e..3063d1938a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Polygon/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Polygon/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Polygon; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; @@ -69,13 +69,13 @@ class Create extends Action ->param('default', null, new Nullable(new Spatial(Database::VAR_POLYGON)), 'Default value for attribute when not provided, three-dimensional array where the outer array holds one or more linear rings, [[[longitude, latitude], …], …], the first ring is the exterior boundary, any additional rings are interior holes, and each ring must start and end with the same coordinate pair. Cannot be set when attribute is required.', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?array $default, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?array $default, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void { if (!$dbForProject->getAdapter()->getSupportForSpatialAttributes()) { throw new Exception(Exception::GENERAL_FEATURE_UNSUPPORTED, 'Spatial columns are not supported by this database.'); @@ -86,7 +86,7 @@ class Create extends Action 'type' => Database::VAR_POLYGON, 'required' => $required, 'default' => $default, - ]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization); + ]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization); $response ->setStatusCode(SwooleResponse::STATUS_CODE_ACCEPTED) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Relationship/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Relationship/Create.php index fdd40aaa8f..ace48a5c56 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Relationship/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Relationship/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Relationship; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; @@ -81,13 +81,13 @@ class Create extends Action ], true), 'Constraints option', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $relatedCollectionId, string $type, bool $twoWay, ?string $key, ?string $twoWayKey, string $onDelete, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $relatedCollectionId, string $type, bool $twoWay, ?string $key, ?string $twoWayKey, string $onDelete, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void { if (!$dbForProject->getAdapter()->getSupportForRelationships()) { throw new Exception(Exception::GENERAL_FEATURE_UNSUPPORTED, 'Relationships are not supported by this database.'); @@ -159,7 +159,7 @@ class Create extends Action 'twoWayKey' => $twoWayKey, 'onDelete' => $onDelete, ] - ]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization); + ]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization); foreach ($attribute->getAttribute('options', []) as $k => $option) { $attribute->setAttribute($k, $option); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/String/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/String/Create.php index c8917c3deb..a32a3083ab 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/String/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/String/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\String; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; @@ -75,7 +75,7 @@ class Create extends Action ->param('encrypt', false, new Boolean(), 'Toggle encryption for the attribute. Encryption enhances security by not storing any plain text values in the database. However, encrypted attributes cannot be queried.', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('plan') ->inject('authorization') @@ -93,7 +93,7 @@ class Create extends Action bool $encrypt, UtopiaResponse $response, Database $dbForProject, - EventDatabase $queueForDatabase, + DatabasePublisher $publisherForDatabase, Event $queueForEvents, array $plan, Authorization $authorization @@ -134,7 +134,7 @@ class Create extends Action ]), $response, $dbForProject, - $queueForDatabase, + $publisherForDatabase, $queueForEvents, $authorization ); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Text/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Text/Create.php index eb6b2f9691..79968d0feb 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Text/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Text/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Text; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; @@ -67,7 +67,7 @@ class Create extends Action ->param('encrypt', false, new Boolean(), 'Toggle encryption for the attribute. Encryption enhances security by not storing any plain text values in the database. However, encrypted attributes cannot be queried.', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('plan') ->inject('authorization') @@ -84,7 +84,7 @@ class Create extends Action bool $encrypt, UtopiaResponse $response, Database $dbForProject, - EventDatabase $queueForDatabase, + DatabasePublisher $publisherForDatabase, Event $queueForEvents, array $plan, Authorization $authorization @@ -112,7 +112,7 @@ class Create extends Action ]), $response, $dbForProject, - $queueForDatabase, + $publisherForDatabase, $queueForEvents, $authorization ); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/URL/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/URL/Create.php index 7ada8c7f7d..7338bdbd1d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/URL/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/URL/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\URL; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Deprecated; @@ -69,7 +69,7 @@ class Create extends Action ->param('array', false, new Boolean(), 'Is attribute an array?', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); @@ -84,7 +84,7 @@ class Create extends Action bool $array, UtopiaResponse $response, Database $dbForProject, - EventDatabase $queueForDatabase, + DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization ): void { @@ -96,7 +96,7 @@ class Create extends Action 'default' => $default, 'array' => $array, 'format' => APP_DATABASE_ATTRIBUTE_URL, - ]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization); + ]), $response, $dbForProject, $publisherForDatabase, $queueForEvents, $authorization); $response ->setStatusCode(SwooleResponse::STATUS_CODE_ACCEPTED) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Varchar/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Varchar/Create.php index 24a36725c8..89690de4e9 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Varchar/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Varchar/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Varchar; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; @@ -70,7 +70,7 @@ class Create extends Action ->param('encrypt', false, new Boolean(), 'Toggle encryption for the attribute. Encryption enhances security by not storing any plain text values in the database. However, encrypted attributes cannot be queried.', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('plan') ->inject('authorization') @@ -88,7 +88,7 @@ class Create extends Action bool $encrypt, UtopiaResponse $response, Database $dbForProject, - EventDatabase $queueForDatabase, + DatabasePublisher $publisherForDatabase, Event $queueForEvents, array $plan, Authorization $authorization @@ -129,7 +129,7 @@ class Create extends Action ]), $response, $dbForProject, - $queueForDatabase, + $publisherForDatabase, $queueForEvents, $authorization ); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php index 7a5b73f7db..87171fb2fe 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Message\Database as DatabaseMessage; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -63,13 +64,13 @@ class Delete extends Action ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void { $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { @@ -89,22 +90,22 @@ class Delete extends Action $dbForDatabases = $getDatabasesDB($database); $dbForDatabases->purgeCachedCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence()); - $queueForDatabase - ->setType(DATABASE_TYPE_DELETE_COLLECTION) - ->setDatabase($database); - - if ($this->isCollectionsAPI()) { - $queueForDatabase->setCollection($collection); - } else { - $queueForDatabase->setTable($collection); - } - $queueForEvents ->setParam('databaseId', $databaseId) ->setContext('database', $database) ->setParam($this->getEventsParamKey(), $collection->getId()) ->setPayload($response->output($collection, $this->getResponseModel())); + $publisherForDatabase->enqueue(new DatabaseMessage( + project: $queueForEvents->getProject(), + user: $queueForEvents->getUser(), + type: DATABASE_TYPE_DELETE_COLLECTION, + database: $database, + collection: $this->isCollectionsAPI() ? $collection : null, + table: $this->isCollectionsAPI() ? null : $collection, + events: Event::generateEvents($queueForEvents->getEvent(), $queueForEvents->getParams()), + )); + $response->noContent(); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php index 7e073c95d4..6c13a5c33c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Indexes; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Message\Database as DatabaseMessage; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -78,13 +79,13 @@ class Create extends Action ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $key, string $type, array $attributes, array $orders, array $lengths, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $key, string $type, array $attributes, array $orders, array $lengths, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void { $db = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -228,20 +229,6 @@ class Create extends Action $dbForProject->purgeCachedDocument('database_' . $db->getSequence(), $collectionId); - $queueForDatabase - ->setType(DATABASE_TYPE_CREATE_INDEX) - ->setDatabase($db); - - if ($this->isCollectionsAPI()) { - $queueForDatabase - ->setCollection($collection) - ->setDocument($index); - } else { - $queueForDatabase - ->setTable($collection) - ->setRow($index); - } - $queueForEvents ->setContext('database', $db) ->setParam('databaseId', $databaseId) @@ -250,6 +237,18 @@ class Create extends Action ->setParam('tableId', $collection->getId()) ->setContext($this->getCollectionsEventsContext(), $collection); + $publisherForDatabase->enqueue(new DatabaseMessage( + project: $queueForEvents->getProject(), + user: $queueForEvents->getUser(), + type: DATABASE_TYPE_CREATE_INDEX, + database: $db, + collection: $this->isCollectionsAPI() ? $collection : null, + document: $this->isCollectionsAPI() ? $index : null, + table: $this->isCollectionsAPI() ? null : $collection, + row: $this->isCollectionsAPI() ? null : $index, + events: Event::generateEvents($queueForEvents->getEvent(), $queueForEvents->getParams()), + )); + $response ->setStatusCode(SwooleResponse::STATUS_CODE_ACCEPTED) ->dynamic($index, $this->getResponseModel()); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Delete.php index dea62bfc16..82cada6e0d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Delete.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Indexes; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Message\Database as DatabaseMessage; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -69,13 +70,13 @@ class Delete extends Action ->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Index Key.', false, ['dbForProject']) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $key, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $key, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents, Authorization $authorization): void { $db = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -103,20 +104,6 @@ class Delete extends Action $dbForProject->purgeCachedDocument('database_' . $db->getSequence(), $collectionId); - $queueForDatabase - ->setType(DATABASE_TYPE_DELETE_INDEX) - ->setDatabase($db); - - if ($this->isCollectionsAPI()) { - $queueForDatabase - ->setCollection($collection) - ->setDocument($index); - } else { - $queueForDatabase - ->setTable($collection) - ->setRow($index); - } - $queueForEvents ->setContext('database', $db) ->setParam('databaseId', $databaseId) @@ -126,6 +113,18 @@ class Delete extends Action ->setContext($this->getCollectionsEventsContext(), $collection) ->setPayload($response->output($index, $this->getResponseModel())); + $publisherForDatabase->enqueue(new DatabaseMessage( + project: $queueForEvents->getProject(), + user: $queueForEvents->getUser(), + type: DATABASE_TYPE_DELETE_INDEX, + database: $db, + collection: $this->isCollectionsAPI() ? $collection : null, + document: $this->isCollectionsAPI() ? $index : null, + table: $this->isCollectionsAPI() ? null : $collection, + row: $this->isCollectionsAPI() ? null : $index, + events: Event::generateEvents($queueForEvents->getEvent(), $queueForEvents->getParams()), + )); + $response->noContent(); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Delete.php index 1046d7e566..058c48d68f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Delete.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases; -use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; +use Appwrite\Event\Message\Database as DatabaseMessage; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -58,12 +59,12 @@ class Delete extends Action ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->callback($this->action(...)); } - public function action(string $databaseId, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents): void + public function action(string $databaseId, UtopiaResponse $response, Database $dbForProject, DatabasePublisher $publisherForDatabase, Event $queueForEvents): void { $database = $dbForProject->getDocument('databases', $databaseId); @@ -78,14 +79,18 @@ class Delete extends Action $dbForProject->purgeCachedDocument('databases', $database->getId()); $dbForProject->purgeCachedCollection('databases_' . $database->getSequence()); - $queueForDatabase - ->setType(DATABASE_TYPE_DELETE_DATABASE) - ->setDatabase($database); - $queueForEvents ->setParam('databaseId', $database->getId()) ->setPayload($response->output($database, UtopiaResponse::MODEL_DATABASE)); + $publisherForDatabase->enqueue(new DatabaseMessage( + project: $queueForEvents->getProject(), + user: $queueForEvents->getUser(), + type: DATABASE_TYPE_DELETE_DATABASE, + database: $database, + events: Event::generateEvents($queueForEvents->getEvent(), $queueForEvents->getParams()), + )); + $response->noContent(); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Delete.php index d698b40203..043f74998d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Delete.php @@ -54,7 +54,7 @@ class Delete extends CollectionDelete ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php index dc3ce34605..637255f16a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php @@ -65,7 +65,7 @@ class Create extends IndexCreate ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Delete.php index d4464f171d..1e3c012b4f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Delete.php @@ -59,7 +59,7 @@ class Delete extends IndexDelete ->param('key', '', new Key(), 'Index Key.') ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php index 1708656c98..5e63ab8a7f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php @@ -48,7 +48,7 @@ class Delete extends DatabaseDelete ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('usage') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Delete.php index 7873d369e6..70dc8430f2 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Delete.php @@ -48,7 +48,7 @@ class Delete extends DatabaseDelete ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->callback($this->action(...)); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/BigInt/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/BigInt/Create.php index 1d32c6bad9..9d882e09a6 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/BigInt/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/BigInt/Create.php @@ -62,7 +62,7 @@ class Create extends BigIntCreate ->param('array', false, new Boolean(), 'Is column an array?', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Create.php index 10cd65bc98..334c8b5124 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Create.php @@ -59,7 +59,7 @@ class Create extends BooleanCreate ->param('array', false, new Boolean(), 'Is column an array?', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Create.php index 64e73e310e..922e071f35 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Create.php @@ -60,7 +60,7 @@ class Create extends DatetimeCreate ->param('array', false, new Boolean(), 'Is column an array?', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Delete.php index f4d606637d..8e0abf211f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Delete.php @@ -57,7 +57,7 @@ class Delete extends AttributesDelete ->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Column Key.', false, ['dbForProject']) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php index d0b2ed3e4b..072e334b4b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php @@ -60,7 +60,7 @@ class Create extends EmailCreate ->param('array', false, new Boolean(), 'Is column an array?', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php index e58ae115fc..9d24f310bd 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php @@ -62,7 +62,7 @@ class Create extends EnumCreate ->param('array', false, new Boolean(), 'Is column an array?', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php index b8e81820aa..d68b3a4921 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php @@ -62,7 +62,7 @@ class Create extends FloatCreate ->param('array', false, new Boolean(), 'Is column an array?', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php index c2faec9aeb..ff5828e749 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php @@ -60,7 +60,7 @@ class Create extends IPCreate ->param('array', false, new Boolean(), 'Is column an array?', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php index 1a965c19dc..dec399cdb2 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php @@ -62,7 +62,7 @@ class Create extends IntegerCreate ->param('array', false, new Boolean(), 'Is column an array?', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php index c2f480d5d0..71548c74da 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php @@ -59,7 +59,7 @@ class Create extends LineCreate ->param('default', null, new Nullable(new Spatial(Database::VAR_LINESTRING)), 'Default value for column when not provided, two-dimensional array of coordinate pairs, [[longitude, latitude], [longitude, latitude], …], listing the vertices of the line in order. Cannot be set when column is required.', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php index 8e2dbd911d..ec0f633400 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php @@ -60,7 +60,7 @@ class Create extends LongtextCreate ->param('encrypt', false, new Boolean(), 'Toggle encryption for the column. Encryption enhances security by not storing any plain text values in the database. However, encrypted columns cannot be queried.', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('plan') ->inject('authorization') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php index f0b8099f02..2728caa58f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php @@ -60,7 +60,7 @@ class Create extends MediumtextCreate ->param('encrypt', false, new Boolean(), 'Toggle encryption for the column. Encryption enhances security by not storing any plain text values in the database. However, encrypted columns cannot be queried.', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('plan') ->inject('authorization') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php index 138ee482c3..601e19299b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php @@ -59,7 +59,7 @@ class Create extends PointCreate ->param('default', null, new Nullable(new Spatial(Database::VAR_POINT)), 'Default value for column when not provided, array of two numbers [longitude, latitude], representing a single coordinate. Cannot be set when column is required.', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php index a03a34f310..36972d5da2 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php @@ -59,7 +59,7 @@ class Create extends PolygonCreate ->param('default', null, new Nullable(new Spatial(Database::VAR_POLYGON)), 'Default value for column when not provided, three-dimensional array where the outer array holds one or more linear rings, [[[longitude, latitude], …], …], the first ring is the exterior boundary, any additional rings are interior holes, and each ring must start and end with the same coordinate pair. Cannot be set when column is required.', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php index 87544926fe..414cf03b3d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php @@ -71,7 +71,7 @@ class Create extends RelationshipCreate ], true), 'Constraints option', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php index 17f60f61c1..8151b3e8da 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php @@ -69,7 +69,7 @@ class Create extends StringCreate ->param('encrypt', false, new Boolean(), 'Toggle encryption for the column. Encryption enhances security by not storing any plain text values in the database. However, encrypted columns cannot be queried.', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('plan') ->inject('authorization') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php index a8fde7d271..bffdc96001 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php @@ -60,7 +60,7 @@ class Create extends TextCreate ->param('encrypt', false, new Boolean(), 'Toggle encryption for the column. Encryption enhances security by not storing any plain text values in the database. However, encrypted columns cannot be queried.', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('plan') ->inject('authorization') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php index 19b33594b7..2edf4a62f6 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php @@ -60,7 +60,7 @@ class Create extends URLCreate ->param('array', false, new Boolean(), 'Is column an array?', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php index 7595f16c45..307a1fd5e3 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php @@ -63,7 +63,7 @@ class Create extends VarcharCreate ->param('encrypt', false, new Boolean(), 'Toggle encryption for the column. Encryption enhances security by not storing any plain text values in the database. However, encrypted columns cannot be queried.', true) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('plan') ->inject('authorization') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php index 97c5465fe3..3a6d6666f2 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php @@ -55,7 +55,7 @@ class Delete extends CollectionDelete ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php index d377bed184..77496fea59 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php @@ -65,7 +65,7 @@ class Create extends IndexCreate ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Delete.php index ca7e4fc2da..6cd5cfe78f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Delete.php @@ -60,7 +60,7 @@ class Delete extends IndexDelete ->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Index Key.', false, ['dbForProject']) ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Delete.php index f1188868aa..6ee83b2530 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Delete.php @@ -54,7 +54,7 @@ class Delete extends CollectionDelete ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Create.php index a535dd5724..bba7ee0579 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Create.php @@ -65,7 +65,7 @@ class Create extends IndexCreate ->inject('response') ->inject('dbForProject') ->inject('getDatabasesDB') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Delete.php index 5c7fc47ee0..67e13dd26a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Delete.php @@ -59,7 +59,7 @@ class Delete extends IndexDelete ->param('key', '', new Key(), 'Index Key.') ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php index c9d36904a9..a33eedccd5 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php @@ -47,7 +47,7 @@ class Delete extends DatabaseDelete ->param('databaseId', '', new UID(), 'Database ID.') ->inject('response') ->inject('dbForProject') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('queueForEvents') ->inject('usage') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php index 39902aea53..ee8494b382 100644 --- a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php +++ b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Databases\Workers; +use Appwrite\Event\Message\Database as DatabaseMessage; use Appwrite\Event\Realtime; use Exception; use Utopia\Console; @@ -60,10 +61,11 @@ class Databases extends Action throw new Exception('Missing payload'); } - $type = $payload['type']; - $document = new Document($payload['row'] ?? $payload['document'] ?? []); - $collection = new Document($payload['table'] ?? $payload['collection'] ?? []); - $database = new Document($payload['database'] ?? []); + $databaseMessage = DatabaseMessage::fromArray($payload); + $type = $databaseMessage->type; + $document = $databaseMessage->row ?? $databaseMessage->document ?? new Document(); + $collection = $databaseMessage->table ?? $databaseMessage->collection ?? new Document(); + $database = $databaseMessage->database ?? new Document(); /** * @var Database $dbForDatabases */ diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Databases/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Databases/Get.php index 213bd8b36c..3bd42b64c6 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Databases/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Databases/Get.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Databases; -use Appwrite\Event\Database; +use Appwrite\Event\Publisher\Database; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -10,6 +10,7 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Document; +use Utopia\Queue\Queue; use Utopia\Validator\Integer; use Utopia\Validator\Text; @@ -44,15 +45,15 @@ class Get extends Base )) ->param('name', 'database_db_main', new Text(256), 'Queue name for which to check the queue size', true) ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('response') ->callback($this->action(...)); } - public function action(string $name, int|string $threshold, Database $queueForDatabase, Response $response): void + public function action(string $name, int|string $threshold, Database $publisherForDatabase, Response $response): void { $threshold = (int) $threshold; - $size = $queueForDatabase->setQueue($name)->getSize(); + $size = $publisherForDatabase->getSize(queue: new Queue($name)); $this->assertQueueThreshold($size, $threshold); diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php index 5aa29fcaba..d3b760d01b 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php @@ -2,11 +2,11 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Failed; -use Appwrite\Event\Database; use Appwrite\Event\Event; use Appwrite\Event\Publisher\Audit; use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Event\Publisher\Certificate; +use Appwrite\Event\Publisher\Database as DatabasePublisher; use Appwrite\Event\Publisher\Delete as DeletePublisher; use Appwrite\Event\Publisher\Func as FunctionPublisher; use Appwrite\Event\Publisher\Mail as MailPublisher; @@ -74,7 +74,7 @@ class Get extends Base ]), 'The name of the queue') ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) ->inject('response') - ->inject('queueForDatabase') + ->inject('publisherForDatabase') ->inject('publisherForDeletes') ->inject('publisherForAudits') ->inject('publisherForMails') @@ -94,7 +94,7 @@ class Get extends Base string $name, int|string $threshold, Response $response, - Database $queueForDatabase, + DatabasePublisher $publisherForDatabase, DeletePublisher $publisherForDeletes, Audit $publisherForAudits, MailPublisher $publisherForMails, @@ -111,7 +111,7 @@ class Get extends Base $threshold = (int) $threshold; $queue = match ($name) { - System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME) => $queueForDatabase, + System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME) => $publisherForDatabase, System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME) => $publisherForDeletes, System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME) => $publisherForAudits, System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME) => $publisherForMails, From 75587e629ee801c4a8088d715e26c58711635ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 15 May 2026 10:23:22 +0200 Subject: [PATCH 25/47] Improve quality of org scopes --- app/config/scopes/organization.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/config/scopes/organization.php b/app/config/scopes/organization.php index 228a1437f2..d74452f259 100644 --- a/app/config/scopes/organization.php +++ b/app/config/scopes/organization.php @@ -4,17 +4,23 @@ return [ "projects.read" => [ - "description" => 'Access to read organization\'s projects', + "description" => 'Access to read organization projects', + "category" => "Projects", ], "projects.write" => [ "description" => - "Access to create, update, and delete projects in organization", + "Access to create, update, and delete organization projects", + "category" => "Projects", ], "devKeys.read" => [ "description" => 'Access to read project\'s development keys', + "category" => "Other", + "deprecated" => true, ], "devKeys.write" => [ "description" => "Access to create, update, and delete project\'s development keys", + "category" => "Other", + "deprecated" => true, ], ]; From 8d8a0e883e2ff47451c8a0453849ddc06cd91ab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 15 May 2026 10:23:32 +0200 Subject: [PATCH 26/47] Add org scopes get console endpoint --- .../Http/Scopes/Organization/XList.php | 69 +++++++++++++++++++ .../Http/Scopes/{Key => Project}/XList.php | 2 +- .../Modules/Console/Services/Http.php | 4 +- 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Console/Http/Scopes/Organization/XList.php rename src/Appwrite/Platform/Modules/Console/Http/Scopes/{Key => Project}/XList.php (96%) diff --git a/src/Appwrite/Platform/Modules/Console/Http/Scopes/Organization/XList.php b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Organization/XList.php new file mode 100644 index 0000000000..4f88df6948 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Organization/XList.php @@ -0,0 +1,69 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/console/scopes/organization') + ->desc('List organization scopes') + ->groups(['api']) + ->label('scope', 'public') + ->label('sdk', new Method( + namespace: 'console', + group: 'console', + name: 'listOrganizationScopes', + description: 'List all scopes available for organization API keys, along with a description for each scope.', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_CONSOLE_KEY_SCOPE_LIST, + ) + ], + contentType: ContentType::JSON + )) + ->inject('response') + ->callback($this->action(...)); + } + + public function action(Response $response): void + { + $scopesConfig = Config::getParam('organizationScopes', []); + + $scopes = []; + foreach ($scopesConfig as $scopeId => $scope) { + $scopes[] = new Document([ + '$id' => $scopeId, + 'description' => $scope['description'] ?? '', + 'category' => $scope['category'] ?? '', + 'deprecated' => $scope['deprecated'] ?? false, + ]); + } + + $response->dynamic(new Document([ + 'total' => \count($scopes), + 'scopes' => $scopes, + ]), Response::MODEL_CONSOLE_KEY_SCOPE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Project/XList.php similarity index 96% rename from src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php rename to src/Appwrite/Platform/Modules/Console/Http/Scopes/Project/XList.php index d951e93886..3e6eceb26c 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php +++ b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Project/XList.php @@ -1,6 +1,6 @@ addAction(GetVariables::getName(), new GetVariables()); $this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers()); $this->addAction(ListKeyScopes::getName(), new ListKeyScopes()); + $this->addAction(ListOrganizationScopes::getName(), new ListOrganizationScopes()); $this->addAction(CreateAssistantQuery::getName(), new CreateAssistantQuery()); $this->addAction(GetResourceAvailability::getName(), new GetResourceAvailability()); From e93a2c710105421e8b1d8be72b865baeddd0b841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 15 May 2026 10:23:40 +0200 Subject: [PATCH 27/47] Add tests for org scopes console get endpoint --- .../Console/ConsoleConsoleClientTest.php | 43 +++++++++++++++++++ .../Console/ConsoleCustomServerTest.php | 31 +++++++++++++ 2 files changed, 74 insertions(+) diff --git a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php index c8f921f2ec..ccb7e8bdf7 100644 --- a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php +++ b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php @@ -175,4 +175,47 @@ class ConsoleConsoleClientTest extends Scope $this->assertNotNull($usersRead); $this->assertEquals('Access to read users', $usersRead['description']); } + + public function testListOrganizationScopes(): void + { + $response = $this->client->call(Client::METHOD_GET, '/console/scopes/organization', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['total']); + $this->assertIsArray($response['body']['scopes']); + $this->assertGreaterThan(0, $response['body']['total']); + $this->assertEquals($response['body']['total'], \count($response['body']['scopes'])); + + $scopeIds = \array_column($response['body']['scopes'], '$id'); + + // Well-known scopes must be present + $this->assertContains('projects.read', $scopeIds); + $this->assertContains('projects.write', $scopeIds); + + // Every scope has the expected shape + foreach ($response['body']['scopes'] as $scope) { + $this->assertArrayHasKey('$id', $scope); + $this->assertIsString($scope['$id']); + $this->assertNotEmpty($scope['$id']); + $this->assertArrayHasKey('description', $scope); + $this->assertIsString($scope['description']); + $this->assertNotEmpty($scope['description']); + $this->assertArrayHasKey('deprecated', $scope); + $this->assertIsBool($scope['deprecated']); + } + + // A specific scope has the expected description + $projectsRead = null; + foreach ($response['body']['scopes'] as $scope) { + if ($scope['$id'] === 'projects.read') { + $projectsRead = $scope; + break; + } + } + $this->assertNotNull($projectsRead); + $this->assertEquals('Access to read organization projects', $projectsRead['description']); + } } diff --git a/tests/e2e/Services/Console/ConsoleCustomServerTest.php b/tests/e2e/Services/Console/ConsoleCustomServerTest.php index f06011843f..e7a95fd357 100644 --- a/tests/e2e/Services/Console/ConsoleCustomServerTest.php +++ b/tests/e2e/Services/Console/ConsoleCustomServerTest.php @@ -74,4 +74,35 @@ class ConsoleCustomServerTest extends Scope $this->assertArrayHasKey('deprecated', $usersRead); $this->assertIsBool($usersRead['deprecated']); } + + public function testListOrganizationScopes(): void + { + // Public endpoint: must succeed without admin authentication. Drop the + // headers from getHeaders() and only pass project + content-type. + $response = $this->client->call(Client::METHOD_GET, '/console/scopes/organization', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['total']); + $this->assertIsArray($response['body']['scopes']); + $this->assertGreaterThan(0, $response['body']['total']); + + $scopeIds = \array_column($response['body']['scopes'], '$id'); + $this->assertContains('projects.read', $scopeIds); + + $projectsRead = null; + foreach ($response['body']['scopes'] as $scope) { + if ($scope['$id'] === 'projects.read') { + $projectsRead = $scope; + break; + } + } + $this->assertNotNull($projectsRead); + $this->assertIsString($projectsRead['description']); + $this->assertNotEmpty($projectsRead['description']); + $this->assertArrayHasKey('deprecated', $projectsRead); + $this->assertIsBool($projectsRead['deprecated']); + } } From e9b026647aef26ad5ebe3273a6d7108111b2fe2c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 15 May 2026 13:57:10 +0530 Subject: [PATCH 28/47] chore: retrigger ci From bf3ac31157858d9f08f97d79974602bef61da7de Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 15 May 2026 13:59:10 +0530 Subject: [PATCH 29/47] chore: retrigger ci From 88e7fdbd9e9d7b4299c9c1e33152307d36b331fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 15 May 2026 10:55:13 +0200 Subject: [PATCH 30/47] Improve tests --- tests/e2e/Services/Console/ConsoleConsoleClientTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php index ccb7e8bdf7..43daba470b 100644 --- a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php +++ b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php @@ -205,6 +205,8 @@ class ConsoleConsoleClientTest extends Scope $this->assertNotEmpty($scope['description']); $this->assertArrayHasKey('deprecated', $scope); $this->assertIsBool($scope['deprecated']); + $this->assertArrayHasKey('category', $scope); + $this->assertIsString($scope['category']); } // A specific scope has the expected description From c5123529ee81be645ac570758422392025535a1f Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Fri, 15 May 2026 13:54:24 +0400 Subject: [PATCH 31/47] Update graphql-php to 15.32 and fix storage tests - Bump graphql-php to 15.32 in composer.json and update composer.lock - Extend Storage/Files/Create with mergeUploadMetadata and pass path and deviceForFiles into the lock callback - Remove obsolete type hints in Storage/Files/Preview/Get - Update Storage tests to tolerate S3-backed signatures --- composer.json | 2 +- composer.lock | 246 +++++----- .../Storage/Http/Buckets/Files/Create.php | 441 +++++++++--------- .../Http/Buckets/Files/Preview/Get.php | 2 - tests/e2e/Services/Storage/StorageBase.php | 10 +- 5 files changed, 369 insertions(+), 332 deletions(-) diff --git a/composer.json b/composer.json index a5b00a8b8b..ca1919a062 100644 --- a/composer.json +++ b/composer.json @@ -94,7 +94,7 @@ "chillerlan/php-qrcode": "4.3.*", "adhocore/jwt": "1.1.*", "spomky-labs/otphp": "11.*", - "webonyx/graphql-php": "15.31.*", + "webonyx/graphql-php": "15.32.*", "league/csv": "9.14.*", "enshrined/svg-sanitize": "0.22.*" }, diff --git a/composer.lock b/composer.lock index 4d920f1eee..43f3dbf228 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fab1d5b01931f0e2545c36c50b3963b7", + "content-hash": "acb2a97c8a37ef8145b70cc385b1091e", "packages": [ { "name": "adhocore/jwt", @@ -2641,16 +2641,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -2663,7 +2663,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -2688,7 +2688,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -2699,25 +2699,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/http-client", - "version": "v7.4.8", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "01933e626c3de76bea1e22641e205e78f6a34342" + "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/01933e626c3de76bea1e22641e205e78f6a34342", - "reference": "01933e626c3de76bea1e22641e205e78f6a34342", + "url": "https://api.github.com/repos/symfony/http-client/zipball/7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6", + "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6", "shasum": "" }, "require": { @@ -2785,7 +2789,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.8" + "source": "https://github.com/symfony/http-client/tree/v7.4.9" }, "funding": [ { @@ -2805,20 +2809,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T12:55:43+00:00" + "time": "2026-04-29T13:25:15+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "75d7043853a42837e68111812f4d964b01e5101c" + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", - "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", "shasum": "" }, "require": { @@ -2831,7 +2835,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -2867,7 +2871,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" }, "funding": [ { @@ -2878,12 +2882,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-29T11:18:49+00:00" + "time": "2026-03-06T13:17:50+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -3212,16 +3220,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -3239,7 +3247,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -3275,7 +3283,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -3295,7 +3303,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "tbachert/spi", @@ -3606,16 +3614,16 @@ }, { "name": "utopia-php/cache", - "version": "1.0.1", + "version": "1.0.3", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "05ceba981436a4022553f7aaa2a05fa049d0f71c" + "reference": "ef52a04e8bfa314c621e3d3326ffcf50db3dfdfa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/05ceba981436a4022553f7aaa2a05fa049d0f71c", - "reference": "05ceba981436a4022553f7aaa2a05fa049d0f71c", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/ef52a04e8bfa314c621e3d3326ffcf50db3dfdfa", + "reference": "ef52a04e8bfa314c621e3d3326ffcf50db3dfdfa", "shasum": "" }, "require": { @@ -3652,9 +3660,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/1.0.1" + "source": "https://github.com/utopia-php/cache/tree/1.0.3" }, - "time": "2026-03-12T03:39:09+00:00" + "time": "2026-05-11T11:02:13+00:00" }, { "name": "utopia-php/cli", @@ -3850,22 +3858,23 @@ }, { "name": "utopia-php/database", - "version": "5.4.1", + "version": "5.7.0", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "688d9422b5ff42ac2ecc29397d94891cfd772e93" + "reference": "eb35e68f7f90932d5a60bd72e70158ae7a4e0511" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/688d9422b5ff42ac2ecc29397d94891cfd772e93", - "reference": "688d9422b5ff42ac2ecc29397d94891cfd772e93", + "url": "https://api.github.com/repos/utopia-php/database/zipball/eb35e68f7f90932d5a60bd72e70158ae7a4e0511", + "reference": "eb35e68f7f90932d5a60bd72e70158ae7a4e0511", "shasum": "" }, "require": { "ext-mbstring": "*", "ext-mongodb": "*", "ext-pdo": "*", + "ext-redis": "*", "php": ">=8.4", "utopia-php/cache": "1.*", "utopia-php/console": "0.1.*", @@ -3903,9 +3912,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.4.1" + "source": "https://github.com/utopia-php/database/tree/5.7.0" }, - "time": "2026-04-29T07:32:59+00:00" + "time": "2026-05-06T01:04:08+00:00" }, { "name": "utopia-php/detector", @@ -4171,22 +4180,21 @@ }, { "name": "utopia-php/emails", - "version": "0.6.9", + "version": "0.6.10", "source": { "type": "git", "url": "https://github.com/utopia-php/emails.git", - "reference": "3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf" + "reference": "2e397754ce68c2ba918564b9f31d9923c0a90429" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/emails/zipball/3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf", - "reference": "3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf", + "url": "https://api.github.com/repos/utopia-php/emails/zipball/2e397754ce68c2ba918564b9f31d9923c0a90429", + "reference": "2e397754ce68c2ba918564b9f31d9923c0a90429", "shasum": "" }, "require": { "php": ">=8.0", "utopia-php/domains": "^1.0", - "utopia-php/fetch": "^0.5", "utopia-php/validators": "0.*" }, "require-dev": { @@ -4194,7 +4202,8 @@ "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^9.3", "utopia-php/cli": "^0.22", - "utopia-php/console": "0.*" + "utopia-php/console": "0.*", + "utopia-php/fetch": "^1.1" }, "type": "library", "autoload": { @@ -4226,9 +4235,9 @@ ], "support": { "issues": "https://github.com/utopia-php/emails/issues", - "source": "https://github.com/utopia-php/emails/tree/0.6.9" + "source": "https://github.com/utopia-php/emails/tree/0.6.10" }, - "time": "2026-03-14T13:52:56+00:00" + "time": "2026-05-08T10:16:22+00:00" }, { "name": "utopia-php/fetch", @@ -4552,16 +4561,16 @@ }, { "name": "utopia-php/messaging", - "version": "0.22.0", + "version": "0.22.2", "source": { "type": "git", "url": "https://github.com/utopia-php/messaging.git", - "reference": "a6ac04fd204fb6a16bf8c75a84d0b9fc10aa5030" + "reference": "f99feceab575243f3a86ee2e90cd1a6407805def" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/messaging/zipball/a6ac04fd204fb6a16bf8c75a84d0b9fc10aa5030", - "reference": "a6ac04fd204fb6a16bf8c75a84d0b9fc10aa5030", + "url": "https://api.github.com/repos/utopia-php/messaging/zipball/f99feceab575243f3a86ee2e90cd1a6407805def", + "reference": "f99feceab575243f3a86ee2e90cd1a6407805def", "shasum": "" }, "require": { @@ -4597,22 +4606,22 @@ ], "support": { "issues": "https://github.com/utopia-php/messaging/issues", - "source": "https://github.com/utopia-php/messaging/tree/0.22.0" + "source": "https://github.com/utopia-php/messaging/tree/0.22.2" }, - "time": "2026-04-02T04:09:19+00:00" + "time": "2026-05-14T08:51:26+00:00" }, { "name": "utopia-php/migration", - "version": "1.9.5", + "version": "1.9.7", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "952a4dfe232702f80e45c35129466a8d8cb4c599" + "reference": "81b608a6871f56b70496803d12010823300aab6e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/952a4dfe232702f80e45c35129466a8d8cb4c599", - "reference": "952a4dfe232702f80e45c35129466a8d8cb4c599", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/81b608a6871f56b70496803d12010823300aab6e", + "reference": "81b608a6871f56b70496803d12010823300aab6e", "shasum": "" }, "require": { @@ -4652,9 +4661,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.9.5" + "source": "https://github.com/utopia-php/migration/tree/1.9.7" }, - "time": "2026-04-29T11:19:13+00:00" + "time": "2026-05-05T07:18:48+00:00" }, { "name": "utopia-php/mongo", @@ -5093,16 +5102,16 @@ }, { "name": "utopia-php/storage", - "version": "2.0.1", + "version": "2.0.3", "source": { "type": "git", "url": "https://github.com/utopia-php/storage.git", - "reference": "8a2e3a86fd01aaed675884146665308c2122264e" + "reference": "37129cf0bfcc03210172000e4388d4d3495ae013" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/storage/zipball/8a2e3a86fd01aaed675884146665308c2122264e", - "reference": "8a2e3a86fd01aaed675884146665308c2122264e", + "url": "https://api.github.com/repos/utopia-php/storage/zipball/37129cf0bfcc03210172000e4388d4d3495ae013", + "reference": "37129cf0bfcc03210172000e4388d4d3495ae013", "shasum": "" }, "require": { @@ -5139,22 +5148,22 @@ ], "support": { "issues": "https://github.com/utopia-php/storage/issues", - "source": "https://github.com/utopia-php/storage/tree/2.0.1" + "source": "https://github.com/utopia-php/storage/tree/2.0.3" }, - "time": "2026-04-29T09:05:48+00:00" + "time": "2026-05-15T09:42:32+00:00" }, { "name": "utopia-php/system", - "version": "0.10.1", + "version": "0.10.2", "source": { "type": "git", "url": "https://github.com/utopia-php/system.git", - "reference": "7c1669533bb9c285de19191270c8c1439161a78a" + "reference": "04229a822b147c1abaf1a92fb42c2d7aad4625df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/system/zipball/7c1669533bb9c285de19191270c8c1439161a78a", - "reference": "7c1669533bb9c285de19191270c8c1439161a78a", + "url": "https://api.github.com/repos/utopia-php/system/zipball/04229a822b147c1abaf1a92fb42c2d7aad4625df", + "reference": "04229a822b147c1abaf1a92fb42c2d7aad4625df", "shasum": "" }, "require": { @@ -5195,9 +5204,9 @@ ], "support": { "issues": "https://github.com/utopia-php/system/issues", - "source": "https://github.com/utopia-php/system/tree/0.10.1" + "source": "https://github.com/utopia-php/system/tree/0.10.2" }, - "time": "2026-03-15T21:07:41+00:00" + "time": "2026-05-05T14:33:41+00:00" }, { "name": "utopia-php/telemetry", @@ -5256,16 +5265,16 @@ }, { "name": "utopia-php/validators", - "version": "0.2.2", + "version": "0.2.3", "source": { "type": "git", "url": "https://github.com/utopia-php/validators.git", - "reference": "5d7d494e64457cd4eb67fdcfd9481f2c89796aa6" + "reference": "9770269c8ed8e6909934965fa8722103c7434c23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/validators/zipball/5d7d494e64457cd4eb67fdcfd9481f2c89796aa6", - "reference": "5d7d494e64457cd4eb67fdcfd9481f2c89796aa6", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/9770269c8ed8e6909934965fa8722103c7434c23", + "reference": "9770269c8ed8e6909934965fa8722103c7434c23", "shasum": "" }, "require": { @@ -5295,9 +5304,9 @@ ], "support": { "issues": "https://github.com/utopia-php/validators/issues", - "source": "https://github.com/utopia-php/validators/tree/0.2.2" + "source": "https://github.com/utopia-php/validators/tree/0.2.3" }, - "time": "2026-04-27T16:30:24+00:00" + "time": "2026-05-14T08:05:44+00:00" }, { "name": "utopia-php/vcs", @@ -5457,16 +5466,16 @@ }, { "name": "webonyx/graphql-php", - "version": "v15.31.5", + "version": "v15.32.3", "source": { "type": "git", "url": "https://github.com/webonyx/graphql-php.git", - "reference": "089c4ef7e112df85788cfe06596278a8f99f4aa9" + "reference": "993bf0bea17f870412ad8a90f60c41cb8d5f1145" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/089c4ef7e112df85788cfe06596278a8f99f4aa9", - "reference": "089c4ef7e112df85788cfe06596278a8f99f4aa9", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/993bf0bea17f870412ad8a90f60c41cb8d5f1145", + "reference": "993bf0bea17f870412ad8a90f60c41cb8d5f1145", "shasum": "" }, "require": { @@ -5475,16 +5484,16 @@ "php": "^7.4 || ^8" }, "require-dev": { - "amphp/amp": "^2.6", - "amphp/http-server": "^2.1", + "amphp/amp": "^2.6 || ^3", + "amphp/http-server": "^2.1 || ^3", "dms/phpunit-arraysubset-asserts": "dev-master", "ergebnis/composer-normalize": "^2.28", - "friendsofphp/php-cs-fixer": "3.94.2", + "friendsofphp/php-cs-fixer": "3.95.1", "mll-lab/php-cs-fixer-config": "5.13.0", "nyholm/psr7": "^1.5", "phpbench/phpbench": "^1.2", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "2.1.46", + "phpstan/phpstan": "2.1.51", "phpstan/phpstan-phpunit": "2.0.16", "phpstan/phpstan-strict-rules": "2.0.10", "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", @@ -5498,6 +5507,7 @@ "ticketswap/phpstan-error-formatter": "1.3.0" }, "suggest": { + "amphp/amp": "To leverage async resolving on AMPHP platform (v3 with AmpFutureAdapter, v2 with AmpPromiseAdapter)", "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", "psr/http-message": "To use standard GraphQL server", "react/promise": "To leverage async resolving on React PHP platform" @@ -5520,7 +5530,7 @@ ], "support": { "issues": "https://github.com/webonyx/graphql-php/issues", - "source": "https://github.com/webonyx/graphql-php/tree/v15.31.5" + "source": "https://github.com/webonyx/graphql-php/tree/v15.32.3" }, "funding": [ { @@ -5532,22 +5542,22 @@ "type": "open_collective" } ], - "time": "2026-04-11T18:06:15+00:00" + "time": "2026-04-24T13:49:35+00:00" } ], "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.25.1", + "version": "1.29.5", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "f21a556b9acdbf75bbdcdc90a078af641646eade" + "reference": "e670edcdfb9ffcec36125b1eb3e4473dce30b620" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/f21a556b9acdbf75bbdcdc90a078af641646eade", - "reference": "f21a556b9acdbf75bbdcdc90a078af641646eade", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/e670edcdfb9ffcec36125b1eb3e4473dce30b620", + "reference": "e670edcdfb9ffcec36125b1eb3e4473dce30b620", "shasum": "" }, "require": { @@ -5583,9 +5593,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.25.1" + "source": "https://github.com/appwrite/sdk-generator/tree/1.29.5" }, - "time": "2026-04-28T11:12:22+00:00" + "time": "2026-05-15T06:49:05+00:00" }, { "name": "brianium/paratest", @@ -6692,16 +6702,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.23", + "version": "12.5.25", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969" + "reference": "792c2980442dfce319226b88fa845b8b6de3b333" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", - "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/792c2980442dfce319226b88fa845b8b6de3b333", + "reference": "792c2980442dfce319226b88fa845b8b6de3b333", "shasum": "" }, "require": { @@ -6770,7 +6780,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.23" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.25" }, "funding": [ { @@ -6778,7 +6788,7 @@ "type": "other" } ], - "time": "2026-04-18T06:12:49+00:00" + "time": "2026-05-13T03:56:57+00:00" }, { "name": "sebastian/cli-parser", @@ -7763,16 +7773,16 @@ }, { "name": "symfony/console", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7" + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7", - "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "url": "https://api.github.com/repos/symfony/console/zipball/3156577f46a38aa1b9323aad223de7a9cd426782", + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782", "shasum": "" }, "require": { @@ -7829,7 +7839,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.8" + "source": "https://github.com/symfony/console/tree/v8.0.11" }, "funding": [ { @@ -7849,7 +7859,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/polyfill-ctype", @@ -8183,16 +8193,16 @@ }, { "name": "symfony/process", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", - "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "url": "https://api.github.com/repos/symfony/process/zipball/26d89e459f037d2873300605d0a07e7a8ef84db0", + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0", "shasum": "" }, "require": { @@ -8224,7 +8234,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.8" + "source": "https://github.com/symfony/process/tree/v8.0.11" }, "funding": [ { @@ -8244,20 +8254,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-11T16:56:32+00:00" }, { "name": "symfony/string", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", - "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", + "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff", + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff", "shasum": "" }, "require": { @@ -8314,7 +8324,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.8" + "source": "https://github.com/symfony/string/tree/v8.0.11" }, "funding": [ { @@ -8334,7 +8344,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "textalk/websocket", diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php index c26e30474f..e9f17fb801 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php @@ -247,8 +247,25 @@ class Create extends Action $metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)]; $completed = false; + $mergeUploadMetadata = function (array $stored, array $current): array { + $merged = \array_merge($stored, $current); + + if (isset($stored['parts']) || isset($current['parts'])) { + $parts = $stored['parts'] ?? []; + foreach (($current['parts'] ?? []) as $part => $value) { + $parts[(int) $part] = $value; + } + \ksort($parts); + + $merged['parts'] = $parts; + $merged['chunks'] = \count($parts); + } + + return $merged; + }; + try { - $lock->withLock(function () use ($bucket, &$chunks, $contentRange, $dbForProject, $fileId, &$metadata, &$completed, $response): void { + $lock->withLock(function () use ($bucket, &$chunks, $contentRange, $dbForProject, $deviceForFiles, $fileId, &$metadata, &$completed, $path, $response): void { $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); if (!$file->isEmpty()) { $chunks = $file->getAttribute('chunksTotal', 1); @@ -268,6 +285,8 @@ class Create extends Action return; } } + + $deviceForFiles->prepareUpload($path, $metadata['content_type'] ?? '', $chunks, $metadata); }, timeout: 120.0); } catch (LockContention) { $response->addHeader('Retry-After', '5'); @@ -278,219 +297,223 @@ class Create extends Action return; } - $chunksUploaded = $deviceForFiles->upload($fileTmpName, $path, $chunk, $chunks, $metadata); + $finalizeUpload = function (int $chunksUploaded) use ($authorization, $bucket, &$chunks, $contentRange, $dbForProject, $deviceForFiles, $fileId, $fileName, $fileSize, &$metadata, $mergeUploadMetadata, $path, $permissions, $queueForEvents, $response): void { + $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); + $uploaded = 0; - if (empty($chunksUploaded)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed uploading file'); - } + if (!$file->isEmpty()) { + $chunks = $file->getAttribute('chunksTotal', 1); + $uploaded = $file->getAttribute('chunksUploaded', 0); + $metadata = $mergeUploadMetadata($file->getAttribute('metadata', []), $metadata); + + if ($uploaded === $chunks) { + if (empty($contentRange)) { + throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic($file, Response::MODEL_FILE); + + return; + } + } + + $chunksUploaded = max($uploaded, $chunksUploaded, (int) ($metadata['chunks'] ?? 0)); + + if ($chunksUploaded === $chunks && $uploaded < $chunks) { + $deviceForFiles->finalizeUpload($path, $chunks, $metadata); + + if (System::getEnv('_APP_STORAGE_ANTIVIRUS') === 'enabled' && $bucket->getAttribute('antivirus', true) && $fileSize <= APP_LIMIT_ANTIVIRUS && $deviceForFiles->getType() === Storage::DEVICE_LOCAL) { + $antivirus = new Network( + System::getEnv('_APP_STORAGE_ANTIVIRUS_HOST', 'clamav'), + (int) System::getEnv('_APP_STORAGE_ANTIVIRUS_PORT', 3310) + ); + + if (!$antivirus->fileScan($path)) { + $deviceForFiles->delete($path); + throw new Exception(Exception::STORAGE_INVALID_FILE); + } + } + + $mimeType = $deviceForFiles->getFileMimeType($path); // Get mime-type before compression and encryption + $fileHash = $deviceForFiles->getFileHash($path); // Get file hash before compression and encryption + $data = ''; + $iv = ''; + $tag = null; + // Compression + $algorithm = $bucket->getAttribute('compression', Compression::NONE); + if ($fileSize <= APP_STORAGE_READ_BUFFER && $algorithm != Compression::NONE) { + $data = $deviceForFiles->read($path); + switch ($algorithm) { + case Compression::ZSTD: + $compressor = new Zstd(); + break; + case Compression::GZIP: + default: + $compressor = new GZIP(); + break; + } + $data = $compressor->compress($data); + } else { + // reset the algorithm to none as we do not compress the file + // if file size exceedes the APP_STORAGE_READ_BUFFER + // regardless the bucket compression algoorithm + $algorithm = Compression::NONE; + } + + if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) { + if (empty($data)) { + $data = $deviceForFiles->read($path); + } + $key = System::getEnv('_APP_OPENSSL_KEY_V1'); + $iv = OpenSSL::randomPseudoBytes(OpenSSL::cipherIVLength(OpenSSL::CIPHER_AES_128_GCM)); + $data = OpenSSL::encrypt($data, OpenSSL::CIPHER_AES_128_GCM, $key, 0, $iv, $tag); + } + + if (!empty($data)) { + if (!$deviceForFiles->write($path, $data, $mimeType)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to save file'); + } + } + + $sizeActual = $deviceForFiles->getFileSize($path); + + $openSSLVersion = null; + $openSSLCipher = null; + $openSSLTag = null; + $openSSLIV = null; + + if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) { + $openSSLVersion = '1'; + $openSSLCipher = OpenSSL::CIPHER_AES_128_GCM; + $openSSLTag = \bin2hex($tag); + $openSSLIV = \bin2hex($iv); + } + + if ($file->isEmpty()) { + $doc = new Document([ + '$id' => $fileId, + '$permissions' => $permissions, + 'bucketId' => $bucket->getId(), + 'bucketInternalId' => $bucket->getSequence(), + 'name' => $fileName, + 'path' => $path, + 'signature' => $fileHash, + 'mimeType' => $mimeType, + 'sizeOriginal' => $fileSize, + 'sizeActual' => $sizeActual, + 'algorithm' => $algorithm, + 'comment' => '', + 'chunksTotal' => $chunks, + 'chunksUploaded' => $chunksUploaded, + 'openSSLVersion' => $openSSLVersion, + 'openSSLCipher' => $openSSLCipher, + 'openSSLTag' => $openSSLTag, + 'openSSLIV' => $openSSLIV, + 'search' => implode(' ', [$fileId, $fileName]), + 'metadata' => $metadata, + ]); + + try { + $file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc); + } catch (DuplicateException) { + throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); + } catch (NotFoundException) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + } else { + /** + * Skip authorization in updateDocument. + * Without this, the file creation will fail when user doesn't have update permission. + * However as with chunk upload even if we are updating, we are essentially creating a file + * adding it's new chunk so we rely on the create-permission check performed earlier. + */ + $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, new Document([ + '$permissions' => $permissions, + 'signature' => $fileHash, + 'mimeType' => $mimeType, + 'sizeActual' => $sizeActual, + 'algorithm' => $algorithm, + 'openSSLVersion' => $openSSLVersion, + 'openSSLCipher' => $openSSLCipher, + 'openSSLTag' => $openSSLTag, + 'openSSLIV' => $openSSLIV, + 'metadata' => $metadata, + 'chunksUploaded' => $chunksUploaded, + ]))); + } + + // Trigger after create success hook + $this->afterCreateSuccess($file); + } else { + if ($file->isEmpty()) { + $doc = new Document([ + '$id' => ID::custom($fileId), + '$permissions' => $permissions, + 'bucketId' => $bucket->getId(), + 'bucketInternalId' => $bucket->getSequence(), + 'name' => $fileName, + 'path' => $path, + 'signature' => '', + 'mimeType' => '', + 'sizeOriginal' => $fileSize, + 'sizeActual' => 0, + 'algorithm' => '', + 'comment' => '', + 'chunksTotal' => $chunks, + 'chunksUploaded' => $chunksUploaded, + 'search' => implode(' ', [$fileId, $fileName]), + 'metadata' => $metadata, + ]); + + try { + $file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc); + } catch (DuplicateException) { + throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); + } catch (NotFoundException) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + } else { + /** + * Skip authorization in updateDocument. + * Without this, the file creation will fail when user doesn't have update permission. + * However as with chunk upload even if we are updating, we are essentially creating a file + * adding it's new chunk so we rely on the create-permission check performed earlier. + */ + try { + $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, new Document([ + 'chunksUploaded' => $chunksUploaded, + 'metadata' => $metadata, + ]))); + } catch (NotFoundException) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + } + } + + if ($chunksUploaded === $chunks) { + $queueForEvents + ->setParam('bucketId', $bucket->getId()) + ->setParam('fileId', $file->getId()) + ->setContext('bucket', $bucket); + } + + $metadata = null; // was causing leaks as it was passed by reference + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($file, Response::MODEL_FILE); + }; try { - $lock->withLock(function () use ($authorization, $bucket, &$chunks, $chunksUploaded, $contentRange, $dbForProject, $deviceForFiles, $fileId, $fileName, $fileSize, &$metadata, $path, $permissions, $queueForEvents, $response): void { - $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); - $uploaded = 0; + $chunksUploaded = $deviceForFiles->uploadChunk($fileTmpName, $path, $chunk, $chunks, $metadata); - if (!$file->isEmpty()) { - $chunks = $file->getAttribute('chunksTotal', 1); - $uploaded = $file->getAttribute('chunksUploaded', 0); - $metadata = \array_merge($file->getAttribute('metadata', []), $metadata); + if (empty($chunksUploaded)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed uploading file'); + } - if ($uploaded === $chunks) { - if (empty($contentRange)) { - throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); - } - - $response - ->setStatusCode(Response::STATUS_CODE_OK) - ->dynamic($file, Response::MODEL_FILE); - - return; - } - } - - $chunksUploaded = max($uploaded, $chunksUploaded); - - if ($chunksUploaded === $chunks && $uploaded < $chunks) { - if (System::getEnv('_APP_STORAGE_ANTIVIRUS') === 'enabled' && $bucket->getAttribute('antivirus', true) && $fileSize <= APP_LIMIT_ANTIVIRUS && $deviceForFiles->getType() === Storage::DEVICE_LOCAL) { - $antivirus = new Network( - System::getEnv('_APP_STORAGE_ANTIVIRUS_HOST', 'clamav'), - (int) System::getEnv('_APP_STORAGE_ANTIVIRUS_PORT', 3310) - ); - - if (!$antivirus->fileScan($path)) { - $deviceForFiles->delete($path); - throw new Exception(Exception::STORAGE_INVALID_FILE); - } - } - - $mimeType = $deviceForFiles->getFileMimeType($path); // Get mime-type before compression and encryption - $fileHash = $deviceForFiles->getFileHash($path); // Get file hash before compression and encryption - $data = ''; - $iv = ''; - $tag = null; - // Compression - $algorithm = $bucket->getAttribute('compression', Compression::NONE); - if ($fileSize <= APP_STORAGE_READ_BUFFER && $algorithm != Compression::NONE) { - $data = $deviceForFiles->read($path); - switch ($algorithm) { - case Compression::ZSTD: - $compressor = new Zstd(); - break; - case Compression::GZIP: - default: - $compressor = new GZIP(); - break; - } - $data = $compressor->compress($data); - } else { - // reset the algorithm to none as we do not compress the file - // if file size exceedes the APP_STORAGE_READ_BUFFER - // regardless the bucket compression algoorithm - $algorithm = Compression::NONE; - } - - if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) { - if (empty($data)) { - $data = $deviceForFiles->read($path); - } - $key = System::getEnv('_APP_OPENSSL_KEY_V1'); - $iv = OpenSSL::randomPseudoBytes(OpenSSL::cipherIVLength(OpenSSL::CIPHER_AES_128_GCM)); - $data = OpenSSL::encrypt($data, OpenSSL::CIPHER_AES_128_GCM, $key, 0, $iv, $tag); - } - - if (!empty($data)) { - if (!$deviceForFiles->write($path, $data, $mimeType)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to save file'); - } - } - - $sizeActual = $deviceForFiles->getFileSize($path); - - $openSSLVersion = null; - $openSSLCipher = null; - $openSSLTag = null; - $openSSLIV = null; - - if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) { - $openSSLVersion = '1'; - $openSSLCipher = OpenSSL::CIPHER_AES_128_GCM; - $openSSLTag = \bin2hex($tag); - $openSSLIV = \bin2hex($iv); - } - - if ($file->isEmpty()) { - $doc = new Document([ - '$id' => $fileId, - '$permissions' => $permissions, - 'bucketId' => $bucket->getId(), - 'bucketInternalId' => $bucket->getSequence(), - 'name' => $fileName, - 'path' => $path, - 'signature' => $fileHash, - 'mimeType' => $mimeType, - 'sizeOriginal' => $fileSize, - 'sizeActual' => $sizeActual, - 'algorithm' => $algorithm, - 'comment' => '', - 'chunksTotal' => $chunks, - 'chunksUploaded' => $chunksUploaded, - 'openSSLVersion' => $openSSLVersion, - 'openSSLCipher' => $openSSLCipher, - 'openSSLTag' => $openSSLTag, - 'openSSLIV' => $openSSLIV, - 'search' => implode(' ', [$fileId, $fileName]), - 'metadata' => $metadata, - ]); - - try { - $file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc); - } catch (DuplicateException) { - throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); - } catch (NotFoundException) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - } else { - /** - * Skip authorization in updateDocument. - * Without this, the file creation will fail when user doesn't have update permission. - * However as with chunk upload even if we are updating, we are essentially creating a file - * adding it's new chunk so we rely on the create-permission check performed earlier. - */ - $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, new Document([ - '$permissions' => $permissions, - 'signature' => $fileHash, - 'mimeType' => $mimeType, - 'sizeActual' => $sizeActual, - 'algorithm' => $algorithm, - 'openSSLVersion' => $openSSLVersion, - 'openSSLCipher' => $openSSLCipher, - 'openSSLTag' => $openSSLTag, - 'openSSLIV' => $openSSLIV, - 'metadata' => $metadata, - 'chunksUploaded' => $chunksUploaded, - ]))); - } - - // Trigger after create success hook - $this->afterCreateSuccess($file); - } else { - if ($file->isEmpty()) { - $doc = new Document([ - '$id' => ID::custom($fileId), - '$permissions' => $permissions, - 'bucketId' => $bucket->getId(), - 'bucketInternalId' => $bucket->getSequence(), - 'name' => $fileName, - 'path' => $path, - 'signature' => '', - 'mimeType' => '', - 'sizeOriginal' => $fileSize, - 'sizeActual' => 0, - 'algorithm' => '', - 'comment' => '', - 'chunksTotal' => $chunks, - 'chunksUploaded' => $chunksUploaded, - 'search' => implode(' ', [$fileId, $fileName]), - 'metadata' => $metadata, - ]); - - try { - $file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc); - } catch (DuplicateException) { - throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); - } catch (NotFoundException) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - } else { - /** - * Skip authorization in updateDocument. - * Without this, the file creation will fail when user doesn't have update permission. - * However as with chunk upload even if we are updating, we are essentially creating a file - * adding it's new chunk so we rely on the create-permission check performed earlier. - */ - try { - $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, new Document([ - 'chunksUploaded' => $chunksUploaded, - 'metadata' => $metadata, - ]))); - } catch (NotFoundException) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - } - } - - if ($chunksUploaded === $chunks) { - $queueForEvents - ->setParam('bucketId', $bucket->getId()) - ->setParam('fileId', $file->getId()) - ->setContext('bucket', $bucket); - } - - $metadata = null; // was causing leaks as it was passed by reference - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($file, Response::MODEL_FILE); - }, timeout: 120.0); + $lock->withLock(fn () => $finalizeUpload($chunksUploaded), timeout: 120.0); } catch (LockContention) { $response->addHeader('Retry-After', '5'); throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'File upload is busy. Try again.'); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index 4fa5006db8..ff4844468c 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -127,7 +127,6 @@ class Get extends Action throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing'); } - /* @type Document $bucket */ $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); $isAPIKey = $user->isApp($authorization->getRoles()); @@ -151,7 +150,6 @@ class Get extends Action if ($fileSecurity && !$valid && !$isToken) { $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); } else { - /* @type Document $file */ $file = $authorization->skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); } diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index af6aa62564..a3aa88db31 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -11,6 +11,8 @@ use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; use Utopia\Database\Validator\Datetime as DatetimeValidator; +use Utopia\Storage\Storage; +use Utopia\System\System; trait StorageBase { @@ -287,7 +289,11 @@ trait StorageBase $this->assertEquals('large-file.mp4', $largeFile['body']['name']); $this->assertEquals('video/mp4', $largeFile['body']['mimeType']); $this->assertEquals($totalSize, $largeFile['body']['sizeOriginal']); - $this->assertEquals(md5_file(realpath(__DIR__ . '/../../../resources/disk-a/large-file.mp4')), $largeFile['body']['signature']); // should validate that the file is not encrypted + if (System::getEnv('_APP_STORAGE_DEVICE') === Storage::DEVICE_S3 || str_starts_with(System::getEnv('_APP_CONNECTIONS_STORAGE', ''), Storage::DEVICE_S3 . '://')) { + $this->assertNotEmpty($largeFile['body']['signature']); + } else { + $this->assertEquals(md5_file(realpath(__DIR__ . '/../../../resources/disk-a/large-file.mp4')), $largeFile['body']['signature']); // should validate that the file is not encrypted + } /** * Failure @@ -391,7 +397,7 @@ trait StorageBase 'bucketId' => ID::unique(), 'name' => 'Test Bucket 2', 'fileSecurity' => true, - 'maximumFileSize' => 6000000000, //6GB + 'maximumFileSize' => (int) System::getEnv('_APP_STORAGE_LIMIT', 0) + 1, 'allowedFileExtensions' => ["jpg", "png"], 'permissions' => [ Permission::read(Role::any()), From e6f30611033dd7be7a2d8143333e6501d6a1712c Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Fri, 15 May 2026 14:15:08 +0400 Subject: [PATCH 32/47] Pin utopia-php/lock to 0.2.x --- composer.json | 2 +- composer.lock | 20 +++++++++----------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/composer.json b/composer.json index fe6ec5dd0d..646112438c 100644 --- a/composer.json +++ b/composer.json @@ -72,7 +72,7 @@ "utopia-php/validators": "0.2.*", "utopia-php/image": "0.8.*", "utopia-php/locale": "0.8.*", - "utopia-php/lock": "dev-main", + "utopia-php/lock": "0.2.*", "utopia-php/logger": "0.8.*", "utopia-php/messaging": "0.22.*", "utopia-php/migration": "1.*", diff --git a/composer.lock b/composer.lock index 8ccfb9e744..ff112ce5b1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "864ea52ffc77c76363c5b487f49a1c77", + "content-hash": "6bc4a0d273ff6b032b36fe205847d02a", "packages": [ { "name": "adhocore/jwt", @@ -4500,16 +4500,16 @@ }, { "name": "utopia-php/lock", - "version": "dev-main", + "version": "0.2.0", "source": { "type": "git", "url": "https://github.com/utopia-php/lock.git", - "reference": "22eda789528da4cc71e767c9ad9e9fd4fa2c9467" + "reference": "49317c9493d8f747e4299aa24c22862aa5f6e106" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/lock/zipball/22eda789528da4cc71e767c9ad9e9fd4fa2c9467", - "reference": "22eda789528da4cc71e767c9ad9e9fd4fa2c9467", + "url": "https://api.github.com/repos/utopia-php/lock/zipball/49317c9493d8f747e4299aa24c22862aa5f6e106", + "reference": "49317c9493d8f747e4299aa24c22862aa5f6e106", "shasum": "" }, "require": { @@ -4526,7 +4526,6 @@ "ext-redis": "Required for the Distributed lock", "ext-swoole": "Required for the Mutex and Semaphore locks (>=6.0)" }, - "default-branch": true, "type": "library", "autoload": { "psr-4": { @@ -4564,12 +4563,12 @@ "email": "team@appwrite.io" } ], - "description": "A simple lock library to coordinate access to shared resources across coroutines, processes and hosts", + "description": "Mutex, semaphore, file and distributed locks for PHP — one interface, four backends.", "support": { - "source": "https://github.com/utopia-php/lock/tree/main", + "source": "https://github.com/utopia-php/lock/tree/0.2.0", "issues": "https://github.com/utopia-php/lock/issues" }, - "time": "2026-04-28T10:07:10+00:00" + "time": "2026-04-24T10:47:56+00:00" }, { "name": "utopia-php/logger", @@ -8640,8 +8639,7 @@ "aliases": [], "minimum-stability": "dev", "stability-flags": { - "utopia-php/http": 5, - "utopia-php/lock": 20 + "utopia-php/http": 5 }, "prefer-stable": true, "prefer-lowest": false, From 97d66566870784510e9a1142ebd94fd766cf9d4a Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Fri, 15 May 2026 14:17:14 +0400 Subject: [PATCH 33/47] Fix deployment upload lock TTL --- .../Platform/Modules/Functions/Http/Deployments/Create.php | 2 +- src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php index 74e32e01c9..1718f7359f 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php @@ -199,7 +199,7 @@ class Create extends Action $path = $deviceForFunctions->getPath($deploymentId . '.' . \pathinfo($fileName, PATHINFO_EXTENSION)); $lockKey = 'functions:deployment:' . $project->getId() . ':' . $functionId . ':' . $deploymentId; - $checkLock = new Distributed($redis, $lockKey, ttl: 120); + $checkLock = new Distributed($redis, $lockKey, ttl: 600); $stateLock = new Distributed($redis, $lockKey, ttl: 600); $metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)]; diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index df61d1809d..2c2c34e913 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -199,7 +199,7 @@ class Create extends Action $path = $deviceForSites->getPath($deploymentId . '.' . \pathinfo($fileName, PATHINFO_EXTENSION)); $lockKey = 'sites:deployment:' . $project->getId() . ':' . $siteId . ':' . $deploymentId; - $checkLock = new Distributed($redis, $lockKey, ttl: 120); + $checkLock = new Distributed($redis, $lockKey, ttl: 600); $stateLock = new Distributed($redis, $lockKey, ttl: 600); $metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)]; From 69735b383b6eed5c40b7284053a182415e974d93 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 15 May 2026 17:09:58 +0530 Subject: [PATCH 34/47] feat: add email template reset and get-default endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /v1/project/templates/email/:templateId/default that returns Appwrite built-in defaults, ignoring any custom project overrides - Add reset param to PATCH /v1/project/templates/email — when reset=true, clears the custom template entry and returns defaults without requiring SMTP to be enabled --- .../Project/Templates/Email/GetDefault.php | 118 ++++++++++++++++++ .../Http/Project/Templates/Email/Update.php | 102 ++++++++++++--- .../Modules/Project/Services/Http.php | 2 + 3 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/GetDefault.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/GetDefault.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/GetDefault.php new file mode 100644 index 0000000000..c89c39e0d6 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/GetDefault.php @@ -0,0 +1,118 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/console/templates/email/:templateId') + ->desc('Get default project email template') + ->groups(['api', 'project']) + ->label('scope', 'templates.read') + ->label('sdk', new Method( + namespace: 'console', + group: 'templates', + name: 'getEmailTemplate', + description: <<param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Email template type. Can be one of: ' . \implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) + ->inject('response') + ->callback($this->action(...)); + } + + public function action( + string $templateId, + string $locale, + Response $response, + ): void { + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); + + $localeObj = new Locale($locale); + $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); + + $response->dynamic(new Document([ + 'templateId' => $templateId, + 'locale' => $locale, + 'subject' => $localeObj->getText('emails.' . $templateId . '.subject'), + 'message' => $this->getDefaultMessage($templateId, $localeObj), + 'senderName' => '', + 'senderEmail' => '', + 'replyToEmail' => '', + 'replyToName' => '', + ]), Response::MODEL_EMAIL_TEMPLATE); + } + + private function getDefaultMessage(string $templateId, Locale $localeObj): string + { + $templateConfigs = [ + 'magicSession' => [ + 'file' => 'email-magic-url.tpl', + 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] + ], + 'mfaChallenge' => [ + 'file' => 'email-mfa-challenge.tpl', + 'placeholders' => ['description', 'clientInfo'] + ], + 'otpSession' => [ + 'file' => 'email-otp.tpl', + 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] + ], + 'sessionAlert' => [ + 'file' => 'email-session-alert.tpl', + 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] + ], + ]; + + $config = $templateConfigs[$templateId] ?? [ + 'file' => 'email-inner-base.tpl', + 'placeholders' => ['buttonText', 'body', 'footer'] + ]; + + $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); + $message = Template::fromString($templateString); + + foreach ($config['placeholders'] as $param) { + $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); + $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml); + } + + $message + ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello")) + ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks")) + ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature")); + + return $message->render(useContent: true); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index ef93abf683..55824f2102 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -7,15 +7,18 @@ use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Template\Template; use Appwrite\Utopia\Response; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\Emails\Validator\Email; +use Utopia\Locale\Locale; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; +use Utopia\Validator\Boolean; use Utopia\Validator\Nullable; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; @@ -64,6 +67,7 @@ class Update extends Action ->param('senderEmail', null, new Nullable(new Email()), 'Email of the sender.', optional: true) ->param('replyToEmail', null, new Nullable(new Email()), 'Reply to email.', optional: true) ->param('replyToName', null, new Nullable(new Text(255, 0)), 'Reply to name.', optional: true) + ->param('reset', false, new Boolean(), 'Reset template to Appwrite default, removing any custom override.', optional: true) ->inject('response') ->inject('queueForEvents') ->inject('dbForPlatform') @@ -81,6 +85,7 @@ class Update extends Action ?string $senderEmail, ?string $replyToEmail, ?string $replyToName, + bool $reset, Response $response, QueueEvent $queueForEvents, Database $dbForPlatform, @@ -89,14 +94,40 @@ class Update extends Action ) { $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); + $templates = $project->getAttribute('templates', []); + + if ($reset) { + unset($templates['email.' . $templateId . '-' . $locale]); + $authorization->skip(fn () => $dbForPlatform->updateDocument( + 'projects', + $project->getId(), + new Document(['templates' => $templates]) + )); + + $queueForEvents->setParam('templateId', $templateId); + + $localeObj = new Locale($locale); + $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); + + $response->dynamic(new Document([ + 'templateId' => $templateId, + 'locale' => $locale, + 'subject' => $localeObj->getText('emails.' . $templateId . '.subject'), + 'message' => $this->getDefaultMessage($templateId, $localeObj), + 'senderName' => '', + 'senderEmail' => '', + 'replyToEmail' => '', + 'replyToName' => '', + ]), Response::MODEL_EMAIL_TEMPLATE); + return; + } + // Prevent template update if custom SMTP is not configured $smtp = $project->getAttribute('smtp', []); if (($smtp['enabled'] ?? false) !== true) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to configure custom email templates.'); } - // Fetch current configuration - $templates = $project->getAttribute('templates', []); $template = $templates['email.' . $templateId . '-' . $locale] ?? []; // Apply changes @@ -120,25 +151,66 @@ class Update extends Action } } - // Save configuration $templates['email.' . $templateId . '-' . $locale] = $template; - $updates = new Document([ - 'templates' => $templates, - ]); - - $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $authorization->skip(fn () => $dbForPlatform->updateDocument( + 'projects', + $project->getId(), + new Document(['templates' => $templates]) + )); $queueForEvents->setParam('templateId', $templateId); $response->dynamic(new Document([ - 'templateId' => $templateId, - 'locale' => $locale, - 'subject' => $template['subject'], - 'message' => $template['message'], - 'senderName' => $template['senderName'] ?? '', - 'senderEmail' => $template['senderEmail'] ?? '', + 'templateId' => $templateId, + 'locale' => $locale, + 'subject' => $template['subject'], + 'message' => $template['message'], + 'senderName' => $template['senderName'] ?? '', + 'senderEmail' => $template['senderEmail'] ?? '', 'replyToEmail' => $template['replyToEmail'] ?? '', - 'replyToName' => $template['replyToName'] ?? '', + 'replyToName' => $template['replyToName'] ?? '', ]), Response::MODEL_EMAIL_TEMPLATE); } + + private function getDefaultMessage(string $templateId, Locale $localeObj): string + { + $templateConfigs = [ + 'magicSession' => [ + 'file' => 'email-magic-url.tpl', + 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] + ], + 'mfaChallenge' => [ + 'file' => 'email-mfa-challenge.tpl', + 'placeholders' => ['description', 'clientInfo'] + ], + 'otpSession' => [ + 'file' => 'email-otp.tpl', + 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] + ], + 'sessionAlert' => [ + 'file' => 'email-session-alert.tpl', + 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] + ], + ]; + + $config = $templateConfigs[$templateId] ?? [ + 'file' => 'email-inner-base.tpl', + 'placeholders' => ['buttonText', 'body', 'footer'] + ]; + + $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); + $message = Template::fromString($templateString); + + foreach ($config['placeholders'] as $param) { + $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); + $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml); + } + + $message + ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello")) + ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks")) + ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature")); + + return $message->render(useContent: true); + } } diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 3fe9f63d9e..17eba57735 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -90,6 +90,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdatePro use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; +use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\GetDefault as DefaultTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Get as GetTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Update as UpdateTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\XList as ListTemplates; @@ -124,6 +125,7 @@ class Http extends Service $this->addAction(ListTemplates::getName(), new ListTemplates()); $this->addAction(GetTemplate::getName(), new GetTemplate()); $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); + $this->addAction(DefaultTemplate::getName(), new DefaultTemplate()); // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); From 0e157efabe3aa7ee70ad1aa81f421c1bedb5c583 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 15 May 2026 17:17:32 +0530 Subject: [PATCH 35/47] chore: fix import ordering in Http.php --- src/Appwrite/Platform/Modules/Project/Services/Http.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 17eba57735..4357f38f4b 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -90,8 +90,8 @@ use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdatePro use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; -use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\GetDefault as DefaultTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Get as GetTemplate; +use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\GetDefault as DefaultTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Update as UpdateTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\XList as ListTemplates; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; From b7bf14a18a93368077158feb4c5252f1b156efc1 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Fri, 15 May 2026 15:58:48 +0400 Subject: [PATCH 36/47] Fix storage multipart upload preparation --- .../Storage/Http/Buckets/Files/Create.php | 86 ++++++++++--------- tests/e2e/Services/Storage/StorageBase.php | 2 +- 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php index e9f17fb801..58f7381001 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php @@ -265,7 +265,7 @@ class Create extends Action }; try { - $lock->withLock(function () use ($bucket, &$chunks, $contentRange, $dbForProject, $deviceForFiles, $fileId, &$metadata, &$completed, $path, $response): void { + $lock->withLock(function () use ($bucket, &$chunks, $contentRange, $dbForProject, $deviceForFiles, $fileId, $fileName, $fileSize, &$metadata, $path, $permissions, $response, &$completed): void { $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); if (!$file->isEmpty()) { $chunks = $file->getAttribute('chunksTotal', 1); @@ -286,7 +286,38 @@ class Create extends Action } } - $deviceForFiles->prepareUpload($path, $metadata['content_type'] ?? '', $chunks, $metadata); + if ($file->isEmpty()) { + $deviceForFiles->prepareUpload($path, $metadata['content_type'] ?? '', $chunks, $metadata); + + if (!empty($contentRange)) { + $doc = new Document([ + '$id' => ID::custom($fileId), + '$permissions' => $permissions, + 'bucketId' => $bucket->getId(), + 'bucketInternalId' => $bucket->getSequence(), + 'name' => $fileName, + 'path' => $path, + 'signature' => '', + 'mimeType' => '', + 'sizeOriginal' => $fileSize, + 'sizeActual' => 0, + 'algorithm' => '', + 'comment' => '', + 'chunksTotal' => $chunks, + 'chunksUploaded' => 0, + 'search' => implode(' ', [$fileId, $fileName]), + 'metadata' => $metadata, + ]); + + try { + $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc); + } catch (DuplicateException) { + throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); + } catch (NotFoundException) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + } + } }, timeout: 120.0); } catch (LockContention) { $response->addHeader('Retry-After', '5'); @@ -447,48 +478,19 @@ class Create extends Action // Trigger after create success hook $this->afterCreateSuccess($file); } else { - if ($file->isEmpty()) { - $doc = new Document([ - '$id' => ID::custom($fileId), - '$permissions' => $permissions, - 'bucketId' => $bucket->getId(), - 'bucketInternalId' => $bucket->getSequence(), - 'name' => $fileName, - 'path' => $path, - 'signature' => '', - 'mimeType' => '', - 'sizeOriginal' => $fileSize, - 'sizeActual' => 0, - 'algorithm' => '', - 'comment' => '', - 'chunksTotal' => $chunks, + /** + * Skip authorization in updateDocument. + * Without this, the file creation will fail when user doesn't have update permission. + * However as with chunk upload even if we are updating, we are essentially creating a file + * adding it's new chunk so we rely on the create-permission check performed earlier. + */ + try { + $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, new Document([ 'chunksUploaded' => $chunksUploaded, - 'search' => implode(' ', [$fileId, $fileName]), 'metadata' => $metadata, - ]); - - try { - $file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc); - } catch (DuplicateException) { - throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); - } catch (NotFoundException) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - } else { - /** - * Skip authorization in updateDocument. - * Without this, the file creation will fail when user doesn't have update permission. - * However as with chunk upload even if we are updating, we are essentially creating a file - * adding it's new chunk so we rely on the create-permission check performed earlier. - */ - try { - $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, new Document([ - 'chunksUploaded' => $chunksUploaded, - 'metadata' => $metadata, - ]))); - } catch (NotFoundException) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } + ]))); + } catch (NotFoundException) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); } } diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index b2d7155208..3049cae5ca 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -397,7 +397,7 @@ trait StorageBase 'bucketId' => ID::unique(), 'name' => 'Test Bucket 2', 'fileSecurity' => true, - 'maximumFileSize' => (int) System::getEnv('_APP_STORAGE_LIMIT', 0) + 1, + 'maximumFileSize' => ((int) System::getEnv('_APP_STORAGE_LIMIT', 0) ?: 6000000000) + 1, 'allowedFileExtensions' => ["jpg", "png"], 'permissions' => [ Permission::read(Role::any()), From a614afe5c7ff4536e7d5dbdb4e89803e2fad9118 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 15 May 2026 18:03:12 +0530 Subject: [PATCH 37/47] refactor: move email template defaults to console module and add tests - Move GetDefault endpoint to Console module as Get.php (GET /v1/console/templates/email/:templateId, console.getEmailTemplate) - Remove from Project module - Add e2e tests for console.getEmailTemplate and reset=true --- .../Http/Templates/Email/Get.php} | 16 +-- .../Modules/Console/Services/Http.php | 2 + .../Modules/Project/Services/Http.php | 2 - tests/e2e/Services/Project/TemplatesBase.php | 136 ++++++++++++++++++ 4 files changed, 146 insertions(+), 10 deletions(-) rename src/Appwrite/Platform/Modules/{Project/Http/Project/Templates/Email/GetDefault.php => Console/Http/Templates/Email/Get.php} (91%) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/GetDefault.php b/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php similarity index 91% rename from src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/GetDefault.php rename to src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php index c89c39e0d6..97d48603df 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/GetDefault.php +++ b/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/console/templates/email/:templateId') - ->desc('Get default project email template') - ->groups(['api', 'project']) - ->label('scope', 'templates.read') + ->desc('Get email template') + ->groups(['api', 'projects']) + ->label('scope', 'projects.read') ->label('sdk', new Method( namespace: 'console', group: 'templates', name: 'getEmailTemplate', description: <<addAction(Web::getName(), new Web()); $this->addAction(GetVariables::getName(), new GetVariables()); + $this->addAction(GetEmailTemplate::getName(), new GetEmailTemplate()); $this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers()); $this->addAction(ListKeyScopes::getName(), new ListKeyScopes()); $this->addAction(ListOrganizationScopes::getName(), new ListOrganizationScopes()); diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 4357f38f4b..3fe9f63d9e 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -91,7 +91,6 @@ use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProj use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Get as GetTemplate; -use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\GetDefault as DefaultTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Update as UpdateTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\XList as ListTemplates; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; @@ -125,7 +124,6 @@ class Http extends Service $this->addAction(ListTemplates::getName(), new ListTemplates()); $this->addAction(GetTemplate::getName(), new GetTemplate()); $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); - $this->addAction(DefaultTemplate::getName(), new DefaultTemplate()); // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index b240c945b3..a1e46debe7 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -1147,6 +1147,142 @@ trait TemplatesBase return $this->client->call(Client::METHOD_PATCH, '/project/templates/email', $headers, $params); } + // Console email template (default) tests + + public function testGetConsoleEmailTemplate(): void + { + $response = $this->getConsoleEmailTemplate('verification', 'en'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + $this->assertSame('', $response['body']['senderName']); + $this->assertSame('', $response['body']['senderEmail']); + $this->assertSame('', $response['body']['replyToEmail']); + $this->assertSame('', $response['body']['replyToName']); + } + + public function testGetConsoleEmailTemplateIgnoresCustomOverride(): void + { + $this->ensureSMTPEnabled(); + + // Set a custom override on the project template. + $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'en', + subject: 'Custom subject', + message: 'Custom message', + senderName: 'Custom Sender', + senderEmail: 'custom@appwrite.io', + ); + + // Console endpoint must always return the built-in default, not the override. + $response = $this->getConsoleEmailTemplate('recovery', 'en'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('recovery', $response['body']['templateId']); + $this->assertNotSame('Custom subject', $response['body']['subject']); + $this->assertSame('', $response['body']['senderName']); + $this->assertSame('', $response['body']['senderEmail']); + } + + public function testGetConsoleEmailTemplateDefaultLocale(): void + { + $response = $this->getConsoleEmailTemplate('magicSession'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + } + + public function testGetConsoleEmailTemplateAllTypes(): void + { + $types = [ + 'verification', + 'magicSession', + 'recovery', + 'invitation', + 'mfaChallenge', + 'sessionAlert', + 'otpSession', + ]; + + foreach ($types as $type) { + $response = $this->getConsoleEmailTemplate($type, 'en'); + $this->assertSame(200, $response['headers']['status-code'], "type={$type}"); + $this->assertNotEmpty($response['body']['subject'], "type={$type} must have subject"); + $this->assertNotEmpty($response['body']['message'], "type={$type} must have message"); + } + } + + public function testResetEmailTemplate(): void + { + $this->ensureSMTPEnabled(); + + // Apply a custom override. + $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'en', + subject: 'Custom invite subject', + message: 'Custom invite message', + senderName: 'Bad Sender', + senderEmail: 'bad@example.com', + ); + + $custom = $this->getEmailTemplate('invitation', 'en'); + $this->assertSame('Custom invite subject', $custom['body']['subject']); + $this->assertSame('Bad Sender', $custom['body']['senderName']); + + // Reset to default. + $response = $this->resetEmailTemplate('invitation', 'en'); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['senderName']); + $this->assertSame('', $response['body']['senderEmail']); + $this->assertNotSame('Custom invite subject', $response['body']['subject']); + + // Confirm the project template now reflects the default. + $after = $this->getEmailTemplate('invitation', 'en'); + $this->assertSame('', $after['body']['senderName']); + $this->assertSame('', $after['body']['senderEmail']); + } + + public function testResetEmailTemplateWithoutSMTP(): void + { + // Reset must work even without SMTP enabled. + $response = $this->resetEmailTemplate('recovery', 'en'); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['senderName']); + $this->assertSame('', $response['body']['senderEmail']); + } + + protected function getConsoleEmailTemplate(string $templateId, ?string $locale = null): mixed + { + $params = []; + if ($locale !== null) { + $params['locale'] = $locale; + } + + return $this->client->call(Client::METHOD_GET, '/console/templates/email/' . $templateId, \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + } + + protected function resetEmailTemplate(string $templateId, ?string $locale = null): mixed + { + $params = ['templateId' => $templateId, 'reset' => true]; + if ($locale !== null) { + $params['locale'] = $locale; + } + + return $this->client->call(Client::METHOD_PATCH, '/project/templates/email', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + } + protected function ensureSMTPEnabled(): void { $this->client->call( From 8781942633aa99f5e6cdfbb58f70d1925b5834c4 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 15 May 2026 18:18:10 +0530 Subject: [PATCH 38/47] Fix console email template spec enum names --- src/Appwrite/SDK/Specification/Format.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index fc67dedb13..d236ee53ce 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -466,6 +466,14 @@ abstract class Format return 'ConsoleResourceValue'; } break; + case 'getEmailTemplate': + switch ($param) { + case 'templateId': + return 'ConsoleEmailTemplateId'; + case 'locale': + return 'ConsoleEmailTemplateLocale'; + } + break; } break; case 'account': From 0e7c50c181ef68b4b81a36a99e0c27c098f08e4e Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 15 May 2026 18:45:38 +0530 Subject: [PATCH 39/47] Align console email template endpoint metadata --- .../Platform/Modules/Console/Http/Templates/Email/Get.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php index 97d48603df..41b56f2aa6 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php @@ -29,16 +29,16 @@ class Get extends Action $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/console/templates/email/:templateId') ->desc('Get email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.read') + ->groups(['api']) + ->label('scope', 'public') ->label('sdk', new Method( namespace: 'console', - group: 'templates', + group: null, name: 'getEmailTemplate', description: << Date: Fri, 15 May 2026 18:51:22 +0530 Subject: [PATCH 40/47] Remove email template reset API changes --- .../Http/Project/Templates/Email/Update.php | 74 ------------------- src/Appwrite/SDK/Specification/Format.php | 4 +- tests/e2e/Services/Project/TemplatesBase.php | 60 +++------------ 3 files changed, 13 insertions(+), 125 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index 55824f2102..8fc2f6eec9 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -7,18 +7,14 @@ use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; -use Appwrite\Template\Template; use Appwrite\Utopia\Response; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\Emails\Validator\Email; -use Utopia\Locale\Locale; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -use Utopia\System\System; -use Utopia\Validator\Boolean; use Utopia\Validator\Nullable; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; @@ -67,7 +63,6 @@ class Update extends Action ->param('senderEmail', null, new Nullable(new Email()), 'Email of the sender.', optional: true) ->param('replyToEmail', null, new Nullable(new Email()), 'Reply to email.', optional: true) ->param('replyToName', null, new Nullable(new Text(255, 0)), 'Reply to name.', optional: true) - ->param('reset', false, new Boolean(), 'Reset template to Appwrite default, removing any custom override.', optional: true) ->inject('response') ->inject('queueForEvents') ->inject('dbForPlatform') @@ -85,7 +80,6 @@ class Update extends Action ?string $senderEmail, ?string $replyToEmail, ?string $replyToName, - bool $reset, Response $response, QueueEvent $queueForEvents, Database $dbForPlatform, @@ -96,32 +90,6 @@ class Update extends Action $templates = $project->getAttribute('templates', []); - if ($reset) { - unset($templates['email.' . $templateId . '-' . $locale]); - $authorization->skip(fn () => $dbForPlatform->updateDocument( - 'projects', - $project->getId(), - new Document(['templates' => $templates]) - )); - - $queueForEvents->setParam('templateId', $templateId); - - $localeObj = new Locale($locale); - $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); - - $response->dynamic(new Document([ - 'templateId' => $templateId, - 'locale' => $locale, - 'subject' => $localeObj->getText('emails.' . $templateId . '.subject'), - 'message' => $this->getDefaultMessage($templateId, $localeObj), - 'senderName' => '', - 'senderEmail' => '', - 'replyToEmail' => '', - 'replyToName' => '', - ]), Response::MODEL_EMAIL_TEMPLATE); - return; - } - // Prevent template update if custom SMTP is not configured $smtp = $project->getAttribute('smtp', []); if (($smtp['enabled'] ?? false) !== true) { @@ -171,46 +139,4 @@ class Update extends Action 'replyToName' => $template['replyToName'] ?? '', ]), Response::MODEL_EMAIL_TEMPLATE); } - - private function getDefaultMessage(string $templateId, Locale $localeObj): string - { - $templateConfigs = [ - 'magicSession' => [ - 'file' => 'email-magic-url.tpl', - 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] - ], - 'mfaChallenge' => [ - 'file' => 'email-mfa-challenge.tpl', - 'placeholders' => ['description', 'clientInfo'] - ], - 'otpSession' => [ - 'file' => 'email-otp.tpl', - 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] - ], - 'sessionAlert' => [ - 'file' => 'email-session-alert.tpl', - 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] - ], - ]; - - $config = $templateConfigs[$templateId] ?? [ - 'file' => 'email-inner-base.tpl', - 'placeholders' => ['buttonText', 'body', 'footer'] - ]; - - $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); - $message = Template::fromString($templateString); - - foreach ($config['placeholders'] as $param) { - $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); - $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml); - } - - $message - ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello")) - ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks")) - ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature")); - - return $message->render(useContent: true); - } } diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index d236ee53ce..6c5d50e016 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -469,9 +469,9 @@ abstract class Format case 'getEmailTemplate': switch ($param) { case 'templateId': - return 'ConsoleEmailTemplateId'; + return 'ProjectEmailTemplateId'; case 'locale': - return 'ConsoleEmailTemplateLocale'; + return 'ProjectEmailTemplateLocale'; } break; } diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index a1e46debe7..15c24f74c9 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -1217,44 +1217,18 @@ trait TemplatesBase } } - public function testResetEmailTemplate(): void + public function testGetConsoleEmailTemplateInvalidTemplateId(): void { - $this->ensureSMTPEnabled(); + $response = $this->getConsoleEmailTemplate('invalidTemplate', 'en'); - // Apply a custom override. - $this->updateEmailTemplate( - templateId: 'invitation', - locale: 'en', - subject: 'Custom invite subject', - message: 'Custom invite message', - senderName: 'Bad Sender', - senderEmail: 'bad@example.com', - ); - - $custom = $this->getEmailTemplate('invitation', 'en'); - $this->assertSame('Custom invite subject', $custom['body']['subject']); - $this->assertSame('Bad Sender', $custom['body']['senderName']); - - // Reset to default. - $response = $this->resetEmailTemplate('invitation', 'en'); - $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame('', $response['body']['senderName']); - $this->assertSame('', $response['body']['senderEmail']); - $this->assertNotSame('Custom invite subject', $response['body']['subject']); - - // Confirm the project template now reflects the default. - $after = $this->getEmailTemplate('invitation', 'en'); - $this->assertSame('', $after['body']['senderName']); - $this->assertSame('', $after['body']['senderEmail']); + $this->assertSame(400, $response['headers']['status-code']); } - public function testResetEmailTemplateWithoutSMTP(): void + public function testGetConsoleEmailTemplateInvalidLocale(): void { - // Reset must work even without SMTP enabled. - $response = $this->resetEmailTemplate('recovery', 'en'); - $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame('', $response['body']['senderName']); - $this->assertSame('', $response['body']['senderEmail']); + $response = $this->getConsoleEmailTemplate('recovery', 'not-a-locale'); + + $this->assertSame(400, $response['headers']['status-code']); } protected function getConsoleEmailTemplate(string $templateId, ?string $locale = null): mixed @@ -1264,23 +1238,11 @@ trait TemplatesBase $params['locale'] = $locale; } - return $this->client->call(Client::METHOD_GET, '/console/templates/email/' . $templateId, \array_merge([ + return $this->client->call(Client::METHOD_GET, '/console/templates/email/' . $templateId, [ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), $params); - } - - protected function resetEmailTemplate(string $templateId, ?string $locale = null): mixed - { - $params = ['templateId' => $templateId, 'reset' => true]; - if ($locale !== null) { - $params['locale'] = $locale; - } - - return $this->client->call(Client::METHOD_PATCH, '/project/templates/email', \array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), $params); + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ], $params); } protected function ensureSMTPEnabled(): void From 7b718e7dcc92a9a2ee0e58677bc0b9587338c86f Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 15 May 2026 18:57:08 +0530 Subject: [PATCH 41/47] Add email template default integration test --- .../Console/Http/Templates/Email/Get.php | 5 + .../Project/EmailTemplatesIntegrationTest.php | 194 ++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 tests/e2e/Services/Project/EmailTemplatesIntegrationTest.php diff --git a/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php index 41b56f2aa6..6906c1fd79 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php @@ -105,6 +105,11 @@ class Get extends Action foreach ($config['placeholders'] as $param) { $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); + if ($templateId === 'magicSession' && $param === 'securityPhrase') { + $message->setParam('{{securityPhrase}}', ''); + continue; + } + $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml); } diff --git a/tests/e2e/Services/Project/EmailTemplatesIntegrationTest.php b/tests/e2e/Services/Project/EmailTemplatesIntegrationTest.php new file mode 100644 index 0000000000..7d8a724c38 --- /dev/null +++ b/tests/e2e/Services/Project/EmailTemplatesIntegrationTest.php @@ -0,0 +1,194 @@ +clearMaildev(); + + $recipientEmail = 'magic-template-' . \uniqid() . '@appwrite.io'; + + $this->updateSMTP(['enabled' => false]); + + $firstEmail = $this->triggerMagicUrlAndGetEmail($recipientEmail); + $defaultSnapshot = $this->normalizeMagicUrlEmail($firstEmail); + + $this->updateSMTP([ + 'enabled' => true, + 'senderName' => 'Template Test Mailer', + 'senderEmail' => 'template-test@appwrite.io', + 'host' => 'maildev', + 'port' => 1025, + 'username' => 'user', + 'password' => 'password', + ]); + + $customSubject = 'Custom magic login ' . \uniqid(); + $customMarker = 'CUSTOM_MAGIC_TEMPLATE_' . \uniqid(); + $this->updateEmailTemplate([ + 'templateId' => 'magicSession', + 'locale' => 'en', + 'subject' => $customSubject, + 'message' => '

' . $customMarker . '

{{redirect}}

', + ]); + + $customEmail = $this->triggerMagicUrlAndGetEmail($recipientEmail); + $this->assertSame($customSubject, $customEmail['subject']); + $this->assertStringContainsString($customMarker, $customEmail['text']); + $this->assertStringContainsString($customMarker, $customEmail['html']); + + $defaultTemplate = $this->getConsoleEmailTemplate('magicSession', 'en'); + $this->assertSame(200, $defaultTemplate['headers']['status-code']); + + $this->updateEmailTemplate([ + 'templateId' => 'magicSession', + 'locale' => 'en', + 'subject' => $defaultTemplate['body']['subject'], + 'message' => $defaultTemplate['body']['message'], + ]); + + $restoredEmail = $this->triggerMagicUrlAndGetEmail($recipientEmail); + $restoredSnapshot = $this->normalizeMagicUrlEmail($restoredEmail); + + $this->assertSame($defaultSnapshot, $restoredSnapshot); + } + + /** + * @param array $params + */ + private function updateSMTP(array $params): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/smtp', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $params); + + $this->assertSame(200, $response['headers']['status-code']); + } + + /** + * @param array $params + */ + private function updateEmailTemplate(array $params): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/templates/email', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $params); + + $this->assertSame(200, $response['headers']['status-code']); + } + + private function getConsoleEmailTemplate(string $templateId, string $locale): array + { + return $this->client->call(Client::METHOD_GET, '/console/templates/email/' . $templateId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ], [ + 'locale' => $locale, + ]); + } + + private function triggerMagicUrlAndGetEmail(string $recipientEmail): array + { + $previousCount = $this->countEmailsTo($recipientEmail); + + $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => ID::unique(), + 'email' => $recipientEmail, + ]); + + $this->assertSame(201, $response['headers']['status-code']); + + return $this->getNextEmailByAddress($recipientEmail, $previousCount); + } + + private function countEmailsTo(string $address): int + { + $emails = \json_decode(\file_get_contents('http://maildev:1080/email'), true) ?? []; + $count = 0; + + foreach ($emails as $email) { + foreach ($email['to'] ?? [] as $recipient) { + if (($recipient['address'] ?? '') === $address) { + $count++; + } + } + } + + return $count; + } + + private function clearMaildev(): void + { + $context = \stream_context_create([ + 'http' => [ + 'method' => 'DELETE', + ], + ]); + + \file_get_contents('http://maildev:1080/email/all', false, $context); + } + + private function getNextEmailByAddress(string $address, int $previousCount): array + { + $result = []; + + $this->assertEventually(function () use (&$result, $address, $previousCount) { + $emails = \json_decode(\file_get_contents('http://maildev:1080/email'), true) ?? []; + $matches = []; + + foreach ($emails as $email) { + foreach ($email['to'] ?? [] as $recipient) { + if (($recipient['address'] ?? '') === $address) { + $matches[] = $email; + break; + } + } + } + + $this->assertGreaterThan($previousCount, \count($matches), 'Expected a new email for ' . $address); + $result = $matches[\count($matches) - 1]; + }, 15_000, 500); + + return $result; + } + + /** + * @return array{subject: string, text: string, html: string} + */ + private function normalizeMagicUrlEmail(array $email): array + { + return [ + 'subject' => $this->normalizeMagicUrlContent($email['subject'] ?? ''), + 'text' => $this->normalizeMagicUrlContent($email['text'] ?? ''), + 'html' => $this->normalizeMagicUrlContent($email['html'] ?? ''), + ]; + } + + private function normalizeMagicUrlContent(string $content): string + { + $content = \html_entity_decode($content, ENT_QUOTES); + $content = \preg_replace('/([?&](?:secret|expire)=)[^&\s<"]+/', '$1{value}', $content) ?? $content; + + return \trim($content); + } +} From 6428b7396630d47f16086fe55e3d5c4a2362749a Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 15 May 2026 19:05:01 +0530 Subject: [PATCH 42/47] Fix email template update System import --- .../Modules/Project/Http/Project/Templates/Email/Update.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index 8fc2f6eec9..256ffcf7d4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -15,6 +15,7 @@ use Utopia\Database\Validator\Authorization; use Utopia\Emails\Validator\Email; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Utopia\System\System; use Utopia\Validator\Nullable; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; From 6a2ddb221cfd0b0a02f5d571cc7c7c66d21e964b Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 15 May 2026 19:34:15 +0530 Subject: [PATCH 43/47] Clean up template update diff and add locale test --- .../Http/Project/Templates/Email/Update.php | 29 ++++++++++--------- tests/e2e/Services/Project/TemplatesBase.php | 11 +++++++ 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index 256ffcf7d4..ef93abf683 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -89,14 +89,14 @@ class Update extends Action ) { $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); - $templates = $project->getAttribute('templates', []); - // Prevent template update if custom SMTP is not configured $smtp = $project->getAttribute('smtp', []); if (($smtp['enabled'] ?? false) !== true) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to configure custom email templates.'); } + // Fetch current configuration + $templates = $project->getAttribute('templates', []); $template = $templates['email.' . $templateId . '-' . $locale] ?? []; // Apply changes @@ -120,24 +120,25 @@ class Update extends Action } } + // Save configuration $templates['email.' . $templateId . '-' . $locale] = $template; - $authorization->skip(fn () => $dbForPlatform->updateDocument( - 'projects', - $project->getId(), - new Document(['templates' => $templates]) - )); + $updates = new Document([ + 'templates' => $templates, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); $queueForEvents->setParam('templateId', $templateId); $response->dynamic(new Document([ - 'templateId' => $templateId, - 'locale' => $locale, - 'subject' => $template['subject'], - 'message' => $template['message'], - 'senderName' => $template['senderName'] ?? '', - 'senderEmail' => $template['senderEmail'] ?? '', + 'templateId' => $templateId, + 'locale' => $locale, + 'subject' => $template['subject'], + 'message' => $template['message'], + 'senderName' => $template['senderName'] ?? '', + 'senderEmail' => $template['senderEmail'] ?? '', 'replyToEmail' => $template['replyToEmail'] ?? '', - 'replyToName' => $template['replyToName'] ?? '', + 'replyToName' => $template['replyToName'] ?? '', ]), Response::MODEL_EMAIL_TEMPLATE); } } diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 15c24f74c9..11dc6dc80b 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -1197,6 +1197,17 @@ trait TemplatesBase $this->assertNotEmpty($response['body']['subject']); } + public function testGetConsoleEmailTemplateNonDefaultLocale(): void + { + $response = $this->getConsoleEmailTemplate('verification', 'fr'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('fr', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + } + public function testGetConsoleEmailTemplateAllTypes(): void { $types = [ From cb80f0e9c892e39ecf7c6bd24e97ba5cdab2205e Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 15 May 2026 19:44:20 +0530 Subject: [PATCH 44/47] Remove email template integration test --- .../Project/EmailTemplatesIntegrationTest.php | 194 ------------------ 1 file changed, 194 deletions(-) delete mode 100644 tests/e2e/Services/Project/EmailTemplatesIntegrationTest.php diff --git a/tests/e2e/Services/Project/EmailTemplatesIntegrationTest.php b/tests/e2e/Services/Project/EmailTemplatesIntegrationTest.php deleted file mode 100644 index 7d8a724c38..0000000000 --- a/tests/e2e/Services/Project/EmailTemplatesIntegrationTest.php +++ /dev/null @@ -1,194 +0,0 @@ -clearMaildev(); - - $recipientEmail = 'magic-template-' . \uniqid() . '@appwrite.io'; - - $this->updateSMTP(['enabled' => false]); - - $firstEmail = $this->triggerMagicUrlAndGetEmail($recipientEmail); - $defaultSnapshot = $this->normalizeMagicUrlEmail($firstEmail); - - $this->updateSMTP([ - 'enabled' => true, - 'senderName' => 'Template Test Mailer', - 'senderEmail' => 'template-test@appwrite.io', - 'host' => 'maildev', - 'port' => 1025, - 'username' => 'user', - 'password' => 'password', - ]); - - $customSubject = 'Custom magic login ' . \uniqid(); - $customMarker = 'CUSTOM_MAGIC_TEMPLATE_' . \uniqid(); - $this->updateEmailTemplate([ - 'templateId' => 'magicSession', - 'locale' => 'en', - 'subject' => $customSubject, - 'message' => '

' . $customMarker . '

{{redirect}}

', - ]); - - $customEmail = $this->triggerMagicUrlAndGetEmail($recipientEmail); - $this->assertSame($customSubject, $customEmail['subject']); - $this->assertStringContainsString($customMarker, $customEmail['text']); - $this->assertStringContainsString($customMarker, $customEmail['html']); - - $defaultTemplate = $this->getConsoleEmailTemplate('magicSession', 'en'); - $this->assertSame(200, $defaultTemplate['headers']['status-code']); - - $this->updateEmailTemplate([ - 'templateId' => 'magicSession', - 'locale' => 'en', - 'subject' => $defaultTemplate['body']['subject'], - 'message' => $defaultTemplate['body']['message'], - ]); - - $restoredEmail = $this->triggerMagicUrlAndGetEmail($recipientEmail); - $restoredSnapshot = $this->normalizeMagicUrlEmail($restoredEmail); - - $this->assertSame($defaultSnapshot, $restoredSnapshot); - } - - /** - * @param array $params - */ - private function updateSMTP(array $params): void - { - $response = $this->client->call(Client::METHOD_PATCH, '/project/smtp', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ], $params); - - $this->assertSame(200, $response['headers']['status-code']); - } - - /** - * @param array $params - */ - private function updateEmailTemplate(array $params): void - { - $response = $this->client->call(Client::METHOD_PATCH, '/project/templates/email', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ], $params); - - $this->assertSame(200, $response['headers']['status-code']); - } - - private function getConsoleEmailTemplate(string $templateId, string $locale): array - { - return $this->client->call(Client::METHOD_GET, '/console/templates/email/' . $templateId, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => 'console', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ], [ - 'locale' => $locale, - ]); - } - - private function triggerMagicUrlAndGetEmail(string $recipientEmail): array - { - $previousCount = $this->countEmailsTo($recipientEmail); - - $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], [ - 'userId' => ID::unique(), - 'email' => $recipientEmail, - ]); - - $this->assertSame(201, $response['headers']['status-code']); - - return $this->getNextEmailByAddress($recipientEmail, $previousCount); - } - - private function countEmailsTo(string $address): int - { - $emails = \json_decode(\file_get_contents('http://maildev:1080/email'), true) ?? []; - $count = 0; - - foreach ($emails as $email) { - foreach ($email['to'] ?? [] as $recipient) { - if (($recipient['address'] ?? '') === $address) { - $count++; - } - } - } - - return $count; - } - - private function clearMaildev(): void - { - $context = \stream_context_create([ - 'http' => [ - 'method' => 'DELETE', - ], - ]); - - \file_get_contents('http://maildev:1080/email/all', false, $context); - } - - private function getNextEmailByAddress(string $address, int $previousCount): array - { - $result = []; - - $this->assertEventually(function () use (&$result, $address, $previousCount) { - $emails = \json_decode(\file_get_contents('http://maildev:1080/email'), true) ?? []; - $matches = []; - - foreach ($emails as $email) { - foreach ($email['to'] ?? [] as $recipient) { - if (($recipient['address'] ?? '') === $address) { - $matches[] = $email; - break; - } - } - } - - $this->assertGreaterThan($previousCount, \count($matches), 'Expected a new email for ' . $address); - $result = $matches[\count($matches) - 1]; - }, 15_000, 500); - - return $result; - } - - /** - * @return array{subject: string, text: string, html: string} - */ - private function normalizeMagicUrlEmail(array $email): array - { - return [ - 'subject' => $this->normalizeMagicUrlContent($email['subject'] ?? ''), - 'text' => $this->normalizeMagicUrlContent($email['text'] ?? ''), - 'html' => $this->normalizeMagicUrlContent($email['html'] ?? ''), - ]; - } - - private function normalizeMagicUrlContent(string $content): string - { - $content = \html_entity_decode($content, ENT_QUOTES); - $content = \preg_replace('/([?&](?:secret|expire)=)[^&\s<"]+/', '$1{value}', $content) ?? $content; - - return \trim($content); - } -} From 5750155591322e06f300d81a51490454a87a5672 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Fri, 15 May 2026 19:54:44 +0400 Subject: [PATCH 45/47] Add pool-backed lock resource --- app/init/registers.php | 8 ++++++++ app/init/resources.php | 11 +++++++++++ .../Modules/Functions/Http/Deployments/Create.php | 11 ++++------- .../Modules/Sites/Http/Deployments/Create.php | 11 ++++------- .../Modules/Storage/Http/Buckets/Files/Create.php | 15 +++++---------- 5 files changed, 32 insertions(+), 24 deletions(-) diff --git a/app/init/registers.php b/app/init/registers.php index 54c0053a33..21ce536a8b 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -240,6 +240,12 @@ $register->set('pools', function () { 'multiple' => true, 'schemes' => ['redis'], ], + 'lock' => [ + 'type' => 'lock', + 'dsns' => $fallbackForRedis, + 'multiple' => false, + 'schemes' => ['redis'], + ], ]; $maxConnections = (int) System::getEnv('_APP_CONNECTIONS_MAX', 151); @@ -369,6 +375,8 @@ $register->set('pools', function () { } return $adapter; + case 'lock': + return $resource(); default: throw new Exception(Exception::GENERAL_SERVER_ERROR, "Server error: Missing adapter implementation."); } diff --git a/app/init/resources.php b/app/init/resources.php index 92b581157f..d48a60c06c 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -29,6 +29,7 @@ use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\DI\Container; use Utopia\DSN\DSN; +use Utopia\Lock\Distributed; use Utopia\Pools\Group; use Utopia\Queue\Broker\Pool as BrokerPool; use Utopia\Queue\Publisher; @@ -248,6 +249,16 @@ $container->set('redis', function () { return $redis; }); +$container->set('locks', function (Group $pools) { + return function (string $key, int $ttl, callable $callback, float $timeout = 0.0) use ($pools): mixed { + return $pools->get('lock')->use(function (\Redis $redis) use ($key, $ttl, $callback, $timeout) { + $lock = new Distributed($redis, $key, ttl: $ttl); + + return $lock->withLock($callback, timeout: $timeout); + }); + }; +}, ['pools']); + $container->set('timelimit', function (\Redis $redis) { return function (string $key, int $limit, int $time) use ($redis) { return new TimeLimitRedis($key, $limit, $time, $redis); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php index 1718f7359f..9af5491598 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php @@ -21,7 +21,6 @@ use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Request; -use Utopia\Lock\Distributed; use Utopia\Lock\Exception\Contention as LockContention; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; @@ -94,7 +93,7 @@ class Create extends Action ->inject('plan') ->inject('authorization') ->inject('platform') - ->inject('redis') + ->inject('locks') ->callback($this->action(...)); } @@ -115,7 +114,7 @@ class Create extends Action array $plan, Authorization $authorization, array $platform, - \Redis $redis + callable $locks ) { $activate = \strval($activate) === 'true' || \strval($activate) === '1'; @@ -199,14 +198,12 @@ class Create extends Action $path = $deviceForFunctions->getPath($deploymentId . '.' . \pathinfo($fileName, PATHINFO_EXTENSION)); $lockKey = 'functions:deployment:' . $project->getId() . ':' . $functionId . ':' . $deploymentId; - $checkLock = new Distributed($redis, $lockKey, ttl: 600); - $stateLock = new Distributed($redis, $lockKey, ttl: 600); $metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)]; $completed = false; try { - $checkLock->withLock(function () use (&$chunks, $dbForProject, $deploymentId, &$metadata, &$completed, $response): void { + $locks($lockKey, 600, function () use (&$chunks, $dbForProject, $deploymentId, &$metadata, &$completed, $response): void { $deployment = $dbForProject->getDocument('deployments', $deploymentId); if (!$deployment->isEmpty()) { @@ -242,7 +239,7 @@ class Create extends Action $type = $request->getHeader('x-sdk-language') === 'cli' ? 'cli' : 'manual'; try { - $stateLock->withLock(function () use ($activate, &$chunks, $chunksUploaded, $commands, $dbForProject, $deploymentId, $deviceForFunctions, $entrypoint, $fileSize, &$function, $functionId, $path, &$metadata, $platform, $project, $publisherForBuilds, $queueForEvents, $response, $type): void { + $locks($lockKey, 600, function () use ($activate, &$chunks, $chunksUploaded, $commands, $dbForProject, $deploymentId, $deviceForFunctions, $entrypoint, $fileSize, &$function, $functionId, $path, &$metadata, $platform, $project, $publisherForBuilds, $queueForEvents, $response, $type): void { $deployment = $dbForProject->getDocument('deployments', $deploymentId); $uploaded = 0; diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index 2c2c34e913..d27755d106 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -21,7 +21,6 @@ use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Request; -use Utopia\Lock\Distributed; use Utopia\Lock\Exception\Contention as LockContention; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; @@ -92,7 +91,7 @@ class Create extends Action ->inject('plan') ->inject('authorization') ->inject('platform') - ->inject('redis') + ->inject('locks') ->callback($this->action(...)); } @@ -115,7 +114,7 @@ class Create extends Action array $plan, Authorization $authorization, array $platform, - \Redis $redis, + callable $locks, ) { $activate = \strval($activate) === 'true' || \strval($activate) === '1'; @@ -199,14 +198,12 @@ class Create extends Action $path = $deviceForSites->getPath($deploymentId . '.' . \pathinfo($fileName, PATHINFO_EXTENSION)); $lockKey = 'sites:deployment:' . $project->getId() . ':' . $siteId . ':' . $deploymentId; - $checkLock = new Distributed($redis, $lockKey, ttl: 600); - $stateLock = new Distributed($redis, $lockKey, ttl: 600); $metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)]; $completed = false; try { - $checkLock->withLock(function () use (&$chunks, $dbForProject, $deploymentId, &$metadata, &$completed, $response): void { + $locks($lockKey, 600, function () use (&$chunks, $dbForProject, $deploymentId, &$metadata, &$completed, $response): void { $deployment = $dbForProject->getDocument('deployments', $deploymentId); if (!$deployment->isEmpty()) { @@ -250,7 +247,7 @@ class Create extends Action } try { - $stateLock->withLock(function () use ($activate, $authorization, $commands, &$chunks, $chunksUploaded, $dbForPlatform, $dbForProject, $deploymentId, $deviceForSites, $fileSize, &$metadata, $outputDirectory, $path, $platform, $project, $publisherForBuilds, $queueForEvents, $response, &$site, $siteId, $type): void { + $locks($lockKey, 600, function () use ($activate, $authorization, $commands, &$chunks, $chunksUploaded, $dbForPlatform, $dbForProject, $deploymentId, $deviceForSites, $fileSize, &$metadata, $outputDirectory, $path, $platform, $project, $publisherForBuilds, $queueForEvents, $response, &$site, $siteId, $type): void { $deployment = $dbForProject->getDocument('deployments', $deploymentId); $uploaded = 0; diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php index 58f7381001..8530475f0c 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php @@ -29,7 +29,6 @@ use Utopia\Database\Validator\Authorization\Input; use Utopia\Database\Validator\Permissions; use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Request; -use Utopia\Lock\Distributed; use Utopia\Lock\Exception\Contention as LockContention; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; @@ -94,7 +93,7 @@ class Create extends Action ->inject('deviceForFiles') ->inject('deviceForLocal') ->inject('authorization') - ->inject('redis') + ->inject('locks') ->callback($this->action(...)); } @@ -112,7 +111,7 @@ class Create extends Action Device $deviceForFiles, Device $deviceForLocal, Authorization $authorization, - \Redis $redis + callable $locks ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); @@ -238,11 +237,7 @@ class Create extends Action $path = $deviceForFiles->getPath($fileId . '.' . \pathinfo($fileName, PATHINFO_EXTENSION)); $path = str_ireplace($deviceForFiles->getRoot(), $deviceForFiles->getRoot() . DIRECTORY_SEPARATOR . $bucket->getId(), $path); // Add bucket id to path after root - $lock = new Distributed( - $redis, - 'storage:file:' . $project->getId() . ':' . $bucket->getId() . ':' . $fileId, - ttl: 600, - ); + $lockKey = 'storage:file:' . $project->getId() . ':' . $bucket->getId() . ':' . $fileId; $metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)]; $completed = false; @@ -265,7 +260,7 @@ class Create extends Action }; try { - $lock->withLock(function () use ($bucket, &$chunks, $contentRange, $dbForProject, $deviceForFiles, $fileId, $fileName, $fileSize, &$metadata, $path, $permissions, $response, &$completed): void { + $locks($lockKey, 600, function () use ($bucket, &$chunks, $contentRange, $dbForProject, $deviceForFiles, $fileId, $fileName, $fileSize, &$metadata, $path, $permissions, $response, &$completed): void { $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); if (!$file->isEmpty()) { $chunks = $file->getAttribute('chunksTotal', 1); @@ -515,7 +510,7 @@ class Create extends Action throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed uploading file'); } - $lock->withLock(fn () => $finalizeUpload($chunksUploaded), timeout: 120.0); + $locks($lockKey, 600, fn () => $finalizeUpload($chunksUploaded), timeout: 120.0); } catch (LockContention) { $response->addHeader('Retry-After', '5'); throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'File upload is busy. Try again.'); From 27bf229524fe6e5e297830a29caafe565648cb5f Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Fri, 15 May 2026 21:12:14 +0400 Subject: [PATCH 46/47] Simplify storage test assumptions --- tests/e2e/Services/Storage/StorageBase.php | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index 3049cae5ca..375e526fcf 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -11,8 +11,6 @@ use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; use Utopia\Database\Validator\Datetime as DatetimeValidator; -use Utopia\Storage\Storage; -use Utopia\System\System; trait StorageBase { @@ -289,11 +287,7 @@ trait StorageBase $this->assertEquals('large-file.mp4', $largeFile['body']['name']); $this->assertEquals('video/mp4', $largeFile['body']['mimeType']); $this->assertEquals($totalSize, $largeFile['body']['sizeOriginal']); - if (System::getEnv('_APP_STORAGE_DEVICE') === Storage::DEVICE_S3 || str_starts_with(System::getEnv('_APP_CONNECTIONS_STORAGE', ''), Storage::DEVICE_S3 . '://')) { - $this->assertNotEmpty($largeFile['body']['signature']); - } else { - $this->assertEquals(md5_file(realpath(__DIR__ . '/../../../resources/disk-a/large-file.mp4')), $largeFile['body']['signature']); // should validate that the file is not encrypted - } + $this->assertEquals(md5_file(realpath(__DIR__ . '/../../../resources/disk-a/large-file.mp4')), $largeFile['body']['signature']); // should validate that the file is not encrypted /** * Failure @@ -397,7 +391,7 @@ trait StorageBase 'bucketId' => ID::unique(), 'name' => 'Test Bucket 2', 'fileSecurity' => true, - 'maximumFileSize' => ((int) System::getEnv('_APP_STORAGE_LIMIT', 0) ?: 6000000000) + 1, + 'maximumFileSize' => 6000000001, 'allowedFileExtensions' => ["jpg", "png"], 'permissions' => [ Permission::read(Role::any()), From 37921a569430080f6476701c8b580973bf681390 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Fri, 15 May 2026 21:20:20 +0400 Subject: [PATCH 47/47] Remove utopia-php/lock vcs repo --- composer.json | 6 ------ composer.lock | 29 ++++------------------------- 2 files changed, 4 insertions(+), 31 deletions(-) diff --git a/composer.json b/composer.json index 646112438c..400e3c1822 100644 --- a/composer.json +++ b/composer.json @@ -112,12 +112,6 @@ "provide": { "ext-phpiredis": "*" }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/utopia-php/lock" - } - ], "config": { "platform": { }, diff --git a/composer.lock b/composer.lock index ff112ce5b1..66d8f62925 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6bc4a0d273ff6b032b36fe205847d02a", + "content-hash": "035685d1335039f13e16d0532c874b21", "packages": [ { "name": "adhocore/jwt", @@ -4532,28 +4532,7 @@ "Utopia\\Lock\\": "src/" } }, - "autoload-dev": { - "psr-4": { - "Utopia\\Lock\\Tests\\": "tests/" - } - }, - "scripts": { - "test": [ - "vendor/bin/phpunit" - ], - "lint": [ - "vendor/bin/pint --test" - ], - "format": [ - "vendor/bin/pint" - ], - "format:check": [ - "vendor/bin/pint --test" - ], - "analyze": [ - "vendor/bin/phpstan analyse --memory-limit=512M" - ] - }, + "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], @@ -4565,8 +4544,8 @@ ], "description": "Mutex, semaphore, file and distributed locks for PHP — one interface, four backends.", "support": { - "source": "https://github.com/utopia-php/lock/tree/0.2.0", - "issues": "https://github.com/utopia-php/lock/issues" + "issues": "https://github.com/utopia-php/lock/issues", + "source": "https://github.com/utopia-php/lock/tree/0.2.0" }, "time": "2026-04-24T10:47:56+00:00" },