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/.env b/.env index ec73bea5b4..cb6b1c91c2 100644 --- a/.env +++ b/.env @@ -148,3 +148,5 @@ _APP_POOL_ADAPTER=stack _APP_WORKER_SCREENSHOTS_ROUTER=http://appwrite _APP_GEO_SECRET=geo-secret-key _APP_GEO_ENDPOINT=http://appwrite-geo/v1 +_TESTS_OAUTH2_GITHUB_CLIENT_ID= +_TESTS_OAUTH2_GITHUB_CLIENT_SECRET= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a056ff8510..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,14 +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 database environment + - 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 @@ -481,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: @@ -489,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 @@ -526,6 +549,8 @@ jobs: docker compose exec -T \ -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ + -e _TESTS_OAUTH2_GITHUB_CLIENT_ID="${{ secrets.TESTS_OAUTH2_GITHUB_CLIENT_ID }}" \ + -e _TESTS_OAUTH2_GITHUB_CLIENT_SECRET="${{ secrets.TESTS_OAUTH2_GITHUB_CLIENT_SECRET }}" \ appwrite vendor/bin/paratest --processes "$PARATEST_PROCESSES" $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml - name: Failure Logs @@ -541,6 +566,7 @@ jobs: permissions: contents: read pull-requests: write + packages: read strategy: fail-fast: false matrix: @@ -551,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: @@ -571,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 @@ -602,6 +633,7 @@ jobs: permissions: contents: read pull-requests: write + packages: read strategy: fail-fast: false matrix: @@ -610,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: @@ -629,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 @@ -671,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 07b0cd59ed..fa112bcb6f 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -384,7 +384,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/oAuthProviders.php b/app/config/oAuthProviders.php index cda6459519..3b492fd8bf 100644 --- a/app/config/oAuthProviders.php +++ b/app/config/oAuthProviders.php @@ -167,6 +167,17 @@ return [ 'mock' => false, 'class' => 'Appwrite\\Auth\\OAuth2\\Figma', ], + 'fusionauth' => [ + 'name' => 'FusionAuth', + 'developers' => 'https://fusionauth.io/docs/', + 'icon' => 'icon-fusionauth', + 'enabled' => true, + 'sandbox' => false, + 'form' => 'fusionauth.phtml', + 'beta' => false, + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\FusionAuth', + ], 'github' => [ 'name' => 'GitHub', 'developers' => 'https://developer.github.com/', @@ -200,6 +211,28 @@ return [ 'mock' => false, 'class' => 'Appwrite\\Auth\\OAuth2\\Google', ], + 'keycloak' => [ + 'name' => 'Keycloak', + 'developers' => 'https://www.keycloak.org/documentation', + 'icon' => 'icon-keycloak', + 'enabled' => true, + 'sandbox' => false, + 'form' => 'keycloak.phtml', + 'beta' => false, + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Keycloak', + ], + 'kick' => [ + 'name' => 'Kick', + 'developers' => 'https://docs.kick.com/', + 'icon' => 'icon-kick', + 'enabled' => true, + 'sandbox' => false, + 'form' => false, + 'beta' => false, + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Kick', + ], 'linkedin' => [ 'name' => 'LinkedIn', 'developers' => 'https://developer.linkedin.com/', diff --git a/app/config/roles.php b/app/config/roles.php index 33c7ffc9de..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', @@ -55,6 +55,8 @@ $admins = [ 'tables.write', 'platforms.read', 'platforms.write', + 'oauth2.read', + 'oauth2.write', 'mocks.read', 'mocks.write', 'policies.read', @@ -79,8 +81,8 @@ $admins = [ 'sites.write', 'log.read', 'log.write', - 'execution.read', - 'execution.write', + 'executions.read', + 'executions.write', 'rules.read', 'rules.write', 'migrations.read', @@ -121,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 592e032ba1..63b946f74f 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -1,231 +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/projects.php b/app/controllers/api/projects.php index cf920b695f..494aa11150 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -58,23 +58,11 @@ Http::get('/v1/projects/:projectId') $response->dynamic($project, Response::MODEL_PROJECT); }); +// Backwards compatibility Http::patch('/v1/projects/:projectId/oauth2') ->desc('Update project OAuth2') ->groups(['api', 'projects']) ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateOAuth2', - description: '/docs/references/projects/update-oauth2.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('provider', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders')), true), 'Provider Name') ->param('appId', null, new Nullable(new Text(256)), 'Provider app ID. Max length: 256 chars.', true) diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 1fbc04bc05..2103948c33 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', @@ -2309,7 +2309,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') @@ -2463,7 +2463,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( @@ -2514,7 +2514,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 fe664fbd71..2085aac3a1 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 Swoole\Http\Request as SwooleRequest; @@ -120,7 +122,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S } } - if (!in_array($host, $platformHostnames)) { + if (!in_array($host, $platformHostnames) && System::getEnv('_APP_OPTIONS_ROUTER_PROTECTION', 'enabled') === 'enabled') { throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Router protection does not allow accessing Appwrite over this domain. Please add it as custom domain to your project or disable _APP_OPTIONS_ROUTER_PROTECTION environment variable.', view: $errorView); } @@ -397,7 +399,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'] = ''; @@ -893,6 +895,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', '')); @@ -917,6 +922,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 7c2f527ccf..c9e4f8b47d 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -183,7 +183,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 b713d61cd2..9530b4b98b 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -56,6 +56,11 @@ 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; use Appwrite\Utopia\Response\Model\ConsoleVariables; use Appwrite\Utopia\Response\Model\Continent; use Appwrite\Utopia\Response\Model\Country; @@ -68,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; @@ -105,6 +111,47 @@ use Appwrite\Utopia\Response\Model\MigrationReport; use Appwrite\Utopia\Response\Model\Mock; use Appwrite\Utopia\Response\Model\MockNumber; use Appwrite\Utopia\Response\Model\None; +use Appwrite\Utopia\Response\Model\OAuth2Amazon; +use Appwrite\Utopia\Response\Model\OAuth2Apple; +use Appwrite\Utopia\Response\Model\OAuth2Auth0; +use Appwrite\Utopia\Response\Model\OAuth2Authentik; +use Appwrite\Utopia\Response\Model\OAuth2Autodesk; +use Appwrite\Utopia\Response\Model\OAuth2Bitbucket; +use Appwrite\Utopia\Response\Model\OAuth2Bitly; +use Appwrite\Utopia\Response\Model\OAuth2Box; +use Appwrite\Utopia\Response\Model\OAuth2Dailymotion; +use Appwrite\Utopia\Response\Model\OAuth2Discord; +use Appwrite\Utopia\Response\Model\OAuth2Disqus; +use Appwrite\Utopia\Response\Model\OAuth2Dropbox; +use Appwrite\Utopia\Response\Model\OAuth2Etsy; +use Appwrite\Utopia\Response\Model\OAuth2Facebook; +use Appwrite\Utopia\Response\Model\OAuth2Figma; +use Appwrite\Utopia\Response\Model\OAuth2FusionAuth; +use Appwrite\Utopia\Response\Model\OAuth2GitHub; +use Appwrite\Utopia\Response\Model\OAuth2Gitlab; +use Appwrite\Utopia\Response\Model\OAuth2Google; +use Appwrite\Utopia\Response\Model\OAuth2Keycloak; +use Appwrite\Utopia\Response\Model\OAuth2Kick; +use Appwrite\Utopia\Response\Model\OAuth2Linkedin; +use Appwrite\Utopia\Response\Model\OAuth2Microsoft; +use Appwrite\Utopia\Response\Model\OAuth2Notion; +use Appwrite\Utopia\Response\Model\OAuth2Oidc; +use Appwrite\Utopia\Response\Model\OAuth2Okta; +use Appwrite\Utopia\Response\Model\OAuth2Paypal; +use Appwrite\Utopia\Response\Model\OAuth2Podio; +use Appwrite\Utopia\Response\Model\OAuth2ProviderList; +use Appwrite\Utopia\Response\Model\OAuth2Salesforce; +use Appwrite\Utopia\Response\Model\OAuth2Slack; +use Appwrite\Utopia\Response\Model\OAuth2Spotify; +use Appwrite\Utopia\Response\Model\OAuth2Stripe; +use Appwrite\Utopia\Response\Model\OAuth2Tradeshift; +use Appwrite\Utopia\Response\Model\OAuth2Twitch; +use Appwrite\Utopia\Response\Model\OAuth2WordPress; +use Appwrite\Utopia\Response\Model\OAuth2X; +use Appwrite\Utopia\Response\Model\OAuth2Yahoo; +use Appwrite\Utopia\Response\Model\OAuth2Yandex; +use Appwrite\Utopia\Response\Model\OAuth2Zoho; +use Appwrite\Utopia\Response\Model\OAuth2Zoom; use Appwrite\Utopia\Response\Model\Phone; use Appwrite\Utopia\Response\Model\PlatformAndroid; use Appwrite\Utopia\Response\Model\PlatformApple; @@ -348,8 +395,50 @@ 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()); +Response::setModel(new OAuth2Discord()); +Response::setModel(new OAuth2Figma()); +Response::setModel(new OAuth2Dropbox()); +Response::setModel(new OAuth2Dailymotion()); +Response::setModel(new OAuth2Bitbucket()); +Response::setModel(new OAuth2Bitly()); +Response::setModel(new OAuth2Box()); +Response::setModel(new OAuth2Autodesk()); +Response::setModel(new OAuth2Google()); +Response::setModel(new OAuth2Zoom()); +Response::setModel(new OAuth2Zoho()); +Response::setModel(new OAuth2Yandex()); +Response::setModel(new OAuth2X()); +Response::setModel(new OAuth2WordPress()); +Response::setModel(new OAuth2Twitch()); +Response::setModel(new OAuth2Stripe()); +Response::setModel(new OAuth2Spotify()); +Response::setModel(new OAuth2Slack()); +Response::setModel(new OAuth2Podio()); +Response::setModel(new OAuth2Notion()); +Response::setModel(new OAuth2Salesforce()); +Response::setModel(new OAuth2Yahoo()); +Response::setModel(new OAuth2Linkedin()); +Response::setModel(new OAuth2Disqus()); +Response::setModel(new OAuth2Amazon()); +Response::setModel(new OAuth2Etsy()); +Response::setModel(new OAuth2Facebook()); +Response::setModel(new OAuth2Tradeshift()); +Response::setModel(new OAuth2Paypal()); +Response::setModel(new OAuth2Gitlab()); +Response::setModel(new OAuth2Authentik()); +Response::setModel(new OAuth2Auth0()); +Response::setModel(new OAuth2FusionAuth()); +Response::setModel(new OAuth2Keycloak()); +Response::setModel(new OAuth2Oidc()); +Response::setModel(new OAuth2Okta()); +Response::setModel(new OAuth2Kick()); +Response::setModel(new OAuth2Apple()); +Response::setModel(new OAuth2Microsoft()); +Response::setModel(new OAuth2ProviderList()); Response::setModel(new PolicyPasswordDictionary()); Response::setModel(new PolicyPasswordHistory()); Response::setModel(new PolicyPasswordPersonalData()); @@ -398,6 +487,11 @@ Response::setModel(new Rule()); Response::setModel(new Schedule()); Response::setModel(new TemplateEmail()); 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/init/realtime/connection.php b/app/init/realtime/connection.php index c0219fa816..a090635bb5 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -327,9 +327,11 @@ return function (Container $container): void { } } - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', ''); - $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); - $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); + // Query params mirror the header fallback pattern used by ?project= and ?devKey=, + // allowing Console to embed impersonation in direct file/image URLs where headers cannot be set. + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', '')); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', (string)$request->getParam('impersonateEmail', '')); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', (string)$request->getParam('impersonatePhone', '')); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { $userDb = ($mode === APP_MODE_ADMIN || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 8486a09b77..e882099689 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -574,10 +574,12 @@ return function (Container $container): void { } } - // Impersonation: if current user has impersonator capability and headers are set, act as another user - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', ''); - $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); - $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); + // Impersonation: if current user has impersonator capability and headers/params are set, act as another user + // Query params mirror the header fallback pattern used by ?project= and ?devKey=, + // allowing Console to embed impersonation in direct file/image URLs where headers cannot be set. + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', '')); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', (string)$request->getParam('impersonateEmail', '')); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', (string)$request->getParam('impersonatePhone', '')); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { $userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; $targetUser = null; 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 6312243e32..683da6f21b 100644 --- a/composer.json +++ b/composer.json @@ -69,6 +69,7 @@ "utopia-php/dsn": "0.2.1", "utopia-php/http": "0.34.*", "utopia-php/fetch": "0.5.*", + "utopia-php/validators": "0.2.*", "utopia-php/image": "0.8.*", "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", @@ -81,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 02590020e0..50b6355c46 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": "c5ae97637fd0ec0a950044d1c33677ea", + "content-hash": "4bee36b21a57e754d2b3417e72dc9599", "packages": [ { "name": "adhocore/jwt", @@ -1996,16 +1996,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.51", + "version": "3.0.52", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748" + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d59c94077f9c9915abb51ddb52ce85188ece1748", - "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce", + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce", "shasum": "" }, "require": { @@ -2086,7 +2086,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.51" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52" }, "funding": [ { @@ -2102,7 +2102,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T01:33:53+00:00" + "time": "2026-04-27T07:02:15+00:00" }, { "name": "psr/clock", @@ -2887,7 +2887,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -2948,7 +2948,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" }, "funding": [ { @@ -2972,7 +2972,7 @@ }, { "name": "symfony/polyfill-php82", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", @@ -3028,7 +3028,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.37.0" }, "funding": [ { @@ -3052,7 +3052,7 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", @@ -3108,7 +3108,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" }, "funding": [ { @@ -3132,16 +3132,16 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "2c408a6bb0313e6001a83628dc5506100474254e" + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/2c408a6bb0313e6001a83628dc5506100474254e", - "reference": "2c408a6bb0313e6001a83628dc5506100474254e", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", "shasum": "" }, "require": { @@ -3188,7 +3188,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" }, "funding": [ { @@ -3208,7 +3208,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:50:15+00:00" + "time": "2026-04-26T13:10:57+00:00" }, { "name": "symfony/service-contracts", @@ -3658,16 +3658,16 @@ }, { "name": "utopia-php/cli", - "version": "0.23.1", + "version": "0.23.2", "source": { "type": "git", "url": "https://github.com/utopia-php/cli.git", - "reference": "8d1955b8bc4dc631f45d7c7df689ed7b63f70621" + "reference": "145b91fef827853bcceaa3ab8ca2b1d6faaca2ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cli/zipball/8d1955b8bc4dc631f45d7c7df689ed7b63f70621", - "reference": "8d1955b8bc4dc631f45d7c7df689ed7b63f70621", + "url": "https://api.github.com/repos/utopia-php/cli/zipball/145b91fef827853bcceaa3ab8ca2b1d6faaca2ab", + "reference": "145b91fef827853bcceaa3ab8ca2b1d6faaca2ab", "shasum": "" }, "require": { @@ -3703,9 +3703,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cli/issues", - "source": "https://github.com/utopia-php/cli/tree/0.23.1" + "source": "https://github.com/utopia-php/cli/tree/0.23.2" }, - "time": "2026-04-05T15:27:35+00:00" + "time": "2026-04-27T09:19:04+00:00" }, { "name": "utopia-php/compression", @@ -4271,21 +4271,20 @@ }, { "name": "utopia-php/http", - "version": "0.34.21", + "version": "0.34.24", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "49a6bd3ea0d2966aa19cf707255d442675288a24" + "reference": "d1eced0627c5a9fceddf53992ed97d664b810d33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/49a6bd3ea0d2966aa19cf707255d442675288a24", - "reference": "49a6bd3ea0d2966aa19cf707255d442675288a24", + "url": "https://api.github.com/repos/utopia-php/http/zipball/d1eced0627c5a9fceddf53992ed97d664b810d33", + "reference": "d1eced0627c5a9fceddf53992ed97d664b810d33", "shasum": "" }, "require": { - "ext-swoole": "*", - "php": ">=8.2", + "php": ">=8.3", "utopia-php/compression": "0.1.*", "utopia-php/di": "0.3.*", "utopia-php/servers": "0.3.*", @@ -4295,11 +4294,14 @@ "require-dev": { "doctrine/instantiator": "^1.5", "laravel/pint": "1.*", - "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "1.*", - "phpunit/phpunit": "^9.5.25", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^12.0", + "rector/rector": "^2.4", "swoole/ide-helper": "4.8.3" }, + "suggest": { + "ext-swoole": "Required to use the Swoole server adapter (\\Utopia\\Http\\Adapter\\Swoole\\Server)." + }, "type": "library", "autoload": { "psr-4": { @@ -4319,9 +4321,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.34.21" + "source": "https://github.com/utopia-php/http/tree/0.34.24" }, - "time": "2026-04-19T19:44:04+00:00" + "time": "2026-04-24T12:16:53+00:00" }, { "name": "utopia-php/image", @@ -4528,16 +4530,16 @@ }, { "name": "utopia-php/migration", - "version": "1.9.1", + "version": "1.9.4", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2" + "reference": "969dc9477ea962f16da9254facdbd8944cf13477" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/7a86aeadf182b63a9f4ceba7e137588b31c5d2e2", - "reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/969dc9477ea962f16da9254facdbd8944cf13477", + "reference": "969dc9477ea962f16da9254facdbd8944cf13477", "shasum": "" }, "require": { @@ -4548,7 +4550,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": "*", @@ -4577,22 +4579,22 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.9.1" + "source": "https://github.com/utopia-php/migration/tree/1.9.4" }, - "time": "2026-03-25T07:05:27+00:00" + "time": "2026-04-27T12:42:51+00:00" }, { "name": "utopia-php/mongo", - "version": "1.0.2", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "677a21c53f7a1316c528b4b45b3fce886cee7223" + "reference": "73593682deee4696525a04e26524c1c1226e1530" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/677a21c53f7a1316c528b4b45b3fce886cee7223", - "reference": "677a21c53f7a1316c528b4b45b3fce886cee7223", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/73593682deee4696525a04e26524c1c1226e1530", + "reference": "73593682deee4696525a04e26524c1c1226e1530", "shasum": "" }, "require": { @@ -4638,9 +4640,9 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/1.0.2" + "source": "https://github.com/utopia-php/mongo/tree/1.1.0" }, - "time": "2026-03-18T02:45:50+00:00" + "time": "2026-04-24T06:15:10+00:00" }, { "name": "utopia-php/platform", @@ -5018,16 +5020,16 @@ }, { "name": "utopia-php/storage", - "version": "1.0.1", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/utopia-php/storage.git", - "reference": "f014be445f0baa635d0764e1673196f412511618" + "reference": "52d1f89a47165ef0d3deff63043cda182175adfb" }, "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/52d1f89a47165ef0d3deff63043cda182175adfb", + "reference": "52d1f89a47165ef0d3deff63043cda182175adfb", "shasum": "" }, "require": { @@ -5041,9 +5043,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": { @@ -5065,9 +5066,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.0" }, - "time": "2026-02-23T05:59:32+00:00" + "time": "2026-04-27T11:39:32+00:00" }, { "name": "utopia-php/system", @@ -5182,16 +5183,16 @@ }, { "name": "utopia-php/validators", - "version": "0.2.0", + "version": "0.2.2", "source": { "type": "git", "url": "https://github.com/utopia-php/validators.git", - "reference": "30b6030a5b100fc1dff34506e5053759594b2a20" + "reference": "5d7d494e64457cd4eb67fdcfd9481f2c89796aa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/validators/zipball/30b6030a5b100fc1dff34506e5053759594b2a20", - "reference": "30b6030a5b100fc1dff34506e5053759594b2a20", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/5d7d494e64457cd4eb67fdcfd9481f2c89796aa6", + "reference": "5d7d494e64457cd4eb67fdcfd9481f2c89796aa6", "shasum": "" }, "require": { @@ -5221,9 +5222,9 @@ ], "support": { "issues": "https://github.com/utopia-php/validators/issues", - "source": "https://github.com/utopia-php/validators/tree/0.2.0" + "source": "https://github.com/utopia-php/validators/tree/0.2.2" }, - "time": "2026-01-13T09:16:51+00:00" + "time": "2026-04-27T16:30:24+00:00" }, { "name": "utopia-php/vcs", @@ -5464,16 +5465,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.20", + "version": "1.25.1", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "525f0630520c95100fcdfb63c9dac859c1d02588" + "reference": "f21a556b9acdbf75bbdcdc90a078af641646eade" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/525f0630520c95100fcdfb63c9dac859c1d02588", - "reference": "525f0630520c95100fcdfb63c9dac859c1d02588", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/f21a556b9acdbf75bbdcdc90a078af641646eade", + "reference": "f21a556b9acdbf75bbdcdc90a078af641646eade", "shasum": "" }, "require": { @@ -5509,9 +5510,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.20" + "source": "https://github.com/appwrite/sdk-generator/tree/1.25.1" }, - "time": "2026-04-20T05:45:00+00:00" + "time": "2026-04-28T11:12:22+00:00" }, { "name": "brianium/paratest", @@ -5793,16 +5794,16 @@ }, { "name": "laravel/pint", - "version": "v1.29.0", + "version": "v1.29.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "bdec963f53172c5e36330f3a400604c69bf02d39" + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39", - "reference": "bdec963f53172c5e36330f3a400604c69bf02d39", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", "shasum": "" }, "require": { @@ -5813,14 +5814,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.94.2", - "illuminate/view": "^12.54.1", - "larastan/larastan": "^3.9.3", - "laravel-zero/framework": "^12.0.5", + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.4.0", "pestphp/pest": "^3.8.6", - "shipfastlabs/agent-detector": "^1.1.0" + "shipfastlabs/agent-detector": "^1.1.3" }, "bin": [ "builds/pint" @@ -5857,7 +5858,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-03-12T15:51:39+00:00" + "time": "2026-04-20T15:26:14+00:00" }, { "name": "matthiasmullie/minify", @@ -6220,11 +6221,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.50", + "version": "2.1.52", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", - "reference": "d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/08a34f8db7ca4daabff74a474fe13c0e56e2b4e5", + "reference": "08a34f8db7ca4daabff74a474fe13c0e56e2b4e5", "shasum": "" }, "require": { @@ -6269,7 +6270,7 @@ "type": "github" } ], - "time": "2026-04-17T13:10:32+00:00" + "time": "2026-04-28T12:17:53+00:00" }, { "name": "phpunit/php-code-coverage", @@ -7779,7 +7780,7 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -7838,7 +7839,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -7862,16 +7863,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df" + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/ad1b7b9092976d6c948b8a187cec9faaea9ec1df", - "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", "shasum": "" }, "require": { @@ -7920,7 +7921,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" }, "funding": [ { @@ -7940,11 +7941,11 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-04-26T13:13:48+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -8005,7 +8006,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" }, "funding": [ { @@ -8029,7 +8030,7 @@ }, { "name": "symfony/polyfill-php81", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -8085,7 +8086,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0" }, "funding": [ { diff --git a/docker-compose.yml b/docker-compose.yml index 30e893e287..c7a1f69b22 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -251,6 +251,8 @@ services: - _APP_MIGRATION_HOST - _APP_GEO_SECRET - _APP_GEO_ENDPOINT + - _TESTS_OAUTH2_GITHUB_CLIENT_ID + - _TESTS_OAUTH2_GITHUB_CLIENT_SECRET extra_hosts: - "host.docker.internal:host-gateway" 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/Auth/OAuth2/FusionAuth.php b/src/Appwrite/Auth/OAuth2/FusionAuth.php new file mode 100644 index 0000000000..415be4c6ad --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/FusionAuth.php @@ -0,0 +1,226 @@ +getFusionAuthDomain() . '/oauth2/authorize?' . \http_build_query([ + 'client_id' => $this->appID, + 'redirect_uri' => $this->callback, + 'state' => \json_encode($this->state), + 'scope' => \implode(' ', $this->getScopes()), + 'response_type' => 'code' + ]); + } + + /** + * @param string $code + * + * @return array + */ + protected function getTokens(string $code): array + { + if (empty($this->tokens)) { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + 'https://' . $this->getFusionAuthDomain() . '/oauth2/token', + $headers, + \http_build_query([ + 'code' => $code, + 'client_id' => $this->appID, + 'client_secret' => $this->getClientSecret(), + 'redirect_uri' => $this->callback, + 'scope' => \implode(' ', $this->getScopes()), + 'grant_type' => 'authorization_code' + ]) + ), true); + } + return $this->tokens; + } + + /** + * @param string $refreshToken + * + * @return array + */ + public function refreshTokens(string $refreshToken): array + { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + 'https://' . $this->getFusionAuthDomain() . '/oauth2/token', + $headers, + \http_build_query([ + 'refresh_token' => $refreshToken, + 'client_id' => $this->appID, + 'client_secret' => $this->getClientSecret(), + 'grant_type' => 'refresh_token' + ]) + ), true); + + if (empty($this->tokens['refresh_token'])) { + $this->tokens['refresh_token'] = $refreshToken; + } + + return $this->tokens; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserID(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['sub'])) { + return $user['sub']; + } + + return ''; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserEmail(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['email'])) { + return $user['email']; + } + + return ''; + } + + /** + * Check if the User email is verified + * + * @param string $accessToken + * + * @return bool + */ + public function isEmailVerified(string $accessToken): bool + { + $user = $this->getUser($accessToken); + + if ($user['email_verified'] ?? false) { + return true; + } + + return false; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserName(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['name'])) { + return $user['name']; + } + + return ''; + } + + /** + * @param string $accessToken + * + * @return array + */ + protected function getUser(string $accessToken): array + { + if (empty($this->user)) { + $headers = ['Authorization: Bearer ' . \urlencode($accessToken)]; + $user = $this->request('GET', 'https://' . $this->getFusionAuthDomain() . '/oauth2/userinfo', $headers); + $this->user = \json_decode($user, true); + } + + return $this->user; + } + + /** + * Extracts the Client Secret from the JSON stored in appSecret + * + * @return string + */ + protected function getClientSecret(): string + { + $secret = $this->getAppSecret(); + + return $secret['clientSecret'] ?? ''; + } + + /** + * Extracts the FusionAuth Domain from the JSON stored in appSecret + * + * @return string + */ + protected function getFusionAuthDomain(): string + { + $secret = $this->getAppSecret(); + return $secret['fusionAuthDomain'] ?? ''; + } + + /** + * Decode the JSON stored in appSecret + * + * @return array + */ + protected function getAppSecret(): array + { + try { + $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable $th) { + throw new \Exception('Invalid secret'); + } + return $secret; + } +} diff --git a/src/Appwrite/Auth/OAuth2/Github.php b/src/Appwrite/Auth/OAuth2/Github.php index 1cefc397c5..d5d3b07918 100644 --- a/src/Appwrite/Auth/OAuth2/Github.php +++ b/src/Appwrite/Auth/OAuth2/Github.php @@ -3,6 +3,7 @@ namespace Appwrite\Auth\OAuth2; use Appwrite\Auth\OAuth2; +use Utopia\Fetch\Client as FetchClient; class Github extends OAuth2 { @@ -219,4 +220,34 @@ class Github extends OAuth2 $repository = \json_decode($repository, true); return $repository; } + + public function verifyCredentials(): void + { + $client = new FetchClient(); + $client->addHeader('Accept', 'application/json'); + + $response = $client->fetch( + url: 'https://github.com/login/oauth/access_token', + method: FetchClient::METHOD_POST, + query: [ + 'client_id' => $this->appID, + 'client_secret' => $this->appSecret, + 'code' => 'intentionally-invalid-code', + 'redirect_uri' => 'intentionally-invalid-redirect', + ] + ); + + $json = \json_decode($response->getBody(), true); + + if (isset($json['error']) && $json['error'] === "Not Found") { + throw new \Exception('GitHub application with provided Client ID is does not exist.'); + } + + if (isset($json['error']) && $json['error'] === "incorrect_client_credentials") { + throw new \Exception('GitHub application with provided Client ID is valid, but the provided Client Secret is incorrect.'); + } + + // We still expect error, like redirect_uri_mismatch or bad_verification_code, + // but that indicates valid credentials + } } diff --git a/src/Appwrite/Auth/OAuth2/Keycloak.php b/src/Appwrite/Auth/OAuth2/Keycloak.php new file mode 100644 index 0000000000..05e007eb7d --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/Keycloak.php @@ -0,0 +1,249 @@ +getRealmBaseURL() . '/protocol/openid-connect/auth?' . \http_build_query([ + 'client_id' => $this->appID, + 'redirect_uri' => $this->callback, + 'state' => \json_encode($this->state), + 'scope' => \implode(' ', $this->getScopes()), + 'response_type' => 'code' + ]); + } + + /** + * @param string $code + * + * @return array + */ + protected function getTokens(string $code): array + { + if (empty($this->tokens)) { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + $this->getRealmBaseURL() . '/protocol/openid-connect/token', + $headers, + \http_build_query([ + 'code' => $code, + 'client_id' => $this->appID, + 'client_secret' => $this->getClientSecret(), + 'redirect_uri' => $this->callback, + 'scope' => \implode(' ', $this->getScopes()), + 'grant_type' => 'authorization_code' + ]) + ), true); + } + return $this->tokens; + } + + /** + * @param string $refreshToken + * + * @return array + */ + public function refreshTokens(string $refreshToken): array + { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + $this->getRealmBaseURL() . '/protocol/openid-connect/token', + $headers, + \http_build_query([ + 'refresh_token' => $refreshToken, + 'client_id' => $this->appID, + 'client_secret' => $this->getClientSecret(), + 'grant_type' => 'refresh_token' + ]) + ), true); + + if (empty($this->tokens['refresh_token'])) { + $this->tokens['refresh_token'] = $refreshToken; + } + + return $this->tokens; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserID(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['sub'])) { + return $user['sub']; + } + + return ''; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserEmail(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['email'])) { + return $user['email']; + } + + return ''; + } + + /** + * Check if the User email is verified + * + * @param string $accessToken + * + * @return bool + */ + public function isEmailVerified(string $accessToken): bool + { + $user = $this->getUser($accessToken); + + if ($user['email_verified'] ?? false) { + return true; + } + + return false; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserName(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['name'])) { + return $user['name']; + } + + return ''; + } + + /** + * @param string $accessToken + * + * @return array + */ + protected function getUser(string $accessToken): array + { + if (empty($this->user)) { + $headers = ['Authorization: Bearer ' . \urlencode($accessToken)]; + $user = $this->request('GET', $this->getRealmBaseURL() . '/protocol/openid-connect/userinfo', $headers); + $this->user = \json_decode($user, true); + } + + return $this->user; + } + + /** + * Extracts the Client Secret from the JSON stored in appSecret + * + * @return string + */ + protected function getClientSecret(): string + { + $secret = $this->getAppSecret(); + + return $secret['clientSecret'] ?? ''; + } + + /** + * Extracts the Keycloak Domain from the JSON stored in appSecret + * + * @return string + */ + protected function getKeycloakDomain(): string + { + $secret = $this->getAppSecret(); + return $secret['keycloakDomain'] ?? ''; + } + + /** + * Extracts the Keycloak Realm from the JSON stored in appSecret + * + * @return string + */ + protected function getKeycloakRealm(): string + { + $secret = $this->getAppSecret(); + return $secret['keycloakRealm'] ?? ''; + } + + /** + * Build the realm-scoped base URL: `https://{domain}/realms/{realm}`. + * Keycloak realm names allow spaces and other characters that must be + * percent-encoded in URLs (e.g. `my realm` → `my%20realm`). + * + * @return string + */ + protected function getRealmBaseURL(): string + { + return 'https://' . $this->getKeycloakDomain() . '/realms/' . \rawurlencode($this->getKeycloakRealm()); + } + + /** + * Decode the JSON stored in appSecret + * + * @return array + */ + protected function getAppSecret(): array + { + try { + $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable $th) { + throw new \Exception('Invalid secret'); + } + return $secret; + } +} diff --git a/src/Appwrite/Auth/OAuth2/Kick.php b/src/Appwrite/Auth/OAuth2/Kick.php new file mode 100644 index 0000000000..85b447fcd8 --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/Kick.php @@ -0,0 +1,230 @@ +state; + $state[self::PKCE_STATE_KEY] = $this->getPKCEVerifier(); + + return 'https://id.kick.com/oauth/authorize?' . \http_build_query([ + 'response_type' => 'code', + 'client_id' => $this->appID, + 'redirect_uri' => $this->callback, + 'scope' => \implode(' ', $this->getScopes()), + 'state' => \json_encode($state), + 'code_challenge' => $this->getPKCEChallenge(), + 'code_challenge_method' => 'S256', + ]); + } + + /** + * @param string $code + * + * @return array + */ + protected function getTokens(string $code): array + { + if (empty($this->tokens)) { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + 'https://id.kick.com/oauth/token', + $headers, + \http_build_query([ + 'grant_type' => 'authorization_code', + 'client_id' => $this->appID, + 'client_secret' => $this->appSecret, + 'redirect_uri' => $this->callback, + 'code_verifier' => $this->getPKCEVerifier(), + 'code' => $code, + ]) + ), true); + } + + return $this->tokens; + } + + /** + * @param string $refreshToken + * + * @return array + */ + public function refreshTokens(string $refreshToken): array + { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + 'https://id.kick.com/oauth/token', + $headers, + \http_build_query([ + 'grant_type' => 'refresh_token', + 'client_id' => $this->appID, + 'client_secret' => $this->appSecret, + 'refresh_token' => $refreshToken, + ]) + ), true); + + if (empty($this->tokens['refresh_token'])) { + $this->tokens['refresh_token'] = $refreshToken; + } + + return $this->tokens; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserID(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return isset($user['user_id']) ? (string)$user['user_id'] : ''; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserEmail(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['email'] ?? ''; + } + + /** + * Check if the OAuth email is verified. + * + * Kick only returns an email when the user has granted the `user:read` + * scope and the account email is verified, so a non-empty email is + * treated as verified. + * + * @param string $accessToken + * + * @return bool + */ + public function isEmailVerified(string $accessToken): bool + { + return !empty($this->getUserEmail($accessToken)); + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserName(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['name'] ?? ''; + } + + /** + * @param string $accessToken + * + * @return array + */ + protected function getUser(string $accessToken): array + { + if (empty($this->user)) { + $headers = ['Authorization: Bearer ' . $accessToken]; + $response = \json_decode($this->request( + 'GET', + 'https://api.kick.com/public/v1/users', + $headers + ), true); + + $this->user = $response['data'][0] ?? []; + } + + return $this->user; + } + + /** + * Extract the PKCE verifier from the state on the callback so the same + * value generated in getLoginURL() can be sent to the token endpoint. + * + * @param string $state + * + * @return array|null + */ + public function parseState(string $state): ?array + { + $parsed = \json_decode($state, true); + + if (!\is_array($parsed)) { + return null; + } + + $verifier = $parsed[self::PKCE_STATE_KEY] ?? null; + if (\is_string($verifier)) { + $this->pkceVerifier = $verifier; + } + + unset($parsed[self::PKCE_STATE_KEY]); + + return $parsed; + } + + private function getPKCEVerifier(): string + { + if ($this->pkceVerifier === '') { + $this->pkceVerifier = \rtrim(\strtr(\base64_encode(\random_bytes(64)), '+/', '-_'), '='); + } + + return $this->pkceVerifier; + } + + private function getPKCEChallenge(): string + { + return \rtrim(\strtr(\base64_encode(\hash('sha256', $this->getPKCEVerifier(), true)), '+/', '-_'), '='); + } +} 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/Avatars/Http/Favicon/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php index a41d0f81da..31ad572f18 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php @@ -94,9 +94,12 @@ class Get extends Action throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); } + $body = $res->getBody(); $doc = new DOMDocument(); $doc->strictErrorChecking = false; - @$doc->loadHTML($res->getBody()); + if (!empty($body)) { + @$doc->loadHTML($body); + } $links = $doc->getElementsByTagName('link'); $outputHref = ''; 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 new file mode 100644 index 0000000000..79a36643a1 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php @@ -0,0 +1,80 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/console/oauth2-providers') + ->desc('List OAuth2 providers') + ->groups(['api']) + ->label('scope', 'public') + ->label('sdk', new Method( + namespace: 'console', + group: 'console', + name: 'listOAuth2Providers', + 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( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_CONSOLE_OAUTH2_PROVIDER_LIST, + ) + ], + contentType: ContentType::JSON + )) + ->inject('response') + ->callback($this->action(...)); + } + + public function action(Response $response): void + { + $providersConfig = Config::getParam('oAuthProviders', []); + $actions = OAuth2Base::getProviderActions(); + + $providers = []; + foreach ($actions as $providerId => $updateClass) { + $config = $providersConfig[$providerId] ?? null; + if ($config === null) { + continue; + } + if (!($config['enabled'] ?? false)) { + continue; + } + if ($config['mock'] ?? false) { + continue; + } + + $providers[] = new Document([ + '$id' => $providerId, + 'parameters' => $updateClass::getParameters(), + ]); + } + + $response->dynamic(new Document([ + 'total' => \count($providers), + 'oAuth2Providers' => $providers, + ]), Response::MODEL_CONSOLE_OAUTH2_PROVIDER_LIST); + } +} 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 f3ca6218f2..2540ae8e01 100644 --- a/src/Appwrite/Platform/Modules/Console/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Console/Services/Http.php @@ -5,6 +5,7 @@ namespace Appwrite\Platform\Modules\Console\Services; use Appwrite\Platform\Modules\Console\Http\Assistant\Create as CreateAssistantQuery; use Appwrite\Platform\Modules\Console\Http\Init\API; use Appwrite\Platform\Modules\Console\Http\Init\Web; +use Appwrite\Platform\Modules\Console\Http\OAuth2Providers\XList as ListOAuth2Providers; use Appwrite\Platform\Modules\Console\Http\Redirects\Auth\Get as RedirectAuth; use Appwrite\Platform\Modules\Console\Http\Redirects\Card\Get as RedirectCard; use Appwrite\Platform\Modules\Console\Http\Redirects\Invite\Get as RedirectInvite; @@ -14,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; @@ -28,6 +30,8 @@ class Http extends Service $this->addAction(Web::getName(), new Web()); $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 f1fc805d53..27ab59c5eb 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/Http/Project/OAuth2/Amazon/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php new file mode 100644 index 0000000000..7c68ff4032 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php @@ -0,0 +1,55 @@ + static::getClientIdParamName(), + 'name' => static::getClientIdName(), + 'example' => static::getClientIdExample(), + 'hint' => '', + ], + [ + '$id' => 'keyId', + 'name' => 'Key ID', + 'example' => 'P4000000N8', + 'hint' => '', + ], + [ + '$id' => 'teamId', + 'name' => 'Team ID', + 'example' => 'D4000000R6', + 'hint' => '', + ], + [ + '$id' => 'p8File', + 'name' => 'P8 File', + 'example' => '-----BEGIN PRIVATE KEY-----MIGTAg...jy2Xbna-----END PRIVATE KEY-----', + 'hint' => '', + ], + ]; + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param('keyId', null, new Nullable(new Text(256, 0)), '\'Key ID\' of Apple OAuth2 app. For example: P4000000N8', optional: true) + ->param('teamId', null, new Nullable(new Text(256, 0)), '\'Team ID\' of Apple OAuth2 app. For example: D4000000R6', optional: true) + ->param('p8File', null, new Nullable(new Text(4096, 0)), 'Contents of the Apple OAuth2 app .p8 private key file. The secret key wrapped by the PEM markers is 200 characters long. For example: -----BEGIN PRIVATE KEY-----MIGTAg...jy2Xbna-----END PRIVATE KEY-----', optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because Apple's + * client secret is composed of three fields (.p8 file contents, Key ID and + * Team ID) that must be JSON-encoded to match the shape Apple's OAuth2 + * adapter expects in getAppSecret(). The method is named differently to + * avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $serviceId, + ?string $keyId, + ?string $teamId, + ?string $p8File, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // The secret is stored as JSON `{"p8": "...", "keyID": "...", "teamID": "..."}` + // to match the shape Apple's OAuth2 adapter expects in getAppSecret(). + // Merge new values with what's already stored so that submitting only + // some of the fields leaves the rest untouched. + $encodedSecret = null; + if (!\is_null($keyId) || !\is_null($teamId) || !\is_null($p8File)) { + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'p8' => $p8File ?? ($existing['p8'] ?? ''), + 'keyID' => $keyId ?? ($existing['keyID'] ?? ''), + 'teamID' => $teamId ?? ($existing['teamID'] ?? ''), + ]); + } + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $serviceId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee keyId/teamId/p8File are write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php new file mode 100644 index 0000000000..aa5f39b213 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php @@ -0,0 +1,175 @@ + 'endpoint', + 'name' => 'Domain', + 'example' => 'example.us.auth0.com', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of Auth0 instance. For example: example.us.auth0.com', optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'endpoint' => $decoded['auth0Domain'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because Auth0 + * takes an additional optional `endpoint` parameter. The method is named + * differently to avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + ?string $endpoint, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // The secret is stored as JSON `{"clientSecret": "...", "auth0Domain": "..."}` + // to match the shape Auth0's OAuth2 adapter expects (getAuth0Domain()). + // Merge new values with existing storage so that submitting only one of + // `clientSecret`/`endpoint` leaves the other untouched. + $encodedSecret = null; + if (!\is_null($clientSecret) || !\is_null($endpoint)) { + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'auth0Domain' => $endpoint ?? ($existing['auth0Domain'] ?? ''), + ]); + } + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php new file mode 100644 index 0000000000..d5d465c3d4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php @@ -0,0 +1,172 @@ + 'endpoint', + 'name' => 'Domain', + 'example' => 'example.authentik.com', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param('endpoint', '', new Text(256, 1), 'Domain of Authentik instance. For example: example.authentik.com', optional: false) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'endpoint' => $decoded['authentikDomain'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because Authentik + * takes an additional required `endpoint` parameter. The method is named + * differently to avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + string $endpoint, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // The secret is stored as JSON `{"clientSecret": "...", "authentikDomain": "..."}` + // to match the shape Authentik's OAuth2 adapter expects (getAuthentikDomain()). + // The `endpoint` param is required on every call, so it's always written. + // `clientSecret` is optional; if omitted, the existing stored secret is preserved. + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'authentikDomain' => $endpoint, + ]); + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php new file mode 100644 index 0000000000..dd4f4f6faa --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php @@ -0,0 +1,55 @@ +' of OAuth2 app. For example: [. ]". + * Returns an empty string when the name is empty. + */ + private static function buildParamDescription(string $name, string $example, string $hint): string + { + if ($name === '') { + return ''; + } + + $description = '\'' . $name . '\' of ' . static::getProviderLabel() . ' OAuth2 app. For example: ' . $example; + if ($hint !== '') { + $description .= '. ' . $hint; + } + + return $description; + } + + /** + * Verbose, user-facing name of the clientId param. Includes alternate + * names when the provider exposes more than one (e.g. "Client ID or App + * ID", "Application ID (also known as Client ID)"). + * + * @return string + */ + abstract public static function getClientIdName(): string; + + /** + * Example value of the clientId param. Used to build the public OAuth2 + * providers metadata response. + * + * @return string + */ + abstract public static function getClientIdExample(): string; + + /** + * Optional hint for the clientId param. Typically used to call out a + * common wrong value (e.g. "Example of wrong value: 370006"). Defaults + * to an empty string. + */ + public static function getClientIdHint(): string + { + return ''; + } + + /** + * Verbose, user-facing name of the clientSecret param. Returns an empty + * string for providers that don't have a single clientSecret param + * (e.g. Apple uses keyId/teamId/p8File instead). + * + * @return string + */ + abstract public static function getClientSecretName(): string; + + /** + * Example value of the clientSecret param. Returns an empty string for + * providers without a clientSecret param. + * + * @return string + */ + abstract public static function getClientSecretExample(): string; + + /** + * Optional hint for the clientSecret param. Defaults to an empty string. + */ + public static function getClientSecretHint(): string + { + return ''; + } + + /** + * Public-facing parameter metadata for this provider. Used by the public + * console OAuth2 providers endpoint to describe the form fields a project + * owner must fill in to configure the provider. + * + * Default shape: clientId + clientSecret. Providers that take additional + * fields (Apple, Auth0, Authentik, Gitlab, Microsoft, Oidc, Okta) + * override this method to add or replace entries. Each parameter is an + * associative array with keys `$id`, `name`, `example`, `hint`. + * + * @return array> + */ + public static function getParameters(): array + { + $parameters = []; + + $clientIdName = static::getClientIdName(); + if ($clientIdName !== '') { + $parameters[] = [ + '$id' => static::getClientIdParamName(), + 'name' => $clientIdName, + 'example' => static::getClientIdExample(), + 'hint' => static::getClientIdHint(), + ]; + } + + $clientSecretName = static::getClientSecretName(); + if ($clientSecretName !== '') { + $parameters[] = [ + '$id' => static::getClientSecretParamName(), + 'name' => $clientSecretName, + 'example' => static::getClientSecretExample(), + 'hint' => static::getClientSecretHint(), + ]; + } + + return $parameters; + } + + /** + * Public-facing name of the clientId param. Some providers use a different + * terminology (e.g. Dropbox calls it "App key"), so the param name and the + * corresponding response field can be customized by overriding this method. + * + * @return string e.g. 'clientId' (default), 'appKey' + */ + public static function getClientIdParamName(): string + { + return 'clientId'; + } + + /** + * Public-facing name of the clientSecret param. Some providers use a + * different terminology (e.g. Dropbox calls it "App secret"), so the param + * name and the corresponding response field can be customized by + * overriding this method. + * + * @return string e.g. 'clientSecret' (default), 'appSecret' + */ + public static function getClientSecretParamName(): string + { + return 'clientSecret'; + } + + /** + * SDK method name exposed to clients. + * + * @return string e.g. 'updateOAuth2GitHub' + */ + abstract public static function getProviderSDKMethod(): string; + + public static function getName() + { + return 'updateProjectOAuth2' . static::getProviderLabel(); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + /** + * Registry of provider ID -> Update action class. Mirrors the OAuth2 + * actions registered in Project\Services\Http. Used by the Get and XList + * read endpoints to dispatch per-provider response shaping. + * + * @return array> + */ + public static function getProviderActions(): array + { + return [ + 'github' => GitHub\Update::class, + 'discord' => Discord\Update::class, + 'figma' => Figma\Update::class, + 'dropbox' => Dropbox\Update::class, + 'dailymotion' => Dailymotion\Update::class, + 'bitbucket' => Bitbucket\Update::class, + 'bitly' => Bitly\Update::class, + 'box' => Box\Update::class, + 'autodesk' => Autodesk\Update::class, + 'google' => Google\Update::class, + 'zoom' => Zoom\Update::class, + 'zoho' => Zoho\Update::class, + 'yandex' => Yandex\Update::class, + 'x' => X\Update::class, + 'wordpress' => WordPress\Update::class, + 'twitch' => Twitch\Update::class, + 'stripe' => Stripe\Update::class, + 'spotify' => Spotify\Update::class, + 'slack' => Slack\Update::class, + 'podio' => Podio\Update::class, + 'notion' => Notion\Update::class, + 'salesforce' => Salesforce\Update::class, + 'yahoo' => Yahoo\Update::class, + 'linkedin' => Linkedin\Update::class, + 'disqus' => Disqus\Update::class, + 'amazon' => Amazon\Update::class, + 'etsy' => Etsy\Update::class, + 'facebook' => Facebook\Update::class, + 'tradeshift' => Tradeshift\Update::class, + 'tradeshiftBox' => TradeshiftSandbox\Update::class, + 'paypal' => Paypal\Update::class, + 'paypalSandbox' => PaypalSandbox\Update::class, + 'gitlab' => Gitlab\Update::class, + 'authentik' => Authentik\Update::class, + 'auth0' => Auth0\Update::class, + 'fusionauth' => FusionAuth\Update::class, + 'keycloak' => Keycloak\Update::class, + 'oidc' => Oidc\Update::class, + 'okta' => Okta\Update::class, + 'kick' => Kick\Update::class, + 'apple' => Apple\Update::class, + 'microsoft' => Microsoft\Update::class, + ]; + } + + /** + * Build the read-only response document for this provider, with credential + * fields zeroed out (write-only). Default implementation handles providers + * that store a plain client ID + client secret. Special providers (Apple, + * Gitlab, Auth0, Authentik, Oidc, Okta) override to expose their + * non-secret extras (endpoint, domain, discovery URLs, ...) decoded from + * the JSON-encoded secret blob. + */ + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + ]); + } + + /** + * Decode the JSON-encoded secret blob stored under `{providerId}Secret`. + * Returns an empty array when the value is empty or not valid JSON. + */ + protected function decodeStoredSecret(Document $project): array + { + $providerId = static::getProviderId(); + $stored = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + + if (empty($stored)) { + return []; + } + + $decoded = \json_decode($stored, true); + return \is_array($decoded) ? $decoded : []; + } + + /** + * Apply the provided credential changes to the project's oAuthProviders map, + * run the optional credential verification hook, persist the project, and + * return the updated project document. + * + * Providers that need to serialize multiple values into a single secret + * (e.g. GitLab, which stores `{clientSecret, endpoint}` as JSON) should + * encode those values into `$clientSecret` before calling this method. + */ + protected function persistCredentials( + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ?string $clientId, + ?string $clientSecret, + ?bool $enabled + ): Document { + $providerId = static::getProviderId(); + if (!(\in_array($providerId, \array_keys(Config::getParam('oAuthProviders'))))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Provider ' . $providerId . ' is not supported by server configuration.'); + } + + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + + $appIdKey = $providerId . 'Appid'; + $appSecretKey = $providerId . 'Secret'; + $enabledKey = $providerId . 'Enabled'; + + if (!\is_null($clientId)) { + $oAuthProviders[$appIdKey] = $clientId; + } + + if (!\is_null($clientSecret)) { + $oAuthProviders[$appSecretKey] = $clientSecret; + } + + if (!\is_null($enabled)) { + $oAuthProviders[$enabledKey] = $enabled; + } + + if ($enabled === true || \is_null($enabled)) { + try { + if (empty($oAuthProviders[$appIdKey]) || empty($oAuthProviders[$appSecretKey])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Client ID and Client Secret are required when enabling OAuth2 provider.'); + } + + $providerClass = static::getProviderClass(); + $providerInstance = new $providerClass(appId: $oAuthProviders[$appIdKey], appSecret: $oAuthProviders[$appSecretKey], callback: '', state: [], scopes: []); + + // E2E integration check + if (\method_exists($providerInstance, 'verifyCredentials')) { + $providerInstance->verifyCredentials(); + } + + $oAuthProviders[$enabledKey] = true; + } catch (\Throwable $err) { + if ($enabled === true) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Could not enable OAuth2 provider: ' . $err->getMessage()); + } + } + } + + $updates = new Document([ + 'oAuthProviders' => $oAuthProviders + ]); + + return $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + } + + public function action( + ?string $clientId, + ?string $clientSecret, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $clientSecret, $enabled); + + $queueForEvents->setParam('providerId', static::getProviderId()); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php new file mode 100644 index 0000000000..a477bfbefb --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php @@ -0,0 +1,65 @@ + 'endpoint', + 'name' => 'Domain', + 'example' => 'example.fusionauth.io', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param('endpoint', '', new Text(256, 1), 'Domain of FusionAuth instance. For example: example.fusionauth.io', optional: false) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'endpoint' => $decoded['fusionAuthDomain'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because FusionAuth + * takes an additional required `endpoint` parameter. The method is named + * differently to avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + string $endpoint, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // The secret is stored as JSON `{"clientSecret": "...", "fusionAuthDomain": "..."}` + // to match the shape FusionAuth's OAuth2 adapter expects (getFusionAuthDomain()). + // The `endpoint` param is required on every call, so it's always written. + // `clientSecret` is optional; if omitted, the existing stored secret is preserved. + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'fusionAuthDomain' => $endpoint, + ]); + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php new file mode 100644 index 0000000000..ae46a59c67 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php @@ -0,0 +1,115 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/oauth2/:provider') + ->desc('Get project OAuth2 provider') + ->groups(['api', 'project']) + ->label('scope', 'oauth2.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: 'getOAuth2Provider', + description: <<param('provider', '', new Text(128), 'OAuth2 provider key. For example: github, google, apple.') + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $provider, + Response $response, + Document $project, + ): void { + $providers = Config::getParam('oAuthProviders', []); + if (!\array_key_exists($provider, $providers) || !($providers[$provider]['enabled'] ?? false)) { + throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED); + } + + $actions = Base::getProviderActions(); + if (!isset($actions[$provider])) { + throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED); + } + + $updateClass = $actions[$provider]; + $action = new $updateClass(); + + $response->dynamic($action->buildReadResponse($project), $updateClass::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php new file mode 100644 index 0000000000..3b6f89db06 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php @@ -0,0 +1,60 @@ + 'endpoint', + 'name' => 'Endpoint', + 'example' => 'https://gitlab.com', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param('endpoint', null, new Nullable(new URL(allowEmpty: true)), 'Endpoint URL of self-hosted GitLab instance. For example: https://gitlab.com', optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'endpoint' => $decoded['endpoint'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because Gitlab + * takes an additional `endpoint` parameter. The method is named + * differently to avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $applicationId, + ?string $secret, + ?string $endpoint, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // The secret is stored as JSON `{"clientSecret": "...", "endpoint": "..."}` + // so that the Gitlab OAuth2 adapter can extract the endpoint via getEndpoint(). + // Merge the new values with what's already stored so that submitting only + // one of `secret`/`endpoint` leaves the other untouched. + $encodedSecret = null; + if (!\is_null($secret) || !\is_null($endpoint)) { + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'clientSecret' => $secret ?? ($existing['clientSecret'] ?? ''), + 'endpoint' => $endpoint ?? ($existing['endpoint'] ?? ''), + ]); + } + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $applicationId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the secret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php new file mode 100644 index 0000000000..9b985f4aed --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php @@ -0,0 +1,55 @@ + 'endpoint', + 'name' => 'Domain', + 'example' => 'keycloak.example.com', + 'hint' => '', + ], + [ + '$id' => 'realmName', + 'name' => 'Realm name', + 'example' => 'appwrite-realm', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param('endpoint', '', new Text(256, 1), 'Domain of Keycloak instance. For example: keycloak.example.com', optional: false) + ->param('realmName', '', new Text(256, 1), 'Keycloak realm name. For example: appwrite-realm', optional: false) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'endpoint' => $decoded['keycloakDomain'] ?? '', + 'realmName' => $decoded['keycloakRealm'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because Keycloak + * takes additional required `endpoint` and `realmName` parameters. The + * method is named differently to avoid an LSP-incompatible override of + * Base::action(). + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + string $endpoint, + string $realmName, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // The secret is stored as JSON `{"clientSecret": "...", "keycloakDomain": "...", "keycloakRealm": "..."}` + // to match the shape Keycloak's OAuth2 adapter expects (getKeycloakDomain(), getKeycloakRealm()). + // The `endpoint` and `realmName` params are required on every call, so they're always written. + // `clientSecret` is optional; if omitted, the existing stored secret is preserved. + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'keycloakDomain' => $endpoint, + 'keycloakRealm' => $realmName, + ]); + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php new file mode 100644 index 0000000000..db4a20174f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php @@ -0,0 +1,55 @@ + 'tenant', + 'name' => 'Tenant', + 'example' => 'common', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param('tenant', '', new Text(256, 1), 'Microsoft Entra ID tenant identifier. Use \'common\', \'organizations\', \'consumers\' or a specific tenant ID. For example: common', optional: false) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'tenant' => $decoded['tenantID'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because Microsoft + * takes an additional required `tenant` parameter. The method is named + * differently to avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $applicationId, + ?string $applicationSecret, + string $tenant, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // The secret is stored as JSON `{"clientSecret": "...", "tenantID": "..."}` + // to match the shape Microsoft's OAuth2 adapter expects (getTenantID()). + // The `tenant` param is required on every call, so it's always written. + // `applicationSecret` is optional; if omitted, the existing stored secret is preserved. + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'clientSecret' => $applicationSecret ?? ($existing['clientSecret'] ?? ''), + 'tenantID' => $tenant, + ]); + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $applicationId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the applicationSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php new file mode 100644 index 0000000000..4b048b0c0b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php @@ -0,0 +1,65 @@ + 'wellKnownURL', + 'name' => 'Well-known URL', + 'example' => 'https://myoauth.com/.well-known/openid-configuration', + 'hint' => '', + ], + [ + '$id' => 'authorizationURL', + 'name' => 'Authorization URL', + 'example' => 'https://myoauth.com/oauth2/authorize', + 'hint' => '', + ], + [ + '$id' => 'tokenUrl', + 'name' => 'Token URL', + 'example' => 'https://myoauth.com/oauth2/token', + 'hint' => '', + ], + [ + '$id' => 'userInfoUrl', + 'name' => 'User Info URL', + 'example' => 'https://myoauth.com/oauth2/userinfo', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param('wellKnownURL', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect well-known configuration URL. When provided, authorization, token, and user info endpoints can be discovered automatically. For example: https://myoauth.com/.well-known/openid-configuration', optional: true) + ->param('authorizationURL', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect authorization endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/authorize', optional: true) + ->param('tokenUrl', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect token endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/token', optional: true) + ->param('userInfoUrl', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect user info endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/userinfo', optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'wellKnownURL' => $decoded['wellKnownEndpoint'] ?? '', + 'authorizationURL' => $decoded['authorizationEndpoint'] ?? '', + 'tokenUrl' => $decoded['tokenEndpoint'] ?? '', + 'userInfoUrl' => $decoded['userInfoEndpoint'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because OIDC takes + * a well-known URL plus three discovery URLs (authorization, token, user + * info), all stored together with the client secret as JSON. The method is + * named differently to avoid an LSP-incompatible override of Base::action(). + * + * Enabling the provider requires either a non-empty `wellKnownEndpoint`, + * or all three of `authorizationEndpoint`, `tokenEndpoint`, and + * `userInfoEndpoint` to be set. The check considers the merged state of + * existing stored values plus the new values from the request, so callers + * can enable the provider in a single request without re-sending fields + * that were configured previously. + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + ?string $wellKnownURL, + ?string $authorizationURL, + ?string $tokenUrl, + ?string $userInfoUrl, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // The secret is stored as JSON + // `{"clientSecret": "...", "wellKnownEndpoint": "...", "authorizationEndpoint": "...", "tokenEndpoint": "...", "userInfoEndpoint": "..."}` + // so that the OIDC OAuth2 adapter can extract each endpoint individually. + // Merge new values with what's already stored so that submitting only a + // subset of fields leaves the others untouched. + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + + $merged = [ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'wellKnownEndpoint' => $wellKnownURL ?? ($existing['wellKnownEndpoint'] ?? ''), + 'authorizationEndpoint' => $authorizationURL ?? ($existing['authorizationEndpoint'] ?? ''), + 'tokenEndpoint' => $tokenUrl ?? ($existing['tokenEndpoint'] ?? ''), + 'userInfoEndpoint' => $userInfoUrl ?? ($existing['userInfoEndpoint'] ?? ''), + ]; + + // When enabling, require either wellKnownEndpoint alone, or all three + // discovery URLs (authorization, token, user info). Skip this check + // when disabling or when leaving the enabled flag unchanged. + if ($enabled === true) { + $hasWellKnown = !empty($merged['wellKnownEndpoint']); + $hasAllDiscovery = !empty($merged['authorizationEndpoint']) + && !empty($merged['tokenEndpoint']) + && !empty($merged['userInfoEndpoint']); + + if (!$hasWellKnown && !$hasAllDiscovery) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Enabling OpenID Connect requires either wellKnownURL, or all of authorizationURL, tokenUrl, and userInfoUrl.'); + } + } + + $encodedSecret = \json_encode($merged); + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php new file mode 100644 index 0000000000..0344b6a14a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php @@ -0,0 +1,198 @@ + 'domain', + 'name' => 'Domain', + 'example' => 'trial-6400025.okta.com', + 'hint' => 'Example of wrong value: trial-6400025-admin.okta.com, or https://trial-6400025.okta.com/', + ], + [ + '$id' => 'authorizationServerId', + 'name' => 'Authorization Server ID', + 'example' => 'aus000000000000000h7z', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param('domain', null, new Nullable(new ValidatorDomain(allowEmpty: true)), 'Okta company domain. Required when enabling the provider. For example: trial-6400025.okta.com. Example of wrong value: trial-6400025-admin.okta.com, or https://trial-6400025.okta.com/', optional: true) + ->param('authorizationServerId', null, new Nullable(new Text(256, 0)), 'Custom Authorization Servers. Optional, can be left empty or unconfigured. For example: aus000000000000000h7z', optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'domain' => $decoded['oktaDomain'] ?? '', + 'authorizationServerId' => $decoded['authorizationServerId'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because Okta + * takes additional optional `domain` and `authorizationServerId` parameters. + * The method is named differently to avoid an LSP-incompatible override of + * Base::action(). + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + ?string $domain, + ?string $authorizationServerId, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // The secret is stored as JSON `{"clientSecret": "...", "oktaDomain": "...", "authorizationServerId": "..."}` + // to match the shape Okta's OAuth2 adapter expects. + // Merge new values with existing storage so that submitting only some of + // the parameters leaves the others untouched. + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + + $encodedSecret = null; + if (!\is_null($clientSecret) || !\is_null($domain) || !\is_null($authorizationServerId)) { + $encodedSecret = \json_encode([ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'oktaDomain' => $domain ?? ($existing['oktaDomain'] ?? ''), + 'authorizationServerId' => $authorizationServerId ?? ($existing['authorizationServerId'] ?? ''), + ]); + } + + // Domain is required when enabling the provider, since Okta builds its + // authorization, token and userinfo URLs from it. + if ($enabled === true) { + $effectiveDomain = $domain ?? ($existing['oktaDomain'] ?? ''); + if (empty($effectiveDomain)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Domain is required when enabling Okta OAuth2 provider.'); + } + } + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php new file mode 100644 index 0000000000..87b4e1576b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php @@ -0,0 +1,60 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/oauth2') + ->desc('List project OAuth2 providers') + ->groups(['api', 'project']) + ->label('scope', 'oauth2.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: 'listOAuth2Providers', + description: <<inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + Response $response, + Document $project, + ): void { + $providers = Config::getParam('oAuthProviders', []); + $actions = Base::getProviderActions(); + + $documents = []; + foreach ($actions as $providerId => $updateClass) { + if (!($providers[$providerId]['enabled'] ?? false)) { + // Disabled by Appwrite configuration, exclude from response + continue; + } + + $action = new $updateClass(); + $documents[] = $action->buildReadResponse($project); + } + + $response->dynamic(new Document([ + 'total' => \count($documents), + 'providers' => $documents, + ]), Response::MODEL_OAUTH2_PROVIDER_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php new file mode 100644 index 0000000000..45cf1f5a66 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php @@ -0,0 +1,55 @@ +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()); @@ -129,5 +175,51 @@ class Http extends Service // Auth Methods $this->addAction(UpdateAuthMethod::getName(), new UpdateAuthMethod()); + + // OAuth2 + $this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers()); + $this->addAction(GetOAuth2Provider::getName(), new GetOAuth2Provider()); + $this->addAction(UpdateOAuth2GitHub::getName(), new UpdateOAuth2GitHub()); + $this->addAction(UpdateOAuth2Discord::getName(), new UpdateOAuth2Discord()); + $this->addAction(UpdateOAuth2Figma::getName(), new UpdateOAuth2Figma()); + $this->addAction(UpdateOAuth2Dropbox::getName(), new UpdateOAuth2Dropbox()); + $this->addAction(UpdateOAuth2Dailymotion::getName(), new UpdateOAuth2Dailymotion()); + $this->addAction(UpdateOAuth2Bitbucket::getName(), new UpdateOAuth2Bitbucket()); + $this->addAction(UpdateOAuth2Bitly::getName(), new UpdateOAuth2Bitly()); + $this->addAction(UpdateOAuth2Box::getName(), new UpdateOAuth2Box()); + $this->addAction(UpdateOAuth2Autodesk::getName(), new UpdateOAuth2Autodesk()); + $this->addAction(UpdateOAuth2Google::getName(), new UpdateOAuth2Google()); + $this->addAction(UpdateOAuth2Zoom::getName(), new UpdateOAuth2Zoom()); + $this->addAction(UpdateOAuth2Zoho::getName(), new UpdateOAuth2Zoho()); + $this->addAction(UpdateOAuth2Yandex::getName(), new UpdateOAuth2Yandex()); + $this->addAction(UpdateOAuth2X::getName(), new UpdateOAuth2X()); + $this->addAction(UpdateOAuth2WordPress::getName(), new UpdateOAuth2WordPress()); + $this->addAction(UpdateOAuth2Twitch::getName(), new UpdateOAuth2Twitch()); + $this->addAction(UpdateOAuth2Stripe::getName(), new UpdateOAuth2Stripe()); + $this->addAction(UpdateOAuth2Spotify::getName(), new UpdateOAuth2Spotify()); + $this->addAction(UpdateOAuth2Slack::getName(), new UpdateOAuth2Slack()); + $this->addAction(UpdateOAuth2Podio::getName(), new UpdateOAuth2Podio()); + $this->addAction(UpdateOAuth2Notion::getName(), new UpdateOAuth2Notion()); + $this->addAction(UpdateOAuth2Salesforce::getName(), new UpdateOAuth2Salesforce()); + $this->addAction(UpdateOAuth2Yahoo::getName(), new UpdateOAuth2Yahoo()); + $this->addAction(UpdateOAuth2Linkedin::getName(), new UpdateOAuth2Linkedin()); + $this->addAction(UpdateOAuth2Disqus::getName(), new UpdateOAuth2Disqus()); + $this->addAction(UpdateOAuth2Amazon::getName(), new UpdateOAuth2Amazon()); + $this->addAction(UpdateOAuth2Etsy::getName(), new UpdateOAuth2Etsy()); + $this->addAction(UpdateOAuth2Facebook::getName(), new UpdateOAuth2Facebook()); + $this->addAction(UpdateOAuth2Tradeshift::getName(), new UpdateOAuth2Tradeshift()); + $this->addAction(UpdateOAuth2TradeshiftSandbox::getName(), new UpdateOAuth2TradeshiftSandbox()); + $this->addAction(UpdateOAuth2Paypal::getName(), new UpdateOAuth2Paypal()); + $this->addAction(UpdateOAuth2PaypalSandbox::getName(), new UpdateOAuth2PaypalSandbox()); + $this->addAction(UpdateOAuth2Gitlab::getName(), new UpdateOAuth2Gitlab()); + $this->addAction(UpdateOAuth2Authentik::getName(), new UpdateOAuth2Authentik()); + $this->addAction(UpdateOAuth2Auth0::getName(), new UpdateOAuth2Auth0()); + $this->addAction(UpdateOAuth2FusionAuth::getName(), new UpdateOAuth2FusionAuth()); + $this->addAction(UpdateOAuth2Keycloak::getName(), new UpdateOAuth2Keycloak()); + $this->addAction(UpdateOAuth2Oidc::getName(), new UpdateOAuth2Oidc()); + $this->addAction(UpdateOAuth2Okta::getName(), new UpdateOAuth2Okta()); + $this->addAction(UpdateOAuth2Kick::getName(), new UpdateOAuth2Kick()); + $this->addAction(UpdateOAuth2Apple::getName(), new UpdateOAuth2Apple()); + $this->addAction(UpdateOAuth2Microsoft::getName(), new UpdateOAuth2Microsoft()); } } 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/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 cfe8d2d567..69f72b8e27 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -391,6 +391,8 @@ class Migrations extends Action 'keys.write', 'platforms.read', 'platforms.write', + 'oauth2.read', + 'oauth2.write', 'mocks.read', 'mocks.write', 'policies.read', @@ -400,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.php b/src/Appwrite/Utopia/Request.php index 66ac4ca932..3004392f76 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -209,7 +209,11 @@ class Request extends UtopiaRequest public function getHeader(string $key, string $default = ''): string { $headers = $this->getHeaders(); - return $headers[$key] ?? $default; + $value = $headers[$key] ?? $default; + if (\is_array($value)) { + $value = $value[0] ?? $default; + } + return \is_string($value) ? $value : $default; } /** 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 c4e616ea12..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'; @@ -278,6 +279,47 @@ class Response extends SwooleResponse public const MODEL_VCS = 'vcs'; public const MODEL_EMAIL_TEMPLATE = 'emailTemplate'; public const MODEL_EMAIL_TEMPLATE_LIST = 'emailTemplateList'; + public const MODEL_OAUTH2_GITHUB = 'oAuth2Github'; + public const MODEL_OAUTH2_DISCORD = 'oAuth2Discord'; + public const MODEL_OAUTH2_FIGMA = 'oAuth2Figma'; + public const MODEL_OAUTH2_DROPBOX = 'oAuth2Dropbox'; + public const MODEL_OAUTH2_DAILYMOTION = 'oAuth2Dailymotion'; + public const MODEL_OAUTH2_BITBUCKET = 'oAuth2Bitbucket'; + public const MODEL_OAUTH2_BITLY = 'oAuth2Bitly'; + public const MODEL_OAUTH2_BOX = 'oAuth2Box'; + public const MODEL_OAUTH2_AUTODESK = 'oAuth2Autodesk'; + public const MODEL_OAUTH2_GOOGLE = 'oAuth2Google'; + public const MODEL_OAUTH2_ZOOM = 'oAuth2Zoom'; + public const MODEL_OAUTH2_ZOHO = 'oAuth2Zoho'; + public const MODEL_OAUTH2_YANDEX = 'oAuth2Yandex'; + public const MODEL_OAUTH2_X = 'oAuth2X'; + public const MODEL_OAUTH2_WORDPRESS = 'oAuth2WordPress'; + public const MODEL_OAUTH2_TWITCH = 'oAuth2Twitch'; + public const MODEL_OAUTH2_STRIPE = 'oAuth2Stripe'; + public const MODEL_OAUTH2_SPOTIFY = 'oAuth2Spotify'; + public const MODEL_OAUTH2_SLACK = 'oAuth2Slack'; + public const MODEL_OAUTH2_PODIO = 'oAuth2Podio'; + public const MODEL_OAUTH2_NOTION = 'oAuth2Notion'; + public const MODEL_OAUTH2_SALESFORCE = 'oAuth2Salesforce'; + public const MODEL_OAUTH2_YAHOO = 'oAuth2Yahoo'; + public const MODEL_OAUTH2_LINKEDIN = 'oAuth2Linkedin'; + public const MODEL_OAUTH2_DISQUS = 'oAuth2Disqus'; + public const MODEL_OAUTH2_AMAZON = 'oAuth2Amazon'; + public const MODEL_OAUTH2_ETSY = 'oAuth2Etsy'; + public const MODEL_OAUTH2_FACEBOOK = 'oAuth2Facebook'; + public const MODEL_OAUTH2_TRADESHIFT = 'oAuth2Tradeshift'; + public const MODEL_OAUTH2_PAYPAL = 'oAuth2Paypal'; + public const MODEL_OAUTH2_GITLAB = 'oAuth2Gitlab'; + public const MODEL_OAUTH2_AUTHENTIK = 'oAuth2Authentik'; + public const MODEL_OAUTH2_AUTH0 = 'oAuth2Auth0'; + public const MODEL_OAUTH2_FUSIONAUTH = 'oAuth2FusionAuth'; + public const MODEL_OAUTH2_KEYCLOAK = 'oAuth2Keycloak'; + public const MODEL_OAUTH2_OIDC = 'oAuth2Oidc'; + public const MODEL_OAUTH2_APPLE = 'oAuth2Apple'; + public const MODEL_OAUTH2_OKTA = 'oAuth2Okta'; + public const MODEL_OAUTH2_KICK = 'oAuth2Kick'; + public const MODEL_OAUTH2_MICROSOFT = 'oAuth2Microsoft'; + public const MODEL_OAUTH2_PROVIDER_LIST = 'oAuth2ProviderList'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; @@ -290,6 +332,11 @@ class Response extends SwooleResponse // Console public const MODEL_CONSOLE_VARIABLES = 'consoleVariables'; + 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/AuthProvider.php b/src/Appwrite/Utopia/Response/Model/AuthProvider.php index 2b8f962cd0..034be623e8 100644 --- a/src/Appwrite/Utopia/Response/Model/AuthProvider.php +++ b/src/Appwrite/Utopia/Response/Model/AuthProvider.php @@ -30,9 +30,9 @@ class AuthProvider extends Model ]) ->addRule('secret', [ 'type' => self::TYPE_STRING, - 'description' => 'OAuth 2.0 application secret. Might be JSON string if provider requires extra configuration.', + 'description' => 'OAuth 2.0 application secret. Might be JSON string if provider requires extra configuration. This property is write-only and always returned empty.', 'default' => '', - 'example' => 'Bpw_g9c2TGXxfgLshDbSaL8tsCcqgczQ', + 'example' => '', ]) ->addRule('enabled', [ 'type' => self::TYPE_BOOLEAN, 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/ConsoleOAuth2Provider.php b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2Provider.php new file mode 100644 index 0000000000..05969a5e8c --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2Provider.php @@ -0,0 +1,37 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'OAuth2 provider ID.', + 'default' => '', + 'example' => 'github', + ]) + ->addRule('parameters', [ + 'type' => Response::MODEL_CONSOLE_OAUTH2_PROVIDER_PARAMETER, + 'description' => 'List of parameters required to configure this OAuth2 provider.', + 'default' => [], + 'array' => true, + ]) + ; + } + + public function getName(): string + { + return 'Console OAuth2 Provider'; + } + + public function getType(): string + { + return Response::MODEL_CONSOLE_OAUTH2_PROVIDER; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderList.php b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderList.php new file mode 100644 index 0000000000..42d6936d42 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderList.php @@ -0,0 +1,37 @@ +addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of OAuth2 providers exposed by the server.', + 'default' => 0, + 'example' => 5, + ]) + ->addRule('oAuth2Providers', [ + 'type' => Response::MODEL_CONSOLE_OAUTH2_PROVIDER, + 'description' => 'List of OAuth2 providers, each with the parameters required to configure it.', + 'default' => [], + 'array' => true, + ]) + ; + } + + public function getName(): string + { + return 'Console OAuth2 Providers List'; + } + + public function getType(): string + { + return Response::MODEL_CONSOLE_OAUTH2_PROVIDER_LIST; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderParameter.php b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderParameter.php new file mode 100644 index 0000000000..a097718492 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderParameter.php @@ -0,0 +1,49 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Parameter ID. Maps to the request body field used by the project OAuth2 update endpoint (e.g. `clientId`, `appKey`, `tenant`).', + 'default' => '', + 'example' => 'clientId', + ]) + ->addRule('name', [ + 'type' => self::TYPE_STRING, + 'description' => 'Verbose, user-facing parameter name as shown in the provider\'s own dashboard. Includes alternate names when the provider exposes more than one.', + 'default' => '', + 'example' => 'Client ID or App ID', + ]) + ->addRule('example', [ + 'type' => self::TYPE_STRING, + 'description' => 'Example value for this parameter.', + 'default' => '', + 'example' => 'e4d87900000000540733', + ]) + ->addRule('hint', [ + 'type' => self::TYPE_STRING, + 'description' => 'Optional hint for this parameter, typically calling out a common wrong value. Empty string when no hint is set.', + 'default' => '', + 'example' => 'Example of wrong value: 370006', + ]) + ; + } + + public function getName(): string + { + return 'Console OAuth2 Provider Parameter'; + } + + public function getType(): string + { + return Response::MODEL_CONSOLE_OAUTH2_PROVIDER_PARAMETER; + } +} 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 @@ + 'amazon', + ]; + + public function getProviderLabel(): string + { + return 'Amazon'; + } + + public function getClientIdExample(): string + { + return 'amzn1.application-oa2-client.87400c00000000000000000000063d5b2'; + } + + public function getClientSecretExample(): string + { + return '79ffe4000000000000000000000000000000000000000000000000000002de55'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Amazon'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_AMAZON; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php b/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php new file mode 100644 index 0000000000..075494b8ef --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php @@ -0,0 +1,103 @@ + 'apple', + ]; + + public function getProviderLabel(): string + { + return 'Apple'; + } + + public function getClientIdExample(): string + { + return 'ip.appwrite.app.web'; + } + + public function getClientSecretExample(): string + { + // Unused: this model overrides __construct() to expose keyId, teamId + // and p8File instead of a single clientSecret field. + return ''; + } + + public function getClientIdFieldName(): string + { + return 'serviceId'; + } + + public function getClientIdLabel(): string + { + return 'service ID'; + } + + public function __construct() + { + // Apple's OAuth2 app credential is split into three fields (.p8 file + // contents, Key ID, Team ID) instead of a single clientSecret, so the + // rules are defined manually rather than delegating to OAuth2Base. + $this + ->addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'OAuth2 provider ID.', + 'default' => '', + 'example' => 'apple', + ]) + ->addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'OAuth2 provider is active and can be used to create sessions.', + 'default' => false, + 'example' => false, + ]) + ->addRule($this->getClientIdFieldName(), [ + 'type' => self::TYPE_STRING, + 'description' => $this->getClientIdDescription(), + 'default' => '', + 'example' => $this->getClientIdExample(), + ]) + ->addRule('keyId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Apple OAuth2 key ID.', + 'default' => '', + 'example' => 'P4000000N8', + ]) + ->addRule('teamId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Apple OAuth2 team ID.', + 'default' => '', + 'example' => 'D4000000R6', + ]) + ->addRule('p8File', [ + 'type' => self::TYPE_STRING, + 'description' => 'Apple OAuth2 .p8 private key file contents. The secret key wrapped by the PEM markers is 200 characters long.', + 'default' => '', + 'example' => '-----BEGIN PRIVATE KEY-----MIGTAg...jy2Xbna-----END PRIVATE KEY-----', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Apple'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_APPLE; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php b/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php new file mode 100644 index 0000000000..6e83b1b05b --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php @@ -0,0 +1,59 @@ + 'auth0', + ]; + + public function getProviderLabel(): string + { + return 'Auth0'; + } + + public function getClientIdExample(): string + { + return 'OaOkIA000000000000000000005KLSYq'; + } + + public function getClientSecretExample(): string + { + return 'zXz0000-00000000000000000000000000000-00000000000000000000PJafnF'; + } + + public function __construct() + { + parent::__construct(); + + $this->addRule('endpoint', [ + 'type' => self::TYPE_STRING, + 'description' => 'Auth0 OAuth2 endpoint domain.', + 'default' => '', + 'example' => 'example.us.auth0.com', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Auth0'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_AUTH0; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php b/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php new file mode 100644 index 0000000000..db192ea24b --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php @@ -0,0 +1,59 @@ + 'authentik', + ]; + + public function getProviderLabel(): string + { + return 'Authentik'; + } + + public function getClientIdExample(): string + { + return 'dTKOPa0000000000000000000000000000e7G8hv'; + } + + public function getClientSecretExample(): string + { + return 'ntQadq000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Hp5WK'; + } + + public function __construct() + { + parent::__construct(); + + $this->addRule('endpoint', [ + 'type' => self::TYPE_STRING, + 'description' => 'Authentik OAuth2 endpoint domain.', + 'default' => '', + 'example' => 'example.authentik.com', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Authentik'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_AUTHENTIK; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Autodesk.php b/src/Appwrite/Utopia/Response/Model/OAuth2Autodesk.php new file mode 100644 index 0000000000..3317f15bec --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Autodesk.php @@ -0,0 +1,47 @@ + 'autodesk', + ]; + + public function getProviderLabel(): string + { + return 'Autodesk'; + } + + public function getClientIdExample(): string + { + return '5zw90v00000000000000000000kVYXN7'; + } + + public function getClientSecretExample(): string + { + return '7I000000000000MW'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Autodesk'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_AUTODESK; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Base.php b/src/Appwrite/Utopia/Response/Model/OAuth2Base.php new file mode 100644 index 0000000000..058afc0fa1 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Base.php @@ -0,0 +1,125 @@ + 'appKey'). + * + * @return string + */ + public function getClientIdFieldName(): string + { + return 'clientId'; + } + + /** + * Public-facing field name of the client secret. Providers may override + * when they use different terminology (e.g. Dropbox -> 'appSecret'). + * + * @return string + */ + public function getClientSecretFieldName(): string + { + return 'clientSecret'; + } + + /** + * Human-readable label for the client ID, used in the generated rule + * description. Providers may override (e.g. Dropbox -> 'app key'). + * + * @return string + */ + public function getClientIdLabel(): string + { + return 'client ID'; + } + + /** + * Human-readable label for the client secret, used in the generated rule + * description. Providers may override (e.g. Dropbox -> 'app secret'). + * + * @return string + */ + public function getClientSecretLabel(): string + { + return 'client secret'; + } + + /** + * Rule description for the client ID. Auto-generated from the provider + * label and client ID label. Providers may override to add extra context. + * + * @return string + */ + public function getClientIdDescription(): string + { + return $this->getProviderLabel() . ' OAuth2 ' . $this->getClientIdLabel() . '.'; + } + + /** + * Rule description for the client secret. Auto-generated from the provider + * label and client secret label. Providers may override to add extra + * context. + * + * @return string + */ + public function getClientSecretDescription(): string + { + return $this->getProviderLabel() . ' OAuth2 ' . $this->getClientSecretLabel() . '.'; + } + + public function __construct() + { + $this + ->addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'OAuth2 provider ID.', + 'default' => '', + 'example' => 'github', + ]) + ->addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'OAuth2 provider is active and can be used to create sessions.', + 'default' => false, + 'example' => false, + ]) + ->addRule($this->getClientIdFieldName(), [ + 'type' => self::TYPE_STRING, + 'description' => $this->getClientIdDescription(), + 'default' => '', + 'example' => $this->getClientIdExample(), + ]) + ->addRule($this->getClientSecretFieldName(), [ + 'type' => self::TYPE_STRING, + 'description' => $this->getClientSecretDescription(), + 'default' => '', + 'example' => $this->getClientSecretExample(), + ]); + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Bitbucket.php b/src/Appwrite/Utopia/Response/Model/OAuth2Bitbucket.php new file mode 100644 index 0000000000..870cd0bda3 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Bitbucket.php @@ -0,0 +1,67 @@ + 'bitbucket', + ]; + + public function getProviderLabel(): string + { + return 'Bitbucket'; + } + + public function getClientIdExample(): string + { + return 'Knt70000000000ByRc'; + } + + public function getClientSecretExample(): string + { + return 'NMfLZJ00000000000000000000TLQdDx'; + } + + public function getClientIdFieldName(): string + { + return 'key'; + } + + public function getClientSecretFieldName(): string + { + return 'secret'; + } + + public function getClientIdLabel(): string + { + return 'key'; + } + + public function getClientSecretLabel(): string + { + return 'secret'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Bitbucket'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_BITBUCKET; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Bitly.php b/src/Appwrite/Utopia/Response/Model/OAuth2Bitly.php new file mode 100644 index 0000000000..6a27176d3d --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Bitly.php @@ -0,0 +1,47 @@ + 'bitly', + ]; + + public function getProviderLabel(): string + { + return 'Bitly'; + } + + public function getClientIdExample(): string + { + return 'd95151000000000000000000000000000067af9b'; + } + + public function getClientSecretExample(): string + { + return 'a13e250000000000000000000000000000d73095'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Bitly'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_BITLY; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Box.php b/src/Appwrite/Utopia/Response/Model/OAuth2Box.php new file mode 100644 index 0000000000..9bbfd6021f --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Box.php @@ -0,0 +1,47 @@ + 'box', + ]; + + public function getProviderLabel(): string + { + return 'Box'; + } + + public function getClientIdExample(): string + { + return 'deglcs00000000000000000000x2og6y'; + } + + public function getClientSecretExample(): string + { + return 'OKM1f100000000000000000000eshEif'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Box'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_BOX; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Dailymotion.php b/src/Appwrite/Utopia/Response/Model/OAuth2Dailymotion.php new file mode 100644 index 0000000000..6c3d0eba95 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Dailymotion.php @@ -0,0 +1,67 @@ + 'dailymotion', + ]; + + public function getProviderLabel(): string + { + return 'Dailymotion'; + } + + public function getClientIdExample(): string + { + return '07a9000000000000067f'; + } + + public function getClientSecretExample(): string + { + return 'a399a90000000000000000000000000000d90639'; + } + + public function getClientIdFieldName(): string + { + return 'apiKey'; + } + + public function getClientSecretFieldName(): string + { + return 'apiSecret'; + } + + public function getClientIdLabel(): string + { + return 'API key'; + } + + public function getClientSecretLabel(): string + { + return 'API secret'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Dailymotion'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_DAILYMOTION; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php b/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php new file mode 100644 index 0000000000..6ac72ad8e4 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php @@ -0,0 +1,47 @@ + 'discord', + ]; + + public function getProviderLabel(): string + { + return 'Discord'; + } + + public function getClientIdExample(): string + { + return '950722000000343754'; + } + + public function getClientSecretExample(): string + { + return 'YmPXnM000000000000000000002zFg5D'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Discord'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_DISCORD; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Disqus.php b/src/Appwrite/Utopia/Response/Model/OAuth2Disqus.php new file mode 100644 index 0000000000..bec78ed189 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Disqus.php @@ -0,0 +1,67 @@ + 'disqus', + ]; + + public function getProviderLabel(): string + { + return 'Disqus'; + } + + public function getClientIdExample(): string + { + return 'cgegH70000000000000000000000000000000000000000000000000000Hr1nYX'; + } + + public function getClientSecretExample(): string + { + return 'W7Bykj00000000000000000000000000000000000000000000000000003o43w9'; + } + + public function getClientIdFieldName(): string + { + return 'publicKey'; + } + + public function getClientSecretFieldName(): string + { + return 'secretKey'; + } + + public function getClientIdLabel(): string + { + return 'public key'; + } + + public function getClientSecretLabel(): string + { + return 'secret key'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Disqus'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_DISQUS; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php b/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php new file mode 100644 index 0000000000..db7285fd47 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php @@ -0,0 +1,67 @@ + 'dropbox', + ]; + + public function getProviderLabel(): string + { + return 'Dropbox'; + } + + public function getClientIdExample(): string + { + return 'jl000000000009t'; + } + + public function getClientSecretExample(): string + { + return 'g200000000000vw'; + } + + public function getClientIdFieldName(): string + { + return 'appKey'; + } + + public function getClientSecretFieldName(): string + { + return 'appSecret'; + } + + public function getClientIdLabel(): string + { + return 'app key'; + } + + public function getClientSecretLabel(): string + { + return 'app secret'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Dropbox'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_DROPBOX; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Etsy.php b/src/Appwrite/Utopia/Response/Model/OAuth2Etsy.php new file mode 100644 index 0000000000..be12e4c51c --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Etsy.php @@ -0,0 +1,67 @@ + 'etsy', + ]; + + public function getProviderLabel(): string + { + return 'Etsy'; + } + + public function getClientIdExample(): string + { + return 'nsgzxh0000000000008j85a2'; + } + + public function getClientSecretExample(): string + { + return 'tp000000ru'; + } + + public function getClientIdFieldName(): string + { + return 'keyString'; + } + + public function getClientSecretFieldName(): string + { + return 'sharedSecret'; + } + + public function getClientIdLabel(): string + { + return 'keystring'; + } + + public function getClientSecretLabel(): string + { + return 'shared secret'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Etsy'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_ETSY; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Facebook.php b/src/Appwrite/Utopia/Response/Model/OAuth2Facebook.php new file mode 100644 index 0000000000..9ad14bdb2a --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Facebook.php @@ -0,0 +1,67 @@ + 'facebook', + ]; + + public function getProviderLabel(): string + { + return 'Facebook'; + } + + public function getClientIdExample(): string + { + return '260600000007694'; + } + + public function getClientSecretExample(): string + { + return '2d0b2800000000000000000000d38af4'; + } + + public function getClientIdFieldName(): string + { + return 'appId'; + } + + public function getClientSecretFieldName(): string + { + return 'appSecret'; + } + + public function getClientIdLabel(): string + { + return 'app ID'; + } + + public function getClientSecretLabel(): string + { + return 'app secret'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Facebook'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_FACEBOOK; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php b/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php new file mode 100644 index 0000000000..9339257e5b --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php @@ -0,0 +1,47 @@ + 'figma', + ]; + + public function getProviderLabel(): string + { + return 'Figma'; + } + + public function getClientIdExample(): string + { + return 'byay5H0000000000VtiI40'; + } + + public function getClientSecretExample(): string + { + return 'yEpOYn0000000000000000004iIsU5'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Figma'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_FIGMA; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2FusionAuth.php b/src/Appwrite/Utopia/Response/Model/OAuth2FusionAuth.php new file mode 100644 index 0000000000..8dbe3c76f0 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2FusionAuth.php @@ -0,0 +1,59 @@ + 'fusionauth', + ]; + + public function getProviderLabel(): string + { + return 'FusionAuth'; + } + + public function getClientIdExample(): string + { + return 'b2222c00-0000-0000-0000-000000862097'; + } + + public function getClientSecretExample(): string + { + return 'Jx4s0C0000000000000000000000000000000wGqLsc'; + } + + public function __construct() + { + parent::__construct(); + + $this->addRule('endpoint', [ + 'type' => self::TYPE_STRING, + 'description' => 'FusionAuth OAuth2 endpoint domain.', + 'default' => '', + 'example' => 'example.fusionauth.io', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2FusionAuth'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_FUSIONAUTH; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php b/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php new file mode 100644 index 0000000000..2f975f16e4 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php @@ -0,0 +1,52 @@ + 'github', + ]; + + public function getProviderLabel(): string + { + return 'GitHub'; + } + + public function getClientIdExample(): string + { + return 'e4d87900000000540733'; + } + + public function getClientSecretExample(): string + { + return '5e07c00000000000000000000000000000198bcc'; + } + + public function getClientIdDescription(): string + { + return parent::getClientIdDescription() . ' For GitHub Apps, use the "App ID" when both an App ID and client ID are available.'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2GitHub'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_GITHUB; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php b/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php new file mode 100644 index 0000000000..39c148caec --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php @@ -0,0 +1,79 @@ + 'gitlab', + ]; + + public function getProviderLabel(): string + { + return 'GitLab'; + } + + public function getClientIdExample(): string + { + return 'd41ffe0000000000000000000000000000000000000000000000000000d5e252'; + } + + public function getClientSecretExample(): string + { + return 'gloas-838cfa0000000000000000000000000000000000000000000000000000ecbb38'; + } + + public function getClientIdFieldName(): string + { + return 'applicationId'; + } + + public function getClientSecretFieldName(): string + { + return 'secret'; + } + + public function getClientIdLabel(): string + { + return 'application ID'; + } + + public function getClientSecretLabel(): string + { + return 'secret'; + } + + public function __construct() + { + parent::__construct(); + + $this->addRule('endpoint', [ + 'type' => self::TYPE_STRING, + 'description' => 'GitLab OAuth2 endpoint URL. Defaults to https://gitlab.com for self-hosted instances.', + 'default' => '', + 'example' => 'https://gitlab.com', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Gitlab'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_GITLAB; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Google.php b/src/Appwrite/Utopia/Response/Model/OAuth2Google.php new file mode 100644 index 0000000000..3dbc892631 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Google.php @@ -0,0 +1,47 @@ + 'google', + ]; + + public function getProviderLabel(): string + { + return 'Google'; + } + + public function getClientIdExample(): string + { + return '120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com'; + } + + public function getClientSecretExample(): string + { + return 'GOCSPX-2k8gsR0000000000000000VNahJj'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Google'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_GOOGLE; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Keycloak.php b/src/Appwrite/Utopia/Response/Model/OAuth2Keycloak.php new file mode 100644 index 0000000000..063f7d2a5c --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Keycloak.php @@ -0,0 +1,66 @@ + 'keycloak', + ]; + + public function getProviderLabel(): string + { + return 'Keycloak'; + } + + public function getClientIdExample(): string + { + return 'appwrite-o0000000st-app'; + } + + public function getClientSecretExample(): string + { + return 'jdjrJd00000000000000000000HUsaZO'; + } + + public function __construct() + { + parent::__construct(); + + $this->addRule('endpoint', [ + 'type' => self::TYPE_STRING, + 'description' => 'Keycloak OAuth2 endpoint domain.', + 'default' => '', + 'example' => 'keycloak.example.com', + ]); + + $this->addRule('realmName', [ + 'type' => self::TYPE_STRING, + 'description' => 'Keycloak OAuth2 realm name.', + 'default' => '', + 'example' => 'appwrite-realm', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Keycloak'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_KEYCLOAK; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php b/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php new file mode 100644 index 0000000000..2f5814f1d3 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php @@ -0,0 +1,47 @@ + 'kick', + ]; + + public function getProviderLabel(): string + { + return 'Kick'; + } + + public function getClientIdExample(): string + { + return '01KQ7C00000000000001MFHS32'; + } + + public function getClientSecretExample(): string + { + return '34ac5600000000000000000000000000000000000000000000000000e830c8b'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Kick'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_KICK; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php b/src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php new file mode 100644 index 0000000000..012aa85735 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php @@ -0,0 +1,57 @@ + 'linkedin', + ]; + + public function getProviderLabel(): string + { + return 'LinkedIn'; + } + + public function getClientIdExample(): string + { + return '770000000000dv'; + } + + public function getClientSecretExample(): string + { + return 'WPL_AP1.2Bf0000000000000./HtlYw=='; + } + + public function getClientSecretFieldName(): string + { + return 'primaryClientSecret'; + } + + public function getClientSecretLabel(): string + { + return 'primary client secret'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Linkedin'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_LINKEDIN; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Microsoft.php b/src/Appwrite/Utopia/Response/Model/OAuth2Microsoft.php new file mode 100644 index 0000000000..b7004fdb85 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Microsoft.php @@ -0,0 +1,79 @@ + 'microsoft', + ]; + + public function getProviderLabel(): string + { + return 'Microsoft'; + } + + public function getClientIdExample(): string + { + return '00001111-aaaa-2222-bbbb-3333cccc4444'; + } + + public function getClientSecretExample(): string + { + return 'A1bC2dE3fH4iJ5kL6mN7oP8qR9sT0u'; + } + + public function getClientIdFieldName(): string + { + return 'applicationId'; + } + + public function getClientSecretFieldName(): string + { + return 'applicationSecret'; + } + + public function getClientIdLabel(): string + { + return 'application ID'; + } + + public function getClientSecretLabel(): string + { + return 'application secret'; + } + + public function __construct() + { + parent::__construct(); + + $this->addRule('tenant', [ + 'type' => self::TYPE_STRING, + 'description' => 'Microsoft Entra ID tenant identifier. Use \'common\', \'organizations\', \'consumers\' or a specific tenant ID.', + 'default' => '', + 'example' => 'common', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Microsoft'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_MICROSOFT; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Notion.php b/src/Appwrite/Utopia/Response/Model/OAuth2Notion.php new file mode 100644 index 0000000000..8796ce603e --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Notion.php @@ -0,0 +1,57 @@ + 'notion', + ]; + + public function getProviderLabel(): string + { + return 'Notion'; + } + + public function getClientIdExample(): string + { + return '341d8700-0000-0000-0000-000000446ee3'; + } + + public function getClientSecretExample(): string + { + return 'secret_dLUr4b000000000000000000000000000000lFHAa9'; + } + + public function getClientIdFieldName(): string + { + return 'oauthClientId'; + } + + public function getClientSecretFieldName(): string + { + return 'oauthClientSecret'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Notion'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_NOTION; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php b/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php new file mode 100644 index 0000000000..e4f0919666 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php @@ -0,0 +1,78 @@ + 'oidc', + ]; + + public function getProviderLabel(): string + { + return 'OpenID Connect'; + } + + public function getClientIdExample(): string + { + return 'qibI2x0000000000000000000000000006L2YFoG'; + } + + public function getClientSecretExample(): string + { + return 'Ah68ed000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003qpcHV'; + } + + public function __construct() + { + parent::__construct(); + + $this + ->addRule('wellKnownURL', [ + 'type' => self::TYPE_STRING, + 'description' => 'OpenID Connect well-known configuration URL. When set, authorization, token, and user info endpoints can be discovered automatically.', + 'default' => '', + 'example' => 'https://myoauth.com/.well-known/openid-configuration', + ]) + ->addRule('authorizationURL', [ + 'type' => self::TYPE_STRING, + 'description' => 'OpenID Connect authorization endpoint URL.', + 'default' => '', + 'example' => 'https://myoauth.com/oauth2/authorize', + ]) + ->addRule('tokenUrl', [ + 'type' => self::TYPE_STRING, + 'description' => 'OpenID Connect token endpoint URL.', + 'default' => '', + 'example' => 'https://myoauth.com/oauth2/token', + ]) + ->addRule('userInfoUrl', [ + 'type' => self::TYPE_STRING, + 'description' => 'OpenID Connect user info endpoint URL.', + 'default' => '', + 'example' => 'https://myoauth.com/oauth2/userinfo', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Oidc'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_OIDC; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php b/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php new file mode 100644 index 0000000000..0804adfa1b --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php @@ -0,0 +1,66 @@ + 'okta', + ]; + + public function getProviderLabel(): string + { + return 'Okta'; + } + + public function getClientIdExample(): string + { + return '0oa00000000000000698'; + } + + public function getClientSecretExample(): string + { + return 'Kiq0000000000000000000000000000000000000-00000000000H2L5-3SJ-vRV'; + } + + public function __construct() + { + parent::__construct(); + + $this->addRule('domain', [ + 'type' => self::TYPE_STRING, + 'description' => 'Okta OAuth2 domain.', + 'default' => '', + 'example' => 'trial-6400025.okta.com', + ]); + + $this->addRule('authorizationServerId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Okta OAuth2 authorization server ID.', + 'default' => '', + 'example' => 'aus000000000000000h7z', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Okta'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_OKTA; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Paypal.php b/src/Appwrite/Utopia/Response/Model/OAuth2Paypal.php new file mode 100644 index 0000000000..20ff9f9ba5 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Paypal.php @@ -0,0 +1,57 @@ + ['paypal', 'paypalSandbox'], + ]; + + public function getProviderLabel(): string + { + return 'PayPal'; + } + + public function getClientIdExample(): string + { + return 'AdhIEG7-000000000000-0000000000000000000000000000000-0000000000000000000000-2pyB'; + } + + public function getClientSecretExample(): string + { + return 'EH8KCXtew--000000000000000000000000000000000000000_C-1_5UP_000000000000000CB7KDp'; + } + + public function getClientSecretFieldName(): string + { + return 'secretKey'; + } + + public function getClientSecretLabel(): string + { + return 'secret key'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Paypal'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_PAYPAL; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Podio.php b/src/Appwrite/Utopia/Response/Model/OAuth2Podio.php new file mode 100644 index 0000000000..f588136a62 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Podio.php @@ -0,0 +1,47 @@ + 'podio', + ]; + + public function getProviderLabel(): string + { + return 'Podio'; + } + + public function getClientIdExample(): string + { + return 'appwrite-oauth-test-app'; + } + + public function getClientSecretExample(): string + { + return 'Rn247T0000000000000000000000000000000000000000000000000000W2zWTN'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Podio'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_PODIO; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php b/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php new file mode 100644 index 0000000000..81c23c803c --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php @@ -0,0 +1,78 @@ +addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of OAuth2 providers in the given project.', + 'default' => 0, + 'example' => 5, + ]) + ->addRule('providers', [ + 'type' => [ + Response::MODEL_OAUTH2_GITHUB, + Response::MODEL_OAUTH2_DISCORD, + Response::MODEL_OAUTH2_FIGMA, + Response::MODEL_OAUTH2_DROPBOX, + Response::MODEL_OAUTH2_DAILYMOTION, + Response::MODEL_OAUTH2_BITBUCKET, + Response::MODEL_OAUTH2_BITLY, + Response::MODEL_OAUTH2_BOX, + Response::MODEL_OAUTH2_AUTODESK, + Response::MODEL_OAUTH2_GOOGLE, + Response::MODEL_OAUTH2_ZOOM, + Response::MODEL_OAUTH2_ZOHO, + Response::MODEL_OAUTH2_YANDEX, + Response::MODEL_OAUTH2_X, + Response::MODEL_OAUTH2_WORDPRESS, + Response::MODEL_OAUTH2_TWITCH, + Response::MODEL_OAUTH2_STRIPE, + Response::MODEL_OAUTH2_SPOTIFY, + Response::MODEL_OAUTH2_SLACK, + Response::MODEL_OAUTH2_PODIO, + Response::MODEL_OAUTH2_NOTION, + Response::MODEL_OAUTH2_SALESFORCE, + Response::MODEL_OAUTH2_YAHOO, + Response::MODEL_OAUTH2_LINKEDIN, + Response::MODEL_OAUTH2_DISQUS, + Response::MODEL_OAUTH2_AMAZON, + Response::MODEL_OAUTH2_ETSY, + Response::MODEL_OAUTH2_FACEBOOK, + Response::MODEL_OAUTH2_TRADESHIFT, + Response::MODEL_OAUTH2_PAYPAL, + Response::MODEL_OAUTH2_GITLAB, + Response::MODEL_OAUTH2_AUTHENTIK, + Response::MODEL_OAUTH2_AUTH0, + Response::MODEL_OAUTH2_FUSIONAUTH, + Response::MODEL_OAUTH2_KEYCLOAK, + Response::MODEL_OAUTH2_OIDC, + Response::MODEL_OAUTH2_APPLE, + Response::MODEL_OAUTH2_OKTA, + Response::MODEL_OAUTH2_KICK, + Response::MODEL_OAUTH2_MICROSOFT, + ], + 'description' => 'List of OAuth2 providers.', + 'default' => [], + 'array' => true, + ]) + ; + } + + public function getName(): string + { + return 'OAuth2 Providers List'; + } + + public function getType(): string + { + return Response::MODEL_OAUTH2_PROVIDER_LIST; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Salesforce.php b/src/Appwrite/Utopia/Response/Model/OAuth2Salesforce.php new file mode 100644 index 0000000000..c76ddce854 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Salesforce.php @@ -0,0 +1,67 @@ + 'salesforce', + ]; + + public function getProviderLabel(): string + { + return 'Salesforce'; + } + + public function getClientIdExample(): string + { + return '3MVG9I0000000000000000000000000000000000000000000000000000000000000000000000000C5Aejq'; + } + + public function getClientSecretExample(): string + { + return '3w000000000000e2'; + } + + public function getClientIdFieldName(): string + { + return 'customerKey'; + } + + public function getClientSecretFieldName(): string + { + return 'customerSecret'; + } + + public function getClientIdLabel(): string + { + return 'consumer key'; + } + + public function getClientSecretLabel(): string + { + return 'consumer secret'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Salesforce'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_SALESFORCE; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Slack.php b/src/Appwrite/Utopia/Response/Model/OAuth2Slack.php new file mode 100644 index 0000000000..47eb058816 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Slack.php @@ -0,0 +1,47 @@ + 'slack', + ]; + + public function getProviderLabel(): string + { + return 'Slack'; + } + + public function getClientIdExample(): string + { + return '23000000089.15000000000023'; + } + + public function getClientSecretExample(): string + { + return '81656000000000000000000000f3d2fd'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Slack'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_SLACK; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Spotify.php b/src/Appwrite/Utopia/Response/Model/OAuth2Spotify.php new file mode 100644 index 0000000000..3fdf9da659 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Spotify.php @@ -0,0 +1,47 @@ + 'spotify', + ]; + + public function getProviderLabel(): string + { + return 'Spotify'; + } + + public function getClientIdExample(): string + { + return '6ec271000000000000000000009beace'; + } + + public function getClientSecretExample(): string + { + return 'db068a000000000000000000008b5b9f'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Spotify'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_SPOTIFY; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Stripe.php b/src/Appwrite/Utopia/Response/Model/OAuth2Stripe.php new file mode 100644 index 0000000000..98c7a88af7 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Stripe.php @@ -0,0 +1,57 @@ + 'stripe', + ]; + + public function getProviderLabel(): string + { + return 'Stripe'; + } + + public function getClientIdExample(): string + { + return 'ca_UKibXX0000000000000000000006byvR'; + } + + public function getClientSecretExample(): string + { + return 'sk_51SfOd000000000000000000000000000000000000000000000000000000000000000000000000000000000000000QGWYfp'; + } + + public function getClientSecretFieldName(): string + { + return 'apiSecretKey'; + } + + public function getClientSecretLabel(): string + { + return 'API secret key'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Stripe'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_STRIPE; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Tradeshift.php b/src/Appwrite/Utopia/Response/Model/OAuth2Tradeshift.php new file mode 100644 index 0000000000..4d2c37a951 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Tradeshift.php @@ -0,0 +1,57 @@ + ['tradeshift', 'tradeshiftBox'], + ]; + + public function getProviderLabel(): string + { + return 'Tradeshift'; + } + + public function getClientIdExample(): string + { + return 'appwrite-test-org.appwrite-test-app'; + } + + public function getClientSecretExample(): string + { + return '7cb52700-0000-0000-0000-000000ca5b83'; + } + + public function getClientIdFieldName(): string + { + return 'oauth2ClientId'; + } + + public function getClientSecretFieldName(): string + { + return 'oauth2ClientSecret'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Tradeshift'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_TRADESHIFT; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Twitch.php b/src/Appwrite/Utopia/Response/Model/OAuth2Twitch.php new file mode 100644 index 0000000000..4b03b3d6cc --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Twitch.php @@ -0,0 +1,47 @@ + 'twitch', + ]; + + public function getProviderLabel(): string + { + return 'Twitch'; + } + + public function getClientIdExample(): string + { + return 'vvi0in000000000000000000ikmt9p'; + } + + public function getClientSecretExample(): string + { + return 'pmapue000000000000000000zylw3v'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Twitch'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_TWITCH; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2WordPress.php b/src/Appwrite/Utopia/Response/Model/OAuth2WordPress.php new file mode 100644 index 0000000000..89df7a081e --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2WordPress.php @@ -0,0 +1,47 @@ + 'wordpress', + ]; + + public function getProviderLabel(): string + { + return 'WordPress'; + } + + public function getClientIdExample(): string + { + return '130005'; + } + + public function getClientSecretExample(): string + { + return 'PlBfJS0000000000000000000000000000000000000000000000000000EdUZJk'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2WordPress'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_WORDPRESS; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2X.php b/src/Appwrite/Utopia/Response/Model/OAuth2X.php new file mode 100644 index 0000000000..2f36166c19 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2X.php @@ -0,0 +1,67 @@ + 'x', + ]; + + public function getProviderLabel(): string + { + return 'X'; + } + + public function getClientIdExample(): string + { + return 'slzZV0000000000000NFLaWT'; + } + + public function getClientSecretExample(): string + { + return 'tkEPkp00000000000000000000000000000000000000FTxbI9'; + } + + public function getClientIdFieldName(): string + { + return 'customerKey'; + } + + public function getClientSecretFieldName(): string + { + return 'secretKey'; + } + + public function getClientIdLabel(): string + { + return 'customer key'; + } + + public function getClientSecretLabel(): string + { + return 'secret key'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2X'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_X; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Yahoo.php b/src/Appwrite/Utopia/Response/Model/OAuth2Yahoo.php new file mode 100644 index 0000000000..0e3bc7b8a6 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Yahoo.php @@ -0,0 +1,47 @@ + 'yahoo', + ]; + + public function getProviderLabel(): string + { + return 'Yahoo'; + } + + public function getClientIdExample(): string + { + return 'dj0yJm000000000000000000000000000000000000000000000000000000000000000000000000000000000000Z4PWRm'; + } + + public function getClientSecretExample(): string + { + return 'cf978f0000000000000000000000000000c5e2e9'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Yahoo'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_YAHOO; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Yandex.php b/src/Appwrite/Utopia/Response/Model/OAuth2Yandex.php new file mode 100644 index 0000000000..dd6b8a4486 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Yandex.php @@ -0,0 +1,47 @@ + 'yandex', + ]; + + public function getProviderLabel(): string + { + return 'Yandex'; + } + + public function getClientIdExample(): string + { + return '6a8a6a0000000000000000000091483c'; + } + + public function getClientSecretExample(): string + { + return 'bbf98500000000000000000000c75a63'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Yandex'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_YANDEX; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Zoho.php b/src/Appwrite/Utopia/Response/Model/OAuth2Zoho.php new file mode 100644 index 0000000000..abf9e98d9a --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Zoho.php @@ -0,0 +1,47 @@ + 'zoho', + ]; + + public function getProviderLabel(): string + { + return 'Zoho'; + } + + public function getClientIdExample(): string + { + return '1000.83C178000000000000000000RPNX0B'; + } + + public function getClientSecretExample(): string + { + return 'fb5cac000000000000000000000000000000a68f6e'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Zoho'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_ZOHO; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Zoom.php b/src/Appwrite/Utopia/Response/Model/OAuth2Zoom.php new file mode 100644 index 0000000000..d14fe6d0cf --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Zoom.php @@ -0,0 +1,47 @@ + 'zoom', + ]; + + public function getProviderLabel(): string + { + return 'Zoom'; + } + + public function getClientIdExample(): string + { + return 'QMAC00000000000000w0AQ'; + } + + public function getClientSecretExample(): string + { + return 'GAWsG4000000000000000000007U01ON'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Zoom'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_ZOOM; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 97b58d8a51..36be3b751f 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -525,7 +525,7 @@ class Project extends Model 'key' => $key, 'name' => $provider['name'] ?? '', 'appId' => $providerValues[$key . 'Appid'] ?? '', - 'secret' => $providerValues[$key . 'Secret'] ?? '', + 'secret' => '', // Write-only: never expose the stored value 'enabled' => $providerValues[$key . 'Enabled'] ?? false, ]); } diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php index 6214bb1f29..4dc0174e50 100644 --- a/src/Appwrite/Vcs/Comment.php +++ b/src/Appwrite/Vcs/Comment.php @@ -31,7 +31,7 @@ class Comment 'Trigger functions via HTTP, SDKs, events, webhooks, or scheduled cron jobs', 'Each function runs in its own isolated container with custom environment variables', 'Build commands execute in runtime containers during deployment', - 'Dynamic API keys are generated automatically for each function execution', + 'Ephemeral API keys are generated automatically for each function execution', 'JWT tokens let functions act on behalf of users while preserving their permissions', 'Storage files get ClamAV malware scanning and encryption by default', 'Roll back Sites deployments instantly by switching between versions', diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index a4f1ae44cd..eb74867c9c 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -378,7 +378,6 @@ class Executor $responseBody = curl_exec($ch); if (isset($callback)) { - curl_close($ch); return []; } @@ -418,8 +417,6 @@ class Executor throw new Exception($curlErrorMessage . ' with status code ' . $responseStatus, $responseStatus); } - curl_close($ch); - $responseHeaders['status-code'] = $responseStatus; return [ diff --git a/tests/benchmarks/bulk-operations/utils.js b/tests/benchmarks/bulk-operations/utils.js index dc8dcac569..5b8bbc6c67 100644 --- a/tests/benchmarks/bulk-operations/utils.js +++ b/tests/benchmarks/bulk-operations/utils.js @@ -197,8 +197,8 @@ const SCOPES = [ "buckets.write", "functions.read", "functions.write", - "execution.read", - "execution.write", + "executions.read", + "executions.write", "targets.read", "targets.write", "providers.read", diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index 4009024069..f7bb54024d 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -75,8 +75,8 @@ const API_SCOPES = [ 'functions.write', 'log.read', 'log.write', - 'execution.read', - 'execution.write', + 'executions.read', + 'executions.write', 'locale.read', 'avatars.read', 'rules.read', @@ -90,6 +90,8 @@ const API_SCOPES = [ 'tokens.write', 'platforms.read', 'platforms.write', + 'oauth2.read', + 'oauth2.write', ]; const BASE_PERMISSIONS = [ diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php index 4358058fe3..6965a87d73 100644 --- a/tests/e2e/Client.php +++ b/tests/e2e/Client.php @@ -294,8 +294,6 @@ class Client throw new Exception(curl_error($ch) . ' with status code ' . $responseStatus, $responseStatus); } - curl_close($ch); - $responseHeaders['status-code'] = $responseStatus; if ($responseStatus === 500) { diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index f531ed774d..3071ddfa2a 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -137,8 +137,8 @@ trait ProjectCustom 'functions.write', 'sites.read', 'sites.write', - 'execution.read', - 'execution.write', + 'executions.read', + 'executions.write', 'log.read', 'log.write', 'locale.read', @@ -169,6 +169,8 @@ trait ProjectCustom 'keys.write', 'platforms.read', 'platforms.write', + 'oauth2.read', + 'oauth2.write', 'mocks.read', 'mocks.write', 'policies.read', diff --git a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php index 373383e3ec..8235ebb7bc 100644 --- a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php +++ b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php @@ -41,4 +41,136 @@ class ConsoleConsoleClientTest extends Scope $this->assertIsString($response['body']['_APP_DB_ADAPTER']); // When adding new keys, dont forget to update count a few lines above } + + public function testListOAuth2Providers(): void + { + $response = $this->client->call(Client::METHOD_GET, '/console/oauth2-providers', 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']['oAuth2Providers']); + $this->assertGreaterThan(0, $response['body']['total']); + $this->assertEquals($response['body']['total'], \count($response['body']['oAuth2Providers'])); + + $providerIds = \array_column($response['body']['oAuth2Providers'], '$id'); + + // Well-known providers must be present + $this->assertContains('github', $providerIds); + $this->assertContains('google', $providerIds); + + // Mock providers must be excluded + $this->assertNotContains('mock', $providerIds); + $this->assertNotContains('mock-unverified', $providerIds); + + // Every provider has the expected shape + foreach ($response['body']['oAuth2Providers'] as $provider) { + $this->assertArrayHasKey('$id', $provider); + $this->assertIsString($provider['$id']); + $this->assertArrayHasKey('parameters', $provider); + $this->assertIsArray($provider['parameters']); + $this->assertGreaterThan(0, \count($provider['parameters'])); + + foreach ($provider['parameters'] as $parameter) { + $this->assertArrayHasKey('$id', $parameter); + $this->assertIsString($parameter['$id']); + $this->assertNotEmpty($parameter['$id']); + $this->assertArrayHasKey('name', $parameter); + $this->assertIsString($parameter['name']); + $this->assertNotEmpty($parameter['name']); + $this->assertArrayHasKey('example', $parameter); + $this->assertIsString($parameter['example']); + $this->assertArrayHasKey('hint', $parameter); + $this->assertIsString($parameter['hint']); + } + } + + // GitHub provider has the expected metadata for clientId, including the hint + $github = null; + foreach ($response['body']['oAuth2Providers'] as $provider) { + if ($provider['$id'] === 'github') { + $github = $provider; + break; + } + } + $this->assertNotNull($github); + $this->assertCount(2, $github['parameters']); + $clientId = $github['parameters'][0]; + $this->assertEquals('clientId', $clientId['$id']); + $this->assertEquals('OAuth 2 app Client ID, or App ID', $clientId['name']); + $this->assertEquals('e4d87900000000540733', $clientId['example']); + $this->assertEquals('Example of wrong value: 370006', $clientId['hint']); + $clientSecret = $github['parameters'][1]; + $this->assertEquals('clientSecret', $clientSecret['$id']); + $this->assertEquals('Client Secret', $clientSecret['name']); + $this->assertNotEmpty($clientSecret['example']); + $this->assertEquals('', $clientSecret['hint']); + + // Multi-parameter provider (Apple) exposes its non-clientSecret fields + $apple = null; + foreach ($response['body']['oAuth2Providers'] as $provider) { + if ($provider['$id'] === 'apple') { + $apple = $provider; + break; + } + } + $this->assertNotNull($apple); + $appleParamIds = \array_column($apple['parameters'], '$id'); + $this->assertContains('serviceId', $appleParamIds); + $this->assertContains('keyId', $appleParamIds); + $this->assertContains('teamId', $appleParamIds); + $this->assertContains('p8File', $appleParamIds); + // Apple does not expose a single clientSecret param + $this->assertNotContains('clientSecret', $appleParamIds); + + // Sandbox providers (e.g. paypalSandbox) are included + $this->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 3748bbe546..f06011843f 100644 --- a/tests/e2e/Services/Console/ConsoleCustomServerTest.php +++ b/tests/e2e/Services/Console/ConsoleCustomServerTest.php @@ -24,4 +24,54 @@ class ConsoleCustomServerTest extends Scope $this->assertEquals(401, $response['headers']['status-code']); } + + public function testListOAuth2Providers(): 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/oauth2-providers', [ + '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']['oAuth2Providers']); + $this->assertGreaterThan(0, $response['body']['total']); + + $providerIds = \array_column($response['body']['oAuth2Providers'], '$id'); + $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/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php new file mode 100644 index 0000000000..8345bfab0a --- /dev/null +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -0,0 +1,2597 @@ +updateOAuth2('amazon', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + + $this->assertSame( + 200, + $response['headers']['status-code'], + 'OAuth2 reset failed — downstream tests will be unreliable. Body: ' . \json_encode($response['body'] ?? null), + ); + } + + // ========================================================================= + // List OAuth2 providers + // ========================================================================= + + public function testListOAuth2Providers(): void + { + $response = $this->listOAuth2Providers(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('total', $response['body']); + $this->assertArrayHasKey('providers', $response['body']); + $this->assertGreaterThan(0, $response['body']['total']); + $this->assertSame($response['body']['total'], \count($response['body']['providers'])); + } + + public function testListOAuth2ProvidersIncludesKnownProviders(): void + { + $response = $this->listOAuth2Providers(); + + $this->assertSame(200, $response['headers']['status-code']); + + $ids = \array_column($response['body']['providers'], '$id'); + + // Spot-check a representative cross-section of providers across all + // provider shapes (plain, multi-field, sandboxed, custom param names). + $expected = [ + 'github', + 'amazon', + 'apple', + 'auth0', + 'authentik', + 'fusionauth', + 'gitlab', + 'keycloak', + 'oidc', + 'okta', + 'microsoft', + 'dropbox', + 'paypalSandbox', + 'kick', + ]; + + foreach ($expected as $providerId) { + $this->assertContains($providerId, $ids, "Missing provider {$providerId} in listOAuth2Providers response"); + } + } + + /** + * Pin the exact set of registered providers — adding or removing a + * provider must be a deliberate change to this assertion. Catches + * registration drift (e.g. forgetting to wire a new provider into + * `Base::getProviderActions()`). + */ + public function testListOAuth2ProvidersExposesEntireRegistry(): void + { + $response = $this->listOAuth2Providers(); + $this->assertSame(200, $response['headers']['status-code']); + + $ids = \array_column($response['body']['providers'], '$id'); + \sort($ids); + + $expected = [ + 'amazon', 'apple', 'auth0', 'authentik', 'autodesk', 'bitbucket', + 'bitly', 'box', 'dailymotion', 'discord', 'disqus', 'dropbox', + 'etsy', 'facebook', 'figma', 'fusionauth', 'github', 'gitlab', + 'google', 'keycloak', 'kick', 'linkedin', 'microsoft', 'notion', + 'oidc', 'okta', 'paypal', 'paypalSandbox', 'podio', 'salesforce', + 'slack', 'spotify', 'stripe', 'tradeshift', 'tradeshiftBox', + 'twitch', 'wordpress', 'x', 'yahoo', 'yandex', 'zoho', 'zoom', + ]; + \sort($expected); + + $this->assertSame($expected, $ids, 'Registry drift — listed providers do not match the expected set.'); + } + + public function testListOAuth2ProvidersResponseShape(): void + { + $response = $this->listOAuth2Providers(); + + $this->assertSame(200, $response['headers']['status-code']); + + foreach ($response['body']['providers'] as $provider) { + $this->assertArrayHasKey('$id', $provider); + $this->assertArrayHasKey('enabled', $provider); + $this->assertIsString($provider['$id']); + $this->assertIsBool($provider['enabled']); + } + } + + public function testListOAuth2ProvidersClientSecretsNotExposed(): void + { + // Seed credentials so the list cannot trivially return empty values. + $this->updateOAuth2('amazon', [ + 'clientId' => 'amzn1.application-oa2-client.testListSeed', + 'clientSecret' => 'super-secret-must-not-leak', + 'enabled' => false, + ]); + + $response = $this->listOAuth2Providers(); + + $this->assertSame(200, $response['headers']['status-code']); + + $matched = false; + foreach ($response['body']['providers'] as $provider) { + if ($provider['$id'] !== 'amazon') { + continue; + } + + $matched = true; + $this->assertSame('amzn1.application-oa2-client.testListSeed', $provider['clientId']); + $this->assertSame('', $provider['clientSecret']); + } + + $this->assertTrue($matched, 'List did not include the seeded provider.'); + } + + public function testListOAuth2ProvidersWithoutAuthentication(): void + { + $response = $this->listOAuth2Providers(authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testListOAuth2ProvidersExcludesUnregisteredConfigEntries(): void + { + // `mock` and `mock-unverified` exist in oAuthProviders config (enabled: true) + // but are intentionally absent from Base::getProviderActions() — they're + // internal Mock OAuth2 adapters used by other test suites, not public + // providers. XList iterates the action registry, so they must never be + // included even though config marks them enabled. + $response = $this->listOAuth2Providers(); + + $this->assertSame(200, $response['headers']['status-code']); + + $ids = \array_column($response['body']['providers'], '$id'); + $this->assertNotContains('mock', $ids); + $this->assertNotContains('mock-unverified', $ids); + } + + // ========================================================================= + // Get OAuth2 provider + // ========================================================================= + + public function testGetOAuth2Provider(): void + { + $response = $this->getOAuth2Provider('github'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('github', $response['body']['$id']); + $this->assertArrayHasKey('enabled', $response['body']); + $this->assertArrayHasKey('clientId', $response['body']); + $this->assertArrayHasKey('clientSecret', $response['body']); + $this->assertSame('', $response['body']['clientSecret']); + } + + public function testGetOAuth2ProviderClientSecretWriteOnly(): void + { + $this->updateOAuth2('amazon', [ + 'clientId' => 'amzn1.application-oa2-client.getSecretCheck', + 'clientSecret' => 'must-never-be-returned', + 'enabled' => false, + ]); + + $response = $this->getOAuth2Provider('amazon'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('amzn1.application-oa2-client.getSecretCheck', $response['body']['clientId']); + $this->assertSame('', $response['body']['clientSecret']); + } + + public function testGetOAuth2ProviderMatchesListEntry(): void + { + $list = $this->listOAuth2Providers(); + $this->assertSame(200, $list['headers']['status-code']); + + // Drive the loop directly off the LIST result so any provider added + // to the registry is automatically checked for List/Get parity. + foreach ($list['body']['providers'] as $listEntry) { + $providerId = $listEntry['$id']; + $get = $this->getOAuth2Provider($providerId); + + $this->assertSame(200, $get['headers']['status-code'], "GET failed for {$providerId}"); + $this->assertSame($listEntry, $get['body'], "List/Get drift on {$providerId}"); + } + } + + public function testGetOAuth2ProviderUnsupported(): void + { + $response = $this->getOAuth2Provider('not-a-real-provider'); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('project_provider_unsupported', $response['body']['type']); + } + + public function testGetOAuth2ProviderRegisteredInConfigButNoUpdateClass(): void + { + // `mock` is present in oAuthProviders config (enabled: true) but is NOT + // registered in Base::getProviderActions(). Get::action has two + // separate `unsupported` throw branches — testGetOAuth2ProviderUnsupported + // covers the first (provider missing from config); this covers the + // second (provider in config but missing from the action registry). + $response = $this->getOAuth2Provider('mock'); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('project_provider_unsupported', $response['body']['type']); + } + + public function testGetOAuth2ProviderWithoutAuthentication(): void + { + $response = $this->getOAuth2Provider('github', authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Update plain provider (Amazon — clientId + clientSecret, no extra fields) + // ========================================================================= + + public function testUpdateOAuth2Plain(): void + { + $response = $this->updateOAuth2('amazon', [ + 'clientId' => 'amzn1.application-oa2-client.test01', + 'clientSecret' => 'test-secret-01', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('amazon', $response['body']['$id']); + $this->assertSame('amzn1.application-oa2-client.test01', $response['body']['clientId']); + $this->assertSame(false, $response['body']['enabled']); + } + + public function testUpdateOAuth2PlainEnable(): void + { + // Amazon has no verifyCredentials() hook, so enabling with arbitrary + // credentials succeeds without making a real network call. + $response = $this->updateOAuth2('amazon', [ + 'clientId' => 'amzn1.application-oa2-client.test02', + 'clientSecret' => 'test-secret-02', + 'enabled' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['enabled']); + } + + public function testUpdateOAuth2PlainDisable(): void + { + $this->updateOAuth2('amazon', [ + 'clientId' => 'amzn1.application-oa2-client.test03', + 'clientSecret' => 'test-secret-03', + 'enabled' => true, + ]); + + $response = $this->updateOAuth2('amazon', [ + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['enabled']); + // Credentials persist across an enabled toggle. + $this->assertSame('amzn1.application-oa2-client.test03', $response['body']['clientId']); + } + + public function testUpdateOAuth2PlainPartial(): void + { + // Seed both credentials. + $this->updateOAuth2('amazon', [ + 'clientId' => 'seed-client-id', + 'clientSecret' => 'seed-secret', + 'enabled' => false, + ]); + + // Patch only clientId. + $response = $this->updateOAuth2('amazon', [ + 'clientId' => 'updated-client-id', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('updated-client-id', $response['body']['clientId']); + + // Read back through GET to confirm the secret is still set internally + // (write-only, so we cannot inspect the value, but enabling should still + // succeed because the secret remains non-empty). + $enable = $this->updateOAuth2('amazon', [ + 'enabled' => true, + ]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertSame(true, $enable['body']['enabled']); + } + + public function testUpdateOAuth2PlainEnableRequiresCredentials(): void + { + // Start from a clean state with no credentials. + $this->updateOAuth2('amazon', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('amazon', [ + 'enabled' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2PlainEnabledOmittedDoesNotThrow(): void + { + // With enabled omitted (null) and no credentials, the silent-validation + // branch must not surface as an error. + $this->updateOAuth2('amazon', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('amazon', [ + 'clientId' => 'partial-only', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['enabled']); + $this->assertSame('partial-only', $response['body']['clientId']); + } + + public function testUpdateOAuth2PlainResponseModel(): void + { + $response = $this->updateOAuth2('amazon', [ + 'clientId' => 'amzn1.application-oa2-client.modelCheck', + 'clientSecret' => 'model-check-secret', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('enabled', $response['body']); + $this->assertArrayHasKey('clientId', $response['body']); + $this->assertArrayHasKey('clientSecret', $response['body']); + } + + public function testUpdateOAuth2WithoutAuthentication(): void + { + $response = $this->updateOAuth2('amazon', [ + 'clientId' => 'no-auth', + 'clientSecret' => 'no-auth', + 'enabled' => false, + ], authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateOAuth2UnknownProvider(): void + { + // Each Update endpoint is registered at a fixed `/oauth2/{providerId}` + // path, so an unknown provider does not match any route → 404. + $response = $this->updateOAuth2('not-a-real-provider', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'enabled' => false, + ]); + + $this->assertSame(404, $response['headers']['status-code']); + } + + public function testUpdateOAuth2InvalidEnabled(): void + { + $response = $this->updateOAuth2('amazon', [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + // ========================================================================= + // Update GitHub (verifyCredentials makes a real call to GitHub on enable) + // + // Only failure paths and the silent-on-disable branch are tested here. + // Happy-path enable would require real GitHub OAuth2 credentials, which + // CI doesn't have. Wiring, validation, and the non-enabling branch are + // sufficient to surface most regressions; success-path issues are caught + // by integration / staging environments instead. + // ========================================================================= + + public function testUpdateOAuth2GitHubInvalidCredentialsRejected(): void + { + // GitHub is the only provider with a real verifyCredentials() hook. + // Enabling with bogus credentials must surface a 400 from the wrapping + // exception, not silently succeed. + $response = $this->updateOAuth2('github', [ + 'clientId' => 'fake-client-id-' . \uniqid(), + 'clientSecret' => 'fake-client-secret', + 'enabled' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + // Cleanup: ensure it's left disabled. + $this->updateOAuth2('github', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2GitHubInvalidCredentialsSilentWhenNotEnabling(): void + { + // When `enabled` is omitted, verifyCredentials() failure is swallowed. + // The provider remains disabled but the request succeeds. + $response = $this->updateOAuth2('github', [ + 'clientId' => 'still-fake-' . \uniqid(), + 'clientSecret' => 'still-fake-secret', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['enabled']); + + // Cleanup + $this->updateOAuth2('github', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Apple (serviceId + keyId + teamId + p8File) + // ========================================================================= + + public function testUpdateOAuth2Apple(): void + { + $response = $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.web', + 'keyId' => 'P4000000N8', + 'teamId' => 'D4000000R6', + 'p8File' => '-----BEGIN PRIVATE KEY-----TEST-----END PRIVATE KEY-----', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('apple', $response['body']['$id']); + $this->assertSame('ip.appwrite.app.web', $response['body']['serviceId']); + // keyId / teamId / p8File are write-only — PATCH response must not echo them back. + $this->assertSame('', $response['body']['keyId']); + $this->assertSame('', $response['body']['teamId']); + $this->assertSame('', $response['body']['p8File']); + $this->assertSame(false, $response['body']['enabled']); + + // Cleanup + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2ApplePartial(): void + { + // Seed all four fields. + $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.seed', + 'keyId' => 'KEYSEED01', + 'teamId' => 'TEAMSEED01', + 'p8File' => '-----BEGIN PRIVATE KEY-----SEED-----END PRIVATE KEY-----', + 'enabled' => false, + ]); + + // Patch only `keyId` — others must be preserved. + $response = $this->updateOAuth2('apple', [ + 'keyId' => 'KEYUPDATED', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + // serviceId is the (non-secret) clientId; keyId/teamId are write-only + // and must not surface in the response. Persistence of the merged + // values is verified separately via the enable-after-merge tests. + $this->assertSame('ip.appwrite.app.seed', $response['body']['serviceId']); + $this->assertSame('', $response['body']['keyId']); + $this->assertSame('', $response['body']['teamId']); + + // Cleanup + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2ApplePartialPreservesEachField(): void + { + // Seed all four fields, then patch each one individually and confirm + // the others survive across the chain. testUpdateOAuth2ApplePartial + // only covers `keyId`; this exercises serviceId/teamId/p8File too. + $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.merge', + 'keyId' => 'KEYMERGE01', + 'teamId' => 'TEAMMERGE', + 'p8File' => '-----BEGIN PRIVATE KEY-----MERGE-----END PRIVATE KEY-----', + 'enabled' => false, + ]); + + // Patch only `teamId`. + $teamOnly = $this->updateOAuth2('apple', [ + 'teamId' => 'TEAMROTATED', + ]); + $this->assertSame(200, $teamOnly['headers']['status-code']); + // teamId is write-only; verify only the non-secret serviceId echo. + // The actual merge is validated by the enable-after-merge call below. + $this->assertSame('', $teamOnly['body']['teamId']); + $this->assertSame('ip.appwrite.app.merge', $teamOnly['body']['serviceId']); + + // Patch only `serviceId` — keyId/teamId/p8File live in the JSON blob + // and must survive a top-level (non-blob) field update. + $serviceOnly = $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.rotated', + ]); + $this->assertSame(200, $serviceOnly['headers']['status-code']); + $this->assertSame('ip.appwrite.app.rotated', $serviceOnly['body']['serviceId']); + + // Patch only `p8File`. keyId/teamId/serviceId must still be set + // internally — confirm by enabling. Apple has no verifyCredentials() + // hook, so persistCredentials only checks for non-empty serviceId and + // non-empty stored secret blob. + $p8Only = $this->updateOAuth2('apple', [ + 'p8File' => '-----BEGIN PRIVATE KEY-----ROTATED-----END PRIVATE KEY-----', + ]); + $this->assertSame(200, $p8Only['headers']['status-code']); + + $enable = $this->updateOAuth2('apple', ['enabled' => true]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2AppleClearAllFieldsBlocksEnable(): void + { + // Seed all four Apple fields. + $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.clearAll', + 'keyId' => 'KEYCLEARALL', + 'teamId' => 'TEAMCLEARALL', + 'p8File' => '-----BEGIN PRIVATE KEY-----CLEARALL-----END PRIVATE KEY-----', + 'enabled' => false, + ]); + + // Clear all credentials with empty strings. With `enabled` omitted, the + // silent-validation branch swallows the empty-credentials throw, so the + // call still succeeds — see testUpdateOAuth2PlainEnabledOmittedDoesNotThrow. + $clear = $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + ]); + $this->assertSame(200, $clear['headers']['status-code']); + $this->assertSame('', $clear['body']['serviceId']); + + // A subsequent `enabled => true` must now 400. Empty serviceId trips + // persistCredentials' empty(appId) guard before any provider hook runs, + // proving that the clear actually took effect on stored state. + $enable = $this->updateOAuth2('apple', [ + 'enabled' => true, + ]); + $this->assertSame(400, $enable['headers']['status-code']); + $this->assertSame('general_argument_invalid', $enable['body']['type']); + + // Cleanup (already cleared; included for reset symmetry). + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2AppleResponseModel(): void + { + $response = $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.shape', + 'keyId' => 'SHAPEKEY01', + 'teamId' => 'SHAPETEAM', + 'p8File' => '-----BEGIN PRIVATE KEY-----SHAPE-----END PRIVATE KEY-----', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('enabled', $response['body']); + $this->assertArrayHasKey('serviceId', $response['body']); + $this->assertArrayHasKey('keyId', $response['body']); + $this->assertArrayHasKey('teamId', $response['body']); + $this->assertArrayHasKey('p8File', $response['body']); + // Apple has no clientId/clientSecret in the response model. + $this->assertArrayNotHasKey('clientId', $response['body']); + $this->assertArrayNotHasKey('clientSecret', $response['body']); + + // Cleanup + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + public function testGetOAuth2AppleSecretsWriteOnly(): void + { + $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.read', + 'keyId' => 'KEYREAD', + 'teamId' => 'TEAMREAD', + 'p8File' => '-----BEGIN PRIVATE KEY-----READ-----END PRIVATE KEY-----', + 'enabled' => false, + ]); + + $response = $this->getOAuth2Provider('apple'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('ip.appwrite.app.read', $response['body']['serviceId']); + // All three secret-bearing fields must be hidden on read. + $this->assertSame('', $response['body']['keyId']); + $this->assertSame('', $response['body']['teamId']); + $this->assertSame('', $response['body']['p8File']); + + // Cleanup + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2AppleEnableAndReadBack(): void + { + // Apple has no verifyCredentials() hook, so enabling with arbitrary + // (well-formed) values succeeds without any real Apple network call. + $update = $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.enable', + 'keyId' => 'ENABLEKEY', + 'teamId' => 'ENABLETEAM', + 'p8File' => '-----BEGIN PRIVATE KEY-----ENABLE-----END PRIVATE KEY-----', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide all three secret-bearing fields while keeping serviceId. + $get = $this->getOAuth2Provider('apple'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('ip.appwrite.app.enable', $get['body']['serviceId']); + $this->assertSame('', $get['body']['keyId']); + $this->assertSame('', $get['body']['teamId']); + $this->assertSame('', $get['body']['p8File']); + + // Cleanup + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Auth0 (clientId + clientSecret + optional endpoint) + // ========================================================================= + + public function testUpdateOAuth2Auth0(): void + { + $response = $this->updateOAuth2('auth0', [ + 'clientId' => 'OaOkIA000000000000000000005KLSYq', + 'clientSecret' => 'auth0-test-secret', + 'endpoint' => 'example.us.auth0.com', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('auth0', $response['body']['$id']); + $this->assertSame('OaOkIA000000000000000000005KLSYq', $response['body']['clientId']); + $this->assertSame('example.us.auth0.com', $response['body']['endpoint']); + + // Cleanup + $this->updateOAuth2('auth0', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2Auth0PartialEndpoint(): void + { + // Seed clientSecret + endpoint. + $this->updateOAuth2('auth0', [ + 'clientId' => 'auth0-seed-client', + 'clientSecret' => 'auth0-seed-secret', + 'endpoint' => 'seed.us.auth0.com', + 'enabled' => false, + ]); + + // Update only endpoint. + $response = $this->updateOAuth2('auth0', [ + 'endpoint' => 'updated.us.auth0.com', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('updated.us.auth0.com', $response['body']['endpoint']); + // clientId is unchanged on top-level provider state. + $this->assertSame('auth0-seed-client', $response['body']['clientId']); + + // Cleanup + $this->updateOAuth2('auth0', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2Auth0PartialPreservesEachField(): void + { + // testUpdateOAuth2Auth0PartialEndpoint only patches `endpoint`. Cover + // patching `clientSecret` alone (must not wipe endpoint) and `clientId` + // alone (must not wipe the JSON-blob fields). + $this->updateOAuth2('auth0', [ + 'clientId' => 'auth0-merge-client', + 'clientSecret' => 'auth0-merge-secret', + 'endpoint' => 'merge.us.auth0.com', + 'enabled' => false, + ]); + + // Patch only clientSecret — clientId and endpoint must survive. + $secretOnly = $this->updateOAuth2('auth0', [ + 'clientSecret' => 'auth0-rotated-secret', + ]); + $this->assertSame(200, $secretOnly['headers']['status-code']); + $this->assertSame('auth0-merge-client', $secretOnly['body']['clientId']); + $this->assertSame('merge.us.auth0.com', $secretOnly['body']['endpoint']); + + // Patch only clientId — endpoint must survive. + $idOnly = $this->updateOAuth2('auth0', [ + 'clientId' => 'auth0-rotated-client', + ]); + $this->assertSame(200, $idOnly['headers']['status-code']); + $this->assertSame('auth0-rotated-client', $idOnly['body']['clientId']); + $this->assertSame('merge.us.auth0.com', $idOnly['body']['endpoint']); + + // Confirm the rotated clientSecret survived the chain by enabling. + // Auth0 has no verifyCredentials() hook; non-empty secret is enough. + $enable = $this->updateOAuth2('auth0', ['enabled' => true]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup + $this->updateOAuth2('auth0', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2Auth0EndpointAcceptsEmpty(): void + { + // Auth0's `endpoint` validator is `Nullable(Text(256, 0))`. Passing + // `''` must clear the stored value rather than leave it untouched + // (would happen if the merge fell back to existing on empty-string). + $this->updateOAuth2('auth0', [ + 'clientId' => 'auth0-clear-client', + 'clientSecret' => 'auth0-clear-secret', + 'endpoint' => 'before.us.auth0.com', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('auth0', [ + 'endpoint' => '', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['endpoint']); + $this->assertSame('auth0-clear-client', $response['body']['clientId']); + + // Cleanup + $this->updateOAuth2('auth0', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2Auth0EnableAndReadBack(): void + { + $update = $this->updateOAuth2('auth0', [ + 'clientId' => 'auth0-enable-client', + 'clientSecret' => 'auth0-enable-secret', + 'endpoint' => 'enable.us.auth0.com', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId and endpoint. + $get = $this->getOAuth2Provider('auth0'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('auth0-enable-client', $get['body']['clientId']); + $this->assertSame('enable.us.auth0.com', $get['body']['endpoint']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup + $this->updateOAuth2('auth0', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Authentik (clientId + clientSecret + REQUIRED endpoint) + // ========================================================================= + + public function testUpdateOAuth2AuthentikRequiresEndpoint(): void + { + // The `endpoint` param is required (Text(min=1)); omitting → 400. + $response = $this->updateOAuth2('authentik', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2AuthentikEmptyEndpointRejected(): void + { + // The `endpoint` validator is Text(min=1). Sending `''` must be + // rejected the same way as omitting — the validator should treat the + // empty-string degenerate case as a missing required field. + $response = $this->updateOAuth2('authentik', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'endpoint' => '', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2Authentik(): void + { + $response = $this->updateOAuth2('authentik', [ + 'clientId' => 'dTKOPa0000000000000000000000000000e7G8hv', + 'clientSecret' => 'authentik-secret', + 'endpoint' => 'example.authentik.com', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('authentik', $response['body']['$id']); + $this->assertSame('dTKOPa0000000000000000000000000000e7G8hv', $response['body']['clientId']); + $this->assertSame('example.authentik.com', $response['body']['endpoint']); + + // Cleanup + $this->updateOAuth2('authentik', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.authentik.com', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2AuthentikPartialPreservesSecret(): void + { + // Authentik's `endpoint` is required on every call, so we always + // re-send it. The `clientSecret` lives in the JSON blob and must + // survive when omitted on a subsequent call that only changes clientId. + $this->updateOAuth2('authentik', [ + 'clientId' => 'authentik-merge-client', + 'clientSecret' => 'authentik-merge-secret', + 'endpoint' => 'merge.authentik.com', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('authentik', [ + 'clientId' => 'authentik-rotated-client', + 'endpoint' => 'merge.authentik.com', + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('authentik-rotated-client', $response['body']['clientId']); + $this->assertSame('merge.authentik.com', $response['body']['endpoint']); + + // Confirm clientSecret survived the omitted-field merge by enabling + // — Authentik has no verifyCredentials() hook, so non-empty stored + // secret is enough. `endpoint` must be re-sent (required on enable too). + $enable = $this->updateOAuth2('authentik', [ + 'endpoint' => 'merge.authentik.com', + 'enabled' => true, + ]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup — endpoint is required, use a placeholder. + $this->updateOAuth2('authentik', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.authentik.com', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2AuthentikEnableAndReadBack(): void + { + $update = $this->updateOAuth2('authentik', [ + 'clientId' => 'authentik-enable-client', + 'clientSecret' => 'authentik-enable-secret', + 'endpoint' => 'enable.authentik.com', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId and endpoint. + $get = $this->getOAuth2Provider('authentik'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('authentik-enable-client', $get['body']['clientId']); + $this->assertSame('enable.authentik.com', $get['body']['endpoint']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup — endpoint is required (Text(min=1)) so use a placeholder. + $this->updateOAuth2('authentik', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.authentik.com', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update FusionAuth (clientId + clientSecret + REQUIRED endpoint) + // ========================================================================= + + public function testUpdateOAuth2FusionAuthRequiresEndpoint(): void + { + // The `endpoint` param is required (Text(min=1)); omitting → 400. + $response = $this->updateOAuth2('fusionauth', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2FusionAuthEmptyEndpointRejected(): void + { + // The `endpoint` validator is Text(min=1). Sending `''` must be + // rejected the same way as omitting — the validator should treat the + // empty-string degenerate case as a missing required field. + $response = $this->updateOAuth2('fusionauth', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'endpoint' => '', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2FusionAuth(): void + { + $response = $this->updateOAuth2('fusionauth', [ + 'clientId' => 'b2222c00-0000-0000-0000-000000862097', + 'clientSecret' => 'fusionauth-secret', + 'endpoint' => 'example.fusionauth.io', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('fusionauth', $response['body']['$id']); + $this->assertSame('b2222c00-0000-0000-0000-000000862097', $response['body']['clientId']); + $this->assertSame('example.fusionauth.io', $response['body']['endpoint']); + + // Cleanup + $this->updateOAuth2('fusionauth', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.fusionauth.io', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2FusionAuthPartialPreservesSecret(): void + { + // FusionAuth's `endpoint` is required on every call, so we always + // re-send it. The `clientSecret` lives in the JSON blob and must + // survive when omitted on a subsequent call that only changes clientId. + $this->updateOAuth2('fusionauth', [ + 'clientId' => 'fusionauth-merge-client', + 'clientSecret' => 'fusionauth-merge-secret', + 'endpoint' => 'merge.fusionauth.io', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('fusionauth', [ + 'clientId' => 'fusionauth-rotated-client', + 'endpoint' => 'merge.fusionauth.io', + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('fusionauth-rotated-client', $response['body']['clientId']); + $this->assertSame('merge.fusionauth.io', $response['body']['endpoint']); + + // Confirm clientSecret survived the omitted-field merge by enabling + // — FusionAuth has no verifyCredentials() hook, so non-empty stored + // secret is enough. `endpoint` must be re-sent (required on enable too). + $enable = $this->updateOAuth2('fusionauth', [ + 'endpoint' => 'merge.fusionauth.io', + 'enabled' => true, + ]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup — endpoint is required, use a placeholder. + $this->updateOAuth2('fusionauth', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.fusionauth.io', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2FusionAuthEnableAndReadBack(): void + { + $update = $this->updateOAuth2('fusionauth', [ + 'clientId' => 'fusionauth-enable-client', + 'clientSecret' => 'fusionauth-enable-secret', + 'endpoint' => 'enable.fusionauth.io', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId and endpoint. + $get = $this->getOAuth2Provider('fusionauth'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('fusionauth-enable-client', $get['body']['clientId']); + $this->assertSame('enable.fusionauth.io', $get['body']['endpoint']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup — endpoint is required (Text(min=1)) so use a placeholder. + $this->updateOAuth2('fusionauth', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.fusionauth.io', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Keycloak (clientId + clientSecret + REQUIRED endpoint + REQUIRED realmName) + // ========================================================================= + + public function testUpdateOAuth2KeycloakRequiresEndpoint(): void + { + // The `endpoint` param is required (Text(min=1)); omitting → 400. + $response = $this->updateOAuth2('keycloak', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'realmName' => 'appwrite-realm', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2KeycloakEmptyEndpointRejected(): void + { + // The `endpoint` validator is Text(min=1). Sending `''` must be + // rejected the same way as omitting — the validator should treat the + // empty-string degenerate case as a missing required field. + $response = $this->updateOAuth2('keycloak', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'endpoint' => '', + 'realmName' => 'appwrite-realm', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2KeycloakRequiresRealmName(): void + { + // The `realmName` param is required (Text(min=1)); omitting → 400. + $response = $this->updateOAuth2('keycloak', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'endpoint' => 'keycloak.example.com', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2KeycloakEmptyRealmNameRejected(): void + { + // The `realmName` validator is Text(min=1). Sending `''` must be + // rejected the same way as omitting. + $response = $this->updateOAuth2('keycloak', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'endpoint' => 'keycloak.example.com', + 'realmName' => '', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2Keycloak(): void + { + $response = $this->updateOAuth2('keycloak', [ + 'clientId' => 'appwrite-o0000000st-app', + 'clientSecret' => 'keycloak-secret', + 'endpoint' => 'keycloak.example.com', + 'realmName' => 'appwrite-realm', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('keycloak', $response['body']['$id']); + $this->assertSame('appwrite-o0000000st-app', $response['body']['clientId']); + $this->assertSame('keycloak.example.com', $response['body']['endpoint']); + $this->assertSame('appwrite-realm', $response['body']['realmName']); + + // Cleanup + $this->updateOAuth2('keycloak', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.keycloak.com', + 'realmName' => 'cleanup-realm', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2KeycloakPartialPreservesSecret(): void + { + // Keycloak's `endpoint` and `realmName` are required on every call, + // so we always re-send them. The `clientSecret` lives in the JSON + // blob and must survive when omitted on a subsequent call that only + // changes clientId. + $this->updateOAuth2('keycloak', [ + 'clientId' => 'keycloak-merge-client', + 'clientSecret' => 'keycloak-merge-secret', + 'endpoint' => 'merge.keycloak.com', + 'realmName' => 'merge-realm', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('keycloak', [ + 'clientId' => 'keycloak-rotated-client', + 'endpoint' => 'merge.keycloak.com', + 'realmName' => 'merge-realm', + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('keycloak-rotated-client', $response['body']['clientId']); + $this->assertSame('merge.keycloak.com', $response['body']['endpoint']); + $this->assertSame('merge-realm', $response['body']['realmName']); + + // Confirm clientSecret survived the omitted-field merge by enabling + // — Keycloak has no verifyCredentials() hook, so non-empty stored + // secret is enough. `endpoint`/`realmName` must be re-sent (required + // on enable too). + $enable = $this->updateOAuth2('keycloak', [ + 'endpoint' => 'merge.keycloak.com', + 'realmName' => 'merge-realm', + 'enabled' => true, + ]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup — endpoint and realmName are required, use placeholders. + $this->updateOAuth2('keycloak', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.keycloak.com', + 'realmName' => 'cleanup-realm', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2KeycloakEnableAndReadBack(): void + { + $update = $this->updateOAuth2('keycloak', [ + 'clientId' => 'keycloak-enable-client', + 'clientSecret' => 'keycloak-enable-secret', + 'endpoint' => 'enable.keycloak.com', + 'realmName' => 'enable-realm', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId, endpoint, realmName. + $get = $this->getOAuth2Provider('keycloak'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('keycloak-enable-client', $get['body']['clientId']); + $this->assertSame('enable.keycloak.com', $get['body']['endpoint']); + $this->assertSame('enable-realm', $get['body']['realmName']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup — endpoint and realmName are required (Text(min=1)) so use placeholders. + $this->updateOAuth2('keycloak', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.keycloak.com', + 'realmName' => 'cleanup-realm', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Microsoft (applicationId + applicationSecret + REQUIRED tenant) + // ========================================================================= + + public function testUpdateOAuth2MicrosoftRequiresTenant(): void + { + $response = $this->updateOAuth2('microsoft', [ + 'applicationId' => 'whatever', + 'applicationSecret' => 'whatever', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2MicrosoftEmptyTenantRejected(): void + { + // The `tenant` validator is Text(min=1). Sending `''` must be rejected + // the same way as omitting — the validator should treat the empty + // string as a missing required field. + $response = $this->updateOAuth2('microsoft', [ + 'applicationId' => 'whatever', + 'applicationSecret' => 'whatever', + 'tenant' => '', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2Microsoft(): void + { + $response = $this->updateOAuth2('microsoft', [ + 'applicationId' => '00001111-aaaa-2222-bbbb-3333cccc4444', + 'applicationSecret' => 'A1bC2dE3fH4iJ5kL6mN7oP8qR9sT0u', + 'tenant' => 'common', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('microsoft', $response['body']['$id']); + $this->assertSame('00001111-aaaa-2222-bbbb-3333cccc4444', $response['body']['applicationId']); + $this->assertSame('common', $response['body']['tenant']); + // Custom param names: applicationId/applicationSecret, not clientId/clientSecret. + $this->assertArrayNotHasKey('clientId', $response['body']); + $this->assertArrayNotHasKey('clientSecret', $response['body']); + + // Cleanup + $this->updateOAuth2('microsoft', [ + 'applicationId' => '', + 'applicationSecret' => '', + 'tenant' => 'common', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2MicrosoftPartialPreservesSecret(): void + { + // Seed full credentials. + $this->updateOAuth2('microsoft', [ + 'applicationId' => 'seed-app-id', + 'applicationSecret' => 'seed-app-secret', + 'tenant' => 'common', + 'enabled' => false, + ]); + + // Patch with only `tenant` (it's required on every call) and a new + // applicationId, leaving applicationSecret omitted. The stored secret + // must not be wiped. + $response = $this->updateOAuth2('microsoft', [ + 'applicationId' => 'updated-app-id', + 'tenant' => 'organizations', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('updated-app-id', $response['body']['applicationId']); + $this->assertSame('organizations', $response['body']['tenant']); + + // Cleanup + $this->updateOAuth2('microsoft', [ + 'applicationId' => '', + 'applicationSecret' => '', + 'tenant' => 'common', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2MicrosoftEnableAndReadBack(): void + { + $update = $this->updateOAuth2('microsoft', [ + 'applicationId' => 'microsoft-enable-app', + 'applicationSecret' => 'microsoft-enable-secret', + 'tenant' => 'common', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide applicationSecret while keeping applicationId/tenant. + $get = $this->getOAuth2Provider('microsoft'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('microsoft-enable-app', $get['body']['applicationId']); + $this->assertSame('common', $get['body']['tenant']); + $this->assertSame('', $get['body']['applicationSecret']); + + // Cleanup — tenant is required (Text(min=1)) so use a placeholder. + $this->updateOAuth2('microsoft', [ + 'applicationId' => '', + 'applicationSecret' => '', + 'tenant' => 'common', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Gitlab (applicationId + secret + optional endpoint, custom names) + // ========================================================================= + + public function testUpdateOAuth2Gitlab(): void + { + $response = $this->updateOAuth2('gitlab', [ + 'applicationId' => 'd41ffe0000000000000000000000000000000000000000000000000000d5e252', + 'secret' => 'gloas-838cfa00', + 'endpoint' => 'https://gitlab.example.com', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('gitlab', $response['body']['$id']); + $this->assertSame('d41ffe0000000000000000000000000000000000000000000000000000d5e252', $response['body']['applicationId']); + $this->assertSame('https://gitlab.example.com', $response['body']['endpoint']); + // Custom names — the response model exposes `applicationId`/`secret`. + $this->assertArrayNotHasKey('clientId', $response['body']); + $this->assertArrayNotHasKey('clientSecret', $response['body']); + + // Cleanup + $this->updateOAuth2('gitlab', [ + 'applicationId' => '', + 'secret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2GitlabInvalidEndpoint(): void + { + $response = $this->updateOAuth2('gitlab', [ + 'applicationId' => 'whatever', + 'secret' => 'whatever', + 'endpoint' => 'not a url', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2GitlabPartialEndpoint(): void + { + $this->updateOAuth2('gitlab', [ + 'applicationId' => 'gitlab-seed-app', + 'secret' => 'gitlab-seed-secret', + 'endpoint' => 'https://seed.gitlab.com', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('gitlab', [ + 'endpoint' => 'https://updated.gitlab.com', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('https://updated.gitlab.com', $response['body']['endpoint']); + $this->assertSame('gitlab-seed-app', $response['body']['applicationId']); + + // Cleanup + $this->updateOAuth2('gitlab', [ + 'applicationId' => '', + 'secret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2GitlabPartialPreservesEachField(): void + { + // testUpdateOAuth2GitlabPartialEndpoint covers patching only `endpoint`. + // Cover patching `secret` alone (must not wipe applicationId/endpoint) + // and `applicationId` alone (must not wipe the JSON-blob endpoint). + $this->updateOAuth2('gitlab', [ + 'applicationId' => 'gitlab-merge-app', + 'secret' => 'gitlab-merge-secret', + 'endpoint' => 'https://merge.gitlab.com', + 'enabled' => false, + ]); + + // Patch only `secret`. + $secretOnly = $this->updateOAuth2('gitlab', [ + 'secret' => 'gitlab-rotated-secret', + ]); + $this->assertSame(200, $secretOnly['headers']['status-code']); + $this->assertSame('gitlab-merge-app', $secretOnly['body']['applicationId']); + $this->assertSame('https://merge.gitlab.com', $secretOnly['body']['endpoint']); + + // Patch only `applicationId`. + $idOnly = $this->updateOAuth2('gitlab', [ + 'applicationId' => 'gitlab-rotated-app', + ]); + $this->assertSame(200, $idOnly['headers']['status-code']); + $this->assertSame('gitlab-rotated-app', $idOnly['body']['applicationId']); + $this->assertSame('https://merge.gitlab.com', $idOnly['body']['endpoint']); + + // Cleanup + $this->updateOAuth2('gitlab', [ + 'applicationId' => '', + 'secret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2GitlabEnableAndReadBack(): void + { + $update = $this->updateOAuth2('gitlab', [ + 'applicationId' => 'gitlab-enable-app', + 'secret' => 'gitlab-enable-secret', + 'endpoint' => 'https://enable.gitlab.com', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide `secret` while keeping applicationId and endpoint. + $get = $this->getOAuth2Provider('gitlab'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('gitlab-enable-app', $get['body']['applicationId']); + $this->assertSame('https://enable.gitlab.com', $get['body']['endpoint']); + $this->assertSame('', $get['body']['secret']); + + // Cleanup + $this->updateOAuth2('gitlab', [ + 'applicationId' => '', + 'secret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2GitlabEndpointAcceptsEmpty(): void + { + // The `endpoint` validator is `Nullable(URL(allowEmpty: true))`. Passing + // `''` must clear the stored value rather than 400 on URL validation. + $this->updateOAuth2('gitlab', [ + 'applicationId' => 'gitlab-clear-app', + 'secret' => 'gitlab-clear-secret', + 'endpoint' => 'https://before.gitlab.com', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('gitlab', [ + 'endpoint' => '', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['endpoint']); + + // Cleanup + $this->updateOAuth2('gitlab', [ + 'applicationId' => '', + 'secret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update OIDC (clientId + secret + wellKnownURL or 3 discovery URLs) + // ========================================================================= + + public function testUpdateOAuth2OidcWithWellKnown(): void + { + $response = $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-client', + 'clientSecret' => 'oidc-secret', + 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('https://idp.example.com/.well-known/openid-configuration', $response['body']['wellKnownURL']); + $this->assertArrayHasKey('authorizationURL', $response['body']); + $this->assertArrayHasKey('tokenUrl', $response['body']); + $this->assertArrayHasKey('userInfoUrl', $response['body']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcWithDiscoveryURLs(): void + { + $response = $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-discovery', + 'clientSecret' => 'oidc-discovery-secret', + 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', + 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'userInfoUrl' => 'https://idp.example.com/oauth2/userinfo', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('https://idp.example.com/oauth2/authorize', $response['body']['authorizationURL']); + $this->assertSame('https://idp.example.com/oauth2/token', $response['body']['tokenUrl']); + $this->assertSame('https://idp.example.com/oauth2/userinfo', $response['body']['userInfoUrl']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcEnableMissingURLs(): void + { + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-no-urls', + 'clientSecret' => 'oidc-no-urls', + 'enabled' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcEnablePartialDiscoveryFails(): void + { + // Only authorization+token, missing userInfo — must fail to enable. + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-partial', + 'clientSecret' => 'oidc-partial-secret', + 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', + 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'enabled' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcEnableSucceedsWithWellKnown(): void + { + $update = $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-enable-client', + 'clientSecret' => 'oidc-enable-secret', + 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId and the URL. + $get = $this->getOAuth2Provider('oidc'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('oidc-enable-client', $get['body']['clientId']); + $this->assertSame('https://idp.example.com/.well-known/openid-configuration', $get['body']['wellKnownURL']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcEnableInSeparateRequestWithWellKnown(): void + { + // Configure URLs first with `enabled: false`. Then enable in a SECOND + // request that omits all URL fields. The merge-on-enable logic in + // Oidc::handle() must see the previously-stored wellKnownEndpoint and + // allow the toggle. This is the headline feature of the merge logic. + $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-split-wk-client', + 'clientSecret' => 'oidc-split-wk-secret', + 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', + 'enabled' => false, + ]); + + $enable = $this->updateOAuth2('oidc', [ + 'enabled' => true, + ]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcEnableAcrossRequestsWithDiscoveryURLs(): void + { + // Reset to clean state — earlier tests in this section may have left + // partial URL state when running in any order. + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + + // Request 1: configure two of the three discovery URLs. + $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-split-discovery', + 'clientSecret' => 'oidc-split-discovery-secret', + 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', + 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'enabled' => false, + ]); + + // Request 2: send only the third URL plus enable=true. The merged + // state must include the two stored URLs + the new one to satisfy + // the all-three-discovery-URLs branch of the enable check. + $enable = $this->updateOAuth2('oidc', [ + 'userInfoUrl' => 'https://idp.example.com/oauth2/userinfo', + 'enabled' => true, + ]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Confirm all three URLs ended up persisted (merge wrote the new + // userInfoUrl while preserving the previously stored two). + $get = $this->getOAuth2Provider('oidc'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('https://idp.example.com/oauth2/authorize', $get['body']['authorizationURL']); + $this->assertSame('https://idp.example.com/oauth2/token', $get['body']['tokenUrl']); + $this->assertSame('https://idp.example.com/oauth2/userinfo', $get['body']['userInfoUrl']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcEnableFailsAfterClearingWellKnown(): void + { + // Seed wellKnownURL only (no discovery URLs). + $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-clear-then-enable', + 'clientSecret' => 'oidc-clear-then-enable-secret', + 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + + // Clear wellKnownURL and try to enable in the same request. Merge + // sees `wellKnown=''` (the cleared empty wins over the stored value + // because the new value is non-null) and no discovery URLs → 400. + // This is the inverse of testUpdateOAuth2OidcEnableInSeparateRequestWithWellKnown: + // confirms the merge correctly *replaces* with empty rather than + // falling back to the stored non-empty value. + $response = $this->updateOAuth2('oidc', [ + 'wellKnownURL' => '', + 'enabled' => true, + ]); + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcSwitchModesWellKnownToDiscovery(): void + { + // Configure with wellKnownURL, then switch to the three-discovery-URL + // mode in a single request: clear wellKnown, set the three URLs, + // enable. Merge sees wellKnown='' AND all three discovery URLs set → + // hasAllDiscovery branch passes. + $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-switch-client', + 'clientSecret' => 'oidc-switch-secret', + 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', + 'enabled' => false, + ]); + + $switch = $this->updateOAuth2('oidc', [ + 'wellKnownURL' => '', + 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', + 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'userInfoUrl' => 'https://idp.example.com/oauth2/userinfo', + 'enabled' => true, + ]); + $this->assertSame(200, $switch['headers']['status-code']); + $this->assertTrue($switch['body']['enabled']); + $this->assertSame('', $switch['body']['wellKnownURL']); + $this->assertSame('https://idp.example.com/oauth2/authorize', $switch['body']['authorizationURL']); + $this->assertSame('https://idp.example.com/oauth2/token', $switch['body']['tokenUrl']); + $this->assertSame('https://idp.example.com/oauth2/userinfo', $switch['body']['userInfoUrl']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcURLsAcceptEmpty(): void + { + // All four URL fields use `Nullable(URL(allowEmpty: true))`. Passing `''` + // for each must clear them rather than 400 on URL validation. + $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-clear-client', + 'clientSecret' => 'oidc-clear-secret', + 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', + 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', + 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'userInfoUrl' => 'https://idp.example.com/oauth2/userinfo', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('oidc', [ + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['wellKnownURL']); + $this->assertSame('', $response['body']['authorizationURL']); + $this->assertSame('', $response['body']['tokenUrl']); + $this->assertSame('', $response['body']['userInfoUrl']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Okta (clientId + clientSecret + optional domain/authServer) + // ========================================================================= + + public function testUpdateOAuth2Okta(): void + { + $response = $this->updateOAuth2('okta', [ + 'clientId' => '0oa00000000000000698', + 'clientSecret' => 'okta-secret', + 'domain' => 'trial-6400025.okta.com', + 'authorizationServerId' => 'aus000000000000000h7z', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('okta', $response['body']['$id']); + $this->assertSame('0oa00000000000000698', $response['body']['clientId']); + $this->assertSame('trial-6400025.okta.com', $response['body']['domain']); + $this->assertSame('aus000000000000000h7z', $response['body']['authorizationServerId']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'authorizationServerId' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OktaInvalidDomain(): void + { + $response = $this->updateOAuth2('okta', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'domain' => 'https://trial-6400025.okta.com/', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2OktaEnableRequiresDomain(): void + { + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'authorizationServerId' => '', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('okta', [ + 'clientId' => 'okta-no-domain', + 'clientSecret' => 'okta-no-domain-secret', + 'enabled' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OktaEnableSucceedsWithDomain(): void + { + $update = $this->updateOAuth2('okta', [ + 'clientId' => 'okta-enable-client', + 'clientSecret' => 'okta-enable-secret', + 'domain' => 'enable.okta.com', + 'authorizationServerId' => 'aus000000000000000h7z', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId, domain and authServerId. + $get = $this->getOAuth2Provider('okta'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('okta-enable-client', $get['body']['clientId']); + $this->assertSame('enable.okta.com', $get['body']['domain']); + $this->assertSame('aus000000000000000h7z', $get['body']['authorizationServerId']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'authorizationServerId' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OktaPartialPreservesEachField(): void + { + // Okta has no field-by-field partial test in the existing suite. Cover + // each of `domain`, `authorizationServerId`, and `clientSecret` being + // patched alone — all three live in the same JSON blob. + $this->updateOAuth2('okta', [ + 'clientId' => 'okta-merge-client', + 'clientSecret' => 'okta-merge-secret', + 'domain' => 'merge.okta.com', + 'authorizationServerId' => 'aus000000000000merge', + 'enabled' => false, + ]); + + // Patch only `domain` — others must survive. + $domainOnly = $this->updateOAuth2('okta', [ + 'domain' => 'rotated.okta.com', + ]); + $this->assertSame(200, $domainOnly['headers']['status-code']); + $this->assertSame('rotated.okta.com', $domainOnly['body']['domain']); + $this->assertSame('okta-merge-client', $domainOnly['body']['clientId']); + $this->assertSame('aus000000000000merge', $domainOnly['body']['authorizationServerId']); + + // Patch only `authorizationServerId`. + $authServerOnly = $this->updateOAuth2('okta', [ + 'authorizationServerId' => 'aus000000000rotated00', + ]); + $this->assertSame(200, $authServerOnly['headers']['status-code']); + $this->assertSame('rotated.okta.com', $authServerOnly['body']['domain']); + $this->assertSame('aus000000000rotated00', $authServerOnly['body']['authorizationServerId']); + + // Patch only `clientSecret` — domain and authServerId in the JSON blob + // must survive. Confirm the rotated secret persisted by enabling. + $secretOnly = $this->updateOAuth2('okta', [ + 'clientSecret' => 'okta-rotated-secret', + ]); + $this->assertSame(200, $secretOnly['headers']['status-code']); + $this->assertSame('rotated.okta.com', $secretOnly['body']['domain']); + $this->assertSame('aus000000000rotated00', $secretOnly['body']['authorizationServerId']); + + $enable = $this->updateOAuth2('okta', ['enabled' => true]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'authorizationServerId' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OktaAuthServerIdAcceptsEmpty(): void + { + // `authorizationServerId` is `Nullable(Text(256, 0))`. Passing `''` + // must clear the stored value while leaving the rest of the JSON blob + // (clientSecret, oktaDomain) untouched. + $this->updateOAuth2('okta', [ + 'clientId' => 'okta-clear-auth-server', + 'clientSecret' => 'okta-clear-auth-server-secret', + 'domain' => 'authserver.okta.com', + 'authorizationServerId' => 'aus0000000000beforeauth', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('okta', [ + 'authorizationServerId' => '', + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['authorizationServerId']); + // domain (also stored in the JSON blob) must NOT have been wiped. + $this->assertSame('authserver.okta.com', $response['body']['domain']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'authorizationServerId' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OktaDomainAcceptsEmpty(): void + { + // The `domain` validator is `Nullable(Domain(allowEmpty: true))`. Passing + // `''` must clear the stored value rather than 400 on Domain validation. + $this->updateOAuth2('okta', [ + 'clientId' => 'okta-clear-client', + 'clientSecret' => 'okta-clear-secret', + 'domain' => 'before.okta.com', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('okta', [ + 'domain' => '', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['domain']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Dropbox (custom param names: appKey + appSecret) + // ========================================================================= + + public function testUpdateOAuth2DropboxFieldNames(): void + { + $response = $this->updateOAuth2('dropbox', [ + 'appKey' => 'jl000000000009t', + 'appSecret' => 'g200000000000vw', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('dropbox', $response['body']['$id']); + $this->assertSame('jl000000000009t', $response['body']['appKey']); + $this->assertArrayHasKey('appSecret', $response['body']); + $this->assertArrayNotHasKey('clientId', $response['body']); + $this->assertArrayNotHasKey('clientSecret', $response['body']); + + // GET enforces write-only on the secret regardless of the custom name. + $get = $this->getOAuth2Provider('dropbox'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('jl000000000009t', $get['body']['appKey']); + $this->assertSame('', $get['body']['appSecret']); + + // Cleanup + $this->updateOAuth2('dropbox', [ + 'appKey' => '', + 'appSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2DropboxPartial(): void + { + // Seed both fields, then patch only `appKey` and verify `appSecret` + // persists by enabling — Dropbox has no verifyCredentials() hook, so + // enabling succeeds purely from local state. + $this->updateOAuth2('dropbox', [ + 'appKey' => 'dropbox-seed-key', + 'appSecret' => 'dropbox-seed-secret', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('dropbox', [ + 'appKey' => 'dropbox-updated-key', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('dropbox-updated-key', $response['body']['appKey']); + + $enable = $this->updateOAuth2('dropbox', [ + 'enabled' => true, + ]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertSame(true, $enable['body']['enabled']); + + // Cleanup + $this->updateOAuth2('dropbox', [ + 'appKey' => '', + 'appSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2DropboxEnableAndReadBack(): void + { + $update = $this->updateOAuth2('dropbox', [ + 'appKey' => 'dropbox-enable-key', + 'appSecret' => 'dropbox-enable-secret', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide `appSecret` while keeping `appKey`. + $get = $this->getOAuth2Provider('dropbox'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('dropbox-enable-key', $get['body']['appKey']); + $this->assertSame('', $get['body']['appSecret']); + + // Cleanup + $this->updateOAuth2('dropbox', [ + 'appKey' => '', + 'appSecret' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Paypal Sandbox (inherits from Paypal — independent provider ID) + // ========================================================================= + + public function testUpdateOAuth2PaypalSandbox(): void + { + $response = $this->updateOAuth2('paypalSandbox', [ + 'clientId' => 'paypal-sandbox-client', + 'clientSecret' => 'paypal-sandbox-secret', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('paypalSandbox', $response['body']['$id']); + $this->assertSame('paypal-sandbox-client', $response['body']['clientId']); + + // Sandbox is independent of the regular paypal entry. + $regular = $this->getOAuth2Provider('paypal'); + $this->assertSame(200, $regular['headers']['status-code']); + $this->assertSame('paypal', $regular['body']['$id']); + $this->assertNotSame('paypal-sandbox-client', $regular['body']['clientId']); + + // Cleanup + $this->updateOAuth2('paypalSandbox', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2PaypalSandboxResponseModel(): void + { + // PaypalSandbox inherits from Paypal: param/response field is + // `secretKey` instead of `clientSecret`. A regression that adds the + // default `clientSecret` to the response model would leak the + // unwritten field; pin its absence on both PATCH and GET. + $update = $this->updateOAuth2('paypalSandbox', [ + 'clientId' => 'paypal-sandbox-shape', + 'secretKey' => 'paypal-sandbox-shape-secret', + 'enabled' => false, + ]); + $this->assertSame(200, $update['headers']['status-code']); + $this->assertArrayHasKey('secretKey', $update['body']); + $this->assertArrayNotHasKey('clientSecret', $update['body']); + + $get = $this->getOAuth2Provider('paypalSandbox'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertArrayHasKey('secretKey', $get['body']); + $this->assertArrayNotHasKey('clientSecret', $get['body']); + + // Cleanup + $this->updateOAuth2('paypalSandbox', [ + 'clientId' => '', + 'secretKey' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2PaypalDoesNotAffectSandbox(): void + { + // Reverse direction: writing to regular paypal must leave sandbox state intact. + $this->updateOAuth2('paypalSandbox', [ + 'clientId' => 'sandbox-untouched', + 'clientSecret' => 'sandbox-secret', + 'enabled' => false, + ]); + + $this->updateOAuth2('paypal', [ + 'clientId' => 'paypal-prod', + 'secretKey' => 'paypal-prod-secret', + 'enabled' => false, + ]); + + $sandbox = $this->getOAuth2Provider('paypalSandbox'); + $this->assertSame(200, $sandbox['headers']['status-code']); + $this->assertSame('sandbox-untouched', $sandbox['body']['clientId']); + + // Cleanup both + $this->updateOAuth2('paypal', [ + 'clientId' => '', + 'secretKey' => '', + 'enabled' => false, + ]); + $this->updateOAuth2('paypalSandbox', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Tradeshift Sandbox (inherits from Tradeshift — independent provider ID) + // ========================================================================= + + public function testUpdateOAuth2TradeshiftBox(): void + { + $response = $this->updateOAuth2('tradeshiftBox', [ + 'oauth2ClientId' => 'tradeshift-sandbox-client', + 'oauth2ClientSecret' => 'tradeshift-sandbox-secret', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('tradeshiftBox', $response['body']['$id']); + $this->assertSame('tradeshift-sandbox-client', $response['body']['oauth2ClientId']); + + // Sandbox is independent of the regular tradeshift entry. + $regular = $this->getOAuth2Provider('tradeshift'); + $this->assertSame(200, $regular['headers']['status-code']); + $this->assertSame('tradeshift', $regular['body']['$id']); + $this->assertNotSame('tradeshift-sandbox-client', $regular['body']['oauth2ClientId']); + + // Cleanup + $this->updateOAuth2('tradeshiftBox', [ + 'oauth2ClientId' => '', + 'oauth2ClientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2TradeshiftBoxResponseModel(): void + { + // TradeshiftSandbox inherits from Tradeshift: both clientId AND + // clientSecret are renamed (oauth2ClientId / oauth2ClientSecret). + // Pin that the default field names are absent from PATCH and GET + // responses so a stray addition to the response model is caught. + $update = $this->updateOAuth2('tradeshiftBox', [ + 'oauth2ClientId' => 'tradeshift-box-shape', + 'oauth2ClientSecret' => 'tradeshift-box-shape-secret', + 'enabled' => false, + ]); + $this->assertSame(200, $update['headers']['status-code']); + $this->assertArrayHasKey('oauth2ClientId', $update['body']); + $this->assertArrayHasKey('oauth2ClientSecret', $update['body']); + $this->assertArrayNotHasKey('clientId', $update['body']); + $this->assertArrayNotHasKey('clientSecret', $update['body']); + + $get = $this->getOAuth2Provider('tradeshiftBox'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertArrayHasKey('oauth2ClientId', $get['body']); + $this->assertArrayHasKey('oauth2ClientSecret', $get['body']); + $this->assertArrayNotHasKey('clientId', $get['body']); + $this->assertArrayNotHasKey('clientSecret', $get['body']); + + // Cleanup + $this->updateOAuth2('tradeshiftBox', [ + 'oauth2ClientId' => '', + 'oauth2ClientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2TradeshiftDoesNotAffectSandbox(): void + { + // Reverse direction: writing to regular tradeshift must not touch sandbox state. + $this->updateOAuth2('tradeshiftBox', [ + 'oauth2ClientId' => 'tradeshift-sandbox-untouched', + 'oauth2ClientSecret' => 'tradeshift-sandbox-secret', + 'enabled' => false, + ]); + + $this->updateOAuth2('tradeshift', [ + 'oauth2ClientId' => 'tradeshift-prod', + 'oauth2ClientSecret' => 'tradeshift-prod-secret', + 'enabled' => false, + ]); + + $sandbox = $this->getOAuth2Provider('tradeshiftBox'); + $this->assertSame(200, $sandbox['headers']['status-code']); + $this->assertSame('tradeshift-sandbox-untouched', $sandbox['body']['oauth2ClientId']); + + // Cleanup both + $this->updateOAuth2('tradeshift', [ + 'oauth2ClientId' => '', + 'oauth2ClientSecret' => '', + 'enabled' => false, + ]); + $this->updateOAuth2('tradeshiftBox', [ + 'oauth2ClientId' => '', + 'oauth2ClientSecret' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Smoke test: every plain (clientId + clientSecret) provider + // + // Ensures each provider's Update endpoint is wired up correctly: routing, + // provider class, response model and `$id`. Custom-shaped providers + // (apple, auth0, authentik, gitlab, microsoft, oidc, okta, dropbox) and + // sandboxes (paypalSandbox, tradeshiftSandbox) have dedicated tests above. + // Github is excluded because its `verifyCredentials()` hook is exercised + // separately. + // ========================================================================= + + /** + * Provider, ID-field, secret-field. Many providers rename one or both of + * the two credential params (`clientId`/`clientSecret`) to match the + * upstream provider's terminology, so the smoke test parameterises both. + * + * @return array> + */ + public static function plainProviders(): array + { + return [ + 'discord' => ['discord', 'clientId', 'clientSecret'], + 'figma' => ['figma', 'clientId', 'clientSecret'], + 'dailymotion' => ['dailymotion', 'apiKey', 'apiSecret'], + 'bitbucket' => ['bitbucket', 'key', 'secret'], + 'bitly' => ['bitly', 'clientId', 'clientSecret'], + 'box' => ['box', 'clientId', 'clientSecret'], + 'autodesk' => ['autodesk', 'clientId', 'clientSecret'], + 'google' => ['google', 'clientId', 'clientSecret'], + 'zoom' => ['zoom', 'clientId', 'clientSecret'], + 'zoho' => ['zoho', 'clientId', 'clientSecret'], + 'yandex' => ['yandex', 'clientId', 'clientSecret'], + 'x' => ['x', 'customerKey', 'secretKey'], + 'wordpress' => ['wordpress', 'clientId', 'clientSecret'], + 'twitch' => ['twitch', 'clientId', 'clientSecret'], + 'stripe' => ['stripe', 'clientId', 'apiSecretKey'], + 'spotify' => ['spotify', 'clientId', 'clientSecret'], + 'slack' => ['slack', 'clientId', 'clientSecret'], + 'podio' => ['podio', 'clientId', 'clientSecret'], + 'notion' => ['notion', 'oauthClientId', 'oauthClientSecret'], + 'salesforce' => ['salesforce', 'customerKey', 'customerSecret'], + 'yahoo' => ['yahoo', 'clientId', 'clientSecret'], + 'linkedin' => ['linkedin', 'clientId', 'primaryClientSecret'], + 'disqus' => ['disqus', 'publicKey', 'secretKey'], + 'etsy' => ['etsy', 'keyString', 'sharedSecret'], + 'facebook' => ['facebook', 'appId', 'appSecret'], + 'tradeshift' => ['tradeshift', 'oauth2ClientId', 'oauth2ClientSecret'], + 'paypal' => ['paypal', 'clientId', 'secretKey'], + 'kick' => ['kick', 'clientId', 'clientSecret'], + ]; + } + + #[DataProvider('plainProviders')] + public function testUpdateOAuth2PlainProvider(string $providerId, string $idField, string $secretField): void + { + $clientId = $providerId . '-smoke-client'; + $clientSecret = $providerId . '-smoke-secret'; + + $update = $this->updateOAuth2($providerId, [ + $idField => $clientId, + $secretField => $clientSecret, + 'enabled' => false, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertSame($providerId, $update['body']['$id']); + $this->assertSame($clientId, $update['body'][$idField]); + $this->assertFalse($update['body']['enabled']); + + // GET round-trip — confirms the value actually persisted (catches a + // PATCH that only echoes input without writing) and that the secret + // is hidden on read. + $get = $this->getOAuth2Provider($providerId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($providerId, $get['body']['$id']); + $this->assertSame($clientId, $get['body'][$idField]); + $this->assertSame('', $get['body'][$secretField]); + $this->assertFalse($get['body']['enabled']); + + // Cleanup + $this->updateOAuth2($providerId, [ + $idField => '', + $secretField => '', + 'enabled' => false, + ]); + } + + /** + * For providers that rename `clientId` / `clientSecret` to a custom field + * (e.g. `apiKey`/`apiSecret`, `customerKey`/`secretKey`, `oauthClientId`), + * the renamed field replaces the default — the response model must NOT + * also expose the default name. Catches a regression where adding a + * custom param name forgets to remove the default from the response. + */ + #[DataProvider('plainProviders')] + public function testUpdateOAuth2PlainProviderResponseDoesNotLeakDefaultNames(string $providerId, string $idField, string $secretField): void + { + if ($idField === 'clientId' && $secretField === 'clientSecret') { + // Default-named provider — nothing to leak. Avoids a no-op assertion. + $this->markTestSkipped("{$providerId} uses default field names."); + } + + $update = $this->updateOAuth2($providerId, [ + $idField => $providerId . '-leak-check-id', + $secretField => $providerId . '-leak-check-secret', + 'enabled' => false, + ]); + $this->assertSame(200, $update['headers']['status-code']); + + if ($idField !== 'clientId') { + $this->assertArrayNotHasKey('clientId', $update['body'], "PATCH response for {$providerId} leaks default `clientId` despite using `{$idField}`."); + } + if ($secretField !== 'clientSecret') { + $this->assertArrayNotHasKey('clientSecret', $update['body'], "PATCH response for {$providerId} leaks default `clientSecret` despite using `{$secretField}`."); + } + + $get = $this->getOAuth2Provider($providerId); + $this->assertSame(200, $get['headers']['status-code']); + if ($idField !== 'clientId') { + $this->assertArrayNotHasKey('clientId', $get['body'], "GET response for {$providerId} leaks default `clientId` despite using `{$idField}`."); + } + if ($secretField !== 'clientSecret') { + $this->assertArrayNotHasKey('clientSecret', $get['body'], "GET response for {$providerId} leaks default `clientSecret` despite using `{$secretField}`."); + } + + // Cleanup + $this->updateOAuth2($providerId, [ + $idField => '', + $secretField => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * @param array $params + */ + protected function updateOAuth2(string $provider, array $params, bool $authenticated = true): mixed + { + $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_PATCH, + '/project/oauth2/' . $provider, + $headers, + $params, + ); + } + + protected function getOAuth2Provider(string $provider, bool $authenticated = true): mixed + { + $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_GET, + '/project/oauth2/' . $provider, + $headers, + ); + } + + protected function listOAuth2Providers(bool $authenticated = true): mixed + { + $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_GET, + '/project/oauth2', + $headers, + ); + } +} diff --git a/tests/e2e/Services/Project/OAuth2ConsoleClientTest.php b/tests/e2e/Services/Project/OAuth2ConsoleClientTest.php new file mode 100644 index 0000000000..b5654ffe4b --- /dev/null +++ b/tests/e2e/Services/Project/OAuth2ConsoleClientTest.php @@ -0,0 +1,14 @@ +markTestSkipped('GitHub OAuth2 credentials not configured (_TESTS_OAUTH2_GITHUB_CLIENT_ID, _TESTS_OAUTH2_GITHUB_CLIENT_SECRET)'); + } + + $consoleHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ]; + + // Step 1: Create new organization (team) + $team = $this->client->call(Client::METHOD_POST, '/teams', $consoleHeaders, [ + 'teamId' => ID::unique(), + 'name' => 'GitHub OAuth Org ' . uniqid(), + ]); + $this->assertSame(201, $team['headers']['status-code']); + $teamId = $team['body']['$id']; + + // Step 2: Create new project + $project = $this->client->call(Client::METHOD_POST, '/projects', $consoleHeaders, [ + 'projectId' => 'githuboauthapp', // Must be this ID, its used in redirect URL set in GitHub app configuration + 'name' => 'GitHub OAuth Project', + 'teamId' => $teamId, + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + $this->assertSame(201, $project['headers']['status-code']); + $newProjectId = $project['body']['$id']; + + // Step 3: Configure GitHub provider on the new project via PATCH /v1/project/oauth2/github + $newProjectAdminHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => $newProjectId, + 'x-appwrite-mode' => 'admin', + ]; + + $configResponse = $this->client->call(Client::METHOD_PATCH, '/project/oauth2/github', $newProjectAdminHeaders, [ + 'clientId' => $clientId, + 'clientSecret' => $clientSecret, + 'enabled' => true, + ]); + $this->assertSame(200, $configResponse['headers']['status-code']); + $this->assertTrue($configResponse['body']['enabled']); + $this->assertSame($clientId, $configResponse['body']['clientId']); + + // Step 4: Verify OAuth provider is enabled via GET /v1/projects/:projectId + $projectDetails = $this->client->call(Client::METHOD_GET, '/projects/' . $newProjectId, $consoleHeaders); + $this->assertSame(200, $projectDetails['headers']['status-code']); + + $githubProvider = null; + foreach ($projectDetails['body']['oAuthProviders'] as $provider) { + if ($provider['key'] === 'github') { + $githubProvider = $provider; + break; + } + } + $this->assertNotNull($githubProvider, 'GitHub OAuth provider not found in project details'); + $this->assertTrue($githubProvider['enabled']); + $this->assertSame($clientId, $githubProvider['appId']); + $this->assertSame('', $githubProvider['secret']); // Write only + + // Step 5: Without client headers (no API key), go through the OAuth flow + $clientHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $newProjectId, + ]; + + $oauthInit = $this->client->call( + Client::METHOD_GET, + '/account/sessions/oauth2/github', + $clientHeaders, + [ + 'success' => 'http://localhost:4000/success', + 'failure' => 'http://localhost:4000/failure', + ], + followRedirects: false + ); + + $this->assertSame(301, $oauthInit['headers']['status-code']); + $this->assertArrayHasKey('location', $oauthInit['headers']); + $this->assertStringStartsWith('https://github.com/login/oauth/authorize', $oauthInit['headers']['location']); + $this->assertStringContainsString('client_id=' . \urlencode($clientId), $oauthInit['headers']['location']); + $this->assertStringContainsString('redirect_uri=', $oauthInit['headers']['location']); + + // Follow the redirect to GitHub's authorization endpoint. With a real user agent, GitHub + // would prompt for login + app approval, then redirect back to Appwrite's callback with a + // valid `code`. Appwrite would then exchange the code, create the session and redirect to + // the success URL with the session cookie set. + $oauthClient = new Client(); + $oauthClient->setEndpoint(''); + + $githubResponse = $oauthClient->call( + Client::METHOD_GET, + $oauthInit['headers']['location'], + [], + [], + decode: false, + followRedirects: false + ); + + // GitHub returns 200 (login HTML) or 302 (redirect to login) — both indicate the flow + // reached GitHub. Anything else means our redirect is malformed. + $this->assertContains($githubResponse['headers']['status-code'], [200, 302]); + + // Cleanup: delete the project + $deleteProject = $this->client->call(Client::METHOD_DELETE, '/projects/' . $newProjectId, $consoleHeaders); + $this->assertSame(204, $deleteProject['headers']['status-code']); + + // Cleanup: delete the organization (team) + $deleteTeam = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, $consoleHeaders); + $this->assertSame(204, $deleteTeam['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index f88db41e8c..6936de9aff 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1634,7 +1634,7 @@ class ProjectsConsoleClientTest extends Scope foreach ($response['body']['oAuthProviders'] as $responseProvider) { if ($responseProvider['key'] === $key) { $this->assertEquals('AppId-' . ucfirst($key), $responseProvider['appId']); - $this->assertEquals('Secret-' . ucfirst($key), $responseProvider['secret']); + $this->assertEmpty($responseProvider['secret']); $this->assertFalse($responseProvider['enabled']); $asserted = true; break; @@ -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 ec174b8657..eb1a06d50a 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']); @@ -2893,7 +3021,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); @@ -2935,7 +3063,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']); @@ -2950,7 +3078,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/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 3255d9a67f..b06e2d88e1 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2708,6 +2708,102 @@ trait UsersBase $this->assertIsArray($response['body']['users']); } + /** + * Test impersonation via URL query params — mirrors the ?project= and ?devKey= pattern. + * Allows Console to embed impersonation in direct file/image URLs where headers cannot be set. + */ + public function testImpersonateByQueryParams(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $emailA = 'queryparam-impersonator-' . \uniqid() . '@appwrite.io'; + $emailB = 'queryparam-target-' . \uniqid() . '@appwrite.io'; + $emailC = 'queryparam-target-c-' . \uniqid() . '@appwrite.io'; + $phone = '+1' . \rand(1000000000, 9999999999); + + $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => $emailA, + 'password' => 'password', + 'name' => 'Query Param Impersonator', + ]); + $this->assertEquals(201, $userA['headers']['status-code']); + $idA = $userA['body']['$id']; + + $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => $emailB, + 'password' => 'password', + 'name' => 'Query Param Target', + ]); + $this->assertEquals(201, $userB['headers']['status-code']); + $idB = $userB['body']['$id']; + + $patch = $this->client->call(Client::METHOD_PATCH, '/users/' . $idA . '/impersonator', $headers, ['impersonator' => true]); + $this->assertEquals(200, $patch['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); + $this->assertEquals(201, $session['headers']['status-code']); + $sessionSecret = $session['body']['secret']; + + $sessionHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $sessionSecret, + ]; + + // Impersonate by user ID via query param + $account = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [ + 'impersonateUserId' => $idB, + ]); + $this->assertEquals(200, $account['headers']['status-code']); + $this->assertEquals($idB, $account['body']['$id']); + $this->assertEquals('Query Param Target', $account['body']['name']); + $this->assertEquals($idA, $account['body']['impersonatorUserId']); + + // Impersonate by email via query param + $accountByEmail = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [ + 'impersonateEmail' => $emailB, + ]); + $this->assertEquals(200, $accountByEmail['headers']['status-code']); + $this->assertEquals($idB, $accountByEmail['body']['$id']); + $this->assertEquals($idA, $accountByEmail['body']['impersonatorUserId']); + + // Impersonate by phone via query param (update target user with a phone first) + $this->client->call(Client::METHOD_PATCH, '/users/' . $idB . '/phone', $headers, [ + 'number' => $phone, + ]); + $accountByPhone = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [ + 'impersonatePhone' => $phone, + ]); + $this->assertEquals(200, $accountByPhone['headers']['status-code']); + $this->assertEquals($idB, $accountByPhone['body']['$id']); + $this->assertEquals($idA, $accountByPhone['body']['impersonatorUserId']); + + // Header takes priority over query param when both are present + $userC = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => $emailC, + 'password' => 'password', + 'name' => 'Query Param Target C', + ]); + $this->assertEquals(201, $userC['headers']['status-code']); + $idC = $userC['body']['$id']; + + $accountHeaderPriority = $this->client->call( + Client::METHOD_GET, + '/account', + array_merge($sessionHeaders, ['x-appwrite-impersonate-user-id' => $idC]), + ['impersonateUserId' => $idB] + ); + $this->assertEquals(200, $accountHeaderPriority['headers']['status-code']); + $this->assertEquals($idC, $accountHeaderPriority['body']['$id'], 'header must take priority over query param'); + } + /** * Test PATCH /users/:userId/impersonator for non-existent user returns 404 */ 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"); + } + } } diff --git a/tests/unit/Utopia/RequestTest.php b/tests/unit/Utopia/RequestTest.php index 81e0ead4b3..57ebae6d1e 100644 --- a/tests/unit/Utopia/RequestTest.php +++ b/tests/unit/Utopia/RequestTest.php @@ -161,6 +161,37 @@ class RequestTest extends TestCase $this->assertSame($secondRoute, $secondRequest->getRoute()); } + public function testGetHeaderReturnsStringValue(): void + { + $this->request->addHeader('referer', 'https://example.com'); + + $this->assertSame('https://example.com', $this->request->getHeader('referer')); + } + + public function testGetHeaderReturnsDefaultWhenMissing(): void + { + $this->assertSame('', $this->request->getHeader('referer')); + $this->assertSame('fallback', $this->request->getHeader('referer', 'fallback')); + } + + public function testGetHeaderCoercesArrayToFirstElement(): void + { + $swoole = new SwooleRequest(); + $swoole->header = ['referer' => ['https://a.example', 'https://b.example']]; + $request = new Request($swoole); + + $this->assertSame('https://a.example', $request->getHeader('referer')); + } + + public function testGetHeaderReturnsDefaultWhenValueNotString(): void + { + $swoole = new SwooleRequest(); + $swoole->header = ['referer' => 123]; + $request = new Request($swoole); + + $this->assertSame('fallback', $request->getHeader('referer', 'fallback')); + } + /** * Helper to attach a route with multiple SDK methods to the request. */