diff --git a/.claude/parallel-chunk-upload-storage-plan.md b/.claude/parallel-chunk-upload-storage-plan.md new file mode 100644 index 0000000000..289fa97f5a --- /dev/null +++ b/.claude/parallel-chunk-upload-storage-plan.md @@ -0,0 +1,174 @@ +# Parallel Chunk Upload Support for utopia-php/storage + +## Context + +The Appwrite API now supports out-of-order chunked uploads (chunks can arrive in any sequence). The next step is **parallel uploads** — multiple chunks uploaded simultaneously via separate HTTP requests. The SDK guarantees the first chunk is sent before any parallel chunks, so the document creation race is handled at the API layer. However, the storage device layer has a race condition that must be fixed. + +## Problem: `Local::joinChunks()` Race + +When two requests upload the final missing chunks in parallel, both can observe `countChunks() == $chunks` and call `joinChunks()` simultaneously. + +### Current behavior (loser throws) + +```php +// Local::joinChunks() +$dest = \fopen($tmpAssemble, 'wb'); +// ... stream all parts into $tmpAssemble ... + +if (! \rename($tmpAssemble, $path)) { + \unlink($tmpAssemble); + throw new Exception('Failed to finalize assembled file '.$path); +} +``` + +The winner succeeds with `rename()`. The loser gets `false` from `rename()` (file already exists at `$path`) and throws a 500-error exception. The client that lost the race receives an error even though the file is fully assembled. + +### Required behavior + +If `$path` already exists, another request already assembled the file. The loser should **silently succeed** — the file is complete, nothing more to do. + +## Proposed Changes + +### 1. `Local::joinChunks()` — Handle assembly race + +Before opening `$tmpAssemble`, check if the final file already exists. If it does, skip assembly entirely. + +```php +private function joinChunks(string $path, int $chunks): void +{ + // Race winner already assembled the file + if (\file_exists($path)) { + return; + } + + $tmp = \dirname($path).DIRECTORY_SEPARATOR.'tmp_'.asename($path); + $tmpAssemble = \dirname($path).DIRECTORY_SEPARATOR.'tmp_assemble_'.asename($path); + + // ... rest of assembly logic ... + + if (! \rename($tmpAssemble, $path)) { + // Another request may have won the race between fclose and rename + if (\file_exists($path)) { + \unlink($tmpAssemble); + return; + } + \unlink($tmpAssemble); + throw new Exception('Failed to finalize assembled file '.$path); + } + + // ... cleanup ... +} +``` + +### 2. `Local::countChunks()` — Reliability under concurrent writes + +`countChunks()` uses `glob()` on the temp directory. Under heavy parallel load, `glob()` might miss files or return inconsistent counts. The current implementation is already fairly robust (it validates `.part.\d+` suffix), but we should document that the return value is a best-effort snapshot. + +No code change needed here unless tests reveal issues. + +### 3. Tests — Concurrent chunk uploads + +Add a test that simulates two parallel requests completing a multi-chunk upload: + +```php +public function testParallelChunkUpload(): void +{ + $storage = $this->makeJoinTestStorage(); + $dest = $storage->getRoot().DIRECTORY_SEPARATOR.'parallel.dat'; + + // Upload chunk 1 (creates temp directory) + $storage->uploadData('AAAA', $dest, 'application/octet-stream', 1, 2); + + // Simulate two parallel requests uploading the last chunk + // In a real test, use pcntl_fork() or pthreads for true concurrency + // For the test suite, sequential calls are sufficient if we verify + // the second call doesn't throw after the first completed assembly + $storage->uploadData('BBBB', $dest, 'application/octet-stream', 2, 2); + + // Verify file exists and is correct + $this->assertTrue(\file_exists($dest)); + $this->assertSame('AAAABBBB', \file_get_contents($dest)); + + // Verify second assembly attempt doesn't throw + // (This simulates the race where another request already assembled) + try { + $storage->uploadData('BBBB', $dest, 'application/octet-stream', 2, 2); + } catch (\Exception $e) { + $this->fail('Duplicate assembly should not throw: '.$e->getMessage()); + } + + $storage->delete($storage->getRoot(), true); +} +``` + +A more realistic concurrent test using `pcntl_fork()`: + +```php +public function testParallelChunkUploadWithFork(): void +{ + if (!\function_exists('pcntl_fork')) { + $this->markTestSkipped('pcntl extension required for fork-based concurrency test'); + } + + $storage = $this->makeJoinTestStorage(); + $dest = $storage->getRoot().DIRECTORY_SEPARATOR.'parallel-fork.dat'; + + // Pre-upload chunk 1 + $storage->uploadData('AAAA', $dest, 'application/octet-stream', 1, 2); + + $pid = pcntl_fork(); + if ($pid === -1) { + $this->fail('Failed to fork'); + } elseif ($pid === 0) { + // Child process: upload chunk 2 + try { + $storage->uploadData('BBBB', $dest, 'application/octet-stream', 2, 2); + exit(0); + } catch (\Exception $e) { + exit(1); + } + } + + // Parent process: also upload chunk 2 (race condition) + $parentSuccess = true; + try { + $storage->uploadData('BBBB', $dest, 'application/octet-stream', 2, 2); + } catch (\Exception $e) { + $parentSuccess = false; + } + + pcntl_waitpid($pid, $status); + $childSuccess = pcntl_wexitstatus($status) === 0; + + // At least one should succeed + $this->assertTrue($parentSuccess || $childSuccess, 'At least one parallel upload should succeed'); + + // File should be correctly assembled + $this->assertTrue(\file_exists($dest)); + $this->assertSame('AAAABBBB', \file_get_contents($dest)); + + $storage->delete($storage->getRoot(), true); +} +``` + +## S3 Device + +S3 already handles out-of-order multipart uploads natively. The `completeMultipartUpload` call with `ksort()` sorts parts by number regardless of upload order. However, parallel `completeMultipartUpload` calls for the same `uploadId` would still be problematic. + +This is an **API-layer concern** — the Appwrite API should ensure only one request calls `completeMultipartUpload` per upload. The S3 device itself does not need changes. + +## Files to Change + +| File | Change | +|------|--------| +| `src/Storage/Device/Local.php` | Add `file_exists($path)` guard at start of `joinChunks()` and in `rename()` failure handler | +| `tests/Storage/Device/LocalTest.php` | Add `testParallelChunkUpload` and `testParallelChunkUploadWithFork` | + +## Backwards Compatibility + +Fully backwards compatible. The change only affects the error path when `rename()` fails due to an existing file. Previously it threw; now it returns silently. No public API signatures change. + +## Related PRs + +- Appwrite server PR: https://github.com/appwrite/appwrite/pull/12138 (out-of-order upload support) +- This storage PR is a prerequisite for the follow-up Appwrite PR that enables parallel chunk uploads at the API level. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e521ac3771..8cc3b3e113 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ concurrency: env: COMPOSE_FILE: docker-compose.yml IMAGE: appwrite-dev + REGISTRY_IMAGE: ghcr.io/${{ github.repository }}/appwrite-dev K6_VERSION: '0.53.0' on: @@ -19,6 +20,10 @@ on: type: string default: '' +permissions: + contents: read + packages: write + jobs: dependencies: name: Checks / Dependencies @@ -258,32 +263,30 @@ jobs: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - - name: Build Appwrite + - name: Build and push Appwrite uses: docker/build-push-action@v6 with: context: . - push: false - tags: ${{ env.IMAGE }} - load: true + push: true + tags: ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max - outputs: type=docker,dest=/tmp/${{ env.IMAGE }}.tar target: development build-args: | DEBUG=false TESTING=true VERSION=dev - - name: Upload Docker Image - uses: actions/upload-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp/${{ env.IMAGE }}.tar - retention-days: 1 - unit: name: Tests / Unit runs-on: ubuntu-latest @@ -291,26 +294,32 @@ jobs: permissions: contents: read pull-requests: write + packages: read steps: - name: checkout uses: actions/checkout@v6 - - name: Download Docker Image - uses: actions/download-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp - - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull Docker Image + run: | + docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }} + - name: Load and Start Appwrite timeout-minutes: 5 run: | - docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable docker compose up -d --quiet-pull --wait @@ -338,26 +347,32 @@ jobs: permissions: contents: read pull-requests: write + packages: read steps: - name: checkout uses: actions/checkout@v6 - - name: Download Docker Image - uses: actions/download-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp - - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull Docker Image + run: | + docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }} + - name: Load and Start Appwrite timeout-minutes: 5 run: | - docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable docker compose up -d --quiet-pull --wait @@ -396,6 +411,7 @@ jobs: permissions: contents: read pull-requests: write + packages: read strategy: fail-fast: false matrix: @@ -450,16 +466,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - - name: Download Docker Image - uses: actions/download-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp - - name: Set environment run: | echo "_APP_OPTIONS_ROUTER_PROTECTION=enabled" >> $GITHUB_ENV - + if [ "${{ matrix.database }}" = "MariaDB" ]; then echo "COMPOSE_PROFILES=mariadb" >> $GITHUB_ENV echo "_APP_DB_ADAPTER=mariadb" >> $GITHUB_ENV @@ -483,6 +493,18 @@ jobs: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull Docker Image + run: | + docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }} + - name: Load and Start Appwrite timeout-minutes: 5 env: @@ -491,7 +513,6 @@ jobs: _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} run: | - docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable docker compose up -d --quiet-pull --wait @@ -545,6 +566,7 @@ jobs: permissions: contents: read pull-requests: write + packages: read strategy: fail-fast: false matrix: @@ -555,18 +577,24 @@ jobs: with: fetch-depth: 1 - - name: Download Docker Image - uses: actions/download-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp - - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull Docker Image + run: | + docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }} + - name: Load and Start Appwrite timeout-minutes: 5 env: @@ -575,7 +603,6 @@ jobs: _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} run: | - docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable docker compose up -d --quiet-pull --wait @@ -606,6 +633,7 @@ jobs: permissions: contents: read pull-requests: write + packages: read strategy: fail-fast: false matrix: @@ -614,18 +642,24 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - - name: Download Docker Image - uses: actions/download-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp - - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull Docker Image + run: | + docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }} + - name: Load and Start Appwrite timeout-minutes: 5 env: @@ -633,7 +667,6 @@ jobs: _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} run: | - docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable docker compose up -d --quiet-pull --wait @@ -675,28 +708,31 @@ jobs: contents: read issues: write pull-requests: write + packages: read steps: - name: Checkout repository uses: actions/checkout@v6 with: fetch-depth: 1 - - name: Download Docker Image - uses: actions/download-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp - - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Load Appwrite image + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull Appwrite image run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - docker tag ${{ env.IMAGE }} ${{ env.IMAGE }}:after + docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}:after - name: Setup k6 uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 diff --git a/.github/workflows/cleanup-cache.yml b/.github/workflows/cleanup-cache.yml index 8f9f05a38c..4b6b13d35d 100644 --- a/.github/workflows/cleanup-cache.yml +++ b/.github/workflows/cleanup-cache.yml @@ -5,6 +5,11 @@ on: types: - closed +permissions: + actions: write + contents: read + packages: write + jobs: cleanup: runs-on: ubuntu-latest @@ -36,4 +41,29 @@ jobs: done done env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Cleanup GHCR image + continue-on-error: true + run: | + package_path="${GITHUB_REPOSITORY#*/}/appwrite-dev" + encoded_path="$(printf '%s' "$package_path" | jq -Rr @uri)" + + gh api --paginate "/repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.pull_request.number }}/commits" --jq '.[].sha' | while read -r sha; do + version_ids=$(gh api --paginate -H "Accept: application/vnd.github+json" \ + "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions" \ + --jq ".[] | select(.metadata.container.tags | index(\"${sha}\")) | .id") + + if [ -z "$version_ids" ]; then + echo "No GHCR version found for SHA ${sha}" + continue + fi + + echo "$version_ids" | while read -r version_id; do + gh api --method DELETE -H "Accept: application/vnd.github+json" \ + "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions/${version_id}" + echo "Deleted ${package_path}:${sha} (version ${version_id})" + done + done + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5cbec8f867..c4289678bb 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -24,9 +24,11 @@ jobs: ignore-unfixed: 'false' severity: 'CRITICAL,HIGH' - name: Upload Docker Image Scan Results - uses: github/codeql-action/upload-sarif@v2 + uses: github/codeql-action/upload-sarif@v4 + if: always() && hashFiles('trivy-image-results.sarif') != '' with: sarif_file: 'trivy-image-results.sarif' + category: 'trivy-image' scan-code: name: Scan Code @@ -42,6 +44,8 @@ jobs: output: 'trivy-fs-results.sarif' severity: 'CRITICAL,HIGH' - name: Upload Code Scan Results - uses: github/codeql-action/upload-sarif@v2 + uses: github/codeql-action/upload-sarif@v4 + if: always() && hashFiles('trivy-fs-results.sarif') != '' with: sarif_file: 'trivy-fs-results.sarif' + category: 'trivy-source' diff --git a/Dockerfile b/Dockerfile index 7cb007c188..1922a0d2b9 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist \ `if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi` -FROM appwrite/base:1.0.1 AS base +FROM appwrite/base:1.2.1 AS base LABEL maintainer="team@appwrite.io" @@ -24,6 +24,10 @@ ENV _APP_VERSION=$VERSION \ _APP_HOME=https://appwrite.io RUN \ + if [ "$DEBUG" != "true" ]; then \ + rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \ + rm -f /usr/local/lib/php/extensions/no-debug-non-zts-*/xdebug.so; \ + fi && \ if [ "$DEBUG" == "true" ]; then \ apk add boost boost-dev; \ fi @@ -100,7 +104,8 @@ RUN mkdir -p /etc/letsencrypt/live/ && chmod -Rf 755 /etc/letsencrypt/live/ FROM base AS production RUN rm -rf /usr/src/code/app/config/specs && \ - rm -f /usr/local/lib/php/extensions/no-debug-non-zts-20240924/xdebug.so && \ + rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini && \ + rm -f /usr/local/lib/php/extensions/no-debug-non-zts-*/xdebug.so && \ find /usr -name '*.a' -delete 2>/dev/null || true && \ find /usr -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true && \ find /usr -name '*.pyc' -delete 2>/dev/null || true diff --git a/app/config/errors.php b/app/config/errors.php index f5603f9223..40d4ceeebc 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -389,7 +389,7 @@ return [ ], Exception::API_KEY_EXPIRED => [ 'name' => Exception::API_KEY_EXPIRED, - 'description' => 'The dynamic API key has expired. Please don\'t use dynamic API keys for more than duration of the execution.', + 'description' => 'The ephemeral API key has expired. Please don\'t use ephemeral API keys for more than duration of the execution.', 'code' => 401, ], diff --git a/app/config/roles.php b/app/config/roles.php index d653b4857c..8fba27e503 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -21,8 +21,8 @@ $member = [ 'projects.read', 'locale.read', 'avatars.read', - 'execution.read', - 'execution.write', + 'executions.read', + 'executions.write', 'targets.read', 'targets.write', 'subscribers.write', @@ -81,8 +81,8 @@ $admins = [ 'sites.write', 'log.read', 'log.write', - 'execution.read', - 'execution.write', + 'executions.read', + 'executions.write', 'rules.read', 'rules.write', 'migrations.read', @@ -123,7 +123,7 @@ return [ 'files.write', 'locale.read', 'avatars.read', - 'execution.write', + 'executions.write', ], ], User::ROLE_USERS => [ diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 947cd863f8..63b946f74f 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -1,239 +1,340 @@ [ - 'description' => 'Access to create, update, and delete user sessions', - ], - 'users.read' => [ - 'description' => 'Access to read your project\'s users', - ], - 'users.write' => [ - 'description' => 'Access to create, update, and delete your project\'s users', - ], - 'teams.read' => [ - 'description' => 'Access to read your project\'s teams', - ], - 'teams.write' => [ - 'description' => 'Access to create, update, and delete your project\'s teams', - ], - 'databases.read' => [ - 'description' => 'Access to read your project\'s databases', - ], - 'databases.write' => [ - 'description' => 'Access to create, update, and delete your project\'s databases', - ], - 'collections.read' => [ - 'description' => 'Access to read your project\'s database collections', - ], - 'collections.write' => [ - 'description' => 'Access to create, update, and delete your project\'s database collections', - ], - 'tables.read' => [ - 'description' => 'Access to read your project\'s database tables', - ], - 'tables.write' => [ - 'description' => 'Access to create, update, and delete your project\'s database tables', - ], - 'attributes.read' => [ - 'description' => 'Access to read your project\'s database collection\'s attributes', - ], - 'attributes.write' => [ - 'description' => 'Access to create, update, and delete your project\'s database collection\'s attributes', - ], - 'columns.read' => [ - 'description' => 'Access to read your project\'s database table\'s columns', - ], - 'columns.write' => [ - 'description' => 'Access to create, update, and delete your project\'s database table\'s columns', - ], - 'indexes.read' => [ - 'description' => 'Access to read your project\'s database table\'s indexes', - ], - 'indexes.write' => [ - 'description' => 'Access to create, update, and delete your project\'s database table\'s indexes', - ], - 'documents.read' => [ - 'description' => 'Access to read your project\'s database documents', - ], - 'documents.write' => [ - 'description' => 'Access to create, update, and delete your project\'s database documents', - ], - 'rows.read' => [ - 'description' => 'Access to read your project\'s database rows', - ], - 'rows.write' => [ - 'description' => 'Access to create, update, and delete your project\'s database rows', - ], - 'files.read' => [ - 'description' => 'Access to read your project\'s storage files and preview images', - ], - 'files.write' => [ - 'description' => 'Access to create, update, and delete your project\'s storage files', - ], - 'buckets.read' => [ - 'description' => 'Access to read your project\'s storage buckets', - ], - 'buckets.write' => [ - 'description' => 'Access to create, update, and delete your project\'s storage buckets', - ], - 'functions.read' => [ - 'description' => 'Access to read your project\'s functions and code deployments', - ], - 'functions.write' => [ - 'description' => 'Access to create, update, and delete your project\'s functions and code deployments', - ], - 'sites.read' => [ - 'description' => 'Access to read your project\'s sites and deployments', - ], - 'sites.write' => [ - 'description' => 'Access to create, update, and delete your project\'s sites and deployments', - ], - 'log.read' => [ - 'description' => 'Access to read your site\'s logs', - ], - 'log.write' => [ - 'description' => 'Access to update, and delete your site\'s logs', - ], - 'execution.read' => [ - 'description' => 'Access to read your project\'s execution logs', - ], - 'execution.write' => [ - 'description' => 'Access to execute your project\'s functions', - ], - 'locale.read' => [ - 'description' => 'Access to access your project\'s Locale service', - ], - 'avatars.read' => [ - 'description' => 'Access to access your project\'s Avatars service', - ], - 'health.read' => [ - 'description' => 'Access to read your project\'s health status', - ], - 'providers.read' => [ - 'description' => 'Access to read your project\'s providers', - ], - 'providers.write' => [ - 'description' => 'Access to create, update, and delete your project\'s providers', - ], - 'messages.read' => [ - 'description' => 'Access to read your project\'s messages', - ], - 'messages.write' => [ - 'description' => 'Access to create, update, and delete your project\'s messages', - ], - 'topics.read' => [ - 'description' => 'Access to read your project\'s topics', - ], - 'topics.write' => [ - 'description' => 'Access to create, update, and delete your project\'s topics', - ], - 'subscribers.read' => [ - 'description' => 'Access to read your project\'s subscribers', - ], - 'subscribers.write' => [ - 'description' => 'Access to create, update, and delete your project\'s subscribers', - ], - 'targets.read' => [ - 'description' => 'Access to read your project\'s targets', - ], - 'targets.write' => [ - 'description' => 'Access to create, update, and delete your project\'s targets', - ], - 'rules.read' => [ - 'description' => 'Access to read your project\'s proxy rules', - ], - 'rules.write' => [ - 'description' => 'Access to create, update, and delete your project\'s proxy rules', - ], - 'schedules.read' => [ - 'description' => 'Access to read your project\'s schedules', - ], - 'schedules.write' => [ - 'description' => 'Access to create, update, and delete your project\'s schedules', - ], - 'migrations.read' => [ - 'description' => 'Access to read your project\'s migrations', - ], - 'migrations.write' => [ - 'description' => 'Access to create, update, and delete your project\'s migrations.', - ], - 'vcs.read' => [ - 'description' => 'Access to read your project\'s VCS repositories', - ], - 'vcs.write' => [ - 'description' => 'Access to create, update, and delete your project\'s VCS repositories', - ], - 'assistant.read' => [ - 'description' => 'Access to read the Assistant service', - ], - 'tokens.read' => [ - 'description' => 'Access to read your project\'s tokens', - ], - 'tokens.write' => [ - 'description' => 'Access to create, update, and delete your project\'s tokens', - ], - "webhooks.read" => [ - "description" => - "Access to read project\'s webhooks", - ], - "webhooks.write" => [ - "description" => - "Access to create, update, and delete project\'s webhooks", - ], +// List of publicly visible scopes +return [ + // Project "project.read" => [ "description" => "Access to read project\'s information", + "category" => "Project", ], "project.write" => [ "description" => "Access to update project\'s information", + "category" => "Project", ], "keys.read" => [ "description" => "Access to read project\'s keys", + "category" => "Project", ], "keys.write" => [ "description" => "Access to create, update, and delete project\'s keys", + "category" => "Project", ], "platforms.read" => [ "description" => "Access to read project\'s platforms", + "category" => "Project", ], "platforms.write" => [ "description" => "Access to create, update, and delete project\'s platforms", + "category" => "Project", ], "mocks.read" => [ "description" => "Access to read project\'s mocks", + "category" => "Project", ], "mocks.write" => [ "description" => "Access to create, update, and delete project\'s mocks", + "category" => "Project", ], "policies.read" => [ "description" => "Access to read project\'s policies", + "category" => "Project", ], "policies.write" => [ "description" => "Access to update project\'s policies", + "category" => "Project", ], "templates.read" => [ "description" => "Access to read project\'s templates", + "category" => "Project", ], "templates.write" => [ "description" => "Access to create, update, and delete project\'s templates", + "category" => "Project", ], "oauth2.read" => [ "description" => "Access to read project\'s OAuth2 configuration", + "category" => "Project", ], "oauth2.write" => [ "description" => "Access to update project\'s OAuth2 configuration", + "category" => "Project", + ], + + // Auth + 'users.read' => [ + 'description' => 'Access to read users', + 'category' => 'Auth', + ], + 'users.write' => [ + 'description' => 'Access to create, update, and delete users', + 'category' => 'Auth', + ], + 'sessions.read' => [ + 'description' => 'Access to read user sessions', + 'category' => 'Auth', + ], + 'sessions.write' => [ + 'description' => 'Access to create, update, and delete user sessions', + 'category' => 'Auth', + ], + 'teams.read' => [ + 'description' => 'Access to read teams', + 'category' => 'Auth', + ], + 'teams.write' => [ + 'description' => 'Access to create, update, and delete teams', + 'category' => 'Auth', + ], + + // Databases + 'databases.read' => [ + 'description' => 'Access to read databases', + 'category' => 'Databases', + ], + 'databases.write' => [ + 'description' => 'Access to create, update, and delete databases', + 'category' => 'Databases', + ], + 'tables.read' => [ + 'description' => 'Access to read database tables', + 'category' => 'Databases', + ], + 'tables.write' => [ + 'description' => 'Access to create, update, and delete database tables', + 'category' => 'Databases', + ], + 'columns.read' => [ + 'description' => 'Access to read database table columns', + 'category' => 'Databases', + ], + 'columns.write' => [ + 'description' => 'Access to create, update, and delete database table columns', + 'category' => 'Databases', + ], + 'indexes.read' => [ + 'description' => 'Access to read database table indexes', + 'category' => 'Databases', + ], + 'indexes.write' => [ + 'description' => 'Access to create, update, and delete database table indexes', + 'category' => 'Databases', + ], + 'rows.read' => [ + 'description' => 'Access to read database table rows', + 'category' => 'Databases', + ], + 'rows.write' => [ + 'description' => 'Access to create, update, and delete database table rows', + 'category' => 'Databases', + ], + 'collections.read' => [ + 'description' => 'Access to read database collections', + 'category' => 'Databases', + 'deprecated' => true, + ], + 'collections.write' => [ + 'description' => 'Access to create, update, and delete database collections', + 'category' => 'Databases', + 'deprecated' => true, + ], + 'attributes.read' => [ + 'description' => 'Access to read database collection attributes', + 'category' => 'Databases', + 'deprecated' => true, + ], + 'attributes.write' => [ + 'description' => 'Access to create, update, and delete database collection attributes', + 'category' => 'Databases', + 'deprecated' => true, + ], + 'documents.read' => [ + 'description' => 'Access to read database collection documents', + 'category' => 'Databases', + 'deprecated' => true, + ], + 'documents.write' => [ + 'description' => 'Access to create, update, and delete database collection documents', + 'category' => 'Databases', + 'deprecated' => true, + ], + + // Storage + 'buckets.read' => [ + 'description' => 'Access to read storage buckets', + 'category' => 'Storage', + ], + 'buckets.write' => [ + 'description' => 'Access to create, update, and delete storage buckets', + 'category' => 'Storage', + ], + 'files.read' => [ + 'description' => 'Access to read storage files and preview images', + 'category' => 'Storage', + ], + 'files.write' => [ + 'description' => 'Access to create, update, and delete storage files', + 'category' => 'Storage', + ], + 'tokens.read' => [ + 'description' => 'Access to read storage file tokens', + 'category' => 'Storage', + ], + 'tokens.write' => [ + 'description' => 'Access to create, update, and delete storage file tokens', + 'category' => 'Storage', + ], + + // Functions + 'functions.read' => [ + 'description' => 'Access to read functions and deployments', + 'category' => 'Functions', + ], + 'functions.write' => [ + 'description' => 'Access to create, update, and delete functions and deployments', + 'category' => 'Functions', + ], + 'executions.read' => [ + 'description' => 'Access to read function executions', + 'category' => 'Functions', + ], + 'executions.write' => [ + 'description' => 'Access to create function executions', + 'category' => 'Functions', + ], + + // Sites + 'sites.read' => [ + 'description' => 'Access to read sites and deployments', + 'category' => 'Sites', + ], + 'sites.write' => [ + 'description' => 'Access to create, update, and delete sites and deployments', + 'category' => 'Sites', + ], + 'log.read' => [ + 'description' => 'Access to read site logs', + 'category' => 'Sites', + ], + 'log.write' => [ + 'description' => 'Access to update, and delete site logs', + 'category' => 'Sites', + ], + + // Messaging + 'providers.read' => [ + 'description' => 'Access to read messaging providers', + 'category' => 'Messaging', + ], + 'providers.write' => [ + 'description' => 'Access to create, update, and delete messaging providers', + 'category' => 'Messaging', + ], + 'topics.read' => [ + 'description' => 'Access to read messaging topics', + 'category' => 'Messaging', + ], + 'topics.write' => [ + 'description' => 'Access to create, update, and delete messaging topics', + 'category' => 'Messaging', + ], + 'subscribers.read' => [ + 'description' => 'Access to read messaging subscribers', + 'category' => 'Messaging', + ], + 'subscribers.write' => [ + 'description' => 'Access to create, update, and delete messaging subscribers', + 'category' => 'Messaging', + ], + 'targets.read' => [ + 'description' => 'Access to read messaging targets', + 'category' => 'Messaging', + ], + 'targets.write' => [ + 'description' => 'Access to create, update, and delete messaging targets', + 'category' => 'Messaging', + ], + 'messages.read' => [ + 'description' => 'Access to read messaging messages', + 'category' => 'Messaging', + ], + 'messages.write' => [ + 'description' => 'Access to create, update, and delete messaging messages', + 'category' => 'Messaging', + ], + + // Other + "webhooks.read" => [ + "description" => + "Access to read webhooks", + 'category' => 'Other', + ], + "webhooks.write" => [ + "description" => + "Access to create, update, and delete webhooks", + 'category' => 'Other', + ], + 'locale.read' => [ + 'description' => 'Access to use Locale service', + 'category' => 'Other', + ], + 'avatars.read' => [ + 'description' => 'Access to use Avatars service', + 'category' => 'Other', + ], + 'health.read' => [ + 'description' => 'Access to use Health service', + 'category' => 'Other', + ], + 'assistant.read' => [ + 'description' => 'Access to use Assistant service', + 'category' => 'Other', + ], + 'migrations.read' => [ + 'description' => 'Access to read migrations', + 'category' => 'Other', + ], + 'migrations.write' => [ + 'description' => 'Access to create, update, and delete migrations.', + 'category' => 'Other', + ], + + // TODO: Figure out where to move those + 'schedules.read' => [ + 'description' => 'Access to read schedules.', + 'category' => 'Other', + ], + 'schedules.write' => [ + 'description' => 'Access to create, update, and delete schedules.', + 'category' => 'Other', + ], + 'vcs.read' => [ + 'description' => 'Access to read resources under VCS service.', + 'category' => 'Other', + ], + 'vcs.write' => [ + 'description' => 'Access to create, update, and delete resources under VCS service.', + 'category' => 'Other', + ], + 'rules.read' => [ + 'description' => 'Access to read proxy rules.', + 'category' => 'Other', + ], + 'rules.write' => [ + 'description' => 'Access to create, update, and delete proxy rules.', + 'category' => 'Other', ], ]; diff --git a/app/config/services.php b/app/config/services.php index 548f659a81..cf2714f8c5 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -286,7 +286,7 @@ return [ 'name' => 'Migrations', 'subtitle' => 'The Migrations service allows you to migrate third-party data to your Appwrite project.', 'description' => '/docs/services/migrations.md', - 'controller' => 'api/migrations.php', + 'controller' => '', // Uses modules 'sdk' => true, 'docs' => true, 'docsUrl' => 'https://appwrite.io/docs/migrations', diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php deleted file mode 100644 index 7338197511..0000000000 --- a/app/controllers/api/migrations.php +++ /dev/null @@ -1,1277 +0,0 @@ - Transfer::GROUP_DATABASES_TABLES_DB, - DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, - DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, - default => throw new \LogicException('Unknown database type: ' . $databaseType), - }; -} - -function getDatabaseResourceType(string $databaseType): string -{ - return match($databaseType) { - DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB, - DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB, - default => Resource::TYPE_DATABASE, - }; -} - -Http::post('/v1/migrations/appwrite') - ->groups(['api', 'migrations']) - ->desc('Create Appwrite migration') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createAppwriteMigration', - description: '/docs/references/migrations/migration-appwrite.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(Appwrite::getSupportedResources())), 'List of resources to migrate') - ->param('endpoint', '', new URL(), 'Source Appwrite endpoint') - ->param('projectId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Source Project ID', false, ['dbForProject']) - ->param('apiKey', '', new Text(512), 'Source API Key') - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('platform') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function (array $resources, string $endpoint, string $projectId, string $apiKey, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => ID::unique(), - 'status' => 'pending', - 'stage' => 'init', - 'source' => Appwrite::getName(), - 'destination' => Appwrite::getName(), - 'credentials' => [ - 'endpoint' => $endpoint, - 'projectId' => $projectId, - 'apiKey' => $apiKey, - ], - 'resources' => $resources, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - // Trigger Transfer - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/firebase') - ->groups(['api', 'migrations']) - ->desc('Create Firebase migration') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createFirebaseMigration', - description: '/docs/references/migrations/migration-firebase.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate') - ->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials') - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('platform') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function (array $resources, string $serviceAccount, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { - $serviceAccountData = json_decode($serviceAccount, true); - - if (empty($serviceAccountData)) { - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); - } - - if (!isset($serviceAccountData['project_id']) || !isset($serviceAccountData['client_email']) || !isset($serviceAccountData['private_key'])) { - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); - } - - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => ID::unique(), - 'status' => 'pending', - 'stage' => 'init', - 'source' => Firebase::getName(), - 'destination' => Appwrite::getName(), - 'credentials' => [ - 'serviceAccount' => $serviceAccount, - ], - 'resources' => $resources, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - // Trigger Transfer - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/supabase') - ->groups(['api', 'migrations']) - ->desc('Create Supabase migration') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createSupabaseMigration', - description: '/docs/references/migrations/migration-supabase.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate') - ->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint') - ->param('apiKey', '', new Text(512), 'Source\'s API Key') - ->param('databaseHost', '', new Text(512), 'Source\'s Database Host') - ->param('username', '', new Text(512), 'Source\'s Database Username') - ->param('password', '', new Text(512), 'Source\'s Database Password') - ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true) - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('platform') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => ID::unique(), - 'status' => 'pending', - 'stage' => 'init', - 'source' => Supabase::getName(), - 'destination' => Appwrite::getName(), - 'credentials' => [ - 'endpoint' => $endpoint, - 'apiKey' => $apiKey, - 'databaseHost' => $databaseHost, - 'username' => $username, - 'password' => $password, - 'port' => $port, - ], - 'resources' => $resources, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - // Trigger Transfer - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/nhost') - ->groups(['api', 'migrations']) - ->desc('Create NHost migration') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createNHostMigration', - description: '/docs/references/migrations/migration-nhost.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate') - ->param('subdomain', '', new Text(512), 'Source\'s Subdomain') - ->param('region', '', new Text(512), 'Source\'s Region') - ->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret') - ->param('database', '', new Text(512), 'Source\'s Database Name') - ->param('username', '', new Text(512), 'Source\'s Database Username') - ->param('password', '', new Text(512), 'Source\'s Database Password') - ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true) - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('platform') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function (array $resources, string $subdomain, string $region, string $adminSecret, string $database, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => ID::unique(), - 'status' => 'pending', - 'stage' => 'init', - 'source' => NHost::getName(), - 'destination' => Appwrite::getName(), - 'credentials' => [ - 'subdomain' => $subdomain, - 'region' => $region, - 'adminSecret' => $adminSecret, - 'database' => $database, - 'username' => $username, - 'password' => $password, - 'port' => $port, - ], - 'resources' => $resources, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - // Trigger Transfer - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/csv/imports') - ->alias('/v1/migrations/csv') - ->groups(['api', 'migrations']) - ->desc('Import documents from a CSV') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createCSVImport', - description: '/docs/references/migrations/migration-csv-import.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('bucketId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).', false, ['dbForProject']) - ->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File ID.', false, ['dbForProject']) - ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') - ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('authorization') - ->inject('project') - ->inject('platform') - ->inject('deviceForFiles') - ->inject('deviceForMigrations') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function ( - string $bucketId, - string $fileId, - string $resourceId, - bool $internalFile, - Response $response, - Database $dbForProject, - Database $dbForPlatform, - Authorization $authorization, - Document $project, - array $platform, - Device $deviceForFiles, - Device $deviceForMigrations, - Event $queueForEvents, - MigrationPublisher $publisherForMigrations - ) { - $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) { - if ($internalFile) { - return $dbForPlatform->getDocument('buckets', 'default'); - } - return $dbForProject->getDocument('buckets', $bucketId); - }); - - if ($bucket->isEmpty()) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - $path = $file->getAttribute('path', ''); - if (!$deviceForFiles->exists($path)) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); - } - - // No encryption or compression on files above 20MB. - $hasEncryption = !empty($file->getAttribute('openSSLCipher')); - $compression = $file->getAttribute('algorithm', Compression::NONE); - $hasCompression = $compression !== Compression::NONE; - - $migrationId = ID::unique(); - $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.csv'); - - if ($hasEncryption || $hasCompression) { - $source = $deviceForFiles->read($path); - - if ($hasEncryption) { - $source = OpenSSL::decrypt( - $source, - $file->getAttribute('openSSLCipher'), - System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), - 0, - hex2bin($file->getAttribute('openSSLIV')), - hex2bin($file->getAttribute('openSSLTag')) - ); - } - - if ($hasCompression) { - switch ($compression) { - case Compression::ZSTD: - $source = (new Zstd())->decompress($source); - break; - case Compression::GZIP: - $source = (new GZIP())->decompress($source); - break; - } - } - - // Manual write after decryption and/or decompression - if (!$deviceForMigrations->write($newPath, $source, 'text/csv')) { - throw new \Exception('Unable to copy file'); - } - } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) { - throw new \Exception('Unable to copy file'); - } - - // getting databasetype - $resources = explode(':', $resourceId); - $databaseId = $resources[0]; - $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - $databaseType = $database->getAttribute('type'); - if (!in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) { - throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv'); - } - $fileSize = $deviceForMigrations->getFileSize($newPath); - $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]); - $resourceType = getDatabaseResourceType($databaseType); - - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => $migrationId, - 'status' => 'pending', - 'stage' => 'init', - 'source' => CSV::getName(), - 'destination' => Appwrite::getName(), - 'resources' => $resources, - 'resourceId' => $resourceId, - 'resourceType' => $resourceType, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - 'options' => [ - 'path' => $newPath, - 'size' => $fileSize, - ], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/csv/exports') - ->groups(['api', 'migrations']) - ->desc('Export documents to CSV') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createCSVExport', - description: '/docs/references/migrations/migration-csv-export.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.') - ->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .csv extension.') - ->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true) - ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) - ->param('delimiter', ',', new Text(1), 'The character that separates each column value. Default is comma.', true) - ->param('enclosure', '"', new Text(1), 'The character that encloses each column value. Default is double quotes.', true) - ->param('escape', '"', new Text(1), 'The escape character for the enclosure character. Default is double quotes.', true) - ->param('header', true, new Boolean(), 'Whether to include the header row with column names. Default is true.', true) - ->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true) - ->inject('user') - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('authorization') - ->inject('project') - ->inject('platform') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function ( - string $resourceId, - string $filename, - array $columns, - array $queries, - string $delimiter, - string $enclosure, - string $escape, - bool $header, - bool $notify, - Document $user, - Response $response, - Database $dbForProject, - Database $dbForPlatform, - Authorization $authorization, - Document $project, - array $platform, - Event $queueForEvents, - MigrationPublisher $publisherForMigrations - ) { - try { - $parsedQueries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - $bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default')); - if ($bucket->isEmpty()) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - [$databaseId, $collectionId] = \explode(':', $resourceId, 2); - if (empty($databaseId)) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - if (empty($collectionId)) { - throw new Exception(Exception::COLLECTION_NOT_FOUND); - } - - $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - if ($database->isEmpty()) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - - $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); - if ($collection->isEmpty()) { - throw new Exception(Exception::COLLECTION_NOT_FOUND); - } - - // getting databasetype - $resources = explode(':', $resourceId); - $databaseId = $resources[0]; - $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - $databaseType = $database->getAttribute('type'); - if (!in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) { - throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv'); - } - - // Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields - $isSchemaless = in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]); - - $validator = new Documents( - attributes: $collection->getAttribute('attributes', []), - indexes: $collection->getAttribute('indexes', []), - idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(), - supportForAttributes: !$isSchemaless, - ); - - if (!$validator->isValid($parsedQueries)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]); - $resourceType = getDatabaseResourceType($databaseType); - - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => ID::unique(), - 'status' => 'pending', - 'stage' => 'init', - 'source' => Appwrite::getName(), - 'destination' => CSV::getName(), - 'resources' => $resources, - 'resourceId' => $resourceId, - 'resourceType' => $resourceType, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - 'options' => [ - 'bucketId' => 'default', // Always use internal bucket - 'filename' => $filename, - 'columns' => $columns, - 'queries' => $queries, - 'delimiter' => $delimiter, - 'enclosure' => $enclosure, - 'escape' => $escape, - 'header' => $header, - 'notify' => $notify, - 'userInternalId' => $user->getSequence(), - ], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/json/imports') - ->groups(['api', 'migrations']) - ->desc('Import documents from a JSON') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createJSONImport', - description: '/docs/references/migrations/migration-json-import.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') - ->param('fileId', '', new UID(), 'File ID.') - ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') - ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('authorization') - ->inject('project') - ->inject('platform') - ->inject('deviceForFiles') - ->inject('deviceForMigrations') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function ( - string $bucketId, - string $fileId, - string $resourceId, - bool $internalFile, - Response $response, - Database $dbForProject, - Database $dbForPlatform, - Authorization $authorization, - Document $project, - array $platform, - Device $deviceForFiles, - Device $deviceForMigrations, - Event $queueForEvents, - MigrationPublisher $publisherForMigrations - ) { - $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) { - if ($internalFile) { - return $dbForPlatform->getDocument('buckets', 'default'); - } - return $dbForProject->getDocument('buckets', $bucketId); - }); - - if ($bucket->isEmpty()) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - $path = $file->getAttribute('path', ''); - if (!$deviceForFiles->exists($path)) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); - } - - // No encryption or compression on files above 20MB. - $hasEncryption = !empty($file->getAttribute('openSSLCipher')); - $compression = $file->getAttribute('algorithm', Compression::NONE); - $hasCompression = $compression !== Compression::NONE; - - $migrationId = ID::unique(); - $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.json'); - - if ($hasEncryption || $hasCompression) { - $source = $deviceForFiles->read($path); - - if ($hasEncryption) { - $source = OpenSSL::decrypt( - $source, - $file->getAttribute('openSSLCipher'), - System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), - 0, - hex2bin($file->getAttribute('openSSLIV')), - hex2bin($file->getAttribute('openSSLTag')) - ); - } - - if ($hasCompression) { - switch ($compression) { - case Compression::ZSTD: - $source = (new Zstd())->decompress($source); - break; - case Compression::GZIP: - $source = (new GZIP())->decompress($source); - break; - } - } - - // Manual write after decryption and/or decompression - if (!$deviceForMigrations->write($newPath, $source, 'application/json')) { - throw new \Exception('Unable to copy file'); - } - } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) { - throw new \Exception('Unable to copy file'); - } - - $fileSize = $deviceForMigrations->getFileSize($newPath); - - [$databaseId] = \explode(':', $resourceId, 2); - $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - if ($database->isEmpty()) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - $databaseType = $database->getAttribute('type'); - $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]); - $resourceType = getDatabaseResourceType($databaseType); - - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => $migrationId, - 'status' => 'pending', - 'stage' => 'init', - 'source' => JSON::getName(), - 'destination' => Appwrite::getName(), - 'resources' => $resources, - 'resourceId' => $resourceId, - 'resourceType' => $resourceType, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - 'options' => [ - 'path' => $newPath, - 'size' => $fileSize, - ], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/json/exports') - ->groups(['api', 'migrations']) - ->desc('Export documents to JSON') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createJSONExport', - description: '/docs/references/migrations/migration-json-export.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.') - ->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .json extension.') - ->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true) - ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) - ->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true) - ->inject('user') - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('authorization') - ->inject('project') - ->inject('platform') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function ( - string $resourceId, - string $filename, - array $columns, - array $queries, - bool $notify, - Document $user, - Response $response, - Database $dbForProject, - Database $dbForPlatform, - Authorization $authorization, - Document $project, - array $platform, - Event $queueForEvents, - MigrationPublisher $publisherForMigrations - ) { - try { - $parsedQueries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - $bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default')); - if ($bucket->isEmpty()) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - [$databaseId, $collectionId] = \explode(':', $resourceId, 2); - if (empty($databaseId)) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - if (empty($collectionId)) { - throw new Exception(Exception::COLLECTION_NOT_FOUND); - } - - $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - if ($database->isEmpty()) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - - $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); - if ($collection->isEmpty()) { - throw new Exception(Exception::COLLECTION_NOT_FOUND); - } - - $databaseType = $database->getAttribute('type'); - - // Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields - $isSchemaless = in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]); - - $validator = new Documents( - attributes: $collection->getAttribute('attributes', []), - indexes: $collection->getAttribute('indexes', []), - idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(), - supportForAttributes: !$isSchemaless, - ); - - if (!$validator->isValid($parsedQueries)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]); - $resourceType = getDatabaseResourceType($databaseType); - - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => ID::unique(), - 'status' => 'pending', - 'stage' => 'init', - 'source' => Appwrite::getName(), - 'destination' => JSON::getName(), - 'resources' => $resources, - 'resourceId' => $resourceId, - 'resourceType' => $resourceType, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - 'options' => [ - 'bucketId' => 'default', // Always use internal bucket - 'filename' => $filename, - 'columns' => $columns, - 'queries' => $queries, - 'notify' => $notify, - 'userInternalId' => $user->getSequence(), - ], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::get('/v1/migrations') - ->groups(['api', 'migrations']) - ->desc('List migrations') - ->label('scope', 'migrations.read') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'list', - description: '/docs/references/migrations/list-migrations.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MIGRATION_LIST, - ) - ] - )) - ->param('queries', [], new Migrations(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Migrations::ALLOWED_ATTRIBUTES), true) - ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->inject('response') - ->inject('dbForProject') - ->action(function (array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject) { - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - if (!empty($search)) { - $queries[] = Query::search('search', $search); - } - - $cursor = Query::getCursorQueries($queries, false); - $cursor = \reset($cursor); - - if ($cursor !== false) { - $validator = new Cursor(); - if (!$validator->isValid($cursor)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $migrationId = $cursor->getValue(); - $cursorDocument = $dbForProject->getDocument('migrations', $migrationId); - - if ($cursorDocument->isEmpty()) { - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Migration '{$migrationId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorDocument); - } - - $filterQueries = Query::groupByType($queries)['filters']; - try { - $migrations = $dbForProject->find('migrations', $queries); - $total = $includeTotal ? $dbForProject->count('migrations', $filterQueries, APP_LIMIT_COUNT) : 0; - } catch (OrderException $e) { - throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); - } - $response->dynamic(new Document([ - 'migrations' => $migrations, - 'total' => $total, - ]), Response::MODEL_MIGRATION_LIST); - }); - -Http::get('/v1/migrations/:migrationId') - ->groups(['api', 'migrations']) - ->desc('Get migration') - ->label('scope', 'migrations.read') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'get', - description: '/docs/references/migrations/get-migration.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration unique ID.', false, ['dbForProject']) - ->inject('response') - ->inject('dbForProject') - ->action(function (string $migrationId, Response $response, Database $dbForProject) { - $migration = $dbForProject->getDocument('migrations', $migrationId); - - if ($migration->isEmpty()) { - throw new Exception(Exception::MIGRATION_NOT_FOUND); - } - - $response->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::get('/v1/migrations/appwrite/report') - ->groups(['api', 'migrations']) - ->desc('Get Appwrite migration report') - ->label('scope', 'migrations.write') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'getAppwriteReport', - description: '/docs/references/migrations/migration-appwrite-report.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MIGRATION_REPORT, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(Appwrite::getSupportedResources())), 'List of resources to migrate') - ->param('endpoint', '', new URL(), "Source's Appwrite Endpoint") - ->param('projectID', '', new Text(512), "Source's Project ID") - ->param('key', '', new Text(512), "Source's API Key") - ->inject('response') - ->inject('getDatabasesDB') - ->action(function (array $resources, string $endpoint, string $projectID, string $key, Response $response, callable $getDatabasesDB) { - - try { - $appwrite = new Appwrite($projectID, $endpoint, $key, $getDatabasesDB); - $report = $appwrite->report($resources); - } catch (\Throwable $e) { - throw new Exception( - Exception::MIGRATION_PROVIDER_ERROR, - 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' - ); - } - - $response - ->setStatusCode(Response::STATUS_CODE_OK) - ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); - }); - -Http::get('/v1/migrations/firebase/report') - ->groups(['api', 'migrations']) - ->desc('Get Firebase migration report') - ->label('scope', 'migrations.write') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'getFirebaseReport', - description: '/docs/references/migrations/migration-firebase-report.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MIGRATION_REPORT, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate') - ->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials') - ->inject('response') - ->action(function (array $resources, string $serviceAccount, Response $response) { - $serviceAccount = json_decode($serviceAccount, true); - - if (empty($serviceAccount)) { - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); - } - - if (!isset($serviceAccount['project_id']) || !isset($serviceAccount['client_email']) || !isset($serviceAccount['private_key'])) { - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); - } - - try { - $firebase = new Firebase($serviceAccount); - $report = $firebase->report($resources); - } catch (\Throwable $e) { - throw new Exception( - Exception::MIGRATION_PROVIDER_ERROR, - 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' - ); - } - - $response - ->setStatusCode(Response::STATUS_CODE_OK) - ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); - }); - -Http::get('/v1/migrations/supabase/report') - ->groups(['api', 'migrations']) - ->desc('Get Supabase migration report') - ->label('scope', 'migrations.write') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'getSupabaseReport', - description: '/docs/references/migrations/migration-supabase-report.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MIGRATION_REPORT, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate') - ->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint.') - ->param('apiKey', '', new Text(512), 'Source\'s API Key.') - ->param('databaseHost', '', new Text(512), 'Source\'s Database Host.') - ->param('username', '', new Text(512), 'Source\'s Database Username.') - ->param('password', '', new Text(512), 'Source\'s Database Password.') - ->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true) - ->inject('response') - ->inject('dbForProject') - ->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response) { - try { - $supabase = new Supabase($endpoint, $apiKey, $databaseHost, 'postgres', $username, $password, $port); - $report = $supabase->report($resources); - } catch (\Throwable $e) { - throw new Exception( - Exception::MIGRATION_PROVIDER_ERROR, - 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' - ); - } - - $response - ->setStatusCode(Response::STATUS_CODE_OK) - ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); - }); - -Http::get('/v1/migrations/nhost/report') - ->groups(['api', 'migrations']) - ->desc('Get NHost migration report') - ->label('scope', 'migrations.write') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'getNHostReport', - description: '/docs/references/migrations/migration-nhost-report.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MIGRATION_REPORT, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate.') - ->param('subdomain', '', new Text(512), 'Source\'s Subdomain.') - ->param('region', '', new Text(512), 'Source\'s Region.') - ->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret.') - ->param('database', '', new Text(512), 'Source\'s Database Name.') - ->param('username', '', new Text(512), 'Source\'s Database Username.') - ->param('password', '', new Text(512), 'Source\'s Database Password.') - ->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true) - ->inject('response') - ->action(function (array $resources, string $subdomain, string $region, string $adminSecret, string $database, string $username, string $password, int $port, Response $response) { - try { - $nhost = new NHost($subdomain, $region, $adminSecret, $database, $username, $password, $port); - $report = $nhost->report($resources); - } catch (\Throwable $e) { - throw new Exception( - Exception::MIGRATION_PROVIDER_ERROR, - 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' - ); - } - - $response - ->setStatusCode(Response::STATUS_CODE_OK) - ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); - }); - -Http::patch('/v1/migrations/:migrationId') - ->groups(['api', 'migrations']) - ->desc('Update retry migration') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].retry') - ->label('audits.event', 'migration.retry') - ->label('audits.resource', 'migrations/{request.migrationId}') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'retry', - description: '/docs/references/migrations/retry-migration.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration unique ID.', false, ['dbForProject']) - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('platform') - ->inject('publisherForMigrations') - ->action(function (string $migrationId, Response $response, Database $dbForProject, Document $project, array $platform, MigrationPublisher $publisherForMigrations) { - $migration = $dbForProject->getDocument('migrations', $migrationId); - - if ($migration->isEmpty()) { - throw new Exception(Exception::MIGRATION_NOT_FOUND); - } - - if ($migration->getAttribute('status') !== 'failed') { - throw new Exception(Exception::MIGRATION_IN_PROGRESS, 'Migration not failed yet'); - } - - $migration - ->setAttribute('status', 'pending') - ->setAttribute('dateUpdated', \time()); - - // Trigger Migration - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response->noContent(); - }); - -Http::delete('/v1/migrations/:migrationId') - ->groups(['api', 'migrations']) - ->desc('Delete migration') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].delete') - ->label('audits.event', 'migrationId.delete') - ->label('audits.resource', 'migrations/{request.migrationId}') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'delete', - description: '/docs/references/migrations/delete-migration.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration ID.', false, ['dbForProject']) - ->inject('response') - ->inject('dbForProject') - ->inject('queueForEvents') - ->action(function (string $migrationId, Response $response, Database $dbForProject, Event $queueForEvents) { - $migration = $dbForProject->getDocument('migrations', $migrationId); - - if ($migration->isEmpty()) { - throw new Exception(Exception::MIGRATION_NOT_FOUND); - } - - if (!$dbForProject->deleteDocument('migrations', $migration->getId())) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove migration from DB'); - } - - $queueForEvents->setParam('migrationId', $migration->getId()); - - $response->noContent(); - }); diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 1346812668..abcecac396 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -856,7 +856,7 @@ Http::get('/v1/users/:userId/targets/:targetId') Http::get('/v1/users/:userId/sessions') ->desc('List user sessions') ->groups(['api', 'users']) - ->label('scope', 'users.read') + ->label('scope', ['users.read', 'sessions.read']) ->label('sdk', new Method( namespace: 'users', group: 'sessions', @@ -2314,7 +2314,7 @@ Http::post('/v1/users/:userId/sessions') ->desc('Create session') ->groups(['api', 'users']) ->label('event', 'users.[userId].sessions.[sessionId].create') - ->label('scope', 'users.write') + ->label('scope', ['users.write', 'sessions.write']) ->label('audits.event', 'session.create') ->label('audits.resource', 'user/{request.userId}') ->label('usage.metric', 'sessions.{scope}.requests.create') @@ -2470,7 +2470,7 @@ Http::delete('/v1/users/:userId/sessions/:sessionId') ->desc('Delete user session') ->groups(['api', 'users']) ->label('event', 'users.[userId].sessions.[sessionId].delete') - ->label('scope', 'users.write') + ->label('scope', ['users.write', 'sessions.write']) ->label('audits.event', 'session.delete') ->label('audits.resource', 'user/{request.userId}') ->label('sdk', new Method( @@ -2521,7 +2521,7 @@ Http::delete('/v1/users/:userId/sessions') ->desc('Delete user sessions') ->groups(['api', 'users']) ->label('event', 'users.[userId].sessions.delete') - ->label('scope', 'users.write') + ->label('scope', ['users.write', 'sessions.write']) ->label('audits.event', 'session.delete') ->label('audits.resource', 'user/{user.$id}') ->label('sdk', new Method( diff --git a/app/controllers/general.php b/app/controllers/general.php index 9eabe56eac..7e3f5ddb9c 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -28,6 +28,7 @@ use Appwrite\Utopia\Request\Filters\V20 as RequestV20; use Appwrite\Utopia\Request\Filters\V21 as RequestV21; use Appwrite\Utopia\Request\Filters\V22 as RequestV22; use Appwrite\Utopia\Request\Filters\V23 as RequestV23; +use Appwrite\Utopia\Request\Filters\V24 as RequestV24; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Filters\V16 as ResponseV16; use Appwrite\Utopia\Response\Filters\V17 as ResponseV17; @@ -37,6 +38,7 @@ use Appwrite\Utopia\Response\Filters\V20 as ResponseV20; use Appwrite\Utopia\Response\Filters\V21 as ResponseV21; use Appwrite\Utopia\Response\Filters\V22 as ResponseV22; use Appwrite\Utopia\Response\Filters\V23 as ResponseV23; +use Appwrite\Utopia\Response\Filters\V24 as ResponseV24; use Appwrite\Utopia\View; use Executor\Executor; use MaxMind\Db\Reader; @@ -396,7 +398,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S 'projectId' => $project->getId(), 'scopes' => $resource->getAttribute('scopes', []) ]); - $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $jwtKey; + $headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $jwtKey; $headers['x-appwrite-trigger'] = 'http'; $headers['x-appwrite-user-jwt'] = ''; @@ -899,6 +901,9 @@ Http::init() if (version_compare($requestFormat, '1.9.2', '<')) { $request->addFilter(new RequestV23()); } + if (version_compare($requestFormat, '1.9.3', '<')) { + $request->addFilter(new RequestV24()); + } } $localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', '')); @@ -923,6 +928,9 @@ Http::init() */ $responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); if ($responseFormat) { + if (version_compare($responseFormat, '1.9.3', '<')) { + $response->addFilter(new ResponseV24()); + } if (version_compare($responseFormat, '1.9.2', '<')) { $response->addFilter(new ResponseV23()); } diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index f88dc5eb4b..a1b68ddcde 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -185,7 +185,8 @@ Http::init() // Handle special app role case if ($apiKey->getRole() === User::ROLE_APPS) { // Disable authorization checks for project API keys - if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_DYNAMIC) && $apiKey->getProjectId() === $project->getId()) { + // Dynamic supported for backwards compatibility + if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_EPHEMERAL || $apiKey->getType() === 'dynamic') && $apiKey->getProjectId() === $project->getId()) { $authorization->setDefaultStatus(false); } diff --git a/app/init/constants.php b/app/init/constants.php index 8eacf2fe12..f27d0c7c70 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -44,8 +44,8 @@ const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours -const APP_CACHE_BUSTER = 4323; -const APP_VERSION_STABLE = '1.9.2'; +const APP_CACHE_BUSTER = 4324; +const APP_VERSION_STABLE = '1.9.3'; const APP_DATABASE_ATTRIBUTE_EMAIL = 'email'; const APP_DATABASE_ATTRIBUTE_ENUM = 'enum'; const APP_DATABASE_ATTRIBUTE_IP = 'ip'; @@ -244,6 +244,7 @@ const APP_AUTH_TYPE_KEY = 'Key'; const APP_AUTH_TYPE_ADMIN = 'Admin'; // Response related const MAX_OUTPUT_CHUNK_SIZE = 10 * 1024 * 1024; // 10MB +const APP_LIMIT_UPLOAD_CHUNK_SIZE = 5 * 1024 * 1024; // 5MB const APP_FUNCTION_LOG_LENGTH_LIMIT = 1000000; const APP_FUNCTION_ERROR_LENGTH_LIMIT = 1000000; // Function headers @@ -255,7 +256,7 @@ const MESSAGE_TYPE_SMS = 'sms'; const MESSAGE_TYPE_PUSH = 'push'; // API key types const API_KEY_STANDARD = 'standard'; -const API_KEY_DYNAMIC = 'dynamic'; +const API_KEY_EPHEMERAL = 'ephemeral'; const API_KEY_ORGANIZATION = 'organization'; const API_KEY_ACCOUNT = 'account'; // Usage metrics diff --git a/app/init/models.php b/app/init/models.php index 77ca9be451..9530b4b98b 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -56,6 +56,8 @@ use Appwrite\Utopia\Response\Model\ColumnString; use Appwrite\Utopia\Response\Model\ColumnText; use Appwrite\Utopia\Response\Model\ColumnURL; use Appwrite\Utopia\Response\Model\ColumnVarchar; +use Appwrite\Utopia\Response\Model\ConsoleKeyScope; +use Appwrite\Utopia\Response\Model\ConsoleKeyScopeList; use Appwrite\Utopia\Response\Model\ConsoleOAuth2Provider; use Appwrite\Utopia\Response\Model\ConsoleOAuth2ProviderList; use Appwrite\Utopia\Response\Model\ConsoleOAuth2ProviderParameter; @@ -71,6 +73,7 @@ use Appwrite\Utopia\Response\Model\DetectionVariable; use Appwrite\Utopia\Response\Model\DevKey; use Appwrite\Utopia\Response\Model\Document as ModelDocument; use Appwrite\Utopia\Response\Model\Embedding; +use Appwrite\Utopia\Response\Model\EphemeralKey; use Appwrite\Utopia\Response\Model\Error; use Appwrite\Utopia\Response\Model\ErrorDev; use Appwrite\Utopia\Response\Model\Execution; @@ -392,6 +395,7 @@ Response::setModel(new Execution()); Response::setModel(new Project()); Response::setModel(new Webhook()); Response::setModel(new Key()); +Response::setModel(new EphemeralKey()); Response::setModel(new DevKey()); Response::setModel(new MockNumber()); Response::setModel(new OAuth2GitHub()); @@ -486,6 +490,8 @@ Response::setModel(new ConsoleVariables()); Response::setModel(new ConsoleOAuth2ProviderParameter()); Response::setModel(new ConsoleOAuth2Provider()); Response::setModel(new ConsoleOAuth2ProviderList()); +Response::setModel(new ConsoleKeyScope()); +Response::setModel(new ConsoleKeyScopeList()); Response::setModel(new MFAChallenge()); Response::setModel(new MFARecoveryCodes()); Response::setModel(new MFAType()); diff --git a/app/realtime.php b/app/realtime.php index 0e7388b83f..352903d942 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -45,7 +45,10 @@ use Utopia\WebSocket\Adapter; use Utopia\WebSocket\Server; require_once __DIR__ . '/init.php'; -require_once __DIR__ . '/init/span.php'; + +if (System::getEnv('_APP_EDITION', 'self-hosted') === 'self-hosted') { + require_once __DIR__ . '/init/span.php'; +} /** @var Registry $register */ $register = $GLOBALS['register'] ?? throw new \RuntimeException('Registry not initialized'); @@ -566,6 +569,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $roles = $user->getRoles($database->getAuthorization()); $authorization = $realtime->connections[$connection]['authorization'] ?? null; + $previousUserId = $realtime->connections[$connection]['userId'] ?? ''; $meta = $realtime->getSubscriptionMetadata($connection); @@ -573,13 +577,19 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, foreach ($meta as $subscriptionId => $subscription) { $queries = Query::parseQueries($subscription['queries'] ?? []); + $channels = Realtime::rebindAccountChannels( + $subscription['channels'] ?? [], + $previousUserId, + $userId + ); $realtime->subscribe( $projectId, $connection, $subscriptionId, $roles, - $subscription['channels'] ?? [], - $queries + $channels, + $queries, + $userId ); } @@ -1068,6 +1078,11 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $authorization = $realtime->connections[$connection]['authorization'] ?? null; $projectId = $realtime->connections[$connection]['projectId'] ?? null; + // Capture the pre-auth userId so we can rebind any account channels + // that were stored under it (e.g. guest who subscribed to `account` + // and now authenticates). unsubscribe() below clears the connection + // entry, so we must read it first. + $previousUserId = $realtime->connections[$connection]['userId'] ?? ''; $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); $meta = $realtime->getSubscriptionMetadata($connection); @@ -1077,13 +1092,18 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re if (!empty($projectId)) { foreach ($meta as $subscriptionId => $subscription) { $queries = Query::parseQueries($subscription['queries'] ?? []); + $channels = Realtime::rebindAccountChannels( + $subscription['channels'] ?? [], + $previousUserId, + $user->getId() + ); $realtime->subscribe( $projectId, $connection, $subscriptionId, $roles, - $subscription['channels'] ?? [], + $channels, $queries, $user->getId() ); diff --git a/composer.json b/composer.json index b1b5211ea0..b88a7e02db 100644 --- a/composer.json +++ b/composer.json @@ -82,7 +82,7 @@ "utopia-php/queue": "0.17.*", "utopia-php/servers": "0.3.*", "utopia-php/registry": "0.5.*", - "utopia-php/storage": "1.0.*", + "utopia-php/storage": "2.*", "utopia-php/system": "0.10.*", "utopia-php/telemetry": "0.2.*", "utopia-php/vcs": "3.*", diff --git a/composer.lock b/composer.lock index 84bd5e4f13..7939de399b 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": "2063a411993b66c5235a87f6cc682ba7", + "content-hash": "a9d25a0518aee3fc29ea57498cb95744", "packages": [ { "name": "adhocore/jwt", @@ -3351,16 +3351,16 @@ }, { "name": "utopia-php/abuse", - "version": "1.2.2", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/utopia-php/abuse.git", - "reference": "20bee84fd14dbe81d50ecabf1ffd81cceca06152" + "reference": "53f4274939353522ba331f55bcff6e6011ffc56c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/abuse/zipball/20bee84fd14dbe81d50ecabf1ffd81cceca06152", - "reference": "20bee84fd14dbe81d50ecabf1ffd81cceca06152", + "url": "https://api.github.com/repos/utopia-php/abuse/zipball/53f4274939353522ba331f55bcff6e6011ffc56c", + "reference": "53f4274939353522ba331f55bcff6e6011ffc56c", "shasum": "" }, "require": { @@ -3397,9 +3397,9 @@ ], "support": { "issues": "https://github.com/utopia-php/abuse/issues", - "source": "https://github.com/utopia-php/abuse/tree/1.2.2" + "source": "https://github.com/utopia-php/abuse/tree/1.2.3" }, - "time": "2026-02-02T10:43:10+00:00" + "time": "2026-04-29T11:19:08+00:00" }, { "name": "utopia-php/agents", @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.22", + "version": "5.4.1", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7" + "reference": "688d9422b5ff42ac2ecc29397d94891cfd772e93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/d765945da6b3141852014b2f96ecf1fe7e3d6ba7", - "reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7", + "url": "https://api.github.com/repos/utopia-php/database/zipball/688d9422b5ff42ac2ecc29397d94891cfd772e93", + "reference": "688d9422b5ff42ac2ecc29397d94891cfd772e93", "shasum": "" }, "require": { @@ -3903,9 +3903,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.22" + "source": "https://github.com/utopia-php/database/tree/5.4.1" }, - "time": "2026-04-20T07:12:46+00:00" + "time": "2026-04-29T07:32:59+00:00" }, { "name": "utopia-php/detector", @@ -4062,16 +4062,16 @@ }, { "name": "utopia-php/domains", - "version": "1.0.5", + "version": "1.0.6", "source": { "type": "git", "url": "https://github.com/utopia-php/domains.git", - "reference": "0edf6bb2b07f30db849a267027077bf5abb994c6" + "reference": "c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/domains/zipball/0edf6bb2b07f30db849a267027077bf5abb994c6", - "reference": "0edf6bb2b07f30db849a267027077bf5abb994c6", + "url": "https://api.github.com/repos/utopia-php/domains/zipball/c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6", + "reference": "c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6", "shasum": "" }, "require": { @@ -4118,9 +4118,9 @@ ], "support": { "issues": "https://github.com/utopia-php/domains/issues", - "source": "https://github.com/utopia-php/domains/tree/1.0.5" + "source": "https://github.com/utopia-php/domains/tree/1.0.6" }, - "time": "2026-03-03T09:20:50+00:00" + "time": "2026-04-29T11:08:10+00:00" }, { "name": "utopia-php/dsn", @@ -4602,16 +4602,16 @@ }, { "name": "utopia-php/migration", - "version": "1.9.3", + "version": "1.9.5", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "111f6221d04578a6f721c23ac872002375f176ae" + "reference": "952a4dfe232702f80e45c35129466a8d8cb4c599" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/111f6221d04578a6f721c23ac872002375f176ae", - "reference": "111f6221d04578a6f721c23ac872002375f176ae", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/952a4dfe232702f80e45c35129466a8d8cb4c599", + "reference": "952a4dfe232702f80e45c35129466a8d8cb4c599", "shasum": "" }, "require": { @@ -4622,7 +4622,7 @@ "php": ">=8.1", "utopia-php/database": "5.*", "utopia-php/dsn": "0.2.*", - "utopia-php/storage": "1.0.*" + "utopia-php/storage": "2.*" }, "require-dev": { "ext-pdo": "*", @@ -4651,9 +4651,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.9.3" + "source": "https://github.com/utopia-php/migration/tree/1.9.5" }, - "time": "2026-04-22T07:13:26+00:00" + "time": "2026-04-29T11:19:13+00:00" }, { "name": "utopia-php/mongo", @@ -5092,16 +5092,16 @@ }, { "name": "utopia-php/storage", - "version": "1.0.1", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/utopia-php/storage.git", - "reference": "f014be445f0baa635d0764e1673196f412511618" + "reference": "8a2e3a86fd01aaed675884146665308c2122264e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/storage/zipball/f014be445f0baa635d0764e1673196f412511618", - "reference": "f014be445f0baa635d0764e1673196f412511618", + "url": "https://api.github.com/repos/utopia-php/storage/zipball/8a2e3a86fd01aaed675884146665308c2122264e", + "reference": "8a2e3a86fd01aaed675884146665308c2122264e", "shasum": "" }, "require": { @@ -5115,9 +5115,8 @@ "utopia-php/validators": "0.2.*" }, "require-dev": { - "laravel/pint": "1.2.*", - "phpunit/phpunit": "^9.3", - "vimeo/psalm": "4.0.1" + "laravel/pint": "^1.21", + "phpunit/phpunit": "^9.3" }, "type": "library", "autoload": { @@ -5139,9 +5138,9 @@ ], "support": { "issues": "https://github.com/utopia-php/storage/issues", - "source": "https://github.com/utopia-php/storage/tree/1.0.1" + "source": "https://github.com/utopia-php/storage/tree/2.0.1" }, - "time": "2026-02-23T05:59:32+00:00" + "time": "2026-04-29T09:05:48+00:00" }, { "name": "utopia-php/system", @@ -5538,16 +5537,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.24.0", + "version": "1.25.1", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "6d4d26659bc7a1c347c1d4d8dae3b77b5562e0cb" + "reference": "f21a556b9acdbf75bbdcdc90a078af641646eade" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/6d4d26659bc7a1c347c1d4d8dae3b77b5562e0cb", - "reference": "6d4d26659bc7a1c347c1d4d8dae3b77b5562e0cb", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/f21a556b9acdbf75bbdcdc90a078af641646eade", + "reference": "f21a556b9acdbf75bbdcdc90a078af641646eade", "shasum": "" }, "require": { @@ -5583,9 +5582,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.24.0" + "source": "https://github.com/appwrite/sdk-generator/tree/1.25.1" }, - "time": "2026-04-24T12:50:05+00:00" + "time": "2026-04-28T11:12:22+00:00" }, { "name": "brianium/paratest", @@ -6294,11 +6293,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.51", + "version": "2.1.54", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc3b523c45e714c70de2ac5113b958223b55dc59", - "reference": "dc3b523c45e714c70de2ac5113b958223b55dc59", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", "shasum": "" }, "require": { @@ -6343,7 +6342,7 @@ "type": "github" } ], - "time": "2026-04-21T18:22:01+00:00" + "time": "2026-04-29T13:31:09+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/docs/references/console/list-oauth2-providers.md b/docs/references/console/list-oauth2-providers.md deleted file mode 100644 index d813296031..0000000000 --- a/docs/references/console/list-oauth2-providers.md +++ /dev/null @@ -1 +0,0 @@ -List all OAuth2 providers supported by the Appwrite server, along with the parameters required to configure each provider. The response excludes mock providers but includes sandbox providers. diff --git a/docs/references/console/variables.md b/docs/references/console/variables.md deleted file mode 100644 index ddfa2b9b72..0000000000 --- a/docs/references/console/variables.md +++ /dev/null @@ -1 +0,0 @@ -Get all Environment Variables that are relevant for the console. \ No newline at end of file diff --git a/src/Appwrite/Auth/Key.php b/src/Appwrite/Auth/Key.php index 8f645f6f08..0cbaefa4b3 100644 --- a/src/Appwrite/Auth/Key.php +++ b/src/Appwrite/Auth/Key.php @@ -105,7 +105,7 @@ class Key /** * Decode the given secret key into a Key object, containing the project ID, type, role, scopes, and name. - * Can be a stored API key or a dynamic key (JWT). + * Can be a stored API key or an ephemeral key (JWT). * * @throws Exception */ @@ -138,7 +138,9 @@ class Key ); switch ($type) { - case API_KEY_DYNAMIC: + // Dynamic supported for backwards compatibility + case API_KEY_EPHEMERAL: + case 'dynamic': $jwtObj = new JWT( key: System::getEnv('_APP_OPENSSL_KEY_V1'), algo: 'HS256', @@ -153,7 +155,7 @@ class Key $expired = true; } - $name = $payload['name'] ?? 'Dynamic Key'; + $name = $payload['name'] ?? 'Ephemeral Key'; $projectId = $payload['projectId'] ?? ''; $disabledMetrics = $payload['disabledMetrics'] ?? []; $hostnameOverride = $payload['hostnameOverride'] ?? false; diff --git a/src/Appwrite/Auth/OAuth2.php b/src/Appwrite/Auth/OAuth2.php index a8a2d175b5..958b28ed18 100644 --- a/src/Appwrite/Auth/OAuth2.php +++ b/src/Appwrite/Auth/OAuth2.php @@ -206,8 +206,6 @@ abstract class OAuth2 $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - \curl_close($ch); - if ($code >= 400) { throw new Exception($response, $code); } diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 8fe7342ec2..5a9c02a2bd 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -14,6 +14,28 @@ use Utopia\Database\Query; class Realtime extends MessagingAdapter { + public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert', 'delete']; + + // Resources whose channels receive an action-suffixed sibling at publish time. + // The suffix loop in fromPayload() treats any channel whose last OR second-to-last + // segment matches an entry here as a candidate for `.{action}` suffixing. + // + // `functions` is intentionally a parent-only entry: fromPayload publishes + // `functions.{functionId}` (suffixed to `functions.{functionId}.{action}`) but + // never emits a bare `functions` channel — so subscribing to bare + // `functions.{action}` is a silent no-op. Per-function filters + // (`functions.{functionId}.{action}`) are the supported form. + private const RESOURCE_LEAF_NAMES = [ + 'documents', + 'rows', + 'files', + 'executions', + 'functions', + 'account', + 'teams', + 'memberships', + ]; + /** * Connection Tree * @@ -45,8 +67,6 @@ class Realtime extends MessagingAdapter /** * Get the PubSubPool instance, initializing it lazily if needed. * This allows unit tests to work without requiring the global $register. - * - * @return PubSubPool */ private function getPubSubPool(): PubSubPool { @@ -54,6 +74,7 @@ class Realtime extends MessagingAdapter global $register; $this->pubSubPool = new PubSubPool($register->get('pools')->get('pubsub')); } + return $this->pubSubPool; } @@ -147,7 +168,7 @@ class Realtime extends MessagingAdapter $roles = $this->connections[$connection]['roles'] ?? []; $channels = $this->connections[$connection]['channels'] ?? []; - if (!$projectId || empty($roles) || empty($channels)) { + if (! $projectId || empty($roles) || empty($channels)) { return []; } @@ -168,7 +189,7 @@ class Realtime extends MessagingAdapter if (!isset($subscriptions[$subscriptionId])) { $subscriptions[$subscriptionId] = [ 'channels' => [], - 'queries' => $data['strings'] ?? [] + 'queries' => $data['strings'] ?? [], ]; } if (!\in_array($channel, $subscriptions[$subscriptionId]['channels'])) { @@ -306,7 +327,7 @@ class Realtime extends MessagingAdapter */ public function hasSubscriber(string $projectId, string $role, string $channel = ''): bool { - //TODO: look into moving it to an abstract class in the parent class + // TODO: look into moving it to an abstract class in the parent class if (empty($channel)) { return array_key_exists($projectId, $this->subscriptions) && array_key_exists($role, $this->subscriptions[$projectId]); @@ -327,6 +348,7 @@ class Realtime extends MessagingAdapter * @param array $roles * @param array $options * @return void + * * @throws \Exception */ public function send(string $projectId, array $payload, array $events, array $channels, array $roles, array $options = []): void @@ -347,8 +369,8 @@ class Realtime extends MessagingAdapter 'events' => $events, 'channels' => $channels, 'timestamp' => DateTime::formatTz(DateTime::now()), - 'payload' => $payload - ] + 'payload' => $payload, + ], ])); } @@ -361,7 +383,6 @@ class Realtime extends MessagingAdapter * - 1.5 ms | 1,000 Connections / 10,000 Subscriptions * - 15 ms | 10,000 Connections / 100,000 Subscriptions * - * @param array $event * @return array Map of connection IDs to matched query groups */ public function getSubscribers(array $event): array @@ -377,7 +398,7 @@ class Realtime extends MessagingAdapter foreach ($this->subscriptions[$event['project']] as $role => $subscriptionsByChannel) { foreach ($event['data']['channels'] as $channel) { if ( - !\array_key_exists($channel, $subscriptionsByChannel) + ! \array_key_exists($channel, $subscriptionsByChannel) || (!\in_array($role, $event['roles']) && !\in_array(Role::any()->toString(), $event['roles'])) ) { continue; @@ -410,6 +431,12 @@ class Realtime extends MessagingAdapter /** * Converts the channels from the Query Params into an array. + * Also renames the account channel to account.USER_ID, rewrites action-suffixed + * account variants (`account.create`, `account.update`, `account.upsert`, + * `account.delete`) to `account.USER_ID.{action}` so they match the channels + * fromPayload() publishes for top-level user events, and removes all other + * illegal account channel variations (e.g. another user's `account.{otherId}`). + * * Also renames the account channel to account.USER_ID and removes all illegal account channel variations. * @param array $channels * @param string $userId @@ -421,27 +448,94 @@ class Realtime extends MessagingAdapter foreach ($channels as $key => $value) { switch (true) { - case str_starts_with($key, 'account.'): - unset($channels[$key]); - break; - case $key === 'account': if (!empty($userId)) { - $channels['account.' . $userId] = $value; + $channels['account.'.$userId] = $value; } break; + + case \in_array(\substr($key, \strlen('account.')), self::SUPPORTED_ACTIONS, true) && str_starts_with($key, 'account.'): + // Authenticated: rewrite `account.{action}` → `account.{userId}.{action}` + // so the subscriber only receives their own account events. + // Guest: keep the literal `account.{action}` so the action filter + // applies to the broadcast `account.{action}` channel that fromPayload + // emits for top-level user events. On in-band auth, rebindAccountChannels + // rewrites the literal to the user-scoped form. + if (!empty($userId)) { + unset($channels[$key]); + $action = \substr($key, \strlen('account.')); + $channels['account.'.$userId.'.'.$action] = $value; + } + break; + + case str_starts_with($key, 'account.'): + unset($channels[$key]); + break; } } return $channels; } + /** + * Rewrites stored account channels to match a new userId. Used when in-band + * authentication changes the connection's user identity: + * + * - guest → authenticated: rewrites the literal `account.{action}` form + * that convertChannels preserves for guests into `account.{userId}.{action}`. + * - reauth as a different user: rewrites `account.{oldUserId}` and + * `account.{oldUserId}.{action}` to the new userId. + * + * Returns channels unchanged when there's nothing to do — same user, or an + * empty target (defensive: avoids producing malformed `account.` strings if + * a caller ever passes `$newUserId = ''`, e.g. an in-band logout flow). + */ + public static function rebindAccountChannels(array $channels, string $oldUserId, string $newUserId): array + { + if ($newUserId === '' || $oldUserId === $newUserId) { + return $channels; + } + + return \array_map(function (string $channel) use ($oldUserId, $newUserId) { + if (!\str_starts_with($channel, 'account.')) { + return $channel; + } + + // Guest origin: literal `account.{action}` (preserved by convertChannels + // for unauthenticated connections) becomes `account.{newUserId}.{action}`. + if ($oldUserId === '') { + $suffix = \substr($channel, \strlen('account.')); + if (\in_array($suffix, self::SUPPORTED_ACTIONS, true)) { + return 'account.'.$newUserId.'.'.$suffix; + } + + return $channel; + } + + // Authenticated → different user. + if ($channel === 'account.'.$oldUserId) { + return 'account.'.$newUserId; + } + + $oldPrefix = 'account.'.$oldUserId.'.'; + if (\str_starts_with($channel, $oldPrefix)) { + $action = \substr($channel, \strlen($oldPrefix)); + if (\in_array($action, self::SUPPORTED_ACTIONS, true)) { + return 'account.'.$newUserId.'.'.$action; + } + } + + return $channel; + }, $channels); + } + /** * Constructs subscriptions from query parameters. * * @param array $channelNames * @param callable $getQueryParam * @return array [index => ['channels' => string[], 'queries' => Query[]]] + * * @throws QueryException */ public static function constructSubscriptions(array $channelNames, callable $getQueryParam): array @@ -482,10 +576,11 @@ class Realtime extends MessagingAdapter if (empty($subscriptions[0]['queries'])) { $subscriptions[0]['queries'] = [Query::select(['*'])]; } + continue; } - if (!\is_array($params)) { + if (! \is_array($params)) { $params = [$params]; } @@ -512,6 +607,7 @@ class Realtime extends MessagingAdapter * Converts the queries from the Query Params into an array. * @param array|string $queries * @return array + * * @throws QueryException */ public static function convertQueries(mixed $queries): array @@ -524,7 +620,7 @@ class Realtime extends MessagingAdapter $query = array_pop($stack); $method = $query->getMethod(); - if (!in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) { + if (! in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) { throw new QueryException( "Query method '{$method}' is not supported in Realtime queries. Allowed: {$allowed}" ); @@ -680,11 +776,53 @@ class Realtime extends MessagingAdapter break; } + // Action is the last segment for plain CRUD events (e.g. `documents.X.create`), + // and the second-to-last segment for attribute-trailing events + // (e.g. `users.U.update.email`, `teams.T.update.prefs`, + // `teams.T.memberships.M.update.status`). Without the second-to-last fallback + $count = \count($parts); + $action = null; + if (\in_array($parts[$count - 1], self::SUPPORTED_ACTIONS, true)) { + $action = $parts[$count - 1]; + } elseif ($count >= 2 && \in_array($parts[$count - 2], self::SUPPORTED_ACTIONS, true)) { + $action = $parts[$count - 2]; + } + + // The `users` branch emits only user-level account channels + // (`account`, `account.{userId}`) regardless of event depth, so nested events + // like `users.U.sessions.S.create` or `users.U.challenges.C.create` would + // otherwise be suffixed as `account.create` — making a subscription to + // `account.create` receive unrelated session/challenge/recovery/verification + // events. Restrict suffixing to top-level user events where the action sits + // at parts[2] (`users.U.create`, `users.U.update.email`, etc.). + if ( + $action !== null + && $parts[0] === 'users' + && ($parts[2] ?? null) !== $action + ) { + $action = null; + } + + if ($action !== null && !empty($channels)) { + $augmented = $channels; + foreach ($channels as $channel) { + $segments = \explode('.', $channel); + $segCount = \count($segments); + $leafIsResource = \in_array($segments[$segCount - 1], self::RESOURCE_LEAF_NAMES, true); + $parentIsResource = $segCount >= 2 && \in_array($segments[$segCount - 2], self::RESOURCE_LEAF_NAMES, true); + + if ($leafIsResource || $parentIsResource) { + $augmented[] = $channel. '.' .$action; + } + } + $channels = \array_values(\array_unique($augmented)); + } + return [ 'channels' => $channels, 'roles' => $roles, 'permissionsChanged' => $permissionsChanged, - 'projectId' => $projectId + 'projectId' => $projectId, ]; } diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index ef0dd9f8b5..359925e368 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -95,6 +95,7 @@ abstract class Migration '1.9.0' => 'V24', '1.9.1' => 'V24', '1.9.2' => 'V24', + '1.9.3' => 'V24', ]; /** diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index 06312d9cb2..88788b73fc 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -9,6 +9,7 @@ use Appwrite\Platform\Modules\Core; use Appwrite\Platform\Modules\Databases; use Appwrite\Platform\Modules\Functions; use Appwrite\Platform\Modules\Health; +use Appwrite\Platform\Modules\Migrations; use Appwrite\Platform\Modules\Project; use Appwrite\Platform\Modules\Projects; use Appwrite\Platform\Modules\Proxy; @@ -39,6 +40,7 @@ class Appwrite extends Platform $this->addModule(new Storage\Module()); $this->addModule(new VCS\Module()); $this->addModule(new Webhooks\Module()); + $this->addModule(new Migrations\Module()); $this->addModule(new Project\Module()); } } diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php b/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php index ab0037f4b2..876dc00215 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php @@ -62,7 +62,6 @@ class Get extends Action curl_setopt_array($ch, $options); curl_exec($ch); $errno = curl_errno($ch); - curl_close($ch); return $errno === 0; } diff --git a/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php b/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php index 554456b041..8953f682d5 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php +++ b/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php @@ -85,8 +85,6 @@ class Create extends Action curl_exec($ch); - curl_close($ch); - $response->chunk('', true); } } diff --git a/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php b/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php index 574f7a5f6a..79a36643a1 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php +++ b/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php @@ -34,7 +34,7 @@ class XList extends Action namespace: 'console', group: 'console', name: 'listOAuth2Providers', - description: '/docs/references/console/list-oauth2-providers.md', + description: 'List all OAuth2 providers supported by the Appwrite server, along with the parameters required to configure each provider. The response excludes mock providers but includes sandbox providers.', auth: [AuthType::ADMIN], responses: [ new SDKResponse( diff --git a/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php new file mode 100644 index 0000000000..d951e93886 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php @@ -0,0 +1,69 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/console/scopes/project') + ->desc('List project scopes') + ->groups(['api']) + ->label('scope', 'public') + ->label('sdk', new Method( + namespace: 'console', + group: 'console', + name: 'listProjectScopes', + description: 'List all scopes available for project 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('projectScopes', []); + + $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/Variables/Get.php b/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php index 8368b272f1..d39049a409 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php +++ b/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php @@ -36,7 +36,7 @@ class Get extends Action namespace: 'console', group: 'console', name: 'variables', - description: '/docs/references/console/variables.md', + description: 'Get all Environment Variables that are relevant for the console.', auth: [AuthType::ADMIN], responses: [ new SDKResponse( diff --git a/src/Appwrite/Platform/Modules/Console/Services/Http.php b/src/Appwrite/Platform/Modules/Console/Services/Http.php index 77029af0f9..2540ae8e01 100644 --- a/src/Appwrite/Platform/Modules/Console/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Console/Services/Http.php @@ -15,6 +15,7 @@ use Appwrite\Platform\Modules\Console\Http\Redirects\Recover\Get as RedirectReco use Appwrite\Platform\Modules\Console\Http\Redirects\Register\Get as RedirectRegister; use Appwrite\Platform\Modules\Console\Http\Redirects\Root\Get as RedirectRoot; use Appwrite\Platform\Modules\Console\Http\Resources\Get as GetResourceAvailability; +use Appwrite\Platform\Modules\Console\Http\Scopes\Key\XList as ListKeyScopes; use Appwrite\Platform\Modules\Console\Http\Variables\Get as GetVariables; use Utopia\Platform\Service; @@ -30,6 +31,7 @@ class Http extends Service $this->addAction(GetVariables::getName(), new GetVariables()); $this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers()); + $this->addAction(ListKeyScopes::getName(), new ListKeyScopes()); $this->addAction(CreateAssistantQuery::getName(), new CreateAssistantQuery()); $this->addAction(GetResourceAvailability::getName(), new GetResourceAvailability()); 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 ddfb023d25..10cd65bc98 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 @@ -34,7 +34,7 @@ class Create extends BooleanCreate ->desc('Create boolean column') ->groups(['api', 'database', 'schema']) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('audits.event', 'column.create') ->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Update.php index c808021796..1e0fe04bdc 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Update.php @@ -34,7 +34,7 @@ class Update extends BooleanUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/boolean/:key') ->desc('Update boolean column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') 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 0698002f61..64e73e310e 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 @@ -34,7 +34,7 @@ class Create extends DatetimeCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/datetime') ->desc('Create datetime column') ->groups(['api', 'database']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Update.php index 035893f33f..44c1a06da8 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Update.php @@ -35,7 +35,7 @@ class Update extends DatetimeUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/datetime/:key') ->desc('Update dateTime column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') 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 81e71df07a..f4d606637d 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 @@ -33,7 +33,7 @@ class Delete extends AttributesDelete ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key') ->desc('Delete column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.delete') 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 b0e81ed6b7..d0b2ed3e4b 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 @@ -34,7 +34,7 @@ class Create extends EmailCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/email') ->desc('Create email column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php index d1278376c1..c116d8c5b1 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php @@ -35,7 +35,7 @@ class Update extends EmailUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/email/:key') ->desc('Update email column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') 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 9aeb9b2d4b..e58ae115fc 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 @@ -35,7 +35,7 @@ class Create extends EnumCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/enum') ->desc('Create enum column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php index 43503ee8ed..208fa9c8cf 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php @@ -36,7 +36,7 @@ class Update extends EnumUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/enum/:key') ->desc('Update enum column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') 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 0dd0ef39e1..b8e81820aa 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 @@ -34,7 +34,7 @@ class Create extends FloatCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/float') ->desc('Create float column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php index 716923cc63..9ab61e642b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php @@ -35,7 +35,7 @@ class Update extends FloatUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/float/:key') ->desc('Update float column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php index 0fe5fa062a..b0ef9e8a85 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php @@ -42,7 +42,7 @@ class Get extends AttributesGet ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key') ->desc('Get column') ->groups(['api', 'database']) - ->label('scope', ['tables.read', 'collections.read']) + ->label('scope', ['tables.read', 'collections.read', 'columns.read', 'attributes.read']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('sdk', new Method( namespace: $this->getSDKNamespace(), 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 c359feaab4..c2faec9aeb 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 @@ -34,7 +34,7 @@ class Create extends IPCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/ip') ->desc('Create IP address column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php index 0c7cc6644b..dcc4160580 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php @@ -35,7 +35,7 @@ class Update extends IPUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/ip/:key') ->desc('Update IP address column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') 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 bbb1710866..1a965c19dc 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 @@ -34,7 +34,7 @@ class Create extends IntegerCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/integer') ->desc('Create integer column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php index a9348f51e0..58dea7c848 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php @@ -35,7 +35,7 @@ class Update extends IntegerUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/integer/:key') ->desc('Update integer column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') 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 fb2c4fd1a8..c2f480d5d0 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 @@ -35,7 +35,7 @@ class Create extends LineCreate ->desc('Create line column') ->groups(['api', 'database', 'schema']) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('audits.event', 'column.create') ->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php index 564b743a2a..e2e8c59121 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php @@ -35,7 +35,7 @@ class Update extends LineUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/line/:key') ->desc('Update line column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') 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 da9471f37c..8e2dbd911d 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 @@ -33,7 +33,7 @@ class Create extends LongtextCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/longtext') ->desc('Create longtext column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php index fe93530cfb..9b90b745a2 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php @@ -34,7 +34,7 @@ class Update extends LongtextUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/longtext/:key') ->desc('Update longtext column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') 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 585856cab9..f0b8099f02 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 @@ -33,7 +33,7 @@ class Create extends MediumtextCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/mediumtext') ->desc('Create mediumtext column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php index 733159d1d4..03009da25c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php @@ -34,7 +34,7 @@ class Update extends MediumtextUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/mediumtext/:key') ->desc('Update mediumtext column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') 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 9736e33158..138ee482c3 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 @@ -35,7 +35,7 @@ class Create extends PointCreate ->desc('Create point column') ->groups(['api', 'database', 'schema']) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('audits.event', 'column.create') ->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php index f104b170bd..66fb451a1f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php @@ -35,7 +35,7 @@ class Update extends PointUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/point/:key') ->desc('Update point column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') 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 177399396c..a03a34f310 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 @@ -35,7 +35,7 @@ class Create extends PolygonCreate ->desc('Create polygon column') ->groups(['api', 'database', 'schema']) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('audits.event', 'column.create') ->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php index e66e19a7b9..7a2fd8a5de 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php @@ -35,7 +35,7 @@ class Update extends PolygonUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/polygon/:key') ->desc('Update polygon column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') 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 84ee3e6863..87544926fe 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 @@ -34,7 +34,7 @@ class Create extends RelationshipCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/relationship') ->desc('Create relationship column') ->groups(['api', 'database']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php index da5c8ca477..47884eda80 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php @@ -34,7 +34,7 @@ class Update extends RelationshipUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key/relationship') ->desc('Update relationship column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') 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 122c8625f9..17f60f61c1 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 @@ -37,7 +37,7 @@ class Create extends StringCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/string') ->desc('Create string column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php index 0974a44d5d..2ec806d4fe 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php @@ -37,7 +37,7 @@ class Update extends StringUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/string/:key') ->desc('Update string column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') 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 2c68431d8c..a8fde7d271 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 @@ -33,7 +33,7 @@ class Create extends TextCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/text') ->desc('Create text column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php index 599c93988d..4c1477fb9e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php @@ -34,7 +34,7 @@ class Update extends TextUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/text/:key') ->desc('Update text column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') 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 0b386c23f6..19b33594b7 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 @@ -34,7 +34,7 @@ class Create extends URLCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/url') ->desc('Create URL column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php index df6117ea77..d680389d9e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php @@ -35,7 +35,7 @@ class Update extends URLUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/url/:key') ->desc('Update URL column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') 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 0ee04f5f63..7595f16c45 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 @@ -35,7 +35,7 @@ class Create extends VarcharCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/varchar') ->desc('Create varchar column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php index 2b8eb9fbd7..dd170a0a19 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php @@ -36,7 +36,7 @@ class Update extends VarcharUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/varchar/:key') ->desc('Update varchar column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php index b38edf6218..56c436a13e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php @@ -33,7 +33,7 @@ class XList extends AttributesXList ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns') ->desc('List columns') ->groups(['api', 'database']) - ->label('scope', ['tables.read', 'collections.read']) + ->label('scope', ['tables.read', 'collections.read', 'columns.read', 'attributes.read']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('sdk', new Method( namespace: $this->getSDKNamespace(), 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 e683aafba1..d377bed184 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 @@ -37,7 +37,7 @@ class Create extends IndexCreate ->desc('Create index') ->groups(['api', 'database']) ->label('event', 'databases.[databaseId].tables.[tableId].indexes.[indexId].create') - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'indexes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('audits.event', 'index.create') ->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}') 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 7750408e29..ca7e4fc2da 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 @@ -36,7 +36,7 @@ class Delete extends IndexDelete ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/indexes/:key') ->desc('Delete index') ->groups(['api', 'database']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'indexes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].indexes.[indexId].update') ->label('audits.event', 'index.delete') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Get.php index 8f721abf0e..9918bcb2b8 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Get.php @@ -32,7 +32,7 @@ class Get extends IndexGet ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/indexes/:key') ->desc('Get index') ->groups(['api', 'database']) - ->label('scope', ['tables.read', 'collections.read']) + ->label('scope', ['tables.read', 'collections.read', 'indexes.read']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('sdk', new Method( namespace: $this->getSDKNamespace(), diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/XList.php index ff1e736c31..5fe3be4c05 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/XList.php @@ -33,7 +33,7 @@ class XList extends IndexXList ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/indexes') ->desc('List indexes') ->groups(['api', 'database']) - ->label('scope', ['tables.read', 'collections.read']) + ->label('scope', ['tables.read', 'collections.read', 'indexes.read']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('sdk', new Method( namespace: $this->getSDKNamespace(), diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php index 11736c8ca5..757edc0484 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php @@ -175,15 +175,8 @@ class Create extends Action throw new Exception(Exception::STORAGE_INVALID_CONTENT_RANGE); } - // TODO remove the condition that checks `$end === $fileSize` in next breaking version - if ($end === $fileSize - 1 || $end === $fileSize) { - //if it's a last chunks the chunk size might differ, so we set the $chunks and $chunk to notify it's last chunk - $chunks = $chunk = -1; - } else { - // Calculate total number of chunks based on the chunk size i.e ($rangeEnd - $rangeStart) - $chunks = (int) ceil($fileSize / ($end + 1 - $start)); - $chunk = (int) ($start / ($end + 1 - $start)) + 1; - } + $chunks = (int) ceil($fileSize / APP_LIMIT_UPLOAD_CHUNK_SIZE); + $chunk = (int) ($start / APP_LIMIT_UPLOAD_CHUNK_SIZE) + 1; } if (!$fileSizeValidator->isValid($fileSize) && $functionSizeLimit !== 0) { // Check if file size is exceeding allowed limit @@ -202,15 +195,14 @@ class Create extends Action $metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)]; if (!$deployment->isEmpty()) { $chunks = $deployment->getAttribute('sourceChunksTotal', 1); + $uploaded = $deployment->getAttribute('sourceChunksUploaded', 0); $metadata = $deployment->getAttribute('sourceMetadata', []); - if ($chunk === -1) { - $chunk = $chunks; - } - } else { - // Guard against manually setting range header for single chunk upload - if ($chunks === -1) { - $chunks = 1; - $chunk = 1; + + if ($uploaded === $chunks) { + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($deployment, Response::MODEL_DEPLOYMENT); + return; } } @@ -258,6 +250,8 @@ class Create extends Action 'sourcePath' => $path, 'sourceSize' => $fileSize, 'totalSize' => $fileSize, + 'sourceChunksTotal' => $chunks, + 'sourceChunksUploaded' => $chunksUploaded, 'activate' => $activate, 'sourceMetadata' => $metadata, 'type' => $type @@ -272,6 +266,7 @@ class Create extends Action } else { $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ 'sourceSize' => $fileSize, + 'sourceChunksUploaded' => $chunksUploaded, 'sourceMetadata' => $metadata, ])); } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 5b2f4ff297..9f15cf9d1e 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -60,7 +60,7 @@ class Create extends Base ->setHttpPath('/v1/functions/:functionId/executions') ->desc('Create execution') ->groups(['api', 'functions']) - ->label('scope', 'execution.write') + ->label('scope', ['executions.write', 'execution.write']) ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) ->label('event', 'functions.[functionId].executions.[executionId].create') ->label('sdk', new Method( @@ -228,7 +228,7 @@ class Create extends Base $executionId = ID::unique(); $headers['x-appwrite-execution-id'] = $executionId; - $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey; + $headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $apiKey; $headers['x-appwrite-trigger'] = 'http'; $headers['x-appwrite-user-id'] = $user->getId(); $headers['x-appwrite-user-jwt'] = $jwt; diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php index 21ec3c66ce..9ecb5c0bf0 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php @@ -35,7 +35,7 @@ class Delete extends Base ->setHttpPath('/v1/functions/:functionId/executions/:executionId') ->desc('Delete execution') ->groups(['api', 'functions']) - ->label('scope', 'execution.write') + ->label('scope', ['executions.write', 'execution.write']) ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) ->label('event', 'functions.[functionId].executions.[executionId].delete') ->label('audits.event', 'executions.delete') diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php index aec9d56543..0a9dd01b7e 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php @@ -31,7 +31,7 @@ class Get extends Base ->setHttpPath('/v1/functions/:functionId/executions/:executionId') ->desc('Get execution') ->groups(['api', 'functions']) - ->label('scope', 'execution.read') + ->label('scope', ['executions.read', 'execution.read']) ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) ->label('sdk', new Method( namespace: 'functions', diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php index b12980b222..6ad2a5ae55 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php @@ -39,7 +39,7 @@ class XList extends Base ->setHttpPath('/v1/functions/:functionId/executions') ->desc('List executions') ->groups(['api', 'functions']) - ->label('scope', 'execution.read') + ->label('scope', ['executions.read', 'execution.read']) ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) ->label('sdk', new Method( namespace: 'functions', diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 286f1c55ee..352fb56e28 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -624,7 +624,7 @@ class Builds extends Action $vars = [ ...$vars, 'APPWRITE_FUNCTION_API_ENDPOINT' => $endpoint, - 'APPWRITE_FUNCTION_API_KEY' => API_KEY_DYNAMIC . '_' . $apiKey, + 'APPWRITE_FUNCTION_API_KEY' => API_KEY_EPHEMERAL . '_' . $apiKey, 'APPWRITE_FUNCTION_ID' => $resource->getId(), 'APPWRITE_FUNCTION_NAME' => $resource->getAttribute('name'), 'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(), @@ -639,7 +639,7 @@ class Builds extends Action $vars = [ ...$vars, 'APPWRITE_SITE_API_ENDPOINT' => $endpoint, - 'APPWRITE_SITE_API_KEY' => API_KEY_DYNAMIC . '_' . $apiKey, + 'APPWRITE_SITE_API_KEY' => API_KEY_EPHEMERAL . '_' . $apiKey, 'APPWRITE_SITE_ID' => $resource->getId(), 'APPWRITE_SITE_NAME' => $resource->getAttribute('name'), 'APPWRITE_SITE_DEPLOYMENT' => $deployment->getId(), diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index a6f1ca1b03..7d1cdc4980 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -168,7 +168,7 @@ class Screenshots extends Action $config = $configs[$key]; $config['headers'] = \array_merge($config['headers'], [ - 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey + 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey ]); $config['sleep'] = 3000; diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php new file mode 100644 index 0000000000..006ab3ae90 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php @@ -0,0 +1,110 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/appwrite') + ->desc('Create Appwrite migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createAppwriteMigration', + description: '/docs/references/migrations/migration-appwrite.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(AppwriteSource::getSupportedResources())), 'List of resources to migrate') + ->param('endpoint', '', new URL(), 'Source Appwrite endpoint') + ->param('projectId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Source Project ID', false, ['dbForProject']) + ->param('apiKey', '', new Text(512), 'Source API Key') + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $endpoint, + string $projectId, + string $apiKey, + Response $response, + Database $dbForProject, + Document $project, + array $platform, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => AppwriteSource::getName(), + 'destination' => AppwriteSource::getName(), + 'credentials' => [ + 'endpoint' => $endpoint, + 'projectId' => $projectId, + 'apiKey' => $apiKey, + ], + 'resources' => $resources, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php new file mode 100644 index 0000000000..32d8a62ec3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php @@ -0,0 +1,80 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/migrations/appwrite/report') + ->desc('Get Appwrite migration report') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'getAppwriteReport', + description: '/docs/references/migrations/migration-appwrite-report.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MIGRATION_REPORT, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(AppwriteSource::getSupportedResources())), 'List of resources to migrate') + ->param('endpoint', '', new URL(), "Source's Appwrite Endpoint") + ->param('projectID', '', new Text(512), "Source's Project ID") + ->param('key', '', new Text(512), "Source's API Key") + ->inject('response') + ->inject('getDatabasesDB') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $endpoint, + string $projectID, + string $key, + Response $response, + callable $getDatabasesDB + ): void { + try { + $appwrite = new AppwriteSource($projectID, $endpoint, $key, $getDatabasesDB); + $report = $appwrite->report($resources); + } catch (\Throwable $e) { + throw new Exception( + Exception::MIGRATION_PROVIDER_ERROR, + 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' + ); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Exports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Exports/Create.php new file mode 100644 index 0000000000..0ab3cecf1a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Exports/Create.php @@ -0,0 +1,213 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/csv/exports') + ->desc('Export documents to CSV') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createCSVExport', + description: '/docs/references/migrations/migration-csv-export.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.') + ->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .csv extension.') + ->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true) + ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('delimiter', ',', new Text(1), 'The character that separates each column value. Default is comma.', true) + ->param('enclosure', '"', new Text(1), 'The character that encloses each column value. Default is double quotes.', true) + ->param('escape', '"', new Text(1), 'The escape character for the enclosure character. Default is double quotes.', true) + ->param('header', true, new Boolean(), 'Whether to include the header row with column names. Default is true.', true) + ->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true) + ->inject('user') + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + string $resourceId, + string $filename, + array $columns, + array $queries, + string $delimiter, + string $enclosure, + string $escape, + bool $header, + bool $notify, + Document $user, + Response $response, + Database $dbForProject, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + array $platform, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + try { + $parsedQueries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default')); + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + [$databaseId, $collectionId] = \explode(':', $resourceId, 2); + if (empty($databaseId)) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + if (empty($collectionId)) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); + if ($collection->isEmpty()) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + $databaseType = $database->getAttribute('type'); + if (!\in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) { + throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv'); + } + + // Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields + $isSchemaless = \in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]); + + $validator = new Documents( + attributes: $collection->getAttribute('attributes', []), + indexes: $collection->getAttribute('indexes', []), + idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(), + supportForAttributes: !$isSchemaless, + ); + + if (!$validator->isValid($parsedQueries)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]); + $resourceType = self::resourceTypeForDatabaseType($databaseType); + + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => AppwriteSource::getName(), + 'destination' => CSV::getName(), + 'resources' => $resources, + 'resourceId' => $resourceId, + 'resourceType' => $resourceType, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + 'options' => [ + 'bucketId' => 'default', // Always use internal bucket + 'filename' => $filename, + 'columns' => $columns, + 'queries' => $queries, + 'delimiter' => $delimiter, + 'enclosure' => $enclosure, + 'escape' => $escape, + 'header' => $header, + 'notify' => $notify, + 'userInternalId' => $user->getSequence(), + ], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } + + private static function transferGroupForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_LEGACY, + DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB, + DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, + DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, + default => throw new \LogicException('Unknown database type: ' . $databaseType), + }; + } + + private static function resourceTypeForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB, + DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB, + default => Resource::TYPE_DATABASE, + }; + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php new file mode 100644 index 0000000000..5cc21241c3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php @@ -0,0 +1,220 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/csv/imports') + ->httpAlias('/v1/migrations/csv') + ->desc('Import documents from a CSV') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createCSVImport', + description: '/docs/references/migrations/migration-csv-import.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('bucketId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).', false, ['dbForProject']) + ->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File ID.', false, ['dbForProject']) + ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') + ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('platform') + ->inject('deviceForFiles') + ->inject('deviceForMigrations') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + string $bucketId, + string $fileId, + string $resourceId, + bool $internalFile, + Response $response, + Database $dbForProject, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + array $platform, + Device $deviceForFiles, + Device $deviceForMigrations, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) { + if ($internalFile) { + return $dbForPlatform->getDocument('buckets', 'default'); + } + return $dbForProject->getDocument('buckets', $bucketId); + }); + + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); + if ($file->isEmpty()) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); + } + + $path = $file->getAttribute('path', ''); + if (!$deviceForFiles->exists($path)) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); + } + + // No encryption or compression on files above 20MB. + $hasEncryption = !empty($file->getAttribute('openSSLCipher')); + $compression = $file->getAttribute('algorithm', Compression::NONE); + $hasCompression = $compression !== Compression::NONE; + + $migrationId = ID::unique(); + $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.csv'); + + if ($hasEncryption || $hasCompression) { + $source = $deviceForFiles->read($path); + + if ($hasEncryption) { + $source = OpenSSL::decrypt( + $source, + $file->getAttribute('openSSLCipher'), + System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), + 0, + hex2bin($file->getAttribute('openSSLIV')), + hex2bin($file->getAttribute('openSSLTag')) + ); + } + + if ($hasCompression) { + switch ($compression) { + case Compression::ZSTD: + $source = (new Zstd())->decompress($source); + break; + case Compression::GZIP: + $source = (new GZIP())->decompress($source); + break; + } + } + + // Manual write after decryption and/or decompression + if (!$deviceForMigrations->write($newPath, $source, 'text/csv')) { + throw new \Exception('Unable to copy file'); + } + } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) { + throw new \Exception('Unable to copy file'); + } + + [$databaseId] = \explode(':', $resourceId, 2); + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + $databaseType = $database->getAttribute('type'); + if (!\in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) { + throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv'); + } + $fileSize = $deviceForMigrations->getFileSize($newPath); + $resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]); + $resourceType = self::resourceTypeForDatabaseType($databaseType); + + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => $migrationId, + 'status' => 'pending', + 'stage' => 'init', + 'source' => CSV::getName(), + 'destination' => AppwriteSource::getName(), + 'resources' => $resources, + 'resourceId' => $resourceId, + 'resourceType' => $resourceType, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + 'options' => [ + 'path' => $newPath, + 'size' => $fileSize, + ], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } + + private static function transferGroupForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_LEGACY, + DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB, + DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, + DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, + default => throw new \LogicException('Unknown database type: ' . $databaseType), + }; + } + + private static function resourceTypeForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB, + DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB, + default => Resource::TYPE_DATABASE, + }; + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Delete.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Delete.php new file mode 100644 index 0000000000..f9c989b5bf --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Delete.php @@ -0,0 +1,74 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/migrations/:migrationId') + ->desc('Delete migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].delete') + ->label('audits.event', 'migrationId.delete') + ->label('audits.resource', 'migrations/{request.migrationId}') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'delete', + description: '/docs/references/migrations/delete-migration.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_NOCONTENT, + model: Response::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action(string $migrationId, Response $response, Database $dbForProject, Event $queueForEvents): void + { + $migration = $dbForProject->getDocument('migrations', $migrationId); + + if ($migration->isEmpty()) { + throw new Exception(Exception::MIGRATION_NOT_FOUND); + } + + if (!$dbForProject->deleteDocument('migrations', $migration->getId())) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove migration from DB'); + } + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Create.php new file mode 100644 index 0000000000..a8347858b4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Create.php @@ -0,0 +1,114 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/firebase') + ->desc('Create Firebase migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createFirebaseMigration', + description: '/docs/references/migrations/migration-firebase.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate') + ->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials') + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $serviceAccount, + Response $response, + Database $dbForProject, + Document $project, + array $platform, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + $serviceAccountData = json_decode($serviceAccount, true); + + if (empty($serviceAccountData)) { + throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); + } + + if (!isset($serviceAccountData['project_id']) || !isset($serviceAccountData['client_email']) || !isset($serviceAccountData['private_key'])) { + throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); + } + + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => Firebase::getName(), + 'destination' => AppwriteSource::getName(), + 'credentials' => [ + 'serviceAccount' => $serviceAccount, + ], + 'resources' => $resources, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Report/Get.php new file mode 100644 index 0000000000..ef8084795e --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Report/Get.php @@ -0,0 +1,80 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/migrations/firebase/report') + ->desc('Get Firebase migration report') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'getFirebaseReport', + description: '/docs/references/migrations/migration-firebase-report.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MIGRATION_REPORT, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate') + ->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(array $resources, string $serviceAccount, Response $response): void + { + $serviceAccount = json_decode($serviceAccount, true); + + if (empty($serviceAccount)) { + throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); + } + + if (!isset($serviceAccount['project_id']) || !isset($serviceAccount['client_email']) || !isset($serviceAccount['private_key'])) { + throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); + } + + try { + $firebase = new Firebase($serviceAccount); + $report = $firebase->report($resources); + } catch (\Throwable $e) { + throw new Exception( + Exception::MIGRATION_PROVIDER_ERROR, + 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' + ); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Get.php new file mode 100644 index 0000000000..14b40e2306 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Get.php @@ -0,0 +1,61 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/migrations/:migrationId') + ->desc('Get migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.read') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'get', + description: '/docs/references/migrations/get-migration.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration unique ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action(string $migrationId, Response $response, Database $dbForProject): void + { + $migration = $dbForProject->getDocument('migrations', $migrationId); + + if ($migration->isEmpty()) { + throw new Exception(Exception::MIGRATION_NOT_FOUND); + } + + $response->dynamic($migration, Response::MODEL_MIGRATION); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Exports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Exports/Create.php new file mode 100644 index 0000000000..d968bd91f6 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Exports/Create.php @@ -0,0 +1,198 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/json/exports') + ->desc('Export documents to JSON') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createJSONExport', + description: '/docs/references/migrations/migration-json-export.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.') + ->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .json extension.') + ->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true) + ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true) + ->inject('user') + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + string $resourceId, + string $filename, + array $columns, + array $queries, + bool $notify, + Document $user, + Response $response, + Database $dbForProject, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + array $platform, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + try { + $parsedQueries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default')); + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + [$databaseId, $collectionId] = \explode(':', $resourceId, 2); + if (empty($databaseId)) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + if (empty($collectionId)) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); + if ($collection->isEmpty()) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + $databaseType = $database->getAttribute('type'); + + // Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields + $isSchemaless = \in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]); + + $validator = new Documents( + attributes: $collection->getAttribute('attributes', []), + indexes: $collection->getAttribute('indexes', []), + idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(), + supportForAttributes: !$isSchemaless, + ); + + if (!$validator->isValid($parsedQueries)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]); + $resourceType = self::resourceTypeForDatabaseType($databaseType); + + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => AppwriteSource::getName(), + 'destination' => JSONSource::getName(), + 'resources' => $resources, + 'resourceId' => $resourceId, + 'resourceType' => $resourceType, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + 'options' => [ + 'bucketId' => 'default', // Always use internal bucket + 'filename' => $filename, + 'columns' => $columns, + 'queries' => $queries, + 'notify' => $notify, + 'userInternalId' => $user->getSequence(), + ], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } + + private static function transferGroupForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_LEGACY, + DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB, + DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, + DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, + default => throw new \LogicException('Unknown database type: ' . $databaseType), + }; + } + + private static function resourceTypeForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB, + DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB, + default => Resource::TYPE_DATABASE, + }; + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php new file mode 100644 index 0000000000..55081b2645 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php @@ -0,0 +1,221 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/json/imports') + ->desc('Import documents from a JSON') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createJSONImport', + description: '/docs/references/migrations/migration-json-import.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') + ->param('fileId', '', new UID(), 'File ID.') + ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') + ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('platform') + ->inject('deviceForFiles') + ->inject('deviceForMigrations') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + string $bucketId, + string $fileId, + string $resourceId, + bool $internalFile, + Response $response, + Database $dbForProject, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + array $platform, + Device $deviceForFiles, + Device $deviceForMigrations, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) { + if ($internalFile) { + return $dbForPlatform->getDocument('buckets', 'default'); + } + return $dbForProject->getDocument('buckets', $bucketId); + }); + + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); + if ($file->isEmpty()) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); + } + + $path = $file->getAttribute('path', ''); + if (!$deviceForFiles->exists($path)) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); + } + + // No encryption or compression on files above 20MB. + $hasEncryption = !empty($file->getAttribute('openSSLCipher')); + $compression = $file->getAttribute('algorithm', Compression::NONE); + $hasCompression = $compression !== Compression::NONE; + + $migrationId = ID::unique(); + $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.json'); + + if ($hasEncryption || $hasCompression) { + $source = $deviceForFiles->read($path); + + if ($hasEncryption) { + $source = OpenSSL::decrypt( + $source, + $file->getAttribute('openSSLCipher'), + System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), + 0, + hex2bin($file->getAttribute('openSSLIV')), + hex2bin($file->getAttribute('openSSLTag')) + ); + } + + if ($hasCompression) { + switch ($compression) { + case Compression::ZSTD: + $source = (new Zstd())->decompress($source); + break; + case Compression::GZIP: + $source = (new GZIP())->decompress($source); + break; + } + } + + // Manual write after decryption and/or decompression + if (!$deviceForMigrations->write($newPath, $source, 'application/json')) { + throw new \Exception('Unable to copy file'); + } + } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) { + throw new \Exception('Unable to copy file'); + } + + $fileSize = $deviceForMigrations->getFileSize($newPath); + + [$databaseId] = \explode(':', $resourceId, 2); + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + $databaseType = $database->getAttribute('type'); + $resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]); + $resourceType = self::resourceTypeForDatabaseType($databaseType); + + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => $migrationId, + 'status' => 'pending', + 'stage' => 'init', + 'source' => JSONSource::getName(), + 'destination' => AppwriteSource::getName(), + 'resources' => $resources, + 'resourceId' => $resourceId, + 'resourceType' => $resourceType, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + 'options' => [ + 'path' => $newPath, + 'size' => $fileSize, + ], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } + + private static function transferGroupForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_LEGACY, + DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB, + DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, + DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, + default => throw new \LogicException('Unknown database type: ' . $databaseType), + }; + } + + private static function resourceTypeForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB, + DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB, + default => Resource::TYPE_DATABASE, + }; + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Create.php new file mode 100644 index 0000000000..fb97b1c16c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Create.php @@ -0,0 +1,122 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/nhost') + ->desc('Create NHost migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createNHostMigration', + description: '/docs/references/migrations/migration-nhost.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate') + ->param('subdomain', '', new Text(512), 'Source\'s Subdomain') + ->param('region', '', new Text(512), 'Source\'s Region') + ->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret') + ->param('database', '', new Text(512), 'Source\'s Database Name') + ->param('username', '', new Text(512), 'Source\'s Database Username') + ->param('password', '', new Text(512), 'Source\'s Database Password') + ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true) + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $subdomain, + string $region, + string $adminSecret, + string $database, + string $username, + string $password, + int $port, + Response $response, + Database $dbForProject, + Document $project, + array $platform, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => NHost::getName(), + 'destination' => AppwriteSource::getName(), + 'credentials' => [ + 'subdomain' => $subdomain, + 'region' => $region, + 'adminSecret' => $adminSecret, + 'database' => $database, + 'username' => $username, + 'password' => $password, + 'port' => $port, + ], + 'resources' => $resources, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Report/Get.php new file mode 100644 index 0000000000..964f2dc347 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Report/Get.php @@ -0,0 +1,86 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/migrations/nhost/report') + ->desc('Get NHost migration report') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'getNHostReport', + description: '/docs/references/migrations/migration-nhost-report.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MIGRATION_REPORT, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate.') + ->param('subdomain', '', new Text(512), 'Source\'s Subdomain.') + ->param('region', '', new Text(512), 'Source\'s Region.') + ->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret.') + ->param('database', '', new Text(512), 'Source\'s Database Name.') + ->param('username', '', new Text(512), 'Source\'s Database Username.') + ->param('password', '', new Text(512), 'Source\'s Database Password.') + ->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true) + ->inject('response') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $subdomain, + string $region, + string $adminSecret, + string $database, + string $username, + string $password, + int $port, + Response $response + ): void { + try { + $nhost = new NHost($subdomain, $region, $adminSecret, $database, $username, $password, $port); + $report = $nhost->report($resources); + } catch (\Throwable $e) { + throw new Exception( + Exception::MIGRATION_PROVIDER_ERROR, + 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' + ); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Create.php new file mode 100644 index 0000000000..98b33e379d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Create.php @@ -0,0 +1,120 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/supabase') + ->desc('Create Supabase migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createSupabaseMigration', + description: '/docs/references/migrations/migration-supabase.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate') + ->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint') + ->param('apiKey', '', new Text(512), 'Source\'s API Key') + ->param('databaseHost', '', new Text(512), 'Source\'s Database Host') + ->param('username', '', new Text(512), 'Source\'s Database Username') + ->param('password', '', new Text(512), 'Source\'s Database Password') + ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true) + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $endpoint, + string $apiKey, + string $databaseHost, + string $username, + string $password, + int $port, + Response $response, + Database $dbForProject, + Document $project, + array $platform, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => Supabase::getName(), + 'destination' => AppwriteSource::getName(), + 'credentials' => [ + 'endpoint' => $endpoint, + 'apiKey' => $apiKey, + 'databaseHost' => $databaseHost, + 'username' => $username, + 'password' => $password, + 'port' => $port, + ], + 'resources' => $resources, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Report/Get.php new file mode 100644 index 0000000000..423e611430 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Report/Get.php @@ -0,0 +1,85 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/migrations/supabase/report') + ->desc('Get Supabase migration report') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'getSupabaseReport', + description: '/docs/references/migrations/migration-supabase-report.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MIGRATION_REPORT, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate') + ->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint.') + ->param('apiKey', '', new Text(512), 'Source\'s API Key.') + ->param('databaseHost', '', new Text(512), 'Source\'s Database Host.') + ->param('username', '', new Text(512), 'Source\'s Database Username.') + ->param('password', '', new Text(512), 'Source\'s Database Password.') + ->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true) + ->inject('response') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $endpoint, + string $apiKey, + string $databaseHost, + string $username, + string $password, + int $port, + Response $response + ): void { + try { + $supabase = new Supabase($endpoint, $apiKey, $databaseHost, 'postgres', $username, $password, $port); + $report = $supabase->report($resources); + } catch (\Throwable $e) { + throw new Exception( + Exception::MIGRATION_PROVIDER_ERROR, + 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' + ); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Update.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Update.php new file mode 100644 index 0000000000..8ecc53c2a3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Update.php @@ -0,0 +1,90 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/migrations/:migrationId') + ->desc('Update retry migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].retry') + ->label('audits.event', 'migration.retry') + ->label('audits.resource', 'migrations/{request.migrationId}') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'retry', + description: '/docs/references/migrations/retry-migration.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration unique ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('platform') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + string $migrationId, + Response $response, + Database $dbForProject, + Document $project, + array $platform, + MigrationPublisher $publisherForMigrations + ): void { + $migration = $dbForProject->getDocument('migrations', $migrationId); + + if ($migration->isEmpty()) { + throw new Exception(Exception::MIGRATION_NOT_FOUND); + } + + if ($migration->getAttribute('status') !== 'failed') { + throw new Exception(Exception::MIGRATION_IN_PROGRESS, 'Migration not failed yet'); + } + + $migration + ->setAttribute('status', 'pending') + ->setAttribute('dateUpdated', \time()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/XList.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/XList.php new file mode 100644 index 0000000000..1a1252be79 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/XList.php @@ -0,0 +1,104 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/migrations') + ->desc('List migrations') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.read') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'list', + description: '/docs/references/migrations/list-migrations.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MIGRATION_LIST, + ) + ] + )) + ->param('queries', [], new Migrations(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Migrations::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action(array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject): void + { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + $cursor = Query::getCursorQueries($queries, false); + $cursor = \reset($cursor); + + if ($cursor !== false) { + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $migrationId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('migrations', $migrationId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Migration '{$migrationId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + try { + $migrations = $dbForProject->find('migrations', $queries); + $total = $includeTotal ? $dbForProject->count('migrations', $filterQueries, APP_LIMIT_COUNT) : 0; + } catch (OrderException $e) { + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); + } + + $response->dynamic(new Document([ + 'migrations' => $migrations, + 'total' => $total, + ]), Response::MODEL_MIGRATION_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Module.php b/src/Appwrite/Platform/Modules/Migrations/Module.php new file mode 100644 index 0000000000..6ec1e49a88 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Services/Http.php b/src/Appwrite/Platform/Modules/Migrations/Services/Http.php new file mode 100644 index 0000000000..1e2c95a78b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Services/Http.php @@ -0,0 +1,59 @@ +type = Service::TYPE_HTTP; + + // Migrations + $this->addAction(ListMigrations::getName(), new ListMigrations()); + $this->addAction(GetMigration::getName(), new GetMigration()); + $this->addAction(UpdateMigration::getName(), new UpdateMigration()); + $this->addAction(DeleteMigration::getName(), new DeleteMigration()); + + // Appwrite source + $this->addAction(CreateAppwriteMigration::getName(), new CreateAppwriteMigration()); + $this->addAction(GetAppwriteReport::getName(), new GetAppwriteReport()); + + // Firebase source + $this->addAction(CreateFirebaseMigration::getName(), new CreateFirebaseMigration()); + $this->addAction(GetFirebaseReport::getName(), new GetFirebaseReport()); + + // Supabase source + $this->addAction(CreateSupabaseMigration::getName(), new CreateSupabaseMigration()); + $this->addAction(GetSupabaseReport::getName(), new GetSupabaseReport()); + + // NHost source + $this->addAction(CreateNHostMigration::getName(), new CreateNHostMigration()); + $this->addAction(GetNHostReport::getName(), new GetNHostReport()); + + // CSV import / export + $this->addAction(CreateCSVImport::getName(), new CreateCSVImport()); + $this->addAction(CreateCSVExport::getName(), new CreateCSVExport()); + + // JSON import / export + $this->addAction(CreateJSONImport::getName(), new CreateJSONImport()); + $this->addAction(CreateJSONExport::getName(), new CreateJSONExport()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php index 236c091c31..eebc0a7067 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php @@ -51,6 +51,8 @@ class Create extends Base name: 'createKey', description: <<setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/keys/ephemeral') + ->httpAlias('/v1/projects/:projectId/jwts') + ->desc('Create ephemeral project key') + ->groups(['api', 'project']) + ->label('scope', 'keys.write') + ->label('event', 'keys.[keyId].create') + ->label('audits.event', 'project.key.create') + ->label('audits.resource', 'project.key/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'createEphemeralKey', + description: <<param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', optional: false) + ->param('duration', null, new Range(1, 3600), 'Time in seconds before ephemeral key expires. Maximum duration is 3600 seconds.', optional: false) + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + array $scopes, + int $duration, + Response $response, + QueueEvent $queueForEvents, + Document $project, + ) { + $keyId = ID::unique(); + + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0); + + $secret = $jwt->encode([ + 'projectId' => $project->getId(), + 'scopes' => $scopes + ]); + + $now = new \DateTime(); + $expire = $now->add(new \DateInterval('PT' . $duration . 'S'))->format('Y-m-d\TH:i:s.u\Z'); + + $key = new Document([ + '$id' => $keyId, + '$createdAt' => DatabaseDateTime::now(), + '$updatedAt' => DatabaseDateTime::now(), + 'name' => '', + 'scopes' => $scopes, + 'expire' => $expire, + 'sdks' => [], + 'accessedAt' => null, + 'secret' => API_KEY_EPHEMERAL . '_' . $secret, + ]); + + $queueForEvents->setParam('keyId', $key->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($key, Response::MODEL_EPHEMERAL_KEY); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 8c6b9da7e7..609de96530 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -7,6 +7,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\AuthMethods\Update as UpdateA use Appwrite\Platform\Modules\Project\Http\Project\Delete as DeleteProject; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Ephemeral\Create as CreateEphemeralKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Update as UpdateKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\XList as ListKeys; @@ -131,6 +132,7 @@ class Http extends Service // Keys $this->addAction(CreateKey::getName(), new CreateKey()); + $this->addAction(CreateEphemeralKey::getName(), new CreateEphemeralKey()); $this->addAction(ListKeys::getName(), new ListKeys()); $this->addAction(GetKey::getName(), new GetKey()); $this->addAction(DeleteKey::getName(), new DeleteKey()); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index 0b8ca24aaa..71ea5ceb2f 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -177,15 +177,8 @@ class Create extends Action throw new Exception(Exception::STORAGE_INVALID_CONTENT_RANGE); } - // TODO remove the condition that checks `$end === $fileSize` in next breaking version - if ($end === $fileSize - 1 || $end === $fileSize) { - //if it's a last chunks the chunk size might differ, so we set the $chunks and $chunk to notify it's last chunk - $chunks = $chunk = -1; - } else { - // Calculate total number of chunks based on the chunk size i.e ($rangeEnd - $rangeStart) - $chunks = (int) ceil($fileSize / ($end + 1 - $start)); - $chunk = (int) ($start / ($end + 1 - $start)) + 1; - } + $chunks = (int) ceil($fileSize / APP_LIMIT_UPLOAD_CHUNK_SIZE); + $chunk = (int) ($start / APP_LIMIT_UPLOAD_CHUNK_SIZE) + 1; } if (!$fileSizeValidator->isValid($fileSize) && $siteSizeLimit !== 0) { // Check if file size is exceeding allowed limit @@ -204,15 +197,14 @@ class Create extends Action $metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)]; if (!$deployment->isEmpty()) { $chunks = $deployment->getAttribute('sourceChunksTotal', 1); + $uploaded = $deployment->getAttribute('sourceChunksUploaded', 0); $metadata = $deployment->getAttribute('sourceMetadata', []); - if ($chunk === -1) { - $chunk = $chunks; - } - } else { - // Guard against manually setting range header for single chunk upload - if ($chunks === -1) { - $chunks = 1; - $chunk = 1; + + if ($uploaded === $chunks) { + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($deployment, Response::MODEL_DEPLOYMENT); + return; } } @@ -268,6 +260,8 @@ class Create extends Action 'sourcePath' => $path, 'sourceSize' => $fileSize, 'totalSize' => $fileSize, + 'sourceChunksTotal' => $chunks, + 'sourceChunksUploaded' => $chunksUploaded, 'activate' => $activate, 'sourceMetadata' => $metadata, 'type' => $type, @@ -315,6 +309,7 @@ class Create extends Action } else { $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ 'sourceSize' => $fileSize, + 'sourceChunksUploaded' => $chunksUploaded, 'sourceMetadata' => $metadata, ])); } 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 befc02a1df..2ce5ef97f5 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php @@ -204,15 +204,8 @@ class Create extends Action throw new Exception(Exception::STORAGE_INVALID_APPWRITE_ID); } - // TODO remove the condition that checks `$end === $fileSize` in next breaking version - if ($end === $fileSize - 1 || $end === $fileSize) { - //if it's a last chunks the chunk size might differ, so we set the $chunks and $chunk to -1 notify it's last chunk - $chunks = $chunk = -1; - } else { - // Calculate total number of chunks based on the chunk size i.e ($rangeEnd - $rangeStart) - $chunks = (int) ceil($fileSize / ($end + 1 - $start)); - $chunk = (int) ($start / ($end + 1 - $start)) + 1; - } + $chunks = (int) ceil($fileSize / APP_LIMIT_UPLOAD_CHUNK_SIZE); + $chunk = (int) ($start / APP_LIMIT_UPLOAD_CHUNK_SIZE) + 1; } /** @@ -249,18 +242,15 @@ class Create extends Action $uploaded = $file->getAttribute('chunksUploaded', 0); $metadata = $file->getAttribute('metadata', []); - if ($chunk === -1) { - $chunk = $chunks; - } - if ($uploaded === $chunks) { - throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); - } - } else { - // Guard against manually setting range header for single chunk upload - if ($chunks === -1) { - $chunks = 1; - $chunk = 1; + if (empty($contentRange)) { + throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic($file, Response::MODEL_FILE); + return; } } diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 8f5397f630..a5fe352b07 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -633,6 +633,89 @@ class Deletes extends Action $dsn = new DSN('mysql://' . $document->getAttribute('database', 'console')); } + // Delete Platforms + try { + $this->deleteByGroup('platforms', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + } catch (Throwable $th) { + Console::error('Failed to delete platforms: ' . $th->getMessage()); + } + + // Delete project and function rules + try { + $this->deleteByGroup('rules', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform, function (Document $document) use ($dbForPlatform, $certificates) { + $this->deleteRule($dbForPlatform, $document, $certificates); + }); + } catch (Throwable $th) { + Console::error('Failed to delete rules: ' . $th->getMessage()); + } + + // Delete Keys + try { + $this->deleteByGroup('keys', [ + Query::equal('resourceType', ['projects']), + Query::equal('resourceInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + } catch (Throwable $th) { + Console::error('Failed to delete keys: ' . $th->getMessage()); + } + + // Delete Webhooks + try { + $this->deleteByGroup('webhooks', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + } catch (Throwable $th) { + Console::error('Failed to delete webhooks: ' . $th->getMessage()); + } + + // Delete VCS Installations + try { + $this->deleteByGroup('installations', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + } catch (Throwable $th) { + Console::error('Failed to delete installations: ' . $th->getMessage()); + } + + // Delete VCS Repositories + try { + $this->deleteByGroup('repositories', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + } catch (Throwable $th) { + Console::error('Failed to delete repositories: ' . $th->getMessage()); + } + + // Delete VCS comments + try { + $this->deleteByGroup('vcsComments', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + } catch (Throwable $th) { + Console::error('Failed to delete VCS comments: ' . $th->getMessage()); + } + + // Delete Schedules + try { + $this->deleteByGroup('schedules', [ + Query::equal('projectId', [$projectId]), + Query::orderAsc() + ], $dbForPlatform); + } catch (Throwable $th) { + Console::error('Failed to delete schedules: ' . $th->getMessage()); + } + /** * @var Database $dbForProject */ @@ -685,75 +768,35 @@ class Deletes extends Action }; batch(array_map( - fn ($databaseDoc) => fn () => $this->cleanDatabase( - $databaseDoc, - $executionActionPerDatabase, - $projectTables, - $projectCollectionIds - ), + fn ($databaseDoc) => function () use ($databaseDoc, $executionActionPerDatabase, $projectTables, $projectCollectionIds) { + try { + $this->cleanDatabase( + $databaseDoc, + $executionActionPerDatabase, + $projectTables, + $projectCollectionIds + ); + } catch (Throwable $th) { + Console::error('Failed to delete database ' . $databaseDoc->getAttribute('database') . ': ' . $th->getMessage()); + } + }, $databasesToClean )); - // Delete Platforms - $this->deleteByGroup('platforms', [ - Query::equal('projectInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform); - - // Delete project and function rules - $this->deleteByGroup('rules', [ - Query::equal('projectInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform, function (Document $document) use ($dbForPlatform, $certificates) { - $this->deleteRule($dbForPlatform, $document, $certificates); - }); - - // Delete Keys - $this->deleteByGroup('keys', [ - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform); - - // Delete Webhooks - $this->deleteByGroup('webhooks', [ - Query::equal('projectInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform); - - // Delete VCS Installations - $this->deleteByGroup('installations', [ - Query::equal('projectInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform); - - // Delete VCS Repositories - $this->deleteByGroup('repositories', [ - Query::equal('projectInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform); - - // Delete VCS comments - $this->deleteByGroup('vcsComments', [ - Query::equal('projectInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform); - - // Delete Schedules - $this->deleteByGroup('schedules', [ - Query::equal('projectId', [$projectId]), - Query::orderAsc() - ], $dbForPlatform); - // Delete metadata table if ($projectTables) { batch(array_map( - fn ($databaseDoc) => fn () => - $executionActionPerDatabase( - $databaseDoc, - fn (Database $dbForDatabases) => - $dbForDatabases->deleteCollection(Database::METADATA) - ), + fn ($databaseDoc) => function () use ($databaseDoc, $executionActionPerDatabase) { + try { + $executionActionPerDatabase( + $databaseDoc, + fn (Database $dbForDatabases) => + $dbForDatabases->deleteCollection(Database::METADATA) + ); + } catch (Throwable $th) { + Console::error('Failed to delete metadata table for database ' . $databaseDoc->getAttribute('database') . ': ' . $th->getMessage()); + } + }, $databasesToClean )); } else { @@ -764,19 +807,47 @@ class Deletes extends Action $queries[] = Query::orderAsc(); - $this->deleteByGroup( - Database::METADATA, - $queries, - $dbForProject - ); + try { + $this->deleteByGroup( + Database::METADATA, + $queries, + $dbForProject + ); + } catch (Throwable $th) { + Console::error('Failed to delete metadata documents: ' . $th->getMessage()); + } } // Delete all storage directories - $deviceForFiles->delete($deviceForFiles->getRoot(), true); - $deviceForSites->delete($deviceForSites->getRoot(), true); - $deviceForFunctions->delete($deviceForFunctions->getRoot(), true); - $deviceForBuilds->delete($deviceForBuilds->getRoot(), true); - $deviceForCache->delete($deviceForCache->getRoot(), true); + try { + $deviceForFiles->delete($deviceForFiles->getRoot(), true); + } catch (Throwable $th) { + Console::error('Failed to delete files storage directory: ' . $th->getMessage()); + } + + try { + $deviceForSites->delete($deviceForSites->getRoot(), true); + } catch (Throwable $th) { + Console::error('Failed to delete sites storage directory: ' . $th->getMessage()); + } + + try { + $deviceForFunctions->delete($deviceForFunctions->getRoot(), true); + } catch (Throwable $th) { + Console::error('Failed to delete functions storage directory: ' . $th->getMessage()); + } + + try { + $deviceForBuilds->delete($deviceForBuilds->getRoot(), true); + } catch (Throwable $th) { + Console::error('Failed to delete builds storage directory: ' . $th->getMessage()); + } + + try { + $deviceForCache->delete($deviceForCache->getRoot(), true); + } catch (Throwable $th) { + Console::error('Failed to delete cache storage directory: ' . $th->getMessage()); + } } finally { $dbForProject->enableValidation(); diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 28c298b050..8167fb975d 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -434,7 +434,7 @@ class Functions extends Action ]); $headers['x-appwrite-execution-id'] = $executionId ?? ''; - $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey; + $headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $apiKey; $headers['x-appwrite-trigger'] = $trigger; $headers['x-appwrite-event'] = $event ?? ''; $headers['x-appwrite-user-id'] = $user->getId(); diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index fa2ed5883f..69f72b8e27 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -402,7 +402,7 @@ class Migrations extends Action ] ]); - return API_KEY_DYNAMIC . '_' . $apiKey; + return API_KEY_EPHEMERAL . '_' . $apiKey; } /** diff --git a/src/Appwrite/Platform/Workers/Webhooks.php b/src/Appwrite/Platform/Workers/Webhooks.php index 5b0497dbea..a7f4595966 100644 --- a/src/Appwrite/Platform/Workers/Webhooks.php +++ b/src/Appwrite/Platform/Workers/Webhooks.php @@ -106,51 +106,47 @@ class Webhooks extends Action $httpPass = $webhook->getAttribute('httpPass'); $ch = \curl_init($webhook->getAttribute('url')); - try { - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); - \curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); - \curl_setopt($ch, CURLOPT_HEADER, 0); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, CURLOPT_TIMEOUT, 15); - \curl_setopt($ch, CURLOPT_MAXFILESIZE, self::MAX_FILE_SIZE); - \curl_setopt($ch, CURLOPT_USERAGENT, \sprintf( - APP_USERAGENT, - System::getEnv('_APP_VERSION', 'UNKNOWN'), - System::getEnv('_APP_EMAIL_SECURITY', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS', APP_EMAIL_SECURITY)) - )); - \curl_setopt( - $ch, - CURLOPT_HTTPHEADER, - [ - 'Content-Type: application/json', - 'Content-Length: ' . \strlen($payload), - 'X-' . APP_NAME . '-Webhook-Id: ' . $webhook->getId(), - 'X-' . APP_NAME . '-Webhook-Events: ' . implode(',', $events), - 'X-' . APP_NAME . '-Webhook-Name: ' . $webhook->getAttribute('name', ''), - 'X-' . APP_NAME . '-Webhook-User-Id: ' . $user->getId(), - 'X-' . APP_NAME . '-Webhook-Project-Id: ' . $project->getId(), - 'X-' . APP_NAME . '-Webhook-Signature: ' . $signature, - ] - ); - \curl_setopt($ch, CURLOPT_MAXREDIRS, 5); + \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); + \curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); + \curl_setopt($ch, CURLOPT_HEADER, 0); + \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, CURLOPT_TIMEOUT, 15); + \curl_setopt($ch, CURLOPT_MAXFILESIZE, self::MAX_FILE_SIZE); + \curl_setopt($ch, CURLOPT_USERAGENT, \sprintf( + APP_USERAGENT, + System::getEnv('_APP_VERSION', 'UNKNOWN'), + System::getEnv('_APP_EMAIL_SECURITY', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS', APP_EMAIL_SECURITY)) + )); + \curl_setopt( + $ch, + CURLOPT_HTTPHEADER, + [ + 'Content-Type: application/json', + 'Content-Length: ' . \strlen($payload), + 'X-' . APP_NAME . '-Webhook-Id: ' . $webhook->getId(), + 'X-' . APP_NAME . '-Webhook-Events: ' . implode(',', $events), + 'X-' . APP_NAME . '-Webhook-Name: ' . $webhook->getAttribute('name', ''), + 'X-' . APP_NAME . '-Webhook-User-Id: ' . $user->getId(), + 'X-' . APP_NAME . '-Webhook-Project-Id: ' . $project->getId(), + 'X-' . APP_NAME . '-Webhook-Signature: ' . $signature, + ] + ); + \curl_setopt($ch, CURLOPT_MAXREDIRS, 5); - if (!$webhook->getAttribute('security', true)) { - \curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); - \curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - } - - if (!empty($httpUser) && !empty($httpPass)) { - \curl_setopt($ch, CURLOPT_USERPWD, "$httpUser:$httpPass"); - \curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); - } - - $responseBody = \curl_exec($ch); - $curlError = \curl_error($ch); - $statusCode = \curl_getinfo($ch, CURLINFO_RESPONSE_CODE); - } finally { - \curl_close($ch); + if (!$webhook->getAttribute('security', true)) { + \curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + \curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); } + if (!empty($httpUser) && !empty($httpPass)) { + \curl_setopt($ch, CURLOPT_USERPWD, "$httpUser:$httpPass"); + \curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); + } + + $responseBody = \curl_exec($ch); + $curlError = \curl_error($ch); + $statusCode = \curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + if (!empty($curlError) || $statusCode >= 400) { $dbForPlatform->increaseDocumentAttribute('webhooks', $webhook->getId(), 'attempts', 1); $webhook = $dbForPlatform->getDocument('webhooks', $webhook->getId()); diff --git a/src/Appwrite/Utopia/Request/Filters/V24.php b/src/Appwrite/Utopia/Request/Filters/V24.php new file mode 100644 index 0000000000..f62c1f8c0b --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V24.php @@ -0,0 +1,36 @@ +fillKeyId($content); + $content = $this->parseKeyScopes($content); + break; + } + + return $content; + } + + protected function fillKeyId(array $content): array + { + $content['keyId'] = $content['keyId'] ?? 'unique()'; + return $content; + } + + protected function parseKeyScopes(array $content): array + { + if (!\is_array($content['scopes'] ?? null)) { + $content['scopes'] = []; + } + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index e37e2c6043..899cdc086a 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -251,6 +251,7 @@ class Response extends SwooleResponse public const MODEL_WEBHOOK_LIST = 'webhookList'; public const MODEL_KEY = 'key'; public const MODEL_KEY_LIST = 'keyList'; + public const MODEL_EPHEMERAL_KEY = 'ephemeralKey'; public const MODEL_DEV_KEY = 'devKey'; public const MODEL_DEV_KEY_LIST = 'devKeyList'; public const MODEL_MOCK_NUMBER = 'mockNumber'; @@ -334,6 +335,8 @@ class Response extends SwooleResponse public const MODEL_CONSOLE_OAUTH2_PROVIDER_PARAMETER = 'consoleOAuth2ProviderParameter'; public const MODEL_CONSOLE_OAUTH2_PROVIDER = 'consoleOAuth2Provider'; public const MODEL_CONSOLE_OAUTH2_PROVIDER_LIST = 'consoleOAuth2ProviderList'; + public const MODEL_CONSOLE_KEY_SCOPE = 'consoleKeyScope'; + public const MODEL_CONSOLE_KEY_SCOPE_LIST = 'consoleKeyScopeList'; // Deprecated public const MODEL_PERMISSIONS = 'permissions'; diff --git a/src/Appwrite/Utopia/Response/Filters/V24.php b/src/Appwrite/Utopia/Response/Filters/V24.php new file mode 100644 index 0000000000..46db062863 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Filters/V24.php @@ -0,0 +1,56 @@ + $this->parseEphemeralKey($content), + default => $content, + }; + } + + private function parseEphemeralKey(array $content): array + { + unset($content['$id']); + unset($content['$createdAt']); + unset($content['$updatedAt']); + unset($content['name']); + unset($content['expire']); + unset($content['sdks']); + unset($content['accessedAt']); + + $secret = $content['secret'] ?? ''; + unset($content['secret']); + + $content['projectId'] = $this->extractProjectId($secret); + $content['jwt'] = $secret; + + return $content; + } + + private function extractProjectId(string $secret): string + { + $token = explode('_', $secret, 2)[1] ?? ''; + if ($token === '') { + return ''; + } + + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256'); + + try { + return $jwt->decode($token, false)['projectId'] ?? ''; + } catch (JWTException) { + return ''; + } + } +} diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleKeyScope.php b/src/Appwrite/Utopia/Response/Model/ConsoleKeyScope.php new file mode 100644 index 0000000000..224d114271 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ConsoleKeyScope.php @@ -0,0 +1,49 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Scope ID.', + 'default' => '', + 'example' => 'users.read', + ]) + ->addRule('description', [ + 'type' => self::TYPE_STRING, + 'description' => 'Scope description.', + 'default' => '', + 'example' => 'Access to read your project\'s users', + ]) + ->addRule('category', [ + 'type' => self::TYPE_STRING, + 'description' => 'Scope category.', + 'default' => '', + 'example' => 'Auth', + ]) + ->addRule('deprecated', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Scope is deprecated.', + 'default' => false, + 'example' => true, + ]) + ; + } + + public function getName(): string + { + return 'Console Key Scope'; + } + + public function getType(): string + { + return Response::MODEL_CONSOLE_KEY_SCOPE; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleKeyScopeList.php b/src/Appwrite/Utopia/Response/Model/ConsoleKeyScopeList.php new file mode 100644 index 0000000000..aadf3afa63 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ConsoleKeyScopeList.php @@ -0,0 +1,37 @@ +addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of key scopes exposed by the server.', + 'default' => 0, + 'example' => 5, + ]) + ->addRule('scopes', [ + 'type' => Response::MODEL_CONSOLE_KEY_SCOPE, + 'description' => 'List of key scopes, each with its ID and description.', + 'default' => [], + 'array' => true, + ]) + ; + } + + public function getName(): string + { + return 'Console Key Scopes List'; + } + + public function getType(): string + { + return Response::MODEL_CONSOLE_KEY_SCOPE_LIST; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/EphemeralKey.php b/src/Appwrite/Utopia/Response/Model/EphemeralKey.php new file mode 100644 index 0000000000..f6b7fdd7f3 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/EphemeralKey.php @@ -0,0 +1,33 @@ +assertContains('paypalSandbox', $providerIds); } + + public function testListKeyScopes(): void + { + $response = $this->client->call(Client::METHOD_GET, '/console/scopes/project', 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('users.read', $scopeIds); + $this->assertContains('users.write', $scopeIds); + $this->assertContains('functions.read', $scopeIds); + $this->assertContains('functions.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 + $usersRead = null; + foreach ($response['body']['scopes'] as $scope) { + if ($scope['$id'] === 'users.read') { + $usersRead = $scope; + break; + } + } + $this->assertNotNull($usersRead); + $this->assertEquals('Access to read users', $usersRead['description']); + } } diff --git a/tests/e2e/Services/Console/ConsoleCustomServerTest.php b/tests/e2e/Services/Console/ConsoleCustomServerTest.php index d3c64ae039..f06011843f 100644 --- a/tests/e2e/Services/Console/ConsoleCustomServerTest.php +++ b/tests/e2e/Services/Console/ConsoleCustomServerTest.php @@ -43,4 +43,35 @@ class ConsoleCustomServerTest extends Scope $this->assertContains('github', $providerIds); $this->assertNotContains('mock', $providerIds); } + + public function testListKeyScopes(): 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/project', [ + '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('users.read', $scopeIds); + + $usersRead = null; + foreach ($response['body']['scopes'] as $scope) { + if ($scope['$id'] === 'users.read') { + $usersRead = $scope; + break; + } + } + $this->assertNotNull($usersRead); + $this->assertIsString($usersRead['description']); + $this->assertNotEmpty($usersRead['description']); + $this->assertArrayHasKey('deprecated', $usersRead); + $this->assertIsBool($usersRead['deprecated']); + } } diff --git a/tests/e2e/Services/Functions/FunctionsBase.php b/tests/e2e/Services/Functions/FunctionsBase.php index 42976cda84..458359bbe9 100644 --- a/tests/e2e/Services/Functions/FunctionsBase.php +++ b/tests/e2e/Services/Functions/FunctionsBase.php @@ -352,7 +352,6 @@ trait FunctionsBase $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); if ($httpCode === 200) { $commitData = json_decode($response, true); diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 4255774f18..899c0ff71f 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -700,7 +700,7 @@ class FunctionsCustomServerTest extends Scope $this->assertEquals(200, $function['headers']['status-code']); $this->assertEquals($deploymentId, $function['body']['deploymentId']); - // Test starter code is used and that dynamic keys work + // Test starter code is used and that ephemeral keys work $execution = $this->createExecution($functionId, [ 'path' => '/ping', ]); @@ -1079,6 +1079,118 @@ class FunctionsCustomServerTest extends Scope }, 120000, 500); } + public function testCreateDeploymentOutOfOrder(): void + { + $data = $this->setupTestFunction(); + $functionId = $data['functionId']; + + // Prepare a code file that spans at least 3 chunks + $folder = 'large'; + $folderPath = realpath(__DIR__ . '/../../../resources/functions') . "/$folder"; + $code = "$folderPath/code.tar.gz"; + + + + $totalSize = filesize($code); + $chunkSize = 5 * 1024 * 1024; // 5MB chunks + $mimeType = 'application/x-gzip'; + $chunksTotal = (int) ceil($totalSize / $chunkSize); + + // Read all chunks into memory + $handle = fopen($code, "rb"); + $this->assertNotFalse($handle, "Could not open test resource: $code"); + $chunks = []; + for ($i = 0; $i < $chunksTotal; $i++) { + $start = $i * $chunkSize; + $end = min($start + $chunkSize, $totalSize); + $length = $end - $start; + $chunkData = fread($handle, $length); + $chunks[] = [ + 'data' => $chunkData, + 'start' => $start, + 'end' => $end - 1, + 'index' => $i, + ]; + } + fclose($handle); + + // We need at least 2 chunks for a meaningful out-of-order test + $this->assertGreaterThanOrEqual(2, count($chunks), 'Test file must span at least 2 chunks'); + + // Upload chunks in out-of-order sequence: last chunk first, then first, then second + $uploadOrder = [count($chunks) - 1, 0, 1]; + $deploymentId = ''; + $deployment = null; + + foreach ($uploadOrder as $chunkIndex) { + $chunk = $chunks[$chunkIndex]; + $curlFile = new \CURLFile( + 'data://' . $mimeType . ';base64,' . base64_encode($chunk['data']), + $mimeType, + 'large-fx.tar.gz' + ); + + $headers = [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes ' . $chunk['start'] . '-' . $chunk['end'] . '/' . $totalSize, + ]; + + if (!empty($deploymentId)) { + $headers['x-appwrite-id'] = $deploymentId; + } + + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge($headers, $this->getHeaders()), [ + 'entrypoint' => 'index.js', + 'code' => $curlFile, + 'activate' => true, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + $deploymentId = $deployment['body']['$id']; + } + + // Upload remaining chunks in any order to complete the file + $remainingChunks = []; + for ($i = 2; $i < count($chunks) - 1; $i++) { + $remainingChunks[] = $i; + } + shuffle($remainingChunks); + + foreach ($remainingChunks as $chunkIndex) { + $chunk = $chunks[$chunkIndex]; + $curlFile = new \CURLFile( + 'data://' . $mimeType . ';base64,' . base64_encode($chunk['data']), + $mimeType, + 'large-fx.tar.gz' + ); + + $headers = [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes ' . $chunk['start'] . '-' . $chunk['end'] . '/' . $totalSize, + 'x-appwrite-id' => $deploymentId, + ]; + + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge($headers, $this->getHeaders()), [ + 'entrypoint' => 'index.js', + 'code' => $curlFile, + 'activate' => true, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + } + + + + // Wait for build to complete + $this->assertEventually(function () use ($functionId, $deploymentId) { + $deployment = $this->getDeployment($functionId, $deploymentId); + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertEquals('ready', $deployment['body']['status']); + }, 120000, 500); + } + public function testUpdateDeployment(): void { $data = $this->setupTestDeployment(); @@ -2129,7 +2241,7 @@ class FunctionsCustomServerTest extends Scope ]); $deploymentId = $this->setupDeployment($functionId, [ - 'code' => $this->packageFunction('dynamic-api-key'), + 'code' => $this->packageFunction('ephemeral-api-key'), 'activate' => true, ]); diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 069dc9cfbb..4346e5a5fa 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1303,6 +1303,7 @@ trait MigrationsBase $mimeType = match ($csvFileName) { default => 'text/csv', + 'missing-column.csv', 'missing-row.csv' => 'text/plain', // invalid csv structure, falls back to plain text! }; diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php index 505c7f6539..c8687d9964 100644 --- a/tests/e2e/Services/Project/KeysBase.php +++ b/tests/e2e/Services/Project/KeysBase.php @@ -239,6 +239,125 @@ trait KeysBase $this->deleteKey($customId); } + // ========================================================================= + // Create ephemeral key tests + // ========================================================================= + + public function testCreateEphemeralKey(): void + { + $duration = 900; + + $key = $this->createEphemeralKey( + ['users.read', 'users.write'], + $duration, + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertNotEmpty($key['body']['$id']); + $this->assertSame('', $key['body']['name']); + $this->assertSame(['users.read', 'users.write'], $key['body']['scopes']); + $this->assertNotEmpty($key['body']['secret']); + $this->assertStringStartsWith(API_KEY_EPHEMERAL . '_', $key['body']['secret']); + $this->assertSame([], $key['body']['sdks']); + $this->assertSame('', $key['body']['accessedAt']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($key['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($key['body']['$updatedAt'])); + $this->assertSame(true, $dateValidator->isValid($key['body']['expire'])); + + // Verify JWT payload + $jwt = substr($key['body']['secret'], strlen(API_KEY_EPHEMERAL . '_')); + $parts = explode('.', $jwt); + $this->assertCount(3, $parts); + $payload = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $parts[1])), true); + $this->assertNotEmpty($payload['projectId']); + $this->assertSame(['users.read', 'users.write'], $payload['scopes']); + + $expireDt = new \DateTime($key['body']['expire']); + $now = new \DateTime(); + $diff = $expireDt->getTimestamp() - $now->getTimestamp(); + $this->assertGreaterThanOrEqual($duration - 10, $diff); + $this->assertLessThanOrEqual($duration + 10, $diff); + } + + public function testCreateEphemeralKeyWithDuration(): void + { + $duration = 1800; + + $key = $this->createEphemeralKey( + ['databases.read'], + $duration, + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame(['databases.read'], $key['body']['scopes']); + + $expireDt = new \DateTime($key['body']['expire']); + $now = new \DateTime(); + $diff = $expireDt->getTimestamp() - $now->getTimestamp(); + $this->assertGreaterThanOrEqual($duration - 10, $diff); + $this->assertLessThanOrEqual($duration + 10, $diff); + } + + public function testCreateEphemeralKeyWithEmptyScopes(): void + { + $key = $this->createEphemeralKey( + [], + 900, + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame([], $key['body']['scopes']); + } + + public function testCreateEphemeralKeyWithoutAuthentication(): void + { + $response = $this->createEphemeralKey( + ['users.read'], + 900, + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateEphemeralKeyMissingDuration(): void + { + $response = $this->createEphemeralKey( + ['users.read'], + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateEphemeralKeyInvalidScope(): void + { + $response = $this->createEphemeralKey( + ['invalid.scope'], + 900, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateEphemeralKeyInvalidDuration(): void + { + $response = $this->createEphemeralKey( + ['users.read'], + 0, + ); + + $this->assertSame(400, $response['headers']['status-code']); + + $response = $this->createEphemeralKey( + ['users.read'], + 3601, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + // ========================================================================= // Update key tests // ========================================================================= @@ -855,4 +974,29 @@ trait KeysBase return $this->client->call(Client::METHOD_DELETE, '/project/keys/' . $keyId, $headers); } + + /** + * @param array $scopes + */ + protected function createEphemeralKey(array $scopes, ?int $duration = null, bool $authenticated = true): mixed + { + $params = [ + 'scopes' => $scopes, + ]; + + if ($duration !== null) { + $params['duration'] = $duration; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_POST, '/project/keys/ephemeral', $headers, $params); + } } diff --git a/tests/e2e/Services/Project/KeysIntegrationTest.php b/tests/e2e/Services/Project/KeysIntegrationTest.php new file mode 100644 index 0000000000..4dc5838e72 --- /dev/null +++ b/tests/e2e/Services/Project/KeysIntegrationTest.php @@ -0,0 +1,103 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $consoleHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-mode' => 'admin', + 'x-appwrite-project' => $projectId, + ]; + + // Step 1: Create an ephemeral key scoped to users.read only. + $ephemeralKey = $this->client->call( + Client::METHOD_POST, + '/project/keys/ephemeral', + $serverHeaders, + [ + 'scopes' => ['users.read'], + 'duration' => 900, + ] + ); + $this->assertSame(201, $ephemeralKey['headers']['status-code']); + $this->assertNotEmpty($ephemeralKey['body']['secret']); + + $ephemeralKeySecret = $ephemeralKey['body']['secret']; + + $ephemeralHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $ephemeralKeySecret, + ]; + + // Step 2: Create a project user using console headers. + $user = $this->client->call( + Client::METHOD_POST, + '/users', + $consoleHeaders, + [ + 'userId' => ID::unique(), + 'email' => 'ephemeral_key_' . \uniqid() . '@localhost.test', + 'password' => 'password1234', + 'name' => 'Ephemeral Key Test User', + ] + ); + $this->assertSame(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + + // Step 3: Ephemeral key can list users. + $list = $this->client->call( + Client::METHOD_GET, + '/users', + $ephemeralHeaders + ); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + + // Step 4: Ephemeral key can get the specific user. + $get = $this->client->call( + Client::METHOD_GET, + '/users/' . $userId, + $ephemeralHeaders + ); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($userId, $get['body']['$id']); + + // Step 5: Ephemeral key cannot create users (missing users.write scope). + $createAttempt = $this->client->call( + Client::METHOD_POST, + '/users', + $ephemeralHeaders, + [ + 'userId' => ID::unique(), + 'email' => 'should_fail_' . \uniqid() . '@localhost.test', + 'password' => 'password1234', + 'name' => 'Should Fail', + ] + ); + $this->assertSame(401, $createAttempt['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 8322e37de1..6936de9aff 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3941,6 +3941,61 @@ class ProjectsConsoleClientTest extends Scope $this->assertEmpty($response['body']); } + // JWT Keys + + public function testJWTKey(): void + { + $data = $this->setupProjectData(); + $id = $data['projectId']; + + // Create JWT key + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/jwts', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.2', + ], $this->getHeaders()), [ + 'duration' => 5, + 'scopes' => ['users.read'], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['jwt']); + $this->assertNotEmpty($response['body']['projectId']); + $this->assertSame($id, $response['body']['projectId']); + + $jwt = $response['body']['jwt']; + + // Ensure JWT key works + $response = $this->client->call(Client::METHOD_GET, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-key' => $jwt, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertArrayHasKey('users', $response['body']); + + // Ensure JWT key respect scopes + $response = $this->client->call(Client::METHOD_GET, '/functions', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-key' => $jwt, + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + + // Ensure JWT key expires + \sleep(10); + + $response = $this->client->call(Client::METHOD_GET, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-key' => $jwt, + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + } + // Platforms public function testCreateProjectPlatform(): void diff --git a/tests/e2e/Services/Projects/Schedules/SchedulesBase.php b/tests/e2e/Services/Projects/Schedules/SchedulesBase.php index 681e39b662..4baaca4e5b 100644 --- a/tests/e2e/Services/Projects/Schedules/SchedulesBase.php +++ b/tests/e2e/Services/Projects/Schedules/SchedulesBase.php @@ -62,8 +62,8 @@ trait SchedulesBase 'scopes' => [ 'functions.read', 'functions.write', - 'execution.read', - 'execution.write', + 'executions.read', + 'executions.write', 'messages.read', 'messages.write', ], diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index ef1c5fce7a..813ef70ff0 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -335,10 +335,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.update.name", $response['data']['events']); $this->assertContains("users.{$userId}.update", $response['data']['events']); $this->assertContains("users.{$userId}", $response['data']['events']); @@ -368,10 +370,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.update.password", $response['data']['events']); $this->assertContains("users.{$userId}.update", $response['data']['events']); $this->assertContains("users.{$userId}", $response['data']['events']); @@ -401,10 +405,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.update.email", $response['data']['events']); $this->assertContains("users.{$userId}.update", $response['data']['events']); $this->assertContains("users.{$userId}", $response['data']['events']); @@ -432,11 +438,14 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); + // Nested user event (verification) — must NOT suffix the account channels. $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertArrayNotHasKey('secret', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertNotContains('account.create', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.create', $response['data']['channels']); $this->assertContains("users.{$userId}.verification.{$verificationId}.create", $response['data']['events']); $this->assertContains("users.{$userId}.verification.{$verificationId}", $response['data']['events']); $this->assertContains("users.{$userId}.verification.*.create", $response['data']['events']); @@ -475,10 +484,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); + // Nested user event (verification) — must NOT suffix the account channels. $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertNotContains('account.update', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.verification.{$verificationId}.update", $response['data']['events']); $this->assertContains("users.{$userId}.verification.{$verificationId}", $response['data']['events']); $this->assertContains("users.{$userId}.verification.*.update", $response['data']['events']); @@ -510,10 +522,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.update.prefs", $response['data']['events']); $this->assertContains("users.{$userId}.update", $response['data']['events']); $this->assertContains("users.{$userId}", $response['data']['events']); @@ -551,10 +565,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); + // Nested user event (sessions) — must NOT suffix the account channels. $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertNotContains('account.create', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.create', $response['data']['channels']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}.create", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.*.create", $response['data']['events']); @@ -583,10 +600,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); + // Nested user event (sessions) — must NOT suffix the account channels. $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertNotContains('account.delete', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.delete', $response['data']['channels']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}.delete", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.*.delete", $response['data']['events']); @@ -620,10 +640,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); + // Nested user event (sessions) — must NOT suffix the account channels. $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertNotContains('account.delete', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.delete', $response['data']['channels']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}.delete", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.*.delete", $response['data']['events']); @@ -661,10 +684,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); + // Nested user event (recovery) — must NOT suffix the account channels. $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertNotContains('account.create', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.create', $response['data']['channels']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}.create", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.*.create", $response['data']['events']); @@ -695,10 +721,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); + // Nested user event (recovery) — must NOT suffix the account channels. $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertNotContains('account.update', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}.update", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.*.update", $response['data']['events']); @@ -820,7 +849,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -865,7 +894,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -921,7 +950,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -977,7 +1006,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); @@ -1009,7 +1038,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); @@ -1058,7 +1087,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -1086,7 +1115,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -1114,7 +1143,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -1151,7 +1180,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -1180,7 +1209,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -1209,7 +1238,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -1256,7 +1285,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -1435,7 +1464,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response1['type']); $this->assertNotEmpty($response1['data']); $this->assertArrayHasKey('timestamp', $response1['data']); - $this->assertCount(8, $response1['data']['channels']); + $this->assertCount(16, $response1['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.create", $response1['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response1['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response1['data']['events']); @@ -1466,7 +1495,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response2['type']); $this->assertNotEmpty($response2['data']); $this->assertArrayHasKey('timestamp', $response2['data']); - $this->assertCount(8, $response2['data']['channels']); + $this->assertCount(16, $response2['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.create", $response2['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response2['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response2['data']['events']); @@ -1516,7 +1545,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response1['type']); $this->assertNotEmpty($response1['data']); $this->assertArrayHasKey('timestamp', $response1['data']); - $this->assertCount(8, $response1['data']['channels']); + $this->assertCount(16, $response1['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.update", $response1['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response1['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response1['data']['events']); @@ -1570,7 +1599,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response2['type']); $this->assertNotEmpty($response2['data']); $this->assertArrayHasKey('timestamp', $response2['data']); - $this->assertCount(8, $response2['data']['channels']); + $this->assertCount(16, $response2['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.update", $response2['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response2['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response2['data']['events']); @@ -1623,7 +1652,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response1['type']); $this->assertNotEmpty($response1['data']); $this->assertArrayHasKey('timestamp', $response1['data']); - $this->assertCount(8, $response1['data']['channels']); + $this->assertCount(16, $response1['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.update", $response1['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response1['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response1['data']['events']); @@ -1650,7 +1679,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response2['type']); $this->assertNotEmpty($response2['data']); $this->assertArrayHasKey('timestamp', $response2['data']); - $this->assertCount(8, $response2['data']['channels']); + $this->assertCount(16, $response2['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.update", $response2['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response2['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response2['data']['events']); @@ -1689,7 +1718,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response1['type']); $this->assertNotEmpty($response1['data']); $this->assertArrayHasKey('timestamp', $response1['data']); - $this->assertCount(8, $response1['data']['channels']); + $this->assertCount(16, $response1['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.delete", $response1['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response1['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response1['data']['events']); @@ -1720,7 +1749,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response2['type']); $this->assertNotEmpty($response2['data']); $this->assertArrayHasKey('timestamp', $response2['data']); - $this->assertCount(8, $response2['data']['channels']); + $this->assertCount(16, $response2['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.delete", $response2['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response2['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response2['data']['events']); @@ -1773,7 +1802,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -1811,7 +1840,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -1953,7 +1982,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -1992,7 +2021,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -2042,7 +2071,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -2130,10 +2159,13 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('files', $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files", $response['data']['channels']); + $this->assertContains('files.create', $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.create", $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.{$fileId}.create", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}.create", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.*.create", $response['data']['events']); @@ -2169,10 +2201,13 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('files', $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files", $response['data']['channels']); + $this->assertContains('files.update', $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.update", $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.{$fileId}.update", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}.update", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.*.update", $response['data']['events']); @@ -2200,10 +2235,13 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('files', $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files", $response['data']['channels']); + $this->assertContains('files.delete', $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.delete", $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.{$fileId}.delete", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}.delete", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.*.delete", $response['data']['events']); @@ -2320,7 +2358,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(5, $response['data']['channels']); + $this->assertCount(8, $response['data']['channels']); $this->assertContains('console', $response['data']['channels']); $this->assertContains("projects.{$this->getProject()['$id']}", $response['data']['channels']); $this->assertContains('executions', $response['data']['channels']); @@ -2343,7 +2381,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $responseUpdate['type']); $this->assertNotEmpty($responseUpdate['data']); $this->assertArrayHasKey('timestamp', $responseUpdate['data']); - $this->assertCount(5, $responseUpdate['data']['channels']); + $this->assertCount(8, $responseUpdate['data']['channels']); $this->assertContains('console', $responseUpdate['data']['channels']); $this->assertContains("projects.{$this->getProject()['$id']}", $response['data']['channels']); $this->assertContains('executions', $responseUpdate['data']['channels']); @@ -2418,9 +2456,11 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertContains('teams', $response['data']['channels']); $this->assertContains("teams.{$teamId}", $response['data']['channels']); + $this->assertContains('teams.create', $response['data']['channels']); + $this->assertContains("teams.{$teamId}.create", $response['data']['channels']); $this->assertContains("teams.{$teamId}.create", $response['data']['events']); $this->assertContains("teams.{$teamId}", $response['data']['events']); $this->assertContains("teams.*.create", $response['data']['events']); @@ -2447,9 +2487,11 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertContains('teams', $response['data']['channels']); $this->assertContains("teams.{$teamId}", $response['data']['channels']); + $this->assertContains('teams.update', $response['data']['channels']); + $this->assertContains("teams.{$teamId}.update", $response['data']['channels']); $this->assertContains("teams.{$teamId}.update", $response['data']['events']); $this->assertContains("teams.{$teamId}", $response['data']['events']); $this->assertContains("teams.*.update", $response['data']['events']); @@ -2480,9 +2522,11 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertContains('teams', $response['data']['channels']); $this->assertContains("teams.{$teamId}", $response['data']['channels']); + $this->assertContains('teams.update', $response['data']['channels']); + $this->assertContains("teams.{$teamId}.update", $response['data']['channels']); $this->assertContains("teams.{$teamId}.update", $response['data']['events']); $this->assertContains("teams.{$teamId}.update.prefs", $response['data']['events']); $this->assertContains("teams.{$teamId}", $response['data']['events']); @@ -2547,9 +2591,11 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertContains('memberships', $response['data']['channels']); $this->assertContains("memberships.{$membershipId}", $response['data']['channels']); + $this->assertContains('memberships.update', $response['data']['channels']); + $this->assertContains("memberships.{$membershipId}.update", $response['data']['channels']); $this->assertContains("teams.{$teamId}.memberships.{$membershipId}.update", $response['data']['events']); $this->assertContains("teams.{$teamId}.memberships.{$membershipId}", $response['data']['events']); $this->assertContains("teams.{$teamId}.memberships.*.update", $response['data']['events']); @@ -4276,7 +4322,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $rowId, $response['data']['channels']); $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -4333,7 +4379,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -4401,7 +4447,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('rows', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['channels']); @@ -4472,7 +4518,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); @@ -4518,7 +4564,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); @@ -4582,7 +4628,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -4624,7 +4670,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -4666,7 +4712,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -4717,7 +4763,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -4760,7 +4806,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -4789,7 +4835,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -4836,7 +4882,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -4957,7 +5003,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -4992,7 +5038,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -5036,7 +5082,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -5080,7 +5126,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertNotEmpty($response['data']['payload']); $this->assertIsArray($response['data']['payload']); $this->assertArrayHasKey('$id', $response['data']['payload']); @@ -5098,7 +5144,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertNotEmpty($response['data']['payload']); $this->assertIsArray($response['data']['payload']); $this->assertArrayHasKey('$id', $response['data']['payload']); @@ -5133,7 +5179,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -5161,7 +5207,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -5189,7 +5235,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -5226,7 +5272,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -5255,7 +5301,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -5284,7 +5330,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -5331,7 +5377,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -5436,7 +5482,7 @@ class RealtimeCustomClientTest extends Scope $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); // vectorsdb channels should include 3 items like documentsdb - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -5467,7 +5513,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); $this->assertNotEmpty($response['data']['payload']); @@ -5486,7 +5532,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -5525,7 +5571,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $response['data']['payload']['$id'] . '.create', $response['data']['events']); $this->assertContains('vectorsdb.*.collections.*.documents.*.create', $response['data']['events']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.*.documents.*.create', $response['data']['events']); @@ -5540,7 +5586,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $response['data']['payload']['$id'] . '.create', $response['data']['events']); $client->close(); @@ -5643,7 +5689,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); $this->assertNotEmpty($response['data']['payload']); @@ -5674,7 +5720,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); $this->assertNotEmpty($response['data']['payload']); diff --git a/tests/e2e/Services/Realtime/RealtimeQueryBase.php b/tests/e2e/Services/Realtime/RealtimeQueryBase.php index 04b8400b57..5ab5c26253 100644 --- a/tests/e2e/Services/Realtime/RealtimeQueryBase.php +++ b/tests/e2e/Services/Realtime/RealtimeQueryBase.php @@ -2446,4 +2446,471 @@ trait RealtimeQueryBase $clientWithMatchingQuery->close(); $clientWithNonMatchingQuery->close(); } + + /** + * Sets up a database + collection + 'name' string attribute, returning their IDs. + * Used by action-channel tests to avoid duplicating fixture code. + * + * @return array{databaseId: string, collectionId: string} + */ + private function createActorsCollection(): array + { + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Action Channel DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Actors', + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/name', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + return ['databaseId' => $databaseId, 'collectionId' => $collectionId]; + } + + /** + * Creates a document with the given ID and name. Returns the parsed body. + * Permissions allow Role::any() for all CRUD so any session can observe the events. + * + * @return array + */ + private function createActor(string $databaseId, string $collectionId, string $documentId, string $name): array + { + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => $documentId, + 'data' => ['name' => $name], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + return $document['body']; + } + + public function testChannelActionFilterReflectedInConnectedResponse(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + // Subscribing with an action suffix should round-trip the original channel + // name on the connected response. Only meaningful in URL-subscribe mode — + // the message-based path consumes the connected response inside its + // getWebsocket helper before returning, so we can't observe it here. + $client = $this->getWebsocket([ + 'documents.create', + 'documents.update', + 'documents.upsert', + 'documents', + ], $headers); + + $connected = $this->assertConnectionStatusIfSupported($client); + if ($connected === null) { + $client->close(); + $this->markTestSkipped('Connected-response channels are not surfaced through the message-based subscribe path.'); + } + + $this->assertContains('documents.create', $connected['data']['channels']); + $this->assertContains('documents.update', $connected['data']['channels']); + $this->assertContains('documents.upsert', $connected['data']['channels']); + $this->assertContains('documents', $connected['data']['channels']); + + $client->close(); + } + + public function testChannelActionFilterDeliversOnlyMatchingActions(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + $createChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.create"; + $updateChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.update"; + $upsertChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.upsert"; + + $clientCreate = $this->getWebsocket([$createChannel], $headers); + $clientUpdate = $this->getWebsocket([$updateChannel], $headers); + $clientUpsert = $this->getWebsocket([$upsertChannel], $headers); + + $this->assertConnectionStatusIfSupported($clientCreate); + $this->assertConnectionStatusIfSupported($clientUpdate); + $this->assertConnectionStatusIfSupported($clientUpsert); + + $documentId = ID::unique(); + $this->createActor($databaseId, $collectionId, $documentId, 'Chris Evans'); + + // Create event delivers only to the .create subscriber. + $createEvent = json_decode($clientCreate->receive(), true); + $this->assertEquals('event', $createEvent['type']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}.create", + $createEvent['data']['events'] + ); + $this->assertEquals('Chris Evans', $createEvent['data']['payload']['name']); + + try { + $clientUpdate->receive(); + $this->fail('Update subscriber should not receive a create event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + try { + $clientUpsert->receive(); + $this->fail('Upsert subscriber should not receive a create event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + // Update fires update events; only the .update subscriber should hear them. + $this->client->call(Client::METHOD_PATCH, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'data' => ['name' => 'Chris Evans 2'], + ]); + + $updateEvent = json_decode($clientUpdate->receive(), true); + $this->assertEquals('event', $updateEvent['type']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}.update", + $updateEvent['data']['events'] + ); + $this->assertEquals('Chris Evans 2', $updateEvent['data']['payload']['name']); + + try { + $clientCreate->receive(); + $this->fail('Create subscriber should not receive an update event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + try { + $clientUpsert->receive(); + $this->fail('Upsert subscriber should not receive an update event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + // PUT bulk upsert fires upsert events; only the .upsert subscriber should hear them. + $this->client->call(Client::METHOD_PUT, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'documents' => [ + [ + '$id' => ID::unique(), + 'name' => 'Robert Downey Jr.', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ], + ], + ]); + + $upsertEvent = json_decode($clientUpsert->receive(), true); + $this->assertEquals('event', $upsertEvent['type']); + $this->assertContains( + "databases.{$databaseId}.collections.*.documents.*.upsert", + $upsertEvent['data']['events'] + ); + + try { + $clientCreate->receive(); + $this->fail('Create subscriber should not receive an upsert event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + try { + $clientUpdate->receive(); + $this->fail('Update subscriber should not receive an upsert event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $clientCreate->close(); + $clientUpdate->close(); + $clientUpsert->close(); + } + + public function testChannelActionFilterByDocumentId(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + // Use a known custom ID so the .id.action channel can be subscribed before the + // document exists. Without this the channel name can't be predicted. + $watchedId = 'actor-watched'; + $idCreateChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create"; + + $clientWatched = $this->getWebsocket([$idCreateChannel], $headers); + $connected = $this->assertConnectionStatusIfSupported($clientWatched); + if ($connected !== null) { + $this->assertContains($idCreateChannel, $connected['data']['channels']); + } + + // Creating a *different* document should not trigger the watched-id subscription. + $this->createActor($databaseId, $collectionId, ID::unique(), 'Other Actor'); + + try { + $clientWatched->receive(); + $this->fail('Subscriber to .{id}.create should not receive events for a different document.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + // Creating the watched document delivers exactly one create event. + $this->createActor($databaseId, $collectionId, $watchedId, 'Watched Actor'); + + $event = json_decode($clientWatched->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create", + $event['data']['events'] + ); + $this->assertEquals($watchedId, $event['data']['payload']['$id']); + $this->assertEquals('Watched Actor', $event['data']['payload']['name']); + + // Updating the watched document does NOT match — action filter is `create` only. + $this->client->call(Client::METHOD_PATCH, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$watchedId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'data' => ['name' => 'Watched Actor v2'], + ]); + + try { + $clientWatched->receive(); + $this->fail('Subscriber to .{id}.create should not receive update events on the same document.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $clientWatched->close(); + } + + public function testChannelActionFilterMultiChannelSubscription(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + $watchedId = 'actor-multi'; + $idCreateChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create"; + $rowsChannel = "databases.{$databaseId}.tables.{$collectionId}.rows"; + + // One subscription that listens on both: + // 1. `databases...documents.{watchedId}.create` — narrow, action-filtered + // 2. `databases...tables.{collectionId}.rows` — broad, non-action (tablesdb mirror) + // A create on the watched document must reach this subscriber via *both* channels. + $clientMulti = $this->getWebsocket([$idCreateChannel, $rowsChannel], $headers); + $connected = $this->assertConnectionStatusIfSupported($clientMulti); + if ($connected !== null) { + $this->assertContains($idCreateChannel, $connected['data']['channels']); + $this->assertContains($rowsChannel, $connected['data']['channels']); + } + + $this->createActor($databaseId, $collectionId, $watchedId, 'Multi Actor'); + + $event = json_decode($clientMulti->receive(), true); + $this->assertEquals('event', $event['type']); + // The event payload's channels list reports the underlying base channels that + // the published event carries. Both the broad rows channel and the document + // channel that the action filter is anchored on should be present. + $this->assertContains($rowsChannel, $event['data']['channels']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}", + $event['data']['channels'] + ); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create", + $event['data']['events'] + ); + $this->assertEquals('Multi Actor', $event['data']['payload']['name']); + + // Update on the same doc: the .{id}.create branch is filtered out, but the + // broad rows channel has no action filter — the subscription still receives + // the event via that branch (a single delivery, not two). + $this->client->call(Client::METHOD_PATCH, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$watchedId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'data' => ['name' => 'Multi Actor v2'], + ]); + + $update = json_decode($clientMulti->receive(), true); + $this->assertEquals('event', $update['type']); + $this->assertContains($rowsChannel, $update['data']['channels']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.update", + $update['data']['events'] + ); + + // No second copy of the same update should arrive — getSubscribers folds + // multi-channel matches into a single connection delivery. + try { + $clientMulti->receive(); + $this->fail('Multi-channel subscriber should receive a single delivery per event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $clientMulti->close(); + } + + public function testChannelActionFilterDeliversDeleteEvents(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + $deleteChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.delete"; + $clientDelete = $this->getWebsocket([$deleteChannel], $headers); + $connected = $this->assertConnectionStatusIfSupported($clientDelete); + if ($connected !== null) { + $this->assertContains($deleteChannel, $connected['data']['channels']); + } + + $documentId = ID::unique(); + $this->createActor($databaseId, $collectionId, $documentId, 'About To Be Deleted'); + + // Create event must not arrive — the action filter is `delete`. + try { + $clientDelete->receive(); + $this->fail('Delete subscriber should not receive a create event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $this->client->call(Client::METHOD_DELETE, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders())); + + $deleteEvent = json_decode($clientDelete->receive(), true); + $this->assertEquals('event', $deleteEvent['type']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}.delete", + $deleteEvent['data']['events'] + ); + $this->assertEquals($documentId, $deleteEvent['data']['payload']['$id']); + + $clientDelete->close(); + } + + public function testChannelActionFilterUnknownSuffixTreatedAsLiteral(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + // An unrecognised suffix is NOT in SUPPORTED_ACTIONS, so parseActionChannel + // leaves the channel name intact and treats it as a literal channel that no + // published event ever carries — the subscriber should receive nothing. + $client = $this->getWebsocket(['documents.bogus'], $headers); + $connected = $this->assertConnectionStatusIfSupported($client); + if ($connected !== null) { + $this->assertContains('documents.bogus', $connected['data']['channels']); + } + + $documentId = ID::unique(); + $this->createActor($databaseId, $collectionId, $documentId, 'No Bogus Listener'); + + $this->client->call(Client::METHOD_DELETE, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders())); + + try { + $client->receive(); + $this->fail('Unrecognised action suffix should not deliver any events.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $client->close(); + } } diff --git a/tests/e2e/Services/Sites/SitesBase.php b/tests/e2e/Services/Sites/SitesBase.php index c3377faad8..7b9c5e86b0 100644 --- a/tests/e2e/Services/Sites/SitesBase.php +++ b/tests/e2e/Services/Sites/SitesBase.php @@ -350,7 +350,6 @@ trait SitesBase $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); if ($httpCode === 200) { $commitData = json_decode($response, true); diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 71f6675561..7d9257c699 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -906,6 +906,134 @@ class SitesCustomServerTest extends Scope $this->cleanupSite($siteId); } + public function testCreateDeploymentOutOfOrder(): void + { + $siteId = $this->setupSite([ + 'buildRuntime' => 'node-22', + 'fallbackFile' => '', + 'framework' => 'other', + 'name' => 'Test Site Out of Order Upload', + 'outputDirectory' => './', + 'providerBranch' => 'main', + 'providerRootDirectory' => './', + 'siteId' => ID::unique() + ]); + + // Create a temporary large site package for chunked upload + $tempDir = sys_get_temp_dir() . '/appwrite-test-site-' . uniqid(); + mkdir($tempDir, 0777, true); + file_put_contents($tempDir . '/index.html', 'Hello World'); + // Add a large dummy file to make the package span multiple chunks + file_put_contents($tempDir . '/large.bin', random_bytes(12 * 1024 * 1024)); // 12MB non-compressible + + $codePath = $tempDir . '/code.tar.gz'; + Console::execute("cd $tempDir && tar --exclude code.tar.gz -czf code.tar.gz .", '', $this->stdout, $this->stderr); + + $totalSize = filesize($codePath); + $chunkSize = 5 * 1024 * 1024; // 5MB chunks + $mimeType = 'application/x-gzip'; + $chunksTotal = (int) ceil($totalSize / $chunkSize); + + $this->assertGreaterThanOrEqual(2, $chunksTotal, 'Test file must span at least 2 chunks'); + + // Read all chunks into memory + $handle = fopen($codePath, "rb"); + $this->assertNotFalse($handle, "Could not open test resource: $codePath"); + $chunks = []; + for ($i = 0; $i < $chunksTotal; $i++) { + $start = $i * $chunkSize; + $end = min($start + $chunkSize, $totalSize); + $length = $end - $start; + $data = fread($handle, $length); + $chunks[] = [ + 'data' => $data, + 'start' => $start, + 'end' => $end - 1, + 'index' => $i, + ]; + } + fclose($handle); + + // Upload chunks in out-of-order sequence: last chunk first, then first, then second + $uploadOrder = [count($chunks) - 1, 0, 1]; + $deploymentId = ''; + $deployment = null; + + foreach ($uploadOrder as $chunkIndex) { + $chunk = $chunks[$chunkIndex]; + $curlFile = new \CURLFile( + 'data://' . $mimeType . ';base64,' . base64_encode($chunk['data']), + $mimeType, + 'code.tar.gz' + ); + + $headers = [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes ' . $chunk['start'] . '-' . $chunk['end'] . '/' . $totalSize, + ]; + + if (!empty($deploymentId)) { + $headers['x-appwrite-id'] = $deploymentId; + } + + $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', array_merge($headers, $this->getHeaders()), [ + 'code' => $curlFile, + 'activate' => true, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + $deploymentId = $deployment['body']['$id']; + } + + // Upload remaining chunks in any order to complete the file + $remainingChunks = []; + for ($i = 2; $i < count($chunks) - 1; $i++) { + $remainingChunks[] = $i; + } + shuffle($remainingChunks); + + foreach ($remainingChunks as $chunkIndex) { + $chunk = $chunks[$chunkIndex]; + $curlFile = new \CURLFile( + 'data://' . $mimeType . ';base64,' . base64_encode($chunk['data']), + $mimeType, + 'code.tar.gz' + ); + + $headers = [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes ' . $chunk['start'] . '-' . $chunk['end'] . '/' . $totalSize, + 'x-appwrite-id' => $deploymentId, + ]; + + $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', array_merge($headers, $this->getHeaders()), [ + 'code' => $curlFile, + 'activate' => true, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + } + + + + // Wait for build to complete + $this->assertEventually(function () use ($siteId, $deploymentId) { + $deployment = $this->getDeployment($siteId, $deploymentId); + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertEquals('ready', $deployment['body']['status']); + }, 120000, 500); + + // Clean up temp files + unlink($codePath); + unlink($tempDir . '/index.html'); + unlink($tempDir . '/large.bin'); + rmdir($tempDir); + + $this->cleanupSite($siteId); + } + public function testCreateDeployment() { $siteId = $this->setupSite([ @@ -2038,7 +2166,7 @@ class SitesCustomServerTest extends Scope 'previewAuthDisabled' => true, ]); $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [ - 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey, ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertStringContainsString("Hello Appwrite", $response['body']); @@ -2046,7 +2174,7 @@ class SitesCustomServerTest extends Scope $this->assertGreaterThan($contentLength, $response['headers']['content-length']); $response = $proxyClient->call(Client::METHOD_GET, '/non-existing-path', followRedirects: false, headers: [ - 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey, ]); $this->assertEquals(404, $response['headers']['status-code']); $this->assertStringContainsString("Page not found", $response['body']); @@ -2882,7 +3010,7 @@ class SitesCustomServerTest extends Scope ]); $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [ - 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey, ]); $this->assertEquals(400, $response['headers']['status-code']); $deployment = $this->getDeployment($siteId, $deploymentId); @@ -2924,7 +3052,7 @@ class SitesCustomServerTest extends Scope // deployment is still building error page $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [ - 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey, ]); $this->assertEquals(400, $response['headers']['status-code']); $this->assertStringContainsString("Deployment is still building", $response['body']); @@ -2939,7 +3067,7 @@ class SitesCustomServerTest extends Scope // deployment failed error page $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [ - 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey, ]); $this->assertEquals(400, $response['headers']['status-code']); $this->assertStringContainsString("Deployment build failed", $response['body']); diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index 60a4aefc85..29f7d70435 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -1227,6 +1227,153 @@ trait StorageBase $this->assertEquals(204, $deleteBucketResponse['headers']['status-code']); } + public function testCreateBucketFileOutOfOrder(): void + { + // Create a bucket for this test + $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 Out of Order Upload', + 'fileSecurity' => true, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $bucket['headers']['status-code']); + $bucketId = $bucket['body']['$id']; + + // Prepare a file that spans at least 3 chunks + $source = __DIR__ . "/../../../resources/disk-a/large-file.mp4"; + $totalSize = \filesize($source); + $chunkSize = 5 * 1024 * 1024; // 5MB chunks + $mimeType = mime_content_type($source); + $chunksTotal = (int) ceil($totalSize / $chunkSize); + + // Read all chunks into memory + $handle = fopen($source, "rb"); + $this->assertNotFalse($handle, "Could not open test resource: $source"); + $chunks = []; + for ($i = 0; $i < $chunksTotal; $i++) { + $start = $i * $chunkSize; + $end = min($start + $chunkSize, $totalSize); + $length = $end - $start; + $data = fread($handle, $length); + $chunks[] = [ + 'data' => $data, + 'start' => $start, + 'end' => $end - 1, + 'index' => $i, + ]; + } + fclose($handle); + + // We need at least 3 chunks for a meaningful out-of-order test + $this->assertGreaterThanOrEqual(3, count($chunks), 'Test file must span at least 3 chunks'); + + // Upload chunks in out-of-order sequence: last chunk first, then first, then middle + $uploadOrder = [count($chunks) - 1, 0, 1]; // last, first, second (for 3+ chunks) + $fileId = ID::unique(); + $id = ''; + $uploadedFile = null; + + foreach ($uploadOrder as $chunkIndex) { + $chunk = $chunks[$chunkIndex]; + $curlFile = new \CURLFile( + 'data://' . $mimeType . ';base64,' . base64_encode($chunk['data']), + $mimeType, + 'large-file.mp4' + ); + + $headers = [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes ' . $chunk['start'] . '-' . $chunk['end'] . '/' . $totalSize, + ]; + + if (!empty($id)) { + $headers['x-appwrite-id'] = $id; + } + + $uploadedFile = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge($headers, $this->getHeaders()), [ + 'fileId' => $fileId, + 'file' => $curlFile, + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $this->assertEquals(201, $uploadedFile['headers']['status-code']); + $id = $uploadedFile['body']['$id']; + } + + // Upload remaining chunks in any order to complete the file + $remainingChunks = []; + for ($i = 2; $i < count($chunks) - 1; $i++) { + $remainingChunks[] = $i; + } + // Shuffle remaining chunks for extra randomness + shuffle($remainingChunks); + + foreach ($remainingChunks as $chunkIndex) { + $chunk = $chunks[$chunkIndex]; + $curlFile = new \CURLFile( + 'data://' . $mimeType . ';base64,' . base64_encode($chunk['data']), + $mimeType, + 'large-file.mp4' + ); + + $headers = [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes ' . $chunk['start'] . '-' . $chunk['end'] . '/' . $totalSize, + 'x-appwrite-id' => $id, + ]; + + $uploadedFile = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge($headers, $this->getHeaders()), [ + 'fileId' => $fileId, + 'file' => $curlFile, + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $this->assertEquals(201, $uploadedFile['headers']['status-code']); + } + + // Verify the final upload response indicates completion + $this->assertEquals($chunksTotal, $uploadedFile['body']['chunksTotal']); + $this->assertEquals($chunksTotal, $uploadedFile['body']['chunksUploaded']); + + // Verify the file can be downloaded and matches the original + $download = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $id . '/download', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $download['headers']['status-code']); + $this->assertEquals($totalSize, strlen($download['body'])); + $this->assertEquals(md5_file($source), md5($download['body'])); + + // Clean up + $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId . '/files/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $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'], + ]); + } + public function testDeleteBucketFile(): void { // Create a fresh file just for deletion testing (not using cache since we delete it) diff --git a/tests/resources/functions/dynamic-api-key/index.js b/tests/resources/functions/ephemeral-api-key/index.js similarity index 100% rename from tests/resources/functions/dynamic-api-key/index.js rename to tests/resources/functions/ephemeral-api-key/index.js diff --git a/tests/resources/functions/dynamic-api-key/package-lock.json b/tests/resources/functions/ephemeral-api-key/package-lock.json similarity index 93% rename from tests/resources/functions/dynamic-api-key/package-lock.json rename to tests/resources/functions/ephemeral-api-key/package-lock.json index 2d86fe18d3..3756c13c0c 100644 --- a/tests/resources/functions/dynamic-api-key/package-lock.json +++ b/tests/resources/functions/ephemeral-api-key/package-lock.json @@ -1,11 +1,11 @@ { - "name": "dynamic-api-key", + "name": "ephemeral-api-key", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "dynamic-api-key", + "name": "ephemeral-api-key", "version": "1.0.0", "license": "ISC", "dependencies": { diff --git a/tests/resources/functions/dynamic-api-key/package.json b/tests/resources/functions/ephemeral-api-key/package.json similarity index 89% rename from tests/resources/functions/dynamic-api-key/package.json rename to tests/resources/functions/ephemeral-api-key/package.json index 19b8158131..35abec4874 100644 --- a/tests/resources/functions/dynamic-api-key/package.json +++ b/tests/resources/functions/ephemeral-api-key/package.json @@ -1,5 +1,5 @@ { - "name": "dynamic-api-key", + "name": "ephemeral-api-key", "version": "1.0.0", "main": "index.js", "scripts": { diff --git a/tests/resources/functions/dynamic-api-key/setup.sh b/tests/resources/functions/ephemeral-api-key/setup.sh similarity index 100% rename from tests/resources/functions/dynamic-api-key/setup.sh rename to tests/resources/functions/ephemeral-api-key/setup.sh diff --git a/tests/unit/Auth/KeyTest.php b/tests/unit/Auth/KeyTest.php index 58fe3113e1..bcdb46180f 100644 --- a/tests/unit/Auth/KeyTest.php +++ b/tests/unit/Auth/KeyTest.php @@ -14,7 +14,7 @@ class KeyTest extends TestCase { public function testDecode(): void { - // Decode dynamic key + // Decode ephemeral key $projectId = 'test'; $usage = false; $scopes = [ @@ -36,12 +36,12 @@ class KeyTest extends TestCase $this->assertEquals($projectId, $decoded->getProjectId()); $this->assertEquals('', $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); - $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); + $this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType()); $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); - $this->assertEquals('Dynamic Key', $decoded->getName()); + $this->assertEquals('Ephemeral Key', $decoded->getName()); - // Decode dynamic key with extras + // Decode ephemeral key with extras $extra = [ 'disabledMetrics' => ['metric123'], 'hostnameOverride' => true, @@ -60,10 +60,10 @@ class KeyTest extends TestCase $this->assertEquals($projectId, $decoded->getProjectId()); $this->assertEquals('', $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); - $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); + $this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType()); $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); - $this->assertEquals('Dynamic Key', $decoded->getName()); + $this->assertEquals('Ephemeral Key', $decoded->getName()); $this->assertEquals(['metric123'], $decoded->getDisabledMetrics()); $this->assertEquals(true, $decoded->getHostnameOverride()); $this->assertEquals(true, $decoded->isBannerDisabled()); @@ -71,8 +71,8 @@ class KeyTest extends TestCase $this->assertEquals(true, $decoded->isPreviewAuthDisabled()); $this->assertEquals(true, $decoded->isDeploymentStatusIgnored()); - // Decode invalid dynamic key - $invalidKey = API_KEY_DYNAMIC . '_invalid_jwt_token'; + // Decode invalid ephemeral key + $invalidKey = API_KEY_EPHEMERAL . '_invalid_jwt_token'; $decoded = Key::decode( project: new Document(['$id' => $projectId]), team: new Document(), @@ -82,12 +82,12 @@ class KeyTest extends TestCase $this->assertEquals($projectId, $decoded->getProjectId()); $this->assertEquals('', $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); - $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); + $this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType()); $this->assertEquals(User::ROLE_GUESTS, $decoded->getRole()); $this->assertEquals($guestRoleScopes, $decoded->getScopes()); $this->assertEquals('UNKNOWN', $decoded->getName()); - // Decode expired dynamic key + // Decode expired ephemeral key $expiredKey = self::generateKey($projectId, $usage, $scopes, maxAge: 1, timestamp: time() - 60); \sleep(2); $decoded = Key::decode( @@ -99,7 +99,7 @@ class KeyTest extends TestCase $this->assertEquals($projectId, $decoded->getProjectId()); $this->assertEquals('', $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); - $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); + $this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType()); $this->assertEquals(User::ROLE_GUESTS, $decoded->getRole()); $this->assertEquals($guestRoleScopes, $decoded->getScopes()); $this->assertEquals('UNKNOWN', $decoded->getName()); @@ -363,6 +363,6 @@ class KeyTest extends TestCase 'scopes' => $scopes, ], $extra)); - return API_KEY_DYNAMIC . '_' . $apiKey; + return API_KEY_EPHEMERAL . '_' . $apiKey; } } diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index f48be46202..bf901bbe43 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -396,6 +396,248 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } + public function testConvertChannelsRewritesAccountActionSuffixes(): void + { + // Authenticated subscriber to `account.{action}` is translated to the + // user-scoped `account.{userId}.{action}` form so events from other + // users' accounts don't leak through the literal channel. + $channels = Realtime::convertChannels( + ['account.create', 'account.update', 'account.upsert', 'account.delete'], + '123', + ); + + $this->assertArrayHasKey('account.123.create', $channels); + $this->assertArrayHasKey('account.123.update', $channels); + $this->assertArrayHasKey('account.123.upsert', $channels); + $this->assertArrayHasKey('account.123.delete', $channels); + $this->assertArrayNotHasKey('account.create', $channels); + $this->assertArrayNotHasKey('account.update', $channels); + $this->assertArrayNotHasKey('account.upsert', $channels); + $this->assertArrayNotHasKey('account.delete', $channels); + + // Other-user channels and unknown action-like suffixes still get stripped. + $channels = Realtime::convertChannels( + ['account.other_id', 'account.bogus', 'account.123', 'account.create'], + '123', + ); + $this->assertArrayNotHasKey('account.other_id', $channels); + $this->assertArrayNotHasKey('account.bogus', $channels); + $this->assertArrayNotHasKey('account.123', $channels); + $this->assertArrayHasKey('account.123.create', $channels); + } + + public function testConvertChannelsPreservesAccountActionsForGuest(): void + { + // Guests can't scope an action filter to a userId yet, so `account.{action}` + // is preserved verbatim. fromPayload publishes the unscoped `account.{action}` + // channel for top-level user events, so the guest's stored form matches and + // delivers correctly. After the connection authenticates, + // rebindAccountChannels rewrites the literal to `account.{userId}.{action}` + // so the action filter survives the auth transition. + $channels = Realtime::convertChannels( + ['account.create', 'account.update', 'account.upsert', 'account.delete', 'account'], + '', + ); + + $this->assertArrayHasKey('account.create', $channels); + $this->assertArrayHasKey('account.update', $channels); + $this->assertArrayHasKey('account.upsert', $channels); + $this->assertArrayHasKey('account.delete', $channels); + $this->assertArrayHasKey('account', $channels); + } + + public function testRebindAccountChannelsRemapsAfterReauth(): void + { + // Reauth as a different user must remap the user-scoped channels so the + // connection no longer receives the previous user's account events. + $rebound = Realtime::rebindAccountChannels( + ['account.A', 'account.A.create', 'account.A.update', 'documents', 'documents.A.something'], + 'A', + 'B', + ); + + $this->assertContains('account.B', $rebound); + $this->assertContains('account.B.create', $rebound); + $this->assertContains('account.B.update', $rebound); + $this->assertNotContains('account.A', $rebound); + $this->assertNotContains('account.A.create', $rebound); + $this->assertNotContains('account.A.update', $rebound); + + // Non-account channels left alone — the rewrite is precise. + $this->assertContains('documents', $rebound); + $this->assertContains('documents.A.something', $rebound); + } + + public function testRebindAccountChannelsIsNoopForUnchangedUser(): void + { + // Same user → nothing to rewrite. Avoids unnecessary churn when the + // permissionsChanged path fires (roles change, userId is constant). + $channels = ['account.A', 'account.A.create', 'documents']; + $this->assertSame($channels, Realtime::rebindAccountChannels($channels, 'A', 'A')); + } + + public function testRebindAccountChannelsIsNoopForEmptyTarget(): void + { + // Defensive: if a caller ever passes an empty $newUserId (e.g. a + // hypothetical in-band logout), we leave channels untouched rather than + // producing malformed `account.` strings. + $channels = ['account.A', 'account.A.create', 'account.create', 'documents']; + $this->assertSame($channels, Realtime::rebindAccountChannels($channels, 'A', '')); + $this->assertSame($channels, Realtime::rebindAccountChannels($channels, '', '')); + } + + public function testRebindAccountChannelsPromotesGuestActionFilters(): void + { + // Guest connections store `account.{action}` literally (convertChannels + // preserves the form when userId is empty). On in-band authentication, + // rebindAccountChannels promotes those literals to user-scoped form so + // the action filter survives. + $rebound = Realtime::rebindAccountChannels( + ['account', 'account.create', 'account.update', 'documents'], + '', + 'B', + ); + + $this->assertContains('account.B.create', $rebound); + $this->assertContains('account.B.update', $rebound); + $this->assertNotContains('account.create', $rebound); + $this->assertNotContains('account.update', $rebound); + + // Plain `account` and unrelated channels are left alone. + $this->assertContains('account', $rebound); + $this->assertContains('documents', $rebound); + } + + public function testRebindAccountChannelsOnlyRemapsKnownActions(): void + { + // Defensive: only suffixes in SUPPORTED_ACTIONS are rewritten, so a + // channel like `account.A.bogus` stays intact rather than being + // silently rebound. + $rebound = Realtime::rebindAccountChannels( + ['account.A.bogus', 'account.A.create'], + 'A', + 'B', + ); + + $this->assertContains('account.A.bogus', $rebound); + $this->assertContains('account.B.create', $rebound); + $this->assertNotContains('account.B.bogus', $rebound); + $this->assertNotContains('account.A.create', $rebound); + } + + public function testReauthThenPermissionsChangeThenReauthPreservesAccountAction(): void + { + // Full lifecycle, mirrors the auth + permissionsChanged handler logic in + // app/realtime.php: + // 1. user A subscribes to account.create (stored as account.A.create) + // 2. in-band reauth as B → rebound to account.B.create, userId=B + // 3. permissions-change for B → userId on connection MUST stay 'B' + // so a subsequent reauth as C still has previousUserId='B'. + // 4. reauth as C → rebound to account.C.create, userId=C + $realtime = new Realtime(); + + // Step 1. + $aChannels = \array_keys(Realtime::convertChannels(['account.create'], 'A')); + $this->assertSame(['account.A.create'], $aChannels); + $realtime->subscribe('1', 1, 'sub-1', [Role::user(ID::custom('A'))->toString()], $aChannels, [], 'A'); + $this->assertSame('A', $realtime->connections[1]['userId']); + + // Step 2: A → B. + $previousUserId = $realtime->connections[1]['userId']; + $meta = $realtime->getSubscriptionMetadata(1); + $realtime->unsubscribe(1); + foreach ($meta as $subId => $sub) { + $rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'B'); + $realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('B'))->toString()], $rebound, [], 'B'); + } + $this->assertSame('B', $realtime->connections[1]['userId']); + $this->assertContains('account.B.create', $realtime->connections[1]['channels']); + + // Step 3: permissions-change for B (userId stays 'B'). + $previousUserId = $realtime->connections[1]['userId']; + $meta = $realtime->getSubscriptionMetadata(1); + $realtime->unsubscribe(1); + foreach ($meta as $subId => $sub) { + $rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'B'); + $realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('B'))->toString()], $rebound, [], 'B'); + } + $this->assertSame('B', $realtime->connections[1]['userId']); + $this->assertContains('account.B.create', $realtime->connections[1]['channels']); + + // Step 4: B → C. + $previousUserId = $realtime->connections[1]['userId']; + $meta = $realtime->getSubscriptionMetadata(1); + $realtime->unsubscribe(1); + foreach ($meta as $subId => $sub) { + $rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'C'); + $realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('C'))->toString()], $rebound, [], 'C'); + } + $this->assertSame('C', $realtime->connections[1]['userId']); + $this->assertContains('account.C.create', $realtime->connections[1]['channels']); + $this->assertNotContains('account.B.create', $realtime->connections[1]['channels']); + $this->assertNotContains('account.A.create', $realtime->connections[1]['channels']); + } + + public function testGuestAccountActionFilterSurvivesAuthenticationEndToEnd(): void + { + // Full lifecycle: + // 1. Guest connects, subscribes to `account.create`. + // 2. fromPayload publishes a top-level `users.B.create` event — guest + // receives it via the unscoped `account.create` broadcast channel. + // 3. Guest authenticates as B. Resubscribe goes through + // rebindAccountChannels so the same subscription is now scoped to + // `account.B.create` and only matches B's events. + $realtime = new Realtime(); + + // Step 1: guest subscribes. convertChannels preserves the literal form. + $guestChannels = \array_keys(Realtime::convertChannels(['account.create'], '')); + $this->assertSame(['account.create'], $guestChannels); + $realtime->subscribe('1', 1, 'sub-1', [Role::guests()->toString()], $guestChannels, [], ''); + + // Step 2: fromPayload publishes account.create alongside the user-scoped form. + $publish = Realtime::fromPayload( + event: 'users.B.create', + payload: new Document(['$id' => ID::custom('B')]), + ); + $this->assertContains('account.create', $publish['channels']); + $this->assertContains('account.B.create', $publish['channels']); + + // Guest receives the unscoped channel. + $event = [ + 'project' => '1', + 'roles' => [Role::guests()->toString()], + 'data' => [ + 'channels' => $publish['channels'], + 'payload' => ['$id' => 'B'], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($event)); + + // Step 3: in-band auth promotes the guest to user 'B'. + $previousUserId = $realtime->connections[1]['userId'] ?? ''; + $meta = $realtime->getSubscriptionMetadata(1); + $realtime->unsubscribe(1); + foreach ($meta as $subId => $sub) { + $rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'B'); + $realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('B'))->toString()], $rebound, [], 'B'); + } + + // Literal channel is gone; user-scoped form is in place. + $this->assertNotContains('account.create', $realtime->connections[1]['channels']); + $this->assertContains('account.B.create', $realtime->connections[1]['channels']); + + // B-scoped event delivers via the user-scoped channel. + $bEvent = [ + 'project' => '1', + 'roles' => [Role::user(ID::custom('B'))->toString()], + 'data' => [ + 'channels' => $publish['channels'], + 'payload' => ['$id' => 'B'], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($bEvent)); + } + public function testFromPayloadPermissions(): void { /** @@ -517,4 +759,270 @@ class MessagingTest extends TestCase $this->assertContains(Role::any()->toString(), $result['roles']); $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } + public function testFromPayloadEmitsActionSuffixedChannels(): void + { + $result = Realtime::fromPayload( + event: 'databases.database_id.collections.collection_id.documents.document_id.create', + payload: new Document([ + '$id' => ID::custom('document_id'), + '$collection' => ID::custom('collection_id'), + '$collectionId' => 'collection_id', + '$permissions' => [Permission::read(Role::any())], + ]), + database: new Document(['$id' => ID::custom('database_id')]), + collection: new Document([ + '$id' => ID::custom('collection_id'), + '$permissions' => [Permission::read(Role::any())], + ]) + ); + + // Base channels remain. + $this->assertContains('documents', $result['channels']); + $this->assertContains('databases.database_id.collections.collection_id.documents', $result['channels']); + $this->assertContains('databases.database_id.collections.collection_id.documents.document_id', $result['channels']); + + // Action-suffixed variants are appended for every base channel. + $this->assertContains('documents.create', $result['channels']); + $this->assertContains('databases.database_id.collections.collection_id.documents.create', $result['channels']); + $this->assertContains('databases.database_id.collections.collection_id.documents.document_id.create', $result['channels']); + + // No mismatched action suffixes leak in. + $this->assertNotContains('documents.update', $result['channels']); + $this->assertNotContains('documents.delete', $result['channels']); + } + + public function testFromPayloadEmitsActionSuffixForEveryAction(): void + { + foreach (['create', 'update', 'upsert', 'delete'] as $action) { + $result = Realtime::fromPayload( + event: "databases.database_id.collections.collection_id.documents.document_id.{$action}", + payload: new Document([ + '$id' => ID::custom('document_id'), + '$collection' => ID::custom('collection_id'), + '$collectionId' => 'collection_id', + '$permissions' => [Permission::read(Role::any())], + ]), + database: new Document(['$id' => ID::custom('database_id')]), + collection: new Document([ + '$id' => ID::custom('collection_id'), + '$permissions' => [Permission::read(Role::any())], + ]) + ); + + $this->assertContains("documents.{$action}", $result['channels'], "documents.{$action} missing"); + $this->assertContains( + "databases.database_id.collections.collection_id.documents.document_id.{$action}", + $result['channels'], + "specific-doc {$action} channel missing" + ); + } + } + + public function testFromPayloadDoesNotSuffixWhenNoAction(): void + { + // Synthetic event without an action segment: e.g. an attribute event whose + // last segment is not a known action and whose second-to-last segment is + // also not a known action. + $result = Realtime::fromPayload( + event: 'buckets.bucket_id.files.file_id.update', + payload: new Document([ + '$id' => ID::custom('file_id'), + 'bucketId' => 'bucket_id', + '$permissions' => [Permission::read(Role::any())], + ]), + bucket: new Document([ + '$id' => ID::custom('bucket_id'), + '$permissions' => [Permission::read(Role::any())], + ]) + ); + + // Action-suffixed variants for the file event. + $this->assertContains('files.update', $result['channels']); + $this->assertContains('buckets.bucket_id.files.update', $result['channels']); + $this->assertContains('buckets.bucket_id.files.file_id.update', $result['channels']); + + // Base channels remain. + $this->assertContains('files', $result['channels']); + $this->assertContains('buckets.bucket_id.files', $result['channels']); + $this->assertContains('buckets.bucket_id.files.file_id', $result['channels']); + } + + public function testFromPayloadDoesNotSuffixAdminChannels(): void + { + // Function execution event emits resource-leaf channels (executions / functions) + // alongside admin channels (console / projects.X). Admin channels must NOT + // get an action suffix — only the resource-leaf channels do. + $result = Realtime::fromPayload( + event: 'functions.function_id.executions.execution_id.create', + payload: new Document([ + '$id' => ID::custom('execution_id'), + 'functionId' => 'function_id', + '$read' => [Role::any()->toString()], + '$permissions' => [Permission::read(Role::any())], + ]), + project: new Document([ + '$id' => ID::custom('project_id'), + 'teamId' => '123abc', + ]) + ); + + // Resource-leaf channels are suffixed. + $this->assertContains('executions', $result['channels']); + $this->assertContains('executions.create', $result['channels']); + $this->assertContains('executions.execution_id', $result['channels']); + $this->assertContains('executions.execution_id.create', $result['channels']); + $this->assertContains('functions.function_id', $result['channels']); + $this->assertContains('functions.function_id.create', $result['channels']); + + // Admin channels are NOT suffixed. + $this->assertContains('console', $result['channels']); + $this->assertNotContains('console.create', $result['channels']); + $this->assertContains('projects.project_id', $result['channels']); + $this->assertNotContains('projects.project_id.create', $result['channels']); + + // The bare `functions` channel is never emitted by fromPayload (only + // `functions.{functionId}` is). The per-function action variant + // (`functions.{functionId}.create`) is the supported subscription + // form — bare `functions.create` would be a silent no-op and must + // therefore NOT appear in the published channel set either. + $this->assertNotContains('functions', $result['channels']); + $this->assertNotContains('functions.create', $result['channels']); + } + + public function testFromPayloadHandlesAttributeTrailingActionEvents(): void + { + // `users.[userId].update.{attr}` (e.g. .email, .prefs, .name) — action is the + // second-to-last segment, not the last one. The suffix must still be `.update`. + $userResult = Realtime::fromPayload( + event: 'users.user_id.update.email', + payload: new Document(['$id' => ID::custom('user_id')]) + ); + + $this->assertContains('account', $userResult['channels']); + $this->assertContains('account.user_id', $userResult['channels']); + $this->assertContains('account.update', $userResult['channels']); + $this->assertContains('account.user_id.update', $userResult['channels']); + // The attribute name must NOT leak into the channel namespace. + $this->assertNotContains('account.email', $userResult['channels']); + $this->assertNotContains('account.user_id.email', $userResult['channels']); + + // `teams.[teamId].update.prefs` — same shape at the team level. + $teamResult = Realtime::fromPayload( + event: 'teams.team_id.update.prefs', + payload: new Document(['$id' => ID::custom('team_id')]) + ); + + $this->assertContains('teams', $teamResult['channels']); + $this->assertContains('teams.team_id', $teamResult['channels']); + $this->assertContains('teams.update', $teamResult['channels']); + $this->assertContains('teams.team_id.update', $teamResult['channels']); + $this->assertNotContains('teams.prefs', $teamResult['channels']); + $this->assertNotContains('teams.team_id.prefs', $teamResult['channels']); + + // `teams.[teamId].memberships.[membershipId].update.{attr}` — same again, deeper. + $membershipResult = Realtime::fromPayload( + event: 'teams.team_id.memberships.membership_id.update.status', + payload: new Document(['$id' => ID::custom('membership_id')]) + ); + + $this->assertContains('memberships', $membershipResult['channels']); + $this->assertContains('memberships.membership_id', $membershipResult['channels']); + $this->assertContains('memberships.update', $membershipResult['channels']); + $this->assertContains('memberships.membership_id.update', $membershipResult['channels']); + $this->assertNotContains('memberships.status', $membershipResult['channels']); + $this->assertNotContains('memberships.membership_id.status', $membershipResult['channels']); + } + + public function testFromPayloadDoesNotSuffixAccountForNestedUserEvents(): void + { + // Nested user events (challenges/sessions/recovery/verification) emit only + // user-level account channels in fromPayload. The trailing action belongs to + // the nested resource, NOT to the user account. A subscriber to + // `account.create` must not receive `users.U.challenges.C.create` or + // `users.U.sessions.S.delete` events — that would silently leak unrelated + // MFA / session traffic into account-level filters. + foreach (['challenges', 'sessions', 'recovery', 'verification'] as $sub) { + foreach (['create', 'update', 'delete'] as $action) { + $result = Realtime::fromPayload( + event: "users.user_id.{$sub}.sub_id.{$action}", + payload: new Document(['$id' => ID::custom('sub_id')]) + ); + + $this->assertContains('account', $result['channels'], "{$sub}.{$action} should still emit base account channel"); + $this->assertContains('account.user_id', $result['channels'], "{$sub}.{$action} should still emit user-scoped account channel"); + $this->assertNotContains("account.{$action}", $result['channels'], "{$sub}.{$action} must NOT leak action suffix onto account channel"); + $this->assertNotContains("account.user_id.{$action}", $result['channels'], "{$sub}.{$action} must NOT leak action suffix onto user-scoped account channel"); + } + } + + // Top-level user events SHOULD still suffix — guard against an over-eager fix + // that suppresses the suffix for legitimate account-level CRUD. + $createResult = Realtime::fromPayload( + event: 'users.user_id.create', + payload: new Document(['$id' => ID::custom('user_id')]) + ); + $this->assertContains('account.create', $createResult['channels']); + $this->assertContains('account.user_id.create', $createResult['channels']); + + $updateResult = Realtime::fromPayload( + event: 'users.user_id.update.email', + payload: new Document(['$id' => ID::custom('user_id')]) + ); + $this->assertContains('account.update', $updateResult['channels']); + $this->assertContains('account.user_id.update', $updateResult['channels']); + } + + public function testActionSuffixDeliversOnlyMatchingActionEndToEnd(): void + { + $realtime = new Realtime(); + + // Subscriber A scopes to creates; Subscriber B scopes to deletes. + $realtime->subscribe('1', 1, 'sub-create', [Role::any()->toString()], ['documents.create']); + $realtime->subscribe('1', 2, 'sub-delete', [Role::any()->toString()], ['documents.delete']); + + // Simulate what fromPayload would publish for a create event. + $createEvent = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents', 'documents.create'], + 'payload' => ['$id' => 'doc'], + ], + ]; + $createReceivers = $realtime->getSubscribers($createEvent); + $this->assertArrayHasKey(1, $createReceivers); + $this->assertArrayNotHasKey(2, $createReceivers); + + // Delete event. + $deleteEvent = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents', 'documents.delete'], + 'payload' => ['$id' => 'doc'], + ], + ]; + $deleteReceivers = $realtime->getSubscribers($deleteEvent); + $this->assertArrayHasKey(2, $deleteReceivers); + $this->assertArrayNotHasKey(1, $deleteReceivers); + } + + public function testPlainChannelStillReceivesAllActionsEndToEnd(): void + { + $realtime = new Realtime(); + + $realtime->subscribe('1', 1, 'sub-all', [Role::any()->toString()], ['documents']); + + foreach (['create', 'update', 'upsert', 'delete'] as $action) { + $event = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents', "documents.{$action}"], + 'payload' => ['$id' => 'doc'], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($event), "plain `documents` should match {$action} event"); + } + } }