diff --git a/.github/workflows/ai-moderator.yml b/.github/workflows/ai-moderator.yml index 483f3dbeee..948fa6c0c1 100644 --- a/.github/workflows/ai-moderator.yml +++ b/.github/workflows/ai-moderator.yml @@ -25,6 +25,6 @@ jobs: runs-on: ubuntu-latest steps: - name: AI Moderator - uses: github/ai-moderator@v1 + uses: github/ai-moderator@81159c370785e295c97461ade67d7c33576e9319 # v1.1.4 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/auto-label-issue.yml b/.github/workflows/auto-label-issue.yml index e0eb0de98d..0151c2f9c1 100644 --- a/.github/workflows/auto-label-issue.yml +++ b/.github/workflows/auto-label-issue.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Issue Labeler - uses: github/issue-labeler@v3.4 + uses: github/issue-labeler@c1b0f9f52a63158c4adc09425e858e87b32e9685 # v3.4 with: configuration-path: .github/labeler.yml enable-versioned-regex: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b57cbc209a..8069db5e6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: actions: read security-events: write contents: read - uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@v2.3.3" + uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5 security: name: Checks / Image @@ -43,13 +43,13 @@ jobs: security-events: write steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 submodules: 'recursive' - name: Build the Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . push: false @@ -58,7 +58,7 @@ jobs: target: production - name: Run Trivy vulnerability scanner on image - uses: aquasecurity/trivy-action@0.35.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: 'pr_image:${{ github.sha }}' format: 'sarif' @@ -66,7 +66,7 @@ jobs: severity: 'CRITICAL,HIGH' - name: Run Trivy vulnerability scanner on source code - uses: aquasecurity/trivy-action@0.35.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: 'fs' scan-ref: '.' @@ -76,14 +76,14 @@ jobs: skip-setup-trivy: true - name: Upload image scan results - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 if: always() && hashFiles('trivy-image-results.sarif') != '' with: sarif_file: 'trivy-image-results.sarif' category: 'trivy-image' - name: Upload source code scan results - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 if: always() && hashFiles('trivy-fs-results.sarif') != '' with: sarif_file: 'trivy-fs-results.sarif' @@ -94,10 +94,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: '8.3' tools: composer:v2 @@ -119,7 +119,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 2 @@ -127,7 +127,7 @@ jobs: if: github.event_name == 'pull_request' - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: '8.3' tools: composer:v2 @@ -144,10 +144,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out the repo - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: '8.3' tools: composer:v2 @@ -157,7 +157,7 @@ jobs: run: composer install --prefer-dist --no-progress --ignore-platform-reqs - name: Cache PHPStan result cache - uses: actions/cache@v4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .phpstan-cache key: phpstan-${{ github.sha }} @@ -172,10 +172,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out the repo - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: '8.3' extensions: swoole @@ -193,10 +193,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out the repo - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24' @@ -212,7 +212,7 @@ jobs: steps: - name: Generate matrix id: generate - uses: actions/github-script@v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const allDatabases = ['MariaDB', 'PostgreSQL', 'MongoDB']; @@ -253,28 +253,28 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: recursive - name: Login to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Build and push Appwrite - uses: docker/build-push-action@v6 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . push: true @@ -297,16 +297,16 @@ jobs: packages: read steps: - name: checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Login to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -327,7 +327,7 @@ jobs: run: docker compose exec -T appwrite vars - name: Run Unit Tests - uses: itznotabug/php-retry@v3 + uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3 with: max_attempts: 2 retry_wait_seconds: 60 @@ -350,16 +350,16 @@ jobs: packages: read steps: - name: checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Login to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -385,7 +385,7 @@ jobs: done - name: Run General Tests - uses: itznotabug/php-retry@v3 + uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3 with: max_attempts: 2 retry_wait_seconds: 60 @@ -446,26 +446,26 @@ jobs: ] include: - service: Databases - runner: blacksmith-4vcpu-ubuntu-2404 + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/volume=120g paratest_processes: 3 timeout_minutes: 30 - service: Sites - runner: blacksmith-4vcpu-ubuntu-2404 + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/volume=120g - service: Functions - runner: blacksmith-4vcpu-ubuntu-2404 + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/volume=120g - service: Avatars - runner: blacksmith-4vcpu-ubuntu-2404 + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/volume=120g - service: Realtime - runner: blacksmith-4vcpu-ubuntu-2404 + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/volume=120g - service: TablesDB - runner: blacksmith-4vcpu-ubuntu-2404 + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/volume=120g paratest_processes: 3 timeout_minutes: 30 - service: Migrations paratest_processes: 1 steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set environment run: | @@ -489,13 +489,13 @@ jobs: fi - name: Login to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -526,7 +526,7 @@ jobs: done - name: Run tests - uses: itznotabug/php-retry@v3 + uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3 with: max_attempts: 2 retry_wait_seconds: 60 @@ -574,18 +574,18 @@ jobs: mode: ${{ fromJSON(needs.matrix.outputs.modes) }} steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - name: Login to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -608,7 +608,7 @@ jobs: docker compose up -d --quiet-pull --wait - name: Run tests - uses: itznotabug/php-retry@v3 + uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3 with: max_attempts: 2 retry_wait_seconds: 60 @@ -641,16 +641,16 @@ jobs: mode: ${{ fromJSON(needs.matrix.outputs.modes) }} steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Login to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -680,7 +680,7 @@ jobs: done - name: Run tests - uses: itznotabug/php-retry@v3 + uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3 with: max_attempts: 2 retry_wait_seconds: 60 @@ -712,18 +712,18 @@ jobs: packages: read steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - name: Login to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -736,7 +736,7 @@ jobs: docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}:after - name: Setup k6 - uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 + uses: grafana/setup-k6-action@db07bd9765aac508ef18982e52ab937fe633a065 # v1.2.1 with: k6-version: ${{ env.K6_VERSION }} @@ -775,7 +775,7 @@ jobs: - name: Benchmark before if: steps.benchmark_before_start.outcome == 'success' continue-on-error: true - uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d + uses: grafana/run-k6-action@de51a7390bdf0ac85a3bef493691bd71d4c7c158 # v1.4.0 env: APPWRITE_ENDPOINT: 'http://localhost/v1' APPWRITE_BENCHMARK_ITERATIONS: '5' @@ -827,7 +827,7 @@ jobs: - name: Benchmark after id: benchmark_after continue-on-error: true - uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d + uses: grafana/run-k6-action@de51a7390bdf0ac85a3bef493691bd71d4c7c158 # v1.4.0 env: APPWRITE_ENDPOINT: 'http://localhost/v1' APPWRITE_BENCHMARK_ITERATIONS: '5' @@ -847,7 +847,7 @@ jobs: - name: Comment on PR if: always() - uses: actions/github-script@v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: BENCHMARK_BASE_REF: ${{ github.event.pull_request.base.ref }} BENCHMARK_HEAD_REF: ${{ github.event.pull_request.head.ref }} @@ -857,7 +857,7 @@ jobs: await comment({ github, context, core }); - name: Save results - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ !cancelled() }} with: name: benchmark-results diff --git a/.github/workflows/cleanup-cache.yml b/.github/workflows/cleanup-cache.yml index 4b6b13d35d..e4f28816be 100644 --- a/.github/workflows/cleanup-cache.yml +++ b/.github/workflows/cleanup-cache.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Cleanup run: | diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7edfde0aae..cb9b09b496 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -34,7 +34,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. @@ -47,14 +47,14 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 with: languages: ${{ matrix.language }} # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -68,4 +68,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index c4289678bb..0a49f658ac 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -10,13 +10,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: recursive - name: Build the Docker image run: DOCKER_BUILDKIT=1 docker build . --target production -t appwrite_image:latest - name: Run Trivy vulnerability scanner on image - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: 'appwrite_image:latest' format: 'sarif' @@ -24,7 +24,7 @@ jobs: ignore-unfixed: 'false' severity: 'CRITICAL,HIGH' - name: Upload Docker Image Scan Results - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 if: always() && hashFiles('trivy-image-results.sarif') != '' with: sarif_file: 'trivy-image-results.sarif' @@ -35,16 +35,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Run Trivy vulnerability scanner on filesystem - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: 'fs' format: 'sarif' output: 'trivy-fs-results.sarif' severity: 'CRITICAL,HIGH' - name: Upload Code Scan Results - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 if: always() && hashFiles('trivy-fs-results.sarif') != '' with: sarif_file: 'trivy-fs-results.sarif' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 692861d44d..68ab657213 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,33 +12,33 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 2 submodules: recursive - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: appwrite/cloud tags: | type=ref,event=tag - name: Build & Publish to DockerHub - uses: docker/build-push-action@v6 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84fc4c9fba..ed4e46d811 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. @@ -20,20 +20,20 @@ jobs: submodules: recursive - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: appwrite/appwrite tags: | @@ -42,7 +42,7 @@ jobs: type=semver,pattern={{major}} - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/sdk-preview.yml b/.github/workflows/sdk-preview.yml index f81346a7d1..dacc37a64a 100644 --- a/.github/workflows/sdk-preview.yml +++ b/.github/workflows/sdk-preview.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set SDK type id: set-sdk @@ -49,7 +49,7 @@ jobs: docker compose exec appwrite sdks --platform=${{ steps.set-sdk.outputs.platform }} --sdk=${{ steps.set-sdk.outputs.sdk_type }} --version=latest --git=no sudo chown -R $USER:$USER ./app/sdks/${{ steps.set-sdk.outputs.platform }}-${{ steps.set-sdk.outputs.sdk_type }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 20 diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml index 6f377354d5..85c76bacd3 100644 --- a/.github/workflows/specs.yml +++ b/.github/workflows/specs.yml @@ -31,7 +31,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: recursive diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 6e4a8ba73b..73b767aafe 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/stale@v10 + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: "This issue has been labeled as a 'question', indicating that it requires additional information from the requestor. It has been inactive for 7 days. If no further activity occurs, this issue will be closed in 14 days." diff --git a/app/config/errors.php b/app/config/errors.php index 209783a290..500db6b8c5 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -623,6 +623,11 @@ return [ 'description' => 'Synchronous function execution timed out. Use asynchronous execution instead, or ensure the execution duration doesn\'t exceed 30 seconds.', 'code' => 408, ], + Exception::FUNCTION_ASYNCHRONOUS_TIMEOUT => [ + 'name' => Exception::FUNCTION_ASYNCHRONOUS_TIMEOUT, + 'description' => 'Asynchronous function execution timed out. Ensure the execution duration doesn\'t exceed the configured function timeout.', + 'code' => 408, + ], Exception::FUNCTION_TEMPLATE_NOT_FOUND => [ 'name' => Exception::FUNCTION_TEMPLATE_NOT_FOUND, 'description' => 'Function Template with the requested ID could not be found.', @@ -687,6 +692,11 @@ return [ 'description' => 'Build with the requested ID failed. Please check the logs for more information.', 'code' => 400, ], + Exception::BUILD_TIMEOUT => [ + 'name' => Exception::BUILD_TIMEOUT, + 'description' => 'Build timed out. Increase the build timeout via the `_APP_COMPUTE_BUILD_TIMEOUT` environment variable, or simplify the build to complete within the limit.', + 'code' => 408, + ], /** Deployments */ Exception::DEPLOYMENT_NOT_FOUND => [ @@ -1236,6 +1246,26 @@ return [ 'description' => 'The specified database type is not supported for CSV import or export operations.', 'code' => 400, ], + Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED => [ + 'name' => Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED, + 'description' => 'A source projectId is required for Appwrite migrations. Provide it in the migration credentials.', + 'code' => 400, + ], + Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND => [ + 'name' => Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND, + 'description' => 'The source project for the provided projectId was not found. Verify the projectId and the API key has access to it.', + 'code' => 404, + ], + Exception::MIGRATION_SOURCE_TYPE_INVALID => [ + 'name' => Exception::MIGRATION_SOURCE_TYPE_INVALID, + 'description' => 'The migration source type is invalid. Use one of the supported source types.', + 'code' => 400, + ], + Exception::MIGRATION_DESTINATION_TYPE_INVALID => [ + 'name' => Exception::MIGRATION_DESTINATION_TYPE_INVALID, + 'description' => 'The migration destination type is invalid. Use one of the supported destination types.', + 'code' => 400, + ], /** Realtime */ Exception::REALTIME_MESSAGE_FORMAT_INVALID => [ diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index c6a5fd6f97..ac07efc72b 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -332,15 +332,15 @@ Http::post('/v1/account') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { + if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { + if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { + if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -830,11 +830,11 @@ Http::patch('/v1/account/sessions/:sessionId') $refreshToken = $session->getAttribute('providerRefreshToken', ''); $oAuthProviders = Config::getParam('oAuthProviders') ?? []; $className = $oAuthProviders[$provider]['class'] ?? null; - if (!empty($provider) && ($className === null || !\class_exists($className))) { + if (!empty($refreshToken) && ($className === null || !\class_exists($className))) { throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED); } - if (!empty($provider) && \class_exists($className)) { + if ($className !== null && \class_exists($className)) { $appId = $project->getAttribute('oAuthProviders', [])[$provider . 'Appid'] ?? ''; $appSecret = $project->getAttribute('oAuthProviders', [])[$provider . 'Secret'] ?? '{}'; @@ -1676,15 +1676,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') $failureRedirect(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { + if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { $failureRedirect(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { + if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { $failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { + if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { $failureRedirect(Exception::USER_EMAIL_FREE); } @@ -1817,15 +1817,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') $failureRedirect(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { + if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { $failureRedirect(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { + if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { $failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { + if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { $failureRedirect(Exception::USER_EMAIL_FREE); } @@ -2175,15 +2175,15 @@ Http::post('/v1/account/tokens/magic-url') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { + if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { + if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { + if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -2496,15 +2496,15 @@ Http::post('/v1/account/tokens/email') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { + if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { + if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { + if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -3417,15 +3417,15 @@ Http::patch('/v1/account/email') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { + if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { + if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { + if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index abcecac396..3f52069609 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -131,15 +131,15 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor } catch (\Throwable) { } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -1563,15 +1563,15 @@ Http::patch('/v1/users/:userId/email') } catch (\Throwable) { } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { throw new Exception(Exception::USER_EMAIL_FREE); } diff --git a/app/controllers/general.php b/app/controllers/general.php index eb4899a3d8..bc63d200d7 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -28,6 +28,7 @@ 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\Request\Filters\V25 as RequestV25; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Filters\V16 as ResponseV16; use Appwrite\Utopia\Response\Filters\V17 as ResponseV17; @@ -38,7 +39,9 @@ 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\Response\Filters\V25 as ResponseV25; use Appwrite\Utopia\View; +use Executor\Exception\Timeout as ExecutorTimeout; use Executor\Executor; use MaxMind\Db\Reader; use Swoole\Http\Request as SwooleRequest; @@ -579,26 +582,30 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S 'site' => '', }; - $executionResponse = $executor->createExecution( - projectId: $project->getId(), - deploymentId: $deployment->getId(), - body: \strlen($body) > 0 ? $body : null, - variables: $vars, - timeout: $resource->getAttribute('timeout', 30), - image: $runtime['image'], - source: $source, - entrypoint: $entrypoint, - version: $version, - path: $path, - method: $method, - headers: $headers, - runtimeEntrypoint: $runtimeEntrypoint, - cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT, - memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, - logging: $resource->getAttribute('logging', true), - requestTimeout: 30, - responseFormat: Executor::RESPONSE_FORMAT_ARRAY_HEADERS - ); + try { + $executionResponse = $executor->createExecution( + projectId: $project->getId(), + deploymentId: $deployment->getId(), + body: \strlen($body) > 0 ? $body : null, + variables: $vars, + timeout: $resource->getAttribute('timeout', 30), + image: $runtime['image'], + source: $source, + entrypoint: $entrypoint, + version: $version, + path: $path, + method: $method, + headers: $headers, + runtimeEntrypoint: $runtimeEntrypoint, + cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT, + memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, + logging: $resource->getAttribute('logging', true), + requestTimeout: 30, + responseFormat: Executor::RESPONSE_FORMAT_ARRAY_HEADERS + ); + } catch (ExecutorTimeout $th) { + throw new AppwriteException(AppwriteException::FUNCTION_SYNCHRONOUS_TIMEOUT, previous: $th); + } $headerOverrides = []; @@ -904,6 +911,9 @@ Http::init() if (version_compare($requestFormat, '1.9.3', '<')) { $request->addFilter(new RequestV24()); } + if (version_compare($requestFormat, '1.9.4', '<')) { + $request->addFilter(new RequestV25()); + } } $localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', '')); @@ -928,6 +938,9 @@ Http::init() */ $responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); if ($responseFormat) { + if (version_compare($responseFormat, '1.9.4', '<')) { + $response->addFilter(new ResponseV25()); + } if (version_compare($responseFormat, '1.9.3', '<')) { $response->addFilter(new ResponseV24()); } diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index c9e4f8b47d..14ffdc059f 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -3,7 +3,6 @@ use Appwrite\Auth\Key; use Appwrite\Auth\MFA\Type\TOTP; use Appwrite\Bus\Events\RequestCompleted; -use Appwrite\Event\Build; use Appwrite\Event\Context\Audit as AuditContext; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; @@ -20,6 +19,8 @@ use Appwrite\Event\Webhook; use Appwrite\Extend\Exception; use Appwrite\Extend\Exception as AppwriteException; use Appwrite\Functions\EventProcessor; +use Appwrite\Platform\Modules\Storage\Config\CacheControl; +use Appwrite\Platform\Modules\Storage\Config\StorageCacheControl; use Appwrite\SDK\Method; use Appwrite\Usage\Context; use Appwrite\Utopia\Database\Documents\User; @@ -489,7 +490,6 @@ Http::init() ->inject('auditContext') ->inject('queueForDeletes') ->inject('queueForDatabase') - ->inject('queueForBuilds') ->inject('usage') ->inject('queueForFunctions') ->inject('queueForMails') @@ -503,7 +503,8 @@ Http::init() ->inject('telemetry') ->inject('platform') ->inject('authorization') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) { + ->inject('cacheControlForStorage') + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization, callable $cacheControlForStorage) { $response->setUser($user); $request->setUser($user); @@ -618,12 +619,10 @@ Http::init() $queueForDatabase->setProject($project); $queueForMessaging->setProject($project); $queueForFunctions->setProject($project); - $queueForBuilds->setProject($project); $queueForMails->setProject($project); /* Auto-set platforms */ $queueForFunctions->setPlatform($platform); - $queueForBuilds->setPlatform($platform); $queueForMails->setPlatform($platform); $useCache = $route->getLabel('cache', false); @@ -643,6 +642,7 @@ Http::init() $data = $cache->load($key, $timestamp); if (! empty($data) && ! $cacheLog->isEmpty()) { + $cacheControl = \sprintf('private, max-age=%d', $timestamp); $parts = explode('/', $cacheLog->getAttribute('resourceType', '')); $type = $parts[0]; @@ -695,6 +695,21 @@ Http::init() ]))); } } + + if ($isImageTransformation) { + $cacheControl = $cacheControlForStorage(new StorageCacheControl( + source: CacheControl::SOURCE_CACHE, + user: $user, + maxAge: $timestamp, + project: $project, + bucket: $bucket, + file: $file, + resourceToken: $resourceToken, + fileSecurity: $fileSecurity, + cacheLog: $cacheLog, + route: $route, + )); + } } $accessedAt = $cacheLog->getAttribute('accessedAt', ''); @@ -707,7 +722,7 @@ Http::init() } $response - ->addHeader('Cache-Control', sprintf('private, max-age=%d', $timestamp)) + ->addHeader('Cache-Control', $cacheControl) ->addHeader('X-Appwrite-Cache', 'hit') ->setContentType($cacheLog->getAttribute('mimeType')); $storageCacheOperationsCounter->add(1, ['result' => 'hit']); @@ -800,7 +815,6 @@ Http::shutdown() ->inject('publisherForUsage') ->inject('queueForDeletes') ->inject('queueForDatabase') - ->inject('queueForBuilds') ->inject('queueForMessaging') ->inject('queueForFunctions') ->inject('queueForWebhooks') @@ -812,7 +826,7 @@ Http::shutdown() ->inject('bus') ->inject('apiKey') ->inject('mode') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { $responsePayload = $response->getPayload(); @@ -961,10 +975,6 @@ Http::shutdown() $queueForDatabase->trigger(); } - if (! empty($queueForBuilds->getType())) { - $queueForBuilds->trigger(); - } - if (! empty($queueForMessaging->getType())) { $queueForMessaging->trigger(); } diff --git a/app/init/constants.php b/app/init/constants.php index 57e355992c..8c570b1865 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -51,7 +51,7 @@ 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 = 4327; -const APP_VERSION_STABLE = '1.9.3'; +const APP_VERSION_STABLE = '1.9.4'; const APP_DATABASE_ATTRIBUTE_EMAIL = 'email'; const APP_DATABASE_ATTRIBUTE_ENUM = 'enum'; const APP_DATABASE_ATTRIBUTE_IP = 'ip'; diff --git a/app/init/resources.php b/app/init/resources.php index 96457294de..c5d034a125 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -2,12 +2,14 @@ use Appwrite\Event\Event; use Appwrite\Event\Publisher\Audit as AuditPublisher; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Event\Publisher\Certificate as CertificatePublisher; use Appwrite\Event\Publisher\Execution as ExecutionPublisher; use Appwrite\Event\Publisher\Migration as MigrationPublisher; use Appwrite\Event\Publisher\Screenshot as ScreenshotPublisher; use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; +use Appwrite\Platform\Modules\Storage\Config\StorageCacheControl; use Appwrite\Utopia\Database\Documents\User; use Executor\Executor; use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis; @@ -112,6 +114,10 @@ $container->set('publisherForStatsResources', fn (Publisher $publisher) => new S $publisher, new Queue(System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME)) ), ['publisher']); +$container->set('publisherForBuilds', fn (Publisher $publisher) => new BuildPublisher( + $publisher, + new Queue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME)) +), ['publisher']); /** * Platform configuration @@ -198,6 +204,10 @@ $container->set('cache', function (Group $pools, Telemetry $telemetry) { return $cache; }, ['pools', 'telemetry']); +$container->set('cacheControlForStorage', fn () => function (StorageCacheControl $config): string { + return \sprintf('private, max-age=%d', $config->maxAge); +}); + $container->set('redis', function () { $host = System::getEnv('_APP_REDIS_HOST', 'localhost'); $port = System::getEnv('_APP_REDIS_PORT', 6379); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 70d691370d..9fd282c4ed 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -4,7 +4,6 @@ use Ahc\Jwt\JWT; use Ahc\Jwt\JWTException; use Appwrite\Auth\Key; use Appwrite\Databases\TransactionState; -use Appwrite\Event\Build; use Appwrite\Event\Context\Audit as AuditContext; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; @@ -123,9 +122,6 @@ return function (Container $container): void { $container->set('queueForMails', function (Publisher $publisher) { return new Mail($publisher); }, ['publisher']); - $container->set('queueForBuilds', function (Publisher $publisher) { - return new Build($publisher); - }, ['publisher']); $container->set('queueForDatabase', function (Publisher $publisher) { return new EventDatabase($publisher); }, ['publisher']); diff --git a/app/init/worker/message.php b/app/init/worker/message.php index 17796fadcd..1469934ad4 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -1,6 +1,5 @@ set('queueForBuilds', function (Publisher $publisher) { - return new Build($publisher); - }, ['publisher']); - $container->set('queueForDeletes', function (Publisher $publisher) { return new Delete($publisher); }, ['publisher']); diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index 1bf36b7f6d..6ce1fb5cea 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -881,6 +881,13 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_ENV - _APP_WORKER_PER_CORE - _APP_OPENSSL_KEY_V1 + - _APP_OPTIONS_FORCE_HTTPS + - _APP_DOMAIN + - _APP_CONSOLE_DOMAIN + - _APP_DOMAIN_FUNCTIONS + - _APP_DOMAIN_SITES + - _APP_MIGRATION_HOST + - _APP_CONSOLE_SCHEMA - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -909,6 +916,13 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_ENV - _APP_WORKER_PER_CORE - _APP_OPENSSL_KEY_V1 + - _APP_OPTIONS_FORCE_HTTPS + - _APP_DOMAIN + - _APP_CONSOLE_DOMAIN + - _APP_DOMAIN_FUNCTIONS + - _APP_DOMAIN_SITES + - _APP_MIGRATION_HOST + - _APP_CONSOLE_SCHEMA - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER diff --git a/composer.json b/composer.json index 9a84be6111..55106d33d1 100644 --- a/composer.json +++ b/composer.json @@ -68,11 +68,11 @@ "utopia-php/dns": "1.6.*", "utopia-php/dsn": "0.2.1", "utopia-php/http": "0.34.*", - "utopia-php/fetch": "0.5.*", + "utopia-php/fetch": "^1.1", "utopia-php/validators": "0.2.*", "utopia-php/image": "0.8.*", "utopia-php/locale": "0.8.*", - "utopia-php/logger": "0.6.*", + "utopia-php/logger": "0.8.*", "utopia-php/messaging": "0.22.*", "utopia-php/migration": "1.*", "utopia-php/platform": "0.13.*", diff --git a/composer.lock b/composer.lock index bbf0d59a96..f7b443b135 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": "ec2ad489c60f0102f0dfab223b6d1fe4", + "content-hash": "4ef65b015dba97e91f6571b061787653", "packages": [ { "name": "adhocore/jwt", @@ -2641,16 +2641,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -2663,7 +2663,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -2688,7 +2688,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -2699,12 +2699,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/http-client", @@ -2809,16 +2813,16 @@ }, { "name": "symfony/http-client-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "75d7043853a42837e68111812f4d964b01e5101c" + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", - "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", "shasum": "" }, "require": { @@ -2831,7 +2835,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -2867,7 +2871,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" }, "funding": [ { @@ -2878,12 +2882,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-29T11:18:49+00:00" + "time": "2026-03-06T13:17:50+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -3212,16 +3220,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -3239,7 +3247,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -3275,7 +3283,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -3295,7 +3303,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "tbachert/spi", @@ -3403,21 +3411,21 @@ }, { "name": "utopia-php/agents", - "version": "1.2.1", + "version": "1.2.2", "source": { "type": "git", "url": "https://github.com/utopia-php/agents.git", - "reference": "052227953678a30ecc4b5467401fcb0b2386471e" + "reference": "0703f4cae02261e09a1bf0d39a4b1ce649cae634" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/agents/zipball/052227953678a30ecc4b5467401fcb0b2386471e", - "reference": "052227953678a30ecc4b5467401fcb0b2386471e", + "url": "https://api.github.com/repos/utopia-php/agents/zipball/0703f4cae02261e09a1bf0d39a4b1ce649cae634", + "reference": "0703f4cae02261e09a1bf0d39a4b1ce649cae634", "shasum": "" }, "require": { "php": ">=8.3", - "utopia-php/fetch": "0.5.*" + "utopia-php/fetch": "^1.1.0" }, "require-dev": { "laravel/pint": "^1.18", @@ -3450,9 +3458,9 @@ ], "support": { "issues": "https://github.com/utopia-php/agents/issues", - "source": "https://github.com/utopia-php/agents/tree/1.2.1" + "source": "https://github.com/utopia-php/agents/tree/1.2.2" }, - "time": "2026-02-24T06:03:55+00:00" + "time": "2026-05-08T10:38:23+00:00" }, { "name": "utopia-php/analytics", @@ -3502,22 +3510,22 @@ }, { "name": "utopia-php/audit", - "version": "2.2.2", + "version": "2.2.3", "source": { "type": "git", "url": "https://github.com/utopia-php/audit.git", - "reference": "90886c202e7983999e6b6a8201004d5ab61d4b57" + "reference": "95e9961fa286d2fdb6bf3eaa198f21d51bf58d9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/audit/zipball/90886c202e7983999e6b6a8201004d5ab61d4b57", - "reference": "90886c202e7983999e6b6a8201004d5ab61d4b57", + "url": "https://api.github.com/repos/utopia-php/audit/zipball/95e9961fa286d2fdb6bf3eaa198f21d51bf58d9c", + "reference": "95e9961fa286d2fdb6bf3eaa198f21d51bf58d9c", "shasum": "" }, "require": { "php": ">=8.0", "utopia-php/database": "5.*", - "utopia-php/fetch": "0.5.*", + "utopia-php/fetch": "^1.1", "utopia-php/validators": "0.2.*" }, "require-dev": { @@ -3545,9 +3553,9 @@ ], "support": { "issues": "https://github.com/utopia-php/audit/issues", - "source": "https://github.com/utopia-php/audit/tree/2.2.2" + "source": "https://github.com/utopia-php/audit/tree/2.2.3" }, - "time": "2026-05-04T06:48:58+00:00" + "time": "2026-05-08T10:38:23+00:00" }, { "name": "utopia-php/auth", @@ -4172,22 +4180,21 @@ }, { "name": "utopia-php/emails", - "version": "0.6.9", + "version": "0.6.10", "source": { "type": "git", "url": "https://github.com/utopia-php/emails.git", - "reference": "3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf" + "reference": "2e397754ce68c2ba918564b9f31d9923c0a90429" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/emails/zipball/3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf", - "reference": "3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf", + "url": "https://api.github.com/repos/utopia-php/emails/zipball/2e397754ce68c2ba918564b9f31d9923c0a90429", + "reference": "2e397754ce68c2ba918564b9f31d9923c0a90429", "shasum": "" }, "require": { "php": ">=8.0", "utopia-php/domains": "^1.0", - "utopia-php/fetch": "^0.5", "utopia-php/validators": "0.*" }, "require-dev": { @@ -4195,7 +4202,8 @@ "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^9.3", "utopia-php/cli": "^0.22", - "utopia-php/console": "0.*" + "utopia-php/console": "0.*", + "utopia-php/fetch": "^1.1" }, "type": "library", "autoload": { @@ -4227,22 +4235,22 @@ ], "support": { "issues": "https://github.com/utopia-php/emails/issues", - "source": "https://github.com/utopia-php/emails/tree/0.6.9" + "source": "https://github.com/utopia-php/emails/tree/0.6.10" }, - "time": "2026-03-14T13:52:56+00:00" + "time": "2026-05-08T10:16:22+00:00" }, { "name": "utopia-php/fetch", - "version": "0.5.1", + "version": "1.1.2", "source": { "type": "git", "url": "https://github.com/utopia-php/fetch.git", - "reference": "a96a010e1c273f3888765449687baf58cbc61fcd" + "reference": "64f2b3a789480f1deb102ce684dac4217d8e98d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/fetch/zipball/a96a010e1c273f3888765449687baf58cbc61fcd", - "reference": "a96a010e1c273f3888765449687baf58cbc61fcd", + "url": "https://api.github.com/repos/utopia-php/fetch/zipball/64f2b3a789480f1deb102ce684dac4217d8e98d5", + "reference": "64f2b3a789480f1deb102ce684dac4217d8e98d5", "shasum": "" }, "require": { @@ -4251,7 +4259,8 @@ "require-dev": { "laravel/pint": "^1.5.0", "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^9.5", + "swoole/ide-helper": "^6.0" }, "type": "library", "autoload": { @@ -4266,9 +4275,9 @@ "description": "A simple library that provides an interface for making HTTP Requests.", "support": { "issues": "https://github.com/utopia-php/fetch/issues", - "source": "https://github.com/utopia-php/fetch/tree/0.5.1" + "source": "https://github.com/utopia-php/fetch/tree/1.1.2" }, - "time": "2025-12-18T16:25:10+00:00" + "time": "2026-04-29T11:19:19+00:00" }, { "name": "utopia-php/http", @@ -4426,20 +4435,21 @@ }, { "name": "utopia-php/logger", - "version": "0.6.2", + "version": "0.8.0", "source": { "type": "git", "url": "https://github.com/utopia-php/logger.git", - "reference": "25b5bd2ad8bb51292f76332faa7034644fd0941d" + "reference": "132236c42222cd614cb882938a48f8729ef3118b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/logger/zipball/25b5bd2ad8bb51292f76332faa7034644fd0941d", - "reference": "25b5bd2ad8bb51292f76332faa7034644fd0941d", + "url": "https://api.github.com/repos/utopia-php/logger/zipball/132236c42222cd614cb882938a48f8729ef3118b", + "reference": "132236c42222cd614cb882938a48f8729ef3118b", "shasum": "" }, "require": { - "php": ">=8.0" + "php": ">=8.1", + "utopia-php/fetch": "^1.1" }, "require-dev": { "laravel/pint": "1.2.*", @@ -4474,9 +4484,9 @@ ], "support": { "issues": "https://github.com/utopia-php/logger/issues", - "source": "https://github.com/utopia-php/logger/tree/0.6.2" + "source": "https://github.com/utopia-php/logger/tree/0.8.0" }, - "time": "2024-10-14T16:02:49+00:00" + "time": "2026-05-05T06:04:27+00:00" }, { "name": "utopia-php/messaging", @@ -4531,16 +4541,16 @@ }, { "name": "utopia-php/migration", - "version": "1.10.0", + "version": "1.10.1", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "55f4863d690e775f44fec3cae4bd1f4491fed5ea" + "reference": "759d6d61b327313cbeeeb4ea0c3e2459164b4827" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/55f4863d690e775f44fec3cae4bd1f4491fed5ea", - "reference": "55f4863d690e775f44fec3cae4bd1f4491fed5ea", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/759d6d61b327313cbeeeb4ea0c3e2459164b4827", + "reference": "759d6d61b327313cbeeeb4ea0c3e2459164b4827", "shasum": "" }, "require": { @@ -4566,7 +4576,25 @@ "Utopia\\Migration\\": "src/Migration" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "psr-4": { + "Utopia\\Tests\\": "tests/Migration" + } + }, + "scripts": { + "test": [ + "./vendor/bin/phpunit" + ], + "lint": [ + "./vendor/bin/pint --test" + ], + "format": [ + "./vendor/bin/pint" + ], + "check": [ + "./vendor/bin/phpstan analyse --level 3 src tests --memory-limit 2G" + ] + }, "license": [ "MIT" ], @@ -4579,10 +4607,10 @@ "utopia" ], "support": { - "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.10.0" + "source": "https://github.com/utopia-php/migration/tree/1.10.1", + "issues": "https://github.com/utopia-php/migration/issues" }, - "time": "2026-05-06T04:35:32+00:00" + "time": "2026-05-07T07:23:57+00:00" }, { "name": "utopia-php/mongo", @@ -5228,23 +5256,23 @@ }, { "name": "utopia-php/vcs", - "version": "3.2.0", + "version": "3.2.1", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "44a84ab52b42fc12f812b4d7331286b519d39db3" + "reference": "03ccd12b75d67d29094eb760b468fddde4b6b5e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/44a84ab52b42fc12f812b4d7331286b519d39db3", - "reference": "44a84ab52b42fc12f812b4d7331286b519d39db3", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/03ccd12b75d67d29094eb760b468fddde4b6b5e5", + "reference": "03ccd12b75d67d29094eb760b468fddde4b6b5e5", "shasum": "" }, "require": { "adhocore/jwt": "^1.1", "php": ">=8.0", "utopia-php/cache": "1.0.*", - "utopia-php/fetch": "0.5.*" + "utopia-php/fetch": "^1.1" }, "require-dev": { "laravel/pint": "1.*.*", @@ -5271,9 +5299,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/3.2.0" + "source": "https://github.com/utopia-php/vcs/tree/3.2.1" }, - "time": "2026-04-08T16:00:31+00:00" + "time": "2026-05-08T10:13:53+00:00" }, { "name": "utopia-php/websocket", diff --git a/docker-compose.yml b/docker-compose.yml index da5efac438..2ae1bf486a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1114,6 +1114,13 @@ services: - _APP_WORKER_PER_CORE - _APP_POOL_ADAPTER - _APP_OPENSSL_KEY_V1 + - _APP_OPTIONS_FORCE_HTTPS + - _APP_DOMAIN + - _APP_CONSOLE_DOMAIN + - _APP_DOMAIN_FUNCTIONS + - _APP_DOMAIN_SITES + - _APP_MIGRATION_HOST + - _APP_CONSOLE_SCHEMA - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -1145,6 +1152,13 @@ services: - _APP_WORKER_PER_CORE - _APP_POOL_ADAPTER - _APP_OPENSSL_KEY_V1 + - _APP_OPTIONS_FORCE_HTTPS + - _APP_DOMAIN + - _APP_CONSOLE_DOMAIN + - _APP_DOMAIN_FUNCTIONS + - _APP_DOMAIN_SITES + - _APP_MIGRATION_HOST + - _APP_CONSOLE_SCHEMA - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -1478,4 +1492,4 @@ volumes: appwrite-sites: appwrite-builds: appwrite-config: - appwrite-models: \ No newline at end of file + appwrite-models: diff --git a/docs/references/vcs/list-repository-branches.md b/docs/references/vcs/list-repository-branches.md index eea1795a3e..b614c2ad13 100644 --- a/docs/references/vcs/list-repository-branches.md +++ b/docs/references/vcs/list-repository-branches.md @@ -1 +1 @@ -Get a list of all branches from a GitHub repository in your installation. This endpoint returns the names of all branches in the repository and their total count. The GitHub installation must be properly configured and have access to the requested repository for this endpoint to work. +Get a list of branches from a GitHub repository in your installation. This endpoint supports filtering by a search term and pagination using query strings such as `Query.limit()`, `Query.offset()`, `Query.cursorAfter()`, and `Query.cursorBefore()`. It returns branch names along with the total number of matches. The GitHub installation must be properly configured and have access to the requested repository for this endpoint to work. diff --git a/src/Appwrite/Event/Build.php b/src/Appwrite/Event/Build.php deleted file mode 100644 index 4eaf108f15..0000000000 --- a/src/Appwrite/Event/Build.php +++ /dev/null @@ -1,146 +0,0 @@ -setQueue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME)) - ->setClass(System::getEnv('_APP_BUILDS_CLASS_NAME', Event::BUILDS_CLASS_NAME)); - } - - /** - * Sets template for the build event. - * - * @param Document $template - * @return self - */ - public function setTemplate(Document $template): self - { - $this->template = $template; - - return $this; - } - - /** - * Sets resource document for the build event. - * - * @param Document $resource - * @return self - */ - public function setResource(Document $resource): self - { - $this->resource = $resource; - - return $this; - } - - /** - * Returns set resource document for the build event. - * - * @return null|Document - */ - public function getResource(): ?Document - { - return $this->resource; - } - - /** - * Sets deployment for the build event. - * - * @param Document $deployment - * @return self - */ - public function setDeployment(Document $deployment): self - { - $this->deployment = $deployment; - - return $this; - } - - /** - * Returns set deployment for the build event. - * - * @return null|Document - */ - public function getDeployment(): ?Document - { - return $this->deployment; - } - - /** - * Sets type for the build event. - * - * @param string $type Can be `BUILD_TYPE_DEPLOYMENT` or `BUILD_TYPE_RETRY`. - * @return self - */ - public function setType(string $type): self - { - $this->type = $type; - - return $this; - } - - /** - * Returns set type for the function event. - * - * @return string - */ - public function getType(): string - { - return $this->type; - } - - /** - * Prepare payload for queue. - * - * @return array - */ - protected function preparePayload(): array - { - $platform = $this->platform; - if (empty($platform)) { - $platform = Config::getParam('platform', []); - } - - return [ - 'project' => $this->project, - 'resource' => $this->resource, - 'deployment' => $this->deployment, - 'type' => $this->type, - 'template' => $this->template, - 'platform' => $platform, - ]; - } - - /** - * Resets event. - * - * @return self - */ - public function reset(): self - { - $this->type = ''; - $this->resource = null; - $this->deployment = null; - $this->template = null; - $this->platform = []; - parent::reset(); - - return $this; - } -} diff --git a/src/Appwrite/Event/Message/Build.php b/src/Appwrite/Event/Message/Build.php new file mode 100644 index 0000000000..0c8967aff6 --- /dev/null +++ b/src/Appwrite/Event/Message/Build.php @@ -0,0 +1,45 @@ +platform) ? $this->platform : Config::getParam('platform', []); + + return [ + 'project' => $this->project->getArrayCopy(), + 'resource' => $this->resource->getArrayCopy(), + 'deployment' => $this->deployment->getArrayCopy(), + 'type' => $this->type, + 'template' => $this->template?->getArrayCopy(), + 'platform' => $platform, + ]; + } + + public static function fromArray(array $data): static + { + return new self( + project: new Document($data['project'] ?? []), + resource: new Document($data['resource'] ?? []), + deployment: new Document($data['deployment'] ?? []), + type: $data['type'] ?? '', + template: !empty($data['template']) ? new Document($data['template']) : null, + platform: $data['platform'] ?? [], + ); + } +} diff --git a/src/Appwrite/Event/Publisher/Build.php b/src/Appwrite/Event/Publisher/Build.php new file mode 100644 index 0000000000..9b2a3b68a0 --- /dev/null +++ b/src/Appwrite/Event/Publisher/Build.php @@ -0,0 +1,27 @@ +publish($queue ?? $this->queue, $message); + } + + public function getSize(bool $failed = false, ?Queue $queue = null): int + { + return $this->getQueueSize($queue ?? $this->queue, $failed); + } +} diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 5fc19dacd6..8105d9e7d8 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -178,6 +178,7 @@ class Exception extends \Exception public const string FUNCTION_RUNTIME_UNSUPPORTED = 'function_runtime_unsupported'; public const string FUNCTION_ENTRYPOINT_MISSING = 'function_entrypoint_missing'; public const string FUNCTION_SYNCHRONOUS_TIMEOUT = 'function_synchronous_timeout'; + public const string FUNCTION_ASYNCHRONOUS_TIMEOUT = 'function_asynchronous_timeout'; public const string FUNCTION_TEMPLATE_NOT_FOUND = 'function_template_not_found'; public const string FUNCTION_RUNTIME_NOT_DETECTED = 'function_runtime_not_detected'; public const string FUNCTION_EXECUTE_PERMISSION_MISSING = 'function_execute_permission_missing'; @@ -192,6 +193,7 @@ class Exception extends \Exception public const string BUILD_ALREADY_COMPLETED = 'build_already_completed'; public const string BUILD_CANCELED = 'build_canceled'; public const string BUILD_FAILED = 'build_failed'; + public const string BUILD_TIMEOUT = 'build_timeout'; /** Execution */ public const string EXECUTION_NOT_FOUND = 'execution_not_found'; @@ -346,6 +348,10 @@ class Exception extends \Exception public const string MIGRATION_IN_PROGRESS = 'migration_in_progress'; public const string MIGRATION_PROVIDER_ERROR = 'migration_provider_error'; public const string MIGRATION_DATABASE_TYPE_UNSUPPORTED = 'migration_database_type_unsupported'; + public const string MIGRATION_SOURCE_PROJECT_ID_REQUIRED = 'migration_source_project_id_required'; + public const string MIGRATION_SOURCE_PROJECT_NOT_FOUND = 'migration_source_project_not_found'; + public const string MIGRATION_SOURCE_TYPE_INVALID = 'migration_source_type_invalid'; + public const string MIGRATION_DESTINATION_TYPE_INVALID = 'migration_destination_type_invalid'; /** Realtime */ public const string REALTIME_MESSAGE_FORMAT_INVALID = 'realtime_message_format_invalid'; diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index 359925e368..08e32a9c74 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -96,6 +96,7 @@ abstract class Migration '1.9.1' => 'V24', '1.9.2' => 'V24', '1.9.3' => 'V24', + '1.9.4' => 'V24', ]; /** diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index 85dfec3cfd..b0efac3829 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -2,7 +2,8 @@ namespace Appwrite\Platform\Modules\Compute; -use Appwrite\Event\Build; +use Appwrite\Event\Message\Build as BuildMessage; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Filter\BranchDomain as BranchDomainFilter; use Appwrite\Platform\Action; @@ -57,7 +58,7 @@ class Base extends Action return $allowedSpecifications[0] ?? APP_COMPUTE_SPECIFICATION_DEFAULT; } - public function redeployVcsFunction(Request $request, Document $function, Document $project, Document $installation, Database $dbForProject, Build $queueForBuilds, Document $template, GitHub $github, bool $activate, string $referenceType = 'branch', string $reference = ''): Document + public function redeployVcsFunction(Request $request, Document $function, Document $project, Document $installation, Database $dbForProject, BuildPublisher $publisherForBuilds, Document $template, GitHub $github, bool $activate, array $platform = [], string $referenceType = 'branch', string $reference = ''): Document { $deploymentId = ID::unique(); $entrypoint = $function->getAttribute('entrypoint', ''); @@ -150,16 +151,19 @@ class Base extends Action 'latestDeploymentStatus' => $deployment->getAttribute('status', ''), ])); - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($function) - ->setDeployment($deployment) - ->setTemplate($template); + $publisherForBuilds->enqueue(new BuildMessage( + project: $project, + resource: $function, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + template: $template, + platform: $platform, + )); return $deployment; } - public function redeployVcsSite(Request $request, Document $site, Document $project, Document $installation, Database $dbForProject, Database $dbForPlatform, Build $queueForBuilds, Document $template, GitHub $github, bool $activate, Authorization $authorization, array $platform, string $referenceType = 'branch', string $reference = ''): Document + public function redeployVcsSite(Request $request, Document $site, Document $project, Document $installation, Database $dbForProject, Database $dbForPlatform, BuildPublisher $publisherForBuilds, Document $template, GitHub $github, bool $activate, Authorization $authorization, array $platform, string $referenceType = 'branch', string $reference = ''): Document { $deploymentId = ID::unique(); $providerInstallationId = $installation->getAttribute('providerInstallationId', ''); @@ -358,11 +362,14 @@ class Base extends Action $this->updateEmptyManualRule($project, $site, $deployment, $dbForPlatform, $authorization); - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($site) - ->setDeployment($deployment) - ->setTemplate($template); + $publisherForBuilds->enqueue(new BuildMessage( + project: $project, + resource: $site, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + template: $template, + platform: $platform, + )); return $deployment; } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php index 757edc0484..57c465faef 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\Functions\Http\Deployments; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Message\Build as BuildMessage; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -87,9 +88,10 @@ class Create extends Action ->inject('project') ->inject('deviceForFunctions') ->inject('deviceForLocal') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('plan') ->inject('authorization') + ->inject('platform') ->callback($this->action(...)); } @@ -106,9 +108,10 @@ class Create extends Action Document $project, Device $deviceForFunctions, Device $deviceForLocal, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, array $plan, - Authorization $authorization + Authorization $authorization, + array $platform ) { $activate = \strval($activate) === 'true' || \strval($activate) === '1'; @@ -272,10 +275,13 @@ class Create extends Action } // Start the build - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($function) - ->setDeployment($deployment); + $publisherForBuilds->enqueue(new BuildMessage( + project: $project, + resource: $function, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + platform: $platform, + )); } else { if ($deployment->isEmpty()) { $deployment = $dbForProject->createDocument('deployments', new Document([ diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php index 9884b12dba..76070c8bf5 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Duplicate; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Message\Build as BuildMessage; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -61,8 +62,10 @@ class Create extends Action ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('deviceForFunctions') + ->inject('project') + ->inject('platform') ->callback($this->action(...)); } @@ -73,8 +76,10 @@ class Create extends Action Response $response, Database $dbForProject, Event $queueForEvents, - Build $queueForBuilds, - Device $deviceForFunctions + BuildPublisher $publisherForBuilds, + Device $deviceForFunctions, + Document $project, + array $platform ) { $function = $dbForProject->getDocument('functions', $functionId); @@ -127,10 +132,13 @@ class Create extends Action 'latestDeploymentStatus' => $function->getAttribute('latestDeploymentStatus'), ])); - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($function) - ->setDeployment($deployment); + $publisherForBuilds->enqueue(new BuildMessage( + project: $project, + resource: $function, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + platform: $platform, + )); $queueForEvents ->setParam('functionId', $function->getId()) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php index 53af82e701..f18543c60e 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Template; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Message\Build as BuildMessage; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; @@ -76,9 +77,10 @@ class Create extends Base ->inject('dbForPlatform') ->inject('queueForEvents') ->inject('project') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('gitHub') ->inject('authorization') + ->inject('platform') ->callback($this->action(...)); } @@ -96,9 +98,10 @@ class Create extends Base Database $dbForPlatform, Event $queueForEvents, Document $project, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, GitHub $github, - Authorization $authorization + Authorization $authorization, + array $platform ) { $function = $dbForProject->getDocument('functions', $functionId); @@ -127,10 +130,11 @@ class Create extends Base project: $project, installation: $installation, dbForProject: $dbForProject, - queueForBuilds: $queueForBuilds, + publisherForBuilds: $publisherForBuilds, template: $template, github: $github, activate: $activate, + platform: $platform, referenceType: $type, reference: $reference ); @@ -184,11 +188,14 @@ class Create extends Base $this->updateEmptyManualRule($project, $function, $deployment, $dbForPlatform, $authorization); - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($function) - ->setDeployment($deployment) - ->setTemplate($template); + $publisherForBuilds->enqueue(new BuildMessage( + project: $project, + resource: $function, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + template: $template, + platform: $platform, + )); $queueForEvents ->setParam('functionId', $function->getId()) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php index 587c09beba..a74fc12593 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Vcs; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; @@ -70,8 +70,9 @@ class Create extends Base ->inject('dbForPlatform') ->inject('project') ->inject('queueForEvents') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('gitHub') + ->inject('platform') ->callback($this->action(...)); } @@ -86,8 +87,9 @@ class Create extends Base Database $dbForPlatform, Document $project, Event $queueForEvents, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, GitHub $github, + array $platform, ) { $function = $dbForProject->getDocument('functions', $functionId); @@ -105,10 +107,11 @@ class Create extends Base project: $project, installation: $installation, dbForProject: $dbForProject, - queueForBuilds: $queueForBuilds, + publisherForBuilds: $publisherForBuilds, template: $template, github: $github, activate: $activate, + platform: $platform, reference: $reference, referenceType: $type ); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 9f15cf9d1e..02dd76294e 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -17,6 +17,7 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Usage\Context; use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response; +use Executor\Exception\Timeout as ExecutorTimeout; use Executor\Executor; use MaxMind\Db\Reader; use Utopia\Auth\Proofs\Token; @@ -417,25 +418,29 @@ class Create extends Base $source = $deployment->getAttribute('buildPath', ''); $extension = str_ends_with($source, '.tar') ? 'tar' : 'tar.gz'; $command = $version === 'v2' ? '' : "cp /tmp/code.$extension /mnt/code/code.$extension && nohup helpers/start.sh \"$command\""; - $executionResponse = $executor->createExecution( - projectId: $project->getId(), - deploymentId: $deployment->getId(), - body: \strlen($body) > 0 ? $body : null, - variables: $vars, - timeout: $function->getAttribute('timeout', 0), - image: $runtime['image'], - source: $source, - entrypoint: $deployment->getAttribute('entrypoint', ''), - version: $version, - path: $path, - method: $method, - headers: $headers, - runtimeEntrypoint: $command, - cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT, - memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, - logging: $function->getAttribute('logging', true), - requestTimeout: 30 - ); + try { + $executionResponse = $executor->createExecution( + projectId: $project->getId(), + deploymentId: $deployment->getId(), + body: \strlen($body) > 0 ? $body : null, + variables: $vars, + timeout: $function->getAttribute('timeout', 0), + image: $runtime['image'], + source: $source, + entrypoint: $deployment->getAttribute('entrypoint', ''), + version: $version, + path: $path, + method: $method, + headers: $headers, + runtimeEntrypoint: $command, + cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT, + memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, + logging: $function->getAttribute('logging', true), + requestTimeout: 30 + ); + } catch (ExecutorTimeout $th) { + throw new AppwriteException(AppwriteException::FUNCTION_SYNCHRONOUS_TIMEOUT, previous: $th); + } $headersFiltered = []; foreach ($executionResponse['headers'] as $key => $value) { diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index 7b294f3f90..00a91141fb 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -2,9 +2,10 @@ namespace Appwrite\Platform\Modules\Functions\Http\Functions; -use Appwrite\Event\Build; use Appwrite\Event\Event; use Appwrite\Event\Func; +use Appwrite\Event\Message\Build as BuildMessage; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Event\Realtime; use Appwrite\Event\Validator\FunctionEvent; use Appwrite\Event\Webhook; @@ -115,7 +116,7 @@ class Create extends Base ->inject('timelimit') ->inject('project') ->inject('queueForEvents') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('queueForRealtime') ->inject('queueForWebhooks') ->inject('queueForFunctions') @@ -157,7 +158,7 @@ class Create extends Base callable $timelimit, Document $project, Event $queueForEvents, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, Realtime $queueForRealtime, Webhook $queueForWebhooks, Func $queueForFunctions, @@ -326,10 +327,11 @@ class Create extends Base project: $project, installation: $installation, dbForProject: $dbForProject, - queueForBuilds: $queueForBuilds, + publisherForBuilds: $publisherForBuilds, template: $template, github: $github, activate: true, + platform: $platform, reference: $providerBranch, referenceType: 'branch' ); @@ -367,11 +369,14 @@ class Create extends Base 'latestDeploymentStatus' => $deployment->getAttribute('status', ''), ])); - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($function) - ->setDeployment($deployment) - ->setTemplate($template); + $publisherForBuilds->enqueue(new BuildMessage( + project: $project, + resource: $function, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + template: $template, + platform: $platform, + )); } $functionsDomain = $platform['functionsDomain']; diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index 7d6572d336..b3fcb2c021 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Functions\Http\Functions; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Event\Validator\FunctionEvent; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; @@ -105,11 +105,12 @@ class Update extends Base ->inject('dbForProject') ->inject('project') ->inject('queueForEvents') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('dbForPlatform') ->inject('gitHub') ->inject('executor') ->inject('authorization') + ->inject('platform') ->callback($this->action(...)); } @@ -139,11 +140,12 @@ class Update extends Base Database $dbForProject, Document $project, Event $queueForEvents, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, Database $dbForPlatform, GitHub $github, Executor $executor, - Authorization $authorization + Authorization $authorization, + array $platform ) { // TODO: If only branch changes, re-deploy $function = $dbForProject->getDocument('functions', $functionId); @@ -281,7 +283,7 @@ class Update extends Base // Redeploy logic if (!$isConnected && !empty($providerRepositoryId)) { - $this->redeployVcsFunction($request, $function, $project, $installation, $dbForProject, $queueForBuilds, new Document(), $github, true); + $this->redeployVcsFunction($request, $function, $project, $installation, $dbForProject, $publisherForBuilds, new Document(), $github, true, $platform); } // Inform scheduler if function is still active diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php index fee5b0095d..de572cd41e 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php @@ -2,11 +2,13 @@ namespace Appwrite\Platform\Modules\Functions\Http\Variables; +use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\DateTime; @@ -38,6 +40,7 @@ class Create extends Base ->groups(['api', 'functions']) ->label('scope', 'functions.write') ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('event', 'variables.[variableId].create') ->label('audits.event', 'variable.create') ->label('audits.resource', 'function/{request.functionId}') ->label('sdk', new Method( @@ -56,10 +59,12 @@ class Create extends Base ] )) ->param('functionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Function unique ID.', false, ['dbForProject']) + ->param('variableId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) ->param('key', null, new Text(Database::LENGTH_KEY), 'Variable key. Max length: ' . Database::LENGTH_KEY . ' chars.', false) ->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.', false) ->param('secret', true, new Boolean(), 'Secret variables can be updated or deleted, but only functions can read them during build and runtime.', true) ->inject('response') + ->inject('queueForEvents') ->inject('dbForProject') ->inject('dbForPlatform') ->inject('project') @@ -69,10 +74,12 @@ class Create extends Base public function action( string $functionId, + string $variableId, string $key, string $value, bool $secret, Response $response, + QueueEvent $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, @@ -84,7 +91,7 @@ class Create extends Base throw new Exception(Exception::FUNCTION_NOT_FOUND); } - $variableId = ID::unique(); + $variableId = ($variableId === 'unique()') ? ID::unique() : $variableId; $teamId = $project->getAttribute('teamId', ''); $variable = new Document([ @@ -120,6 +127,8 @@ class Create extends Base 'active' => $schedule->getAttribute('active'), ]))); + $queueForEvents->setParam('variableId', $variable->getId()); + $response ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic($variable, Response::MODEL_VARIABLE); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php index f6d77c2a0d..fa9f19ba8f 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Functions\Http\Variables; +use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; @@ -35,6 +36,7 @@ class Delete extends Base ->groups(['api', 'functions']) ->label('scope', 'functions.write') ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('event', 'variables.[variableId].delete') ->label('audits.event', 'variable.delete') ->label('audits.resource', 'function/{request.functionId}') ->label('sdk', new Method( @@ -56,6 +58,7 @@ class Delete extends Base ->param('functionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Function unique ID.', false, ['dbForProject']) ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject']) ->inject('response') + ->inject('queueForEvents') ->inject('dbForProject') ->inject('dbForPlatform') ->inject('authorization') @@ -66,6 +69,7 @@ class Delete extends Base string $functionId, string $variableId, Response $response, + QueueEvent $queueForEvents, Database $dbForProject, Database $dbForPlatform, Authorization $authorization @@ -98,6 +102,8 @@ class Delete extends Base 'active' => $schedule->getAttribute('active'), ]))); + $queueForEvents->setParam('variableId', $variable->getId()); + $response->noContent(); } } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php index 54d7a647a3..6413b29f82 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Functions\Http\Variables; +use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; @@ -38,6 +39,7 @@ class Update extends Base ->groups(['api', 'functions']) ->label('scope', 'functions.write') ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('event', 'variables.[variableId].update') ->label('audits.event', 'variable.update') ->label('audits.resource', 'function/{request.functionId}') ->label('sdk', new Method( @@ -57,10 +59,11 @@ class Update extends Base )) ->param('functionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Function unique ID.', false, ['dbForProject']) ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject']) - ->param('key', null, new Text(255), 'Variable key. Max length: 255 chars.', false) + ->param('key', null, new Nullable(new Text(255, 0)), 'Variable key. Max length: 255 chars.', true) ->param('value', null, new Nullable(new Text(8192, 0)), 'Variable value. Max length: 8192 chars.', true) ->param('secret', null, new Nullable(new Boolean()), 'Secret variables can be updated or deleted, but only functions can read them during build and runtime.', true) ->inject('response') + ->inject('queueForEvents') ->inject('dbForProject') ->inject('dbForPlatform') ->inject('authorization') @@ -70,10 +73,11 @@ class Update extends Base public function action( string $functionId, string $variableId, - string $key, + ?string $key, ?string $value, ?bool $secret, Response $response, + QueueEvent $queueForEvents, Database $dbForProject, Database $dbForPlatform, Authorization $authorization @@ -93,19 +97,27 @@ class Update extends Base throw new Exception(Exception::VARIABLE_CANNOT_UNSET_SECRET); } - $variable - ->setAttribute('key', $key) - ->setAttribute('value', $value ?? $variable->getAttribute('value')) - ->setAttribute('secret', $secret ?? $variable->getAttribute('secret')) - ->setAttribute('search', implode(' ', [$variableId, $function->getId(), $key, 'function'])); + if (\is_null($key) && \is_null($value) && \is_null($secret)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID); + } + + $updates = new Document(); + + if (!\is_null($key)) { + $updates->setAttribute('key', $key); + $updates->setAttribute('search', implode(' ', [$variableId, $function->getId(), $key, 'function'])); + } + + if (!\is_null($value)) { + $updates->setAttribute('value', $value); + } + + if (!\is_null($secret)) { + $updates->setAttribute('secret', $secret); + } try { - $dbForProject->updateDocument('variables', $variable->getId(), new Document([ - 'key' => $key, - 'value' => $value ?? $variable->getAttribute('value'), - 'secret' => $secret ?? $variable->getAttribute('secret'), - 'search' => implode(' ', [$variableId, $function->getId(), $key, 'function']), - ])); + $variable = $dbForProject->updateDocument('variables', $variable->getId(), $updates); } catch (DuplicateException $th) { throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); } @@ -125,6 +137,8 @@ class Update extends Base 'active' => $schedule->getAttribute('active'), ]))); + $queueForEvents->setParam('variableId', $variable->getId()); + $response->dynamic($variable, Response::MODEL_VARIABLE); } } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/XList.php index 55dea3be1e..b330812b96 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/XList.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/XList.php @@ -7,12 +7,18 @@ use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Validator\Queries\Variables; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception\Order as OrderException; +use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Query; +use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Utopia\Validator\Boolean; class XList extends Base { @@ -51,22 +57,74 @@ class XList extends Base ) ) ->param('functionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Function unique ID.', false, ['dbForProject']) + ->param('queries', [], new Variables(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Variables::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') ->callback($this->action(...)); } - public function action(string $functionId, Response $response, Database $dbForProject) - { + /** + * @param array $queries + */ + public function action( + string $functionId, + array $queries, + bool $includeTotal, + Response $response, + Database $dbForProject + ) { $function = $dbForProject->getDocument('functions', $functionId); if ($function->isEmpty()) { throw new Exception(Exception::FUNCTION_NOT_FOUND); } + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $queries[] = Query::equal('resourceType', ['function']); + $queries[] = Query::equal('resourceInternalId', [$function->getSequence()]); + $queries[] = Query::orderAsc(); + + $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()); + } + + $variableId = $cursor->getValue(); + $cursorDocument = $dbForProject->findOne('variables', [ + Query::equal('$id', [$variableId]), + Query::equal('resourceType', ['function']), + Query::equal('resourceInternalId', [$function->getSequence()]), + ]); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Variable '{$variableId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $variables = $dbForProject->find('variables', $queries); + $total = $includeTotal ? $dbForProject->count('variables', $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([ - 'variables' => $function->getAttribute('vars', []), - 'total' => \count($function->getAttribute('vars', [])), + 'variables' => $variables, + 'total' => $total, ]), Response::MODEL_VARIABLE_LIST); } } diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 352fb56e28..a0bd37732f 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -10,11 +10,13 @@ use Appwrite\Event\Publisher\Screenshot; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; use Appwrite\Event\Webhook; +use Appwrite\Extend\Exception as AppwriteException; use Appwrite\Filter\BranchDomain as BranchDomainFilter; use Appwrite\Usage\Context; use Appwrite\Utopia\Response\Model\Deployment; use Appwrite\Vcs\Comment; use Exception; +use Executor\Exception\Timeout as ExecutorTimeout; use Executor\Executor; use Swoole\Coroutine as Co; use Utopia\Cache\Cache; @@ -34,6 +36,7 @@ use Utopia\Detector\Detector\Rendering; use Utopia\Logger\Log; use Utopia\Platform\Action; use Utopia\Queue\Message; +use Utopia\Span\Span; use Utopia\Storage\Device; use Utopia\Storage\Device\Local; use Utopia\System\System; @@ -183,6 +186,12 @@ class Builds extends Action array $platform, int $timeout ): void { + Span::add('projectId', $project->getId()); + Span::add('resourceId', $resource->getId()); + Span::add('resourceType', $resource->getCollection()); + Span::add('deploymentId', $deployment->getId()); + Span::add('timeout', $timeout); + Console::info('Deployment action started'); $startTime = DateTime::now(); @@ -223,8 +232,12 @@ class Builds extends Action $version = $this->getVersion($resource); $runtime = $this->getRuntime($resource, $version); + Span::add('runtime', $resource->getAttribute($resource->getCollection() === 'sites' ? 'buildRuntime' : 'runtime', '')); + Span::add('version', $version); $spec = Config::getParam('specifications')[$resource->getAttribute('buildSpecification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; + Span::add('cpus', (float) ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT)); + Span::add('memory', (int) ($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT)); // Realtime preparation $event = "{$resource->getCollection()}.[{$resourceKey}].deployments.[deploymentId].update"; @@ -720,6 +733,9 @@ class Builds extends Action ); Console::log('createRuntime finished'); + } catch (ExecutorTimeout $error) { + Console::warning('createRuntime timed out'); + $err = new AppwriteException(AppwriteException::BUILD_TIMEOUT, previous: $error); } catch (\Throwable $error) { Console::warning('createRuntime failed'); $err = $error; @@ -1147,13 +1163,11 @@ class Builds extends Action $message = \str_replace('{APPWRITE_DETECTION_SEPARATOR_START}', '', $message); $message = \str_replace('{APPWRITE_DETECTION_SEPARATOR_END}', '', $message); - // Combine with previous logs if deployment got past build process - $previousLogs = ''; - if (! is_null($deployment->getAttribute('buildSize', null))) { - $previousLogs = $deployment->getAttribute('buildLogs', ''); - if (! empty($previousLogs)) { - $message = $previousLogs . "\n" . $message; - } + // Append error to whatever build logs were already streamed + $deployment = $dbForProject->getDocument('deployments', $deploymentId); + $previousLogs = $deployment->getAttribute('buildLogs', ''); + if (! empty($previousLogs)) { + $message = $previousLogs . "\n" . $message; } $endTime = DateTime::now(); diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index 7d1cdc4980..c766f73929 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -109,9 +109,7 @@ class Screenshots extends Action throw new \Exception("Rule for deployment not found"); } - $client = new FetchClient(); - $client->setTimeout(\intval($site->getAttribute('timeout', '15')) * 1000); - $client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON); + $timeout = \intval($site->getAttribute('timeout', '15')) * 1000; $bucket = $dbForPlatform->getDocument('buckets', 'screenshots'); @@ -162,8 +160,8 @@ class Screenshots extends Action ]); $screenshotError = null; - $screenshots = batch(\array_map(function ($key) use ($configs, $apiKey, $site, $client, &$screenshotError) { - return function () use ($key, $configs, $apiKey, $site, $client, &$screenshotError) { + $screenshots = batch(\array_map(function ($key) use ($configs, $apiKey, $site, $timeout, &$screenshotError) { + return function () use ($key, $configs, $apiKey, $site, $timeout, &$screenshotError) { try { $config = $configs[$key]; @@ -179,6 +177,10 @@ class Screenshots extends Action } $browserEndpoint = System::getEnv('_APP_BROWSER_HOST', 'http://appwrite-browser:3000/v1'); + $client = new FetchClient(); + $client->setTimeout($timeout); + $client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON); + $fetchResponse = $client->fetch( url: $browserEndpoint . '/screenshots', method: 'POST', diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php index 8ae7c8687a..98e65e37e5 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Builds; -use Appwrite\Event\Build; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -42,16 +42,16 @@ class Get extends Base contentType: ContentType::JSON )) ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('response') ->callback($this->action(...)); } - public function action(int|string $threshold, Build $queueForBuilds, Response $response): void + public function action(int|string $threshold, BuildPublisher $publisherForBuilds, Response $response): void { $threshold = (int) $threshold; - $size = $queueForBuilds->getSize(); + $size = $publisherForBuilds->getSize(); $this->assertQueueThreshold($size, $threshold); diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php index 7602de45d3..0d0a787b46 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Failed; -use Appwrite\Event\Build; use Appwrite\Event\Database; use Appwrite\Event\Delete; use Appwrite\Event\Event; @@ -10,6 +9,7 @@ use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; use Appwrite\Event\Publisher\Audit; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Event\Publisher\Certificate; use Appwrite\Event\Publisher\Migration as MigrationPublisher; use Appwrite\Event\Publisher\Screenshot; @@ -83,7 +83,7 @@ class Get extends Base ->inject('publisherForUsage') ->inject('queueForWebhooks') ->inject('publisherForCertificates') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('queueForMessaging') ->inject('publisherForMigrations') ->inject('publisherForScreenshots') @@ -103,7 +103,7 @@ class Get extends Base UsagePublisher $publisherForUsage, Webhook $queueForWebhooks, Certificate $publisherForCertificates, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, Messaging $queueForMessaging, MigrationPublisher $publisherForMigrations, Screenshot $publisherForScreenshots, @@ -120,7 +120,7 @@ class Get extends Base System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME) => $publisherForUsage, System::getEnv('_APP_WEBHOOK_QUEUE_NAME', Event::WEBHOOK_QUEUE_NAME) => $queueForWebhooks, System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME) => $publisherForCertificates, - System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $queueForBuilds, + System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $publisherForBuilds, System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $publisherForScreenshots, System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $queueForMessaging, System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $publisherForMigrations, diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php index 006ab3ae90..fa700877a1 100644 --- a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php @@ -13,6 +13,7 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Validator\UID; +use Utopia\Migration\Destinations\OnDuplicate; use Utopia\Migration\Sources\Appwrite as AppwriteSource; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; @@ -57,6 +58,7 @@ class Create extends Action ->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') + ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true) ->inject('response') ->inject('dbForProject') ->inject('project') @@ -71,6 +73,7 @@ class Create extends Action string $endpoint, string $projectId, string $apiKey, + string $onDuplicate, Response $response, Database $dbForProject, Document $project, @@ -93,6 +96,9 @@ class Create extends Action 'statusCounters' => '{}', 'resourceData' => '{}', 'errors' => [], + 'options' => [ + 'onDuplicate' => $onDuplicate, + ], ])); $queueForEvents->setParam('migrationId', $migration->getId()); 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 index 5cc21241c3..4b47ed7d58 100644 --- a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php @@ -20,6 +20,7 @@ use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; +use Utopia\Migration\Destinations\OnDuplicate; use Utopia\Migration\Resource; use Utopia\Migration\Sources\Appwrite as AppwriteSource; use Utopia\Migration\Sources\CSV; @@ -29,6 +30,7 @@ use Utopia\Platform\Scope\HTTP; use Utopia\Storage\Device; use Utopia\System\System; use Utopia\Validator\Boolean; +use Utopia\Validator\WhiteList; class Create extends Action { @@ -67,6 +69,7 @@ class Create extends Action ->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) + ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true) ->inject('response') ->inject('dbForProject') ->inject('dbForPlatform') @@ -85,6 +88,7 @@ class Create extends Action string $fileId, string $resourceId, bool $internalFile, + string $onDuplicate, Response $response, Database $dbForProject, Database $dbForPlatform, @@ -183,6 +187,7 @@ class Create extends Action 'options' => [ 'path' => $newPath, 'size' => $fileSize, + 'onDuplicate' => $onDuplicate, ], ])); 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 index 55081b2645..c5d936711e 100644 --- a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php @@ -20,6 +20,7 @@ use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; +use Utopia\Migration\Destinations\OnDuplicate; use Utopia\Migration\Resource; use Utopia\Migration\Sources\Appwrite as AppwriteSource; use Utopia\Migration\Sources\JSON as JSONSource; @@ -29,6 +30,7 @@ use Utopia\Platform\Scope\HTTP; use Utopia\Storage\Device; use Utopia\System\System; use Utopia\Validator\Boolean; +use Utopia\Validator\WhiteList; class Create extends Action { @@ -66,6 +68,7 @@ class Create extends Action ->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) + ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true) ->inject('response') ->inject('dbForProject') ->inject('dbForPlatform') @@ -84,6 +87,7 @@ class Create extends Action string $fileId, string $resourceId, bool $internalFile, + string $onDuplicate, Response $response, Database $dbForProject, Database $dbForPlatform, @@ -183,6 +187,7 @@ class Create extends Action 'options' => [ 'path' => $newPath, 'size' => $fileSize, + 'onDuplicate' => $onDuplicate, ], ])); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php index 9598ff4c43..697b306be8 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php @@ -82,13 +82,13 @@ class Update extends Base 'hint' => '', ], [ - '$id' => 'tokenUrl', + '$id' => 'tokenURL', 'name' => 'Token URL', 'example' => 'https://myoauth.com/oauth2/token', 'hint' => '', ], [ - '$id' => 'userInfoUrl', + '$id' => 'userInfoURL', 'name' => 'User Info URL', 'example' => 'https://myoauth.com/oauth2/userinfo', 'hint' => '', @@ -127,8 +127,8 @@ class Update extends Base ->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('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, aliases: ['tokenUrl']) + ->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, aliases: ['userInfoUrl']) ->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') @@ -151,8 +151,8 @@ class Update extends Base static::getClientSecretParamName() => '', 'wellKnownURL' => $decoded['wellKnownEndpoint'] ?? '', 'authorizationURL' => $decoded['authorizationEndpoint'] ?? '', - 'tokenUrl' => $decoded['tokenEndpoint'] ?? '', - 'userInfoUrl' => $decoded['userInfoEndpoint'] ?? '', + 'tokenURL' => $decoded['tokenEndpoint'] ?? '', + 'userInfoURL' => $decoded['userInfoEndpoint'] ?? '', ]); } @@ -174,8 +174,8 @@ class Update extends Base ?string $clientSecret, ?string $wellKnownURL, ?string $authorizationURL, - ?string $tokenUrl, - ?string $userInfoUrl, + ?string $tokenURL, + ?string $userInfoURL, ?bool $enabled, Response $response, Database $dbForPlatform, @@ -201,8 +201,8 @@ class Update extends Base 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), 'wellKnownEndpoint' => $wellKnownURL ?? ($existing['wellKnownEndpoint'] ?? ''), 'authorizationEndpoint' => $authorizationURL ?? ($existing['authorizationEndpoint'] ?? ''), - 'tokenEndpoint' => $tokenUrl ?? ($existing['tokenEndpoint'] ?? ''), - 'userInfoEndpoint' => $userInfoUrl ?? ($existing['userInfoEndpoint'] ?? ''), + 'tokenEndpoint' => $tokenURL ?? ($existing['tokenEndpoint'] ?? ''), + 'userInfoEndpoint' => $userInfoURL ?? ($existing['userInfoEndpoint'] ?? ''), ]; // When enabling, require either wellKnownEndpoint alone, or all three @@ -215,7 +215,7 @@ class Update extends Base && !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.'); + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Enabling OpenID Connect requires either wellKnownURL, or all of authorizationURL, tokenURL, and userInfoURL.'); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php index 8dbc720045..8c76ed2a8e 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php @@ -53,7 +53,7 @@ class Create extends Action ) ], )) - ->param('variableId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) + ->param('variableId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) ->param('key', null, new Text(Database::LENGTH_KEY), 'Variable key. Max length: ' . Database::LENGTH_KEY . ' chars.') ->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.') ->param('secret', true, new Boolean(), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true) @@ -72,7 +72,7 @@ class Create extends Action QueueEvent $queueForEvents, Database $dbForProject, ) { - $variableId = ($variableId == 'unique()') ? ID::unique() : $variableId; + $variableId = ($variableId === 'unique()') ? ID::unique() : $variableId; $variable = new Document([ '$id' => $variableId, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php index 2b0ae8feb1..553fb09e54 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php @@ -51,7 +51,7 @@ class Delete extends Action ], contentType: ContentType::NONE )) - ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID.', false, ['dbForProject']) + ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject']) ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php index af14148c92..d9030421d7 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php @@ -44,7 +44,7 @@ class Get extends Action ) ] )) - ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID.', false, ['dbForProject']) + ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject']) ->inject('response') ->inject('dbForProject') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php index 988a7c0849..6b05e19a78 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php @@ -52,7 +52,7 @@ class Update extends Action ) ] )) - ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID.', false, ['dbForProject']) + ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject']) ->param('key', null, new Nullable(new Text(255, 0)), 'Variable key. Max length: 255 chars.', true) ->param('value', null, new Nullable(new Text(8192, 0)), 'Variable value. Max length: 8192 chars.', true) ->param('secret', null, new Nullable(new Boolean()), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true) diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index 71ea5ceb2f..63ed776709 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\Sites\Http\Deployments; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Message\Build as BuildMessage; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -85,7 +86,7 @@ class Create extends Action ->inject('queueForEvents') ->inject('deviceForSites') ->inject('deviceForLocal') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('plan') ->inject('authorization') ->inject('platform') @@ -107,7 +108,7 @@ class Create extends Action Event $queueForEvents, Device $deviceForSites, Device $deviceForLocal, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, array $plan, Authorization $authorization, array $platform, @@ -315,10 +316,13 @@ class Create extends Action } // Start the build - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($site) - ->setDeployment($deployment); + $publisherForBuilds->enqueue(new BuildMessage( + project: $project, + resource: $site, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + platform: $platform, + )); } else { if ($deployment->isEmpty()) { $deployment = $dbForProject->createDocument('deployments', new Document([ diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php index 546549604b..b3619c6017 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\Sites\Http\Deployments\Duplicate; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Message\Build as BuildMessage; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -63,7 +64,7 @@ class Create extends Action ->inject('dbForProject') ->inject('dbForPlatform') ->inject('queueForEvents') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('deviceForSites') ->inject('authorization') ->inject('platform') @@ -79,7 +80,7 @@ class Create extends Action Database $dbForProject, Database $dbForPlatform, Event $queueForEvents, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, Device $deviceForSites, Authorization $authorization, array $platform @@ -177,10 +178,13 @@ class Create extends Action ])) ); - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($site) - ->setDeployment($deployment); + $publisherForBuilds->enqueue(new BuildMessage( + project: $project, + resource: $site, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + platform: $platform, + )); $queueForEvents ->setParam('siteId', $site->getId()) diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php index f648c57a83..29854d473b 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\Sites\Http\Deployments\Template; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Message\Build as BuildMessage; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; @@ -77,7 +78,7 @@ class Create extends Base ->inject('dbForPlatform') ->inject('project') ->inject('queueForEvents') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('gitHub') ->inject('authorization') ->inject('platform') @@ -98,7 +99,7 @@ class Create extends Base Database $dbForPlatform, Document $project, Event $queueForEvents, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, GitHub $github, Authorization $authorization, array $platform @@ -130,7 +131,7 @@ class Create extends Base installation: $installation, dbForProject: $dbForProject, dbForPlatform: $dbForPlatform, - queueForBuilds: $queueForBuilds, + publisherForBuilds: $publisherForBuilds, template: $template, github: $github, activate: $activate, @@ -223,11 +224,14 @@ class Create extends Base $this->updateEmptyManualRule($project, $site, $deployment, $dbForPlatform, $authorization); - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($site) - ->setDeployment($deployment) - ->setTemplate($template); + $publisherForBuilds->enqueue(new BuildMessage( + project: $project, + resource: $site, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + template: $template, + platform: $platform, + )); $queueForEvents ->setParam('siteId', $site->getId()) diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php index 4351dd8dd9..d34b8c4055 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Sites\Http\Deployments\Vcs; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; @@ -71,7 +71,7 @@ class Create extends Base ->inject('dbForPlatform') ->inject('project') ->inject('queueForEvents') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('gitHub') ->inject('authorization') ->inject('platform') @@ -89,7 +89,7 @@ class Create extends Base Database $dbForPlatform, Document $project, Event $queueForEvents, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, GitHub $github, Authorization $authorization, array $platform @@ -111,7 +111,7 @@ class Create extends Base installation: $installation, dbForProject: $dbForProject, dbForPlatform: $dbForPlatform, - queueForBuilds: $queueForBuilds, + publisherForBuilds: $publisherForBuilds, template: $template, github: $github, activate: $activate, diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php index 3c0d090b7b..2aee03265e 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Sites\Http\Sites; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\Platform\Modules\Compute\Validator\Specification; @@ -99,10 +99,11 @@ class Update extends Base ->inject('dbForProject') ->inject('project') ->inject('queueForEvents') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('dbForPlatform') ->inject('gitHub') ->inject('executor') + ->inject('platform') ->callback($this->action(...)); } @@ -133,10 +134,11 @@ class Update extends Base Database $dbForProject, Document $project, Event $queueForEvents, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, Database $dbForPlatform, GitHub $github, - Executor $executor + Executor $executor, + array $platform ) { if (!empty($adapter)) { $configFramework = Config::getParam('frameworks')[$framework] ?? []; @@ -279,7 +281,7 @@ class Update extends Base // Redeploy logic if (!$isConnected && !empty($providerRepositoryId)) { - $this->redeployVcsFunction($request, $site, $project, $installation, $dbForProject, $queueForBuilds, new Document(), $github, true); + $this->redeployVcsFunction($request, $site, $project, $installation, $dbForProject, $publisherForBuilds, new Document(), $github, true, $platform); } $queueForEvents->setParam('siteId', $site->getId()); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php index 04b30fbc9c..edd3412b8f 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php @@ -2,11 +2,13 @@ namespace Appwrite\Platform\Modules\Sites\Http\Variables; +use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\Document; @@ -36,6 +38,7 @@ class Create extends Base ->groups(['api', 'sites']) ->label('scope', 'sites.write') ->label('resourceType', RESOURCE_TYPE_SITES) + ->label('event', 'variables.[variableId].create') ->label('audits.event', 'variable.create') ->label('audits.resource', 'site/{request.siteId}') ->label('sdk', new Method( @@ -54,16 +57,18 @@ class Create extends Base ] )) ->param('siteId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Site unique ID.', false, ['dbForProject']) + ->param('variableId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) ->param('key', null, new Text(Database::LENGTH_KEY), 'Variable key. Max length: ' . Database::LENGTH_KEY . ' chars.', false) ->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.', false) ->param('secret', true, new Boolean(), 'Secret variables can be updated or deleted, but only sites can read them during build and runtime.', true) ->inject('response') + ->inject('queueForEvents') ->inject('dbForProject') ->inject('project') ->callback($this->action(...)); } - public function action(string $siteId, string $key, string $value, bool $secret, Response $response, Database $dbForProject, Document $project) + public function action(string $siteId, string $variableId, string $key, string $value, bool $secret, Response $response, QueueEvent $queueForEvents, Database $dbForProject, Document $project) { $site = $dbForProject->getDocument('sites', $siteId); @@ -71,7 +76,7 @@ class Create extends Base throw new Exception(Exception::SITE_NOT_FOUND); } - $variableId = ID::unique(); + $variableId = ($variableId === 'unique()') ? ID::unique() : $variableId; $teamId = $project->getAttribute('teamId', ''); $variable = new Document([ @@ -96,6 +101,8 @@ class Create extends Base 'live' => false, ])); + $queueForEvents->setParam('variableId', $variable->getId()); + $response ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic($variable, Response::MODEL_VARIABLE); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php index d61c9892cf..74c638bddc 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Sites\Http\Variables; +use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; @@ -33,6 +34,7 @@ class Delete extends Base ->groups(['api', 'sites']) ->label('scope', 'sites.write') ->label('resourceType', RESOURCE_TYPE_SITES) + ->label('event', 'variables.[variableId].delete') ->label('audits.event', 'variable.delete') ->label('audits.resource', 'site/{request.siteId}') ->label('sdk', new Method( @@ -54,11 +56,12 @@ class Delete extends Base ->param('siteId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Site unique ID.', false, ['dbForProject']) ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject']) ->inject('response') + ->inject('queueForEvents') ->inject('dbForProject') ->callback($this->action(...)); } - public function action(string $siteId, string $variableId, Response $response, Database $dbForProject) + public function action(string $siteId, string $variableId, Response $response, QueueEvent $queueForEvents, Database $dbForProject) { $site = $dbForProject->getDocument('sites', $siteId); @@ -77,6 +80,8 @@ class Delete extends Base 'live' => false, ])); + $queueForEvents->setParam('variableId', $variable->getId()); + $response->noContent(); } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php index 08cdd4ac38..0ed7414b9d 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Sites\Http\Variables; +use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; @@ -35,6 +36,7 @@ class Update extends Base ->desc('Update variable') ->groups(['api', 'sites']) ->label('scope', 'sites.write') + ->label('event', 'variables.[variableId].update') ->label('audits.event', 'variable.update') ->label('audits.resource', 'site/{request.siteId}') ->label('resourceType', RESOURCE_TYPE_SITES) @@ -55,10 +57,11 @@ class Update extends Base )) ->param('siteId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Site unique ID.', false, ['dbForProject']) ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject']) - ->param('key', null, new Text(255), 'Variable key. Max length: 255 chars.', false) + ->param('key', null, new Nullable(new Text(255, 0)), 'Variable key. Max length: 255 chars.', true) ->param('value', null, new Nullable(new Text(8192, 0)), 'Variable value. Max length: 8192 chars.', true) ->param('secret', null, new Nullable(new Boolean()), 'Secret variables can be updated or deleted, but only sites can read them during build and runtime.', true) ->inject('response') + ->inject('queueForEvents') ->inject('dbForProject') ->callback($this->action(...)); } @@ -66,10 +69,11 @@ class Update extends Base public function action( string $siteId, string $variableId, - string $key, + ?string $key, ?string $value, ?bool $secret, Response $response, + QueueEvent $queueForEvents, Database $dbForProject ) { $site = $dbForProject->getDocument('sites', $siteId); @@ -87,19 +91,27 @@ class Update extends Base throw new Exception(Exception::VARIABLE_CANNOT_UNSET_SECRET); } - $variable - ->setAttribute('key', $key) - ->setAttribute('value', $value ?? $variable->getAttribute('value')) - ->setAttribute('secret', $secret ?? $variable->getAttribute('secret')) - ->setAttribute('search', implode(' ', [$variableId, $site->getId(), $key, 'site'])); + if (\is_null($key) && \is_null($value) && \is_null($secret)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID); + } + + $updates = new Document(); + + if (!\is_null($key)) { + $updates->setAttribute('key', $key); + $updates->setAttribute('search', implode(' ', [$variableId, $site->getId(), $key, 'site'])); + } + + if (!\is_null($value)) { + $updates->setAttribute('value', $value); + } + + if (!\is_null($secret)) { + $updates->setAttribute('secret', $secret); + } try { - $dbForProject->updateDocument('variables', $variable->getId(), new Document([ - 'key' => $variable->getAttribute('key'), - 'value' => $variable->getAttribute('value'), - 'secret' => $variable->getAttribute('secret'), - 'search' => $variable->getAttribute('search'), - ])); + $variable = $dbForProject->updateDocument('variables', $variable->getId(), $updates); } catch (DuplicateException $th) { throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); } @@ -108,6 +120,8 @@ class Update extends Base 'live' => false, ])); + $queueForEvents->setParam('variableId', $variable->getId()); + $response->dynamic($variable, Response::MODEL_VARIABLE); } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/XList.php index 669aa8be98..1270fe4925 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/XList.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/XList.php @@ -7,12 +7,18 @@ use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Validator\Queries\Variables; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception\Order as OrderException; +use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Query; +use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Utopia\Validator\Boolean; class XList extends Base { @@ -51,13 +57,20 @@ class XList extends Base ) ) ->param('siteId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Site unique ID.', false, ['dbForProject']) + ->param('queries', [], new Variables(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Variables::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') ->callback($this->action(...)); } + /** + * @param array $queries + */ public function action( string $siteId, + array $queries, + bool $includeTotal, Response $response, Database $dbForProject ) { @@ -67,9 +80,51 @@ class XList extends Base throw new Exception(Exception::SITE_NOT_FOUND); } + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $queries[] = Query::equal('resourceType', ['site']); + $queries[] = Query::equal('resourceInternalId', [$site->getSequence()]); + $queries[] = Query::orderAsc(); + + $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()); + } + + $variableId = $cursor->getValue(); + $cursorDocument = $dbForProject->findOne('variables', [ + Query::equal('$id', [$variableId]), + Query::equal('resourceType', ['site']), + Query::equal('resourceInternalId', [$site->getSequence()]), + ]); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Variable '{$variableId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $variables = $dbForProject->find('variables', $queries); + $total = $includeTotal ? $dbForProject->count('variables', $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([ - 'variables' => $site->getAttribute('vars', []), - 'total' => \count($site->getAttribute('vars', [])), + 'variables' => $variables, + 'total' => $total, ]), Response::MODEL_VARIABLE_LIST); } } diff --git a/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php b/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php new file mode 100644 index 0000000000..ef2ace34ff --- /dev/null +++ b/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php @@ -0,0 +1,20 @@ +inject('project') ->inject('authorization') ->inject('user') + ->inject('cacheControlForStorage') ->callback($this->action(...)); } @@ -120,7 +123,8 @@ class Get extends Action Device $deviceForLocal, Document $project, Authorization $authorization, - User $user + User $user, + callable $cacheControlForStorage ) { if (!\extension_loaded('imagick')) { @@ -241,28 +245,43 @@ class Get extends Action throw new Exception(Exception::STORAGE_FILE_TYPE_UNSUPPORTED, $e->getMessage()); } - $image->crop((int) $width, (int) $height, $gravity); + if ($width > 0 || $height > 0 || $gravity !== Image::GRAVITY_CENTER) { + Span::add('storage.transform.crop.width', $width); + Span::add('storage.transform.crop.height', $height); + Span::add('storage.transform.crop.gravity', $gravity); + $image->crop($width, $height, $gravity); + } - if (!empty($opacity)) { + if ($opacity !== 1.0) { + Span::add('storage.transform.opacity', $opacity); $image->setOpacity($opacity); } if (!empty($background)) { + Span::add('storage.transform.background', $background); $image->setBackground('#' . $background); } - if (!empty($borderWidth)) { + if ($borderWidth > 0) { + Span::add('storage.transform.border.width', $borderWidth); + Span::add('storage.transform.border.color', $borderColor); $image->setBorder($borderWidth, '#' . $borderColor); } - if (!empty($borderRadius)) { + if ($borderRadius > 0) { + Span::add('storage.transform.borderRadius', $borderRadius); $image->setBorderRadius($borderRadius); } - if (!empty($rotation)) { + if ($rotation !== 0) { + Span::add('storage.transform.rotation', $rotation); $image->setRotation(($rotation + 360) % 360); } + if ($quality !== -1) { + Span::add('storage.transform.quality', $quality); + } + $data = $image->output($output, $quality); $renderingTime = \microtime(true) - $startTime - $downloadTime - $decryptionTime - $decompressionTime; @@ -294,8 +313,20 @@ class Get extends Action } } + $maxAge = 2592000; // 30 days + $cacheControl = $cacheControlForStorage(new StorageCacheControl( + source: CacheControl::SOURCE_ACTION, + user: $user, + maxAge: $maxAge, + project: $project, + bucket: $bucket, + file: $file, + resourceToken: $resourceToken, + fileSecurity: $fileSecurity, + )); + $response - ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days + ->addHeader('Cache-Control', $cacheControl) ->setContentType($contentType) ->file($data); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index e174029031..51115b7861 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -189,15 +189,15 @@ class Create extends Action } catch (\Throwable) { } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { throw new Exception(Exception::USER_EMAIL_FREE); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php index 8b320535e9..a40d7fc6b9 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Authorize\External; -use Appwrite\Event\Build; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Action; use Appwrite\Platform\Modules\VCS\Http\GitHub\Deployment; @@ -60,7 +60,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('authorization') ->inject('getProjectDB') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('platform') ->callback($this->action(...)); } @@ -75,7 +75,7 @@ class Update extends Action Database $dbForPlatform, Authorization $authorization, callable $getProjectDB, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, array $platform ) { $installation = $dbForPlatform->getDocument('installations', $installationId); @@ -130,7 +130,7 @@ class Update extends Action $providerCommitAuthor = $commitDetails["commitAuthor"] ?? ''; $providerCommitAuthorUrl = $commitDetails["commitAuthorUrl"] ?? ''; - $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform); + $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform); $response->noContent(); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index 33d7e984fb..8bc090bb03 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\VCS\Http\GitHub; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Message\Build as BuildMessage; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Filter\BranchDomain as BranchDomainFilter; use Appwrite\Vcs\Comment; @@ -43,7 +44,7 @@ trait Deployment bool $external, Database $dbForPlatform, Authorization $authorization, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, callable $getProjectDB, array $platform, ) { @@ -528,14 +529,16 @@ trait Deployment $queueName = $this->getBuildQueueName($project, $dbForPlatform, $authorization); - $queueForBuilds - ->setQueue($queueName) - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($resource) - ->setDeployment($deployment) - ->setProject($project); // set the project because it won't be set for git deployments - - $queueForBuilds->trigger(); // must trigger here so that we create a build for each function/site + $publisherForBuilds->enqueue( + new BuildMessage( + project: $project, + resource: $resource, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + platform: $platform, + ), + new \Utopia\Queue\Queue($queueName) + ); Span::add("{$logBase}.build.triggered", 'true'); //TODO: Add event? @@ -545,8 +548,6 @@ trait Deployment } } - $queueForBuilds->reset(); // prevent shutdown hook from triggering again - if (!empty($errors)) { throw new Exception(Exception::GENERAL_UNKNOWN, \implode("\n", $errors)); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php index e3dbcfa0e9..0b81504309 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Events; -use Appwrite\Event\Build; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Action; use Appwrite\Platform\Modules\VCS\Http\GitHub\Deployment; @@ -41,7 +41,7 @@ class Create extends Action ->inject('dbForPlatform') ->inject('authorization') ->inject('getProjectDB') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('platform') ->callback($this->action(...)); } @@ -53,7 +53,7 @@ class Create extends Action Database $dbForPlatform, Authorization $authorization, callable $getProjectDB, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, array $platform ) { $this->preprocessEvent($request); @@ -78,8 +78,8 @@ class Create extends Action match ($event) { $github::EVENT_INSTALLATION => $this->handleInstallationEvent($parsedPayload, $dbForPlatform, $authorization), - $github::EVENT_PUSH => $this->handlePushEvent($parsedPayload, $githubAppId, $privateKey, $github, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform), - $github::EVENT_PULL_REQUEST => $this->handlePullRequestEvent($parsedPayload, $privateKey, $githubAppId, $github, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform), + $github::EVENT_PUSH => $this->handlePushEvent($parsedPayload, $githubAppId, $privateKey, $github, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform), + $github::EVENT_PULL_REQUEST => $this->handlePullRequestEvent($parsedPayload, $privateKey, $githubAppId, $github, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform), default => null, }; @@ -129,7 +129,7 @@ class Create extends Action GitHub $github, Database $dbForPlatform, Authorization $authorization, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, callable $getProjectDB, array $platform, ) { @@ -164,7 +164,7 @@ class Create extends Action // Create new deployment only on push (not committed by us) and not when branch is deleted if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchDeleted) { - $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform); + $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform); } } @@ -175,7 +175,7 @@ class Create extends Action GitHub $github, Database $dbForPlatform, Authorization $authorization, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, callable $getProjectDB, array $platform, ) { @@ -216,7 +216,7 @@ class Create extends Action Query::orderDesc('$createdAt') ])); - $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform); + $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform); } elseif ($action == "closed") { // Allowed external contributions cleanup diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php index 8ead94b7cb..fda462159f 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php @@ -7,9 +7,12 @@ use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Validator\Queries\Branches; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Query; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; use Utopia\Validator\Text; @@ -49,6 +52,8 @@ class XList extends Action )) ->param('installationId', '', new Text(256), 'Installation Id') ->param('providerRepositoryId', '', new Text(256), 'Repository Id') + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('queries', [], new Branches(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit, offset, cursorAfter, and cursorBefore', true) ->inject('gitHub') ->inject('response') ->inject('dbForPlatform') @@ -58,10 +63,18 @@ class XList extends Action public function action( string $installationId, string $providerRepositoryId, + string $search, + array $queries, GitHub $github, Response $response, Database $dbForPlatform ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + $installation = $dbForPlatform->getDocument('installations', $installationId); if ($installation->isEmpty()) { @@ -85,11 +98,48 @@ class XList extends Action $branches = $github->listBranches($owner, $repositoryName); + if (!empty($search)) { + $branches = \array_values(\array_filter($branches, fn (string $branch) => \stripos($branch, $search) !== false)); + } + + $total = \count($branches); + [ + 'limit' => $limit, + 'offset' => $offset, + ] = Query::groupByType($queries); + $cursorQuery = \current(Query::getCursorQueries($queries, false)); + + $limit ??= APP_LIMIT_LIST_DEFAULT; + $offset ??= 0; + + if ($cursorQuery instanceof Query) { + $cursor = $cursorQuery->getValue(); + $cursorDirection = $cursorQuery->getMethod() === Query::TYPE_CURSOR_AFTER + ? Database::CURSOR_AFTER + : Database::CURSOR_BEFORE; + + $cursorIndex = \array_search($cursor, $branches, true); + if ($cursorIndex === false) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Branch '{$cursor}' for the 'cursor' value not found."); + } + + $offset += $cursorDirection === Database::CURSOR_AFTER ? $cursorIndex + 1 : 0; + + if ($cursorDirection === Database::CURSOR_BEFORE) { + $start = \max(0, $cursorIndex - $limit); + $branches = \array_slice($branches, $start, $cursorIndex - $start); + } else { + $branches = \array_slice($branches, $offset, $limit); + } + } else { + $branches = \array_slice($branches, $offset, $limit); + } + $response->dynamic(new Document([ 'branches' => \array_map(function ($branch) { return new Document(['name' => $branch]); }, $branches), - 'total' => \count($branches), + 'total' => $total, ]), Response::MODEL_BRANCH_LIST); } } diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 8167fb975d..a72b16cc23 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -10,6 +10,7 @@ use Appwrite\Event\Realtime; use Appwrite\Event\Webhook; use Appwrite\Extend\Exception as AppwriteException; use Appwrite\Utopia\Response\Model\Execution; +use Executor\Exception\Timeout as ExecutorTimeout; use Executor\Executor; use Utopia\Bus\Bus; use Utopia\Config\Config; @@ -565,24 +566,28 @@ class Functions extends Action Span::add('trigger', $trigger); Span::current()?->finish(); } - $executionResponse = $executor->createExecution( - projectId: $project->getId(), - deploymentId: $deploymentId, - body: \strlen($body) > 0 ? $body : null, - variables: $vars, - timeout: $function->getAttribute('timeout', 0), - image: $runtime['image'], - source: $source, - entrypoint: $deployment->getAttribute('entrypoint', ''), - version: $version, - path: $path, - method: $method, - headers: $headers, - runtimeEntrypoint: $command, - cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT, - memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, - logging: $function->getAttribute('logging', true), - ); + try { + $executionResponse = $executor->createExecution( + projectId: $project->getId(), + deploymentId: $deploymentId, + body: \strlen($body) > 0 ? $body : null, + variables: $vars, + timeout: $function->getAttribute('timeout', 0), + image: $runtime['image'], + source: $source, + entrypoint: $deployment->getAttribute('entrypoint', ''), + version: $version, + path: $path, + method: $method, + headers: $headers, + runtimeEntrypoint: $command, + cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT, + memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, + logging: $function->getAttribute('logging', true), + ); + } catch (ExecutorTimeout $th) { + throw new AppwriteException(AppwriteException::FUNCTION_ASYNCHRONOUS_TIMEOUT, previous: $th); + } $status = $executionResponse['statusCode'] >= 500 ? 'failed' : 'completed'; diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 3fd86baea9..b6c295b3bb 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -30,6 +30,7 @@ use Utopia\Migration\Destination; use Utopia\Migration\Destinations\Appwrite as DestinationAppwrite; use Utopia\Migration\Destinations\CSV as DestinationCSV; use Utopia\Migration\Destinations\JSON as DestinationJSON; +use Utopia\Migration\Destinations\OnDuplicate; use Utopia\Migration\Exception as MigrationException; use Utopia\Migration\Resource; use Utopia\Migration\Resources\Database\Database as ResourceDatabase; @@ -196,13 +197,13 @@ class Migrations extends Action $projectDB = null; $useAppwriteApiSource = false; if ($source === SourceAppwrite::getName() && empty($credentials['projectId'])) { - throw new \Exception('Source projectId is required for Appwrite migrations'); + throw new Exception(Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED); } if (! empty($credentials['projectId'])) { $this->sourceProject = $this->dbForPlatform->getDocument('projects', $credentials['projectId']); if ($this->sourceProject->isEmpty()) { - throw new \Exception('Source project not found for provided projectId'); + throw new Exception(Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND); } $sourceRegion = $this->sourceProject->getAttribute('region', 'default'); @@ -265,7 +266,7 @@ class Migrations extends Action $this->deviceForMigrations, $this->dbForProject, ), - default => throw new \Exception('Invalid source type'), + default => throw new Exception(Exception::MIGRATION_SOURCE_TYPE_INVALID), }; $resources = $migration->getAttribute('resources', []); @@ -291,6 +292,7 @@ class Migrations extends Action $this->dbForProject, $this->getDatabasesDB, Config::getParam('collections', [])['databases']['collections'], + OnDuplicate::tryFrom($options['onDuplicate'] ?? '') ?? OnDuplicate::Fail, ), DestinationCSV::getName() => new DestinationCSV( $this->deviceForFiles, @@ -310,7 +312,7 @@ class Migrations extends Action $options['filename'], $options['columns'] ?? [], ), - default => throw new \Exception('Invalid destination type'), + default => throw new Exception(Exception::MIGRATION_DESTINATION_TYPE_INVALID), }; } @@ -436,6 +438,7 @@ class Migrations extends Action $transfer = $source = $destination = null; $aggregatedResources = []; + $caughtError = null; $host = System::getEnv('_APP_MIGRATION_HOST'); if (empty($host)) { @@ -529,7 +532,6 @@ class Migrations extends Action if (!empty($sourceErrors) || ! empty($destinationErrors)) { $migration->setAttribute('status', 'failed'); $migration->setAttribute('stage', 'finished'); - $migration->setAttribute('errors', $this->sanitizeErrors($sourceErrors, $destinationErrors)); return; } @@ -544,35 +546,69 @@ class Migrations extends Action $migration->setAttribute('status', 'failed'); $migration->setAttribute('stage', 'finished'); - call_user_func($this->logError, $th, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [ - 'migrationId' => $migration->getId(), - 'source' => $migration->getAttribute('source') ?? '', - 'destination' => $migration->getAttribute('destination') ?? '', - ]); + $caughtError = $th; + // Mirror general.php's HTTP-error pattern: typed AppwriteException uses its + // registry-driven isPublishable() flag; library-thrown Migration\Exception is + // always user-facing; anything else is unknown and surfaced to Sentry. + if ($th instanceof Exception) { + $publish = $th->isPublishable(); + } elseif ($th instanceof MigrationException) { + $publish = false; + } else { + $publish = true; + } + + if ($publish) { + call_user_func($this->logError, $th, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [ + 'migrationId' => $migration->getId(), + 'source' => $migration->getAttribute('source') ?? '', + 'destination' => $migration->getAttribute('destination') ?? '', + ]); + } } finally { try { + $sourceErrors = $source?->getErrors() ?? []; + $destinationErrors = $destination?->getErrors() ?? []; + + if ($caughtError !== null) { + if ($caughtError instanceof MigrationException) { + // library-thrown, message constructed by us + $bubbled = $caughtError; + } elseif ($caughtError instanceof Exception) { + // typed AppwriteException — message comes from the curated registry + $bubbled = new MigrationException( + resourceName: '', + resourceGroup: '', + message: $caughtError->getMessage(), + code: $caughtError->getCode(), + previous: $caughtError, + ); + } else { + // unknown throwable — raw message may embed internal hostnames, + // DSNs, tokens, etc. Replace with a generic user-facing string; + // the original is preserved on `previous:` for Sentry. + $bubbled = new MigrationException( + resourceName: '', + resourceGroup: '', + message: 'Migration failed due to an unexpected error.', + code: $caughtError->getCode() ?: 500, + previous: $caughtError, + ); + } + $destinationErrors[] = $bubbled; + } + + $migration->setAttribute('errors', $this->sanitizeErrors( + $sourceErrors, + $destinationErrors, + )); + $this->updateMigrationDocument($migration, $project, $queueForRealtime); if ($migration->getAttribute('status', '') === 'failed') { Console::error('Migration(' . $migration->getSequence() . ':' . $migration->getId() . ') failed, Project(' . $this->project->getSequence() . ':' . $this->project->getId() . ')'); - $sourceErrors = $source?->getErrors() ?? []; - $destinationErrors = $destination?->getErrors() ?? []; - - foreach ([...$sourceErrors, ...$destinationErrors] as $error) { - /** @var MigrationException $error */ - if ($error->getCode() === 0 || $error->getCode() >= 500) { - ($this->logError)($error, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [ - 'migrationId' => $migration->getId(), - 'source' => $migration->getAttribute('source') ?? '', - 'destination' => $migration->getAttribute('destination') ?? '', - 'resourceName' => $error->getResourceName(), - 'resourceGroup' => $error->getResourceGroup(), - ]); - } - } - $source?->error(); $destination?->error(); } diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 30df5acf52..4c7fa6f377 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -68,6 +68,17 @@ abstract class Format 'mock-unverified' ], ], + [ + 'namespace' => 'project', + 'methods' => [ + 'getOAuth2Provider' + ], + 'parameter' => 'providerId', + 'excludeKeys' => [ + 'mock', + 'mock-unverified' + ], + ], ]; /** @@ -743,6 +754,24 @@ abstract class Format break; case 'project': switch ($method) { + case 'updateAuthMethod': + switch ($param) { + case 'methodId': + return 'AuthMethod'; + } + break; + case 'getPolicy': + switch ($param) { + case 'policyId': + return 'ProjectPolicy'; + } + break; + case 'getOAuth2Provider': + switch ($param) { + case 'providerId': + return 'OAuthProvider'; + } + break; case 'getEmailTemplate': case 'updateEmailTemplate': switch ($param) { diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 962bc8948a..f69ec3972a 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -524,6 +524,7 @@ class OpenAPI3 extends Format case \Appwrite\Utopia\Database\Validator\Queries\Identities::class: case \Appwrite\Utopia\Database\Validator\Queries\Indexes::class: case \Appwrite\Utopia\Database\Validator\Queries\Installations::class: + case \Appwrite\Utopia\Database\Validator\Queries\Branches::class: case \Appwrite\Utopia\Database\Validator\Queries\Memberships::class: case \Appwrite\Utopia\Database\Validator\Queries\Messages::class: case \Appwrite\Utopia\Database\Validator\Queries\Migrations::class: @@ -755,7 +756,18 @@ class OpenAPI3 extends Format $node['schema']['default'] = $param['default']; } - if (false !== \strpos($url, ':' . $name)) { // Param is in URL path + $pathAliases = [$name, ...($param['aliases'] ?? [])]; + $pathAliasMap = \array_flip($pathAliases); + $isPathParam = false; + + foreach (\explode('/', $url) as $segment) { + if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) { + $isPathParam = true; + break; + } + } + + if ($isPathParam) { // Param is in URL path (directly or through alias) $node['in'] = 'path'; $temp['parameters'][] = $node; } elseif ($route->getMethod() == 'GET') { // Param is in query @@ -796,7 +808,14 @@ class OpenAPI3 extends Format } } - $url = \str_replace(':' . $name, '{' . $name . '}', $url); + $segments = \explode('/', $url); + foreach ($segments as &$segment) { + if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) { + $segment = '{' . $name . '}'; + } + } + unset($segment); + $url = \implode('/', $segments); } if (!empty($bodyRequired)) { diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index d07d957577..52e33bcc27 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -511,6 +511,7 @@ class Swagger2 extends Format case \Utopia\Database\Validator\Queries::class: case \Utopia\Database\Validator\Queries\Document::class: case \Utopia\Database\Validator\Queries\Documents::class: + case \Appwrite\Utopia\Database\Validator\Queries\Branches::class: case \Appwrite\Utopia\Database\Validator\Queries\Columns::class: case \Appwrite\Utopia\Database\Validator\Queries\Tables::class: $node['type'] = 'array'; @@ -722,7 +723,18 @@ class Swagger2 extends Format $node['default'] = $param['default']; } - if (\str_contains($url, ':' . $name)) { // Param is in URL path + $pathAliases = [$name, ...($param['aliases'] ?? [])]; + $pathAliasMap = \array_flip($pathAliases); + $isPathParam = false; + + foreach (\explode('/', $url) as $segment) { + if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) { + $isPathParam = true; + break; + } + } + + if ($isPathParam) { // Param is in URL path (directly or through alias) $node['in'] = 'path'; $temp['parameters'][] = $node; } elseif ($route->getMethod() == 'GET') { // Param is in query @@ -767,7 +779,14 @@ class Swagger2 extends Format } } - $url = \str_replace(':' . $name, '{' . $name . '}', $url); + $segments = \explode('/', $url); + foreach ($segments as &$segment) { + if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) { + $segment = '{' . $name . '}'; + } + } + unset($segment); + $url = \implode('/', $segments); } if (!empty($bodyRequired)) { diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Branches.php b/src/Appwrite/Utopia/Database/Validator/Queries/Branches.php new file mode 100644 index 0000000000..82ca911747 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Branches.php @@ -0,0 +1,20 @@ +getMethod(); + + if (!\in_array($method, [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE], true)) { + $this->message = 'Invalid query method: ' . $method; + return false; + } + + $cursor = $value->getValue(); + + $validator = new Text(256); + if (!$validator->isValid($cursor)) { + $this->message = 'Invalid cursor: ' . $validator->getDescription(); + return false; + } + + return true; + } + + public function getMethodType(): string + { + return self::METHOD_TYPE_CURSOR; + } +} diff --git a/src/Appwrite/Utopia/Request/Filters/V25.php b/src/Appwrite/Utopia/Request/Filters/V25.php new file mode 100644 index 0000000000..cba70a5f7b --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V25.php @@ -0,0 +1,27 @@ +fillVariableId($content); + break; + } + + return $content; + } + + protected function fillVariableId(array $content): array + { + $content['variableId'] = $content['variableId'] ?? 'unique()'; + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Filters/V25.php b/src/Appwrite/Utopia/Response/Filters/V25.php new file mode 100644 index 0000000000..bda98ed0d8 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Filters/V25.php @@ -0,0 +1,34 @@ + $this->parseOAuth2Oidc($content), + Response::MODEL_OAUTH2_PROVIDER_LIST => $this->handleList($content, 'providers', fn ($item) => ($item['$id'] ?? null) === 'oidc' ? $this->parseOAuth2Oidc($item) : $item), + default => $content, + }; + } + + private function parseOAuth2Oidc(array $content): array + { + if (isset($content['tokenURL'])) { + $content['tokenUrl'] = $content['tokenURL']; + unset($content['tokenURL']); + } + + if (isset($content['userInfoURL'])) { + $content['userInfoUrl'] = $content['userInfoURL']; + unset($content['userInfoURL']); + } + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php b/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php index e4f0919666..0b18539423 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php @@ -42,13 +42,13 @@ class OAuth2Oidc extends OAuth2Base 'default' => '', 'example' => 'https://myoauth.com/oauth2/authorize', ]) - ->addRule('tokenUrl', [ + ->addRule('tokenURL', [ 'type' => self::TYPE_STRING, 'description' => 'OpenID Connect token endpoint URL.', 'default' => '', 'example' => 'https://myoauth.com/oauth2/token', ]) - ->addRule('userInfoUrl', [ + ->addRule('userInfoURL', [ 'type' => self::TYPE_STRING, 'description' => 'OpenID Connect user info endpoint URL.', 'default' => '', diff --git a/src/Executor/Exception.php b/src/Executor/Exception.php new file mode 100644 index 0000000000..b799d22567 --- /dev/null +++ b/src/Executor/Exception.php @@ -0,0 +1,7 @@ +timeoutSeconds; + } +} diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index eb74867c9c..c570970732 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -2,9 +2,9 @@ namespace Executor; -use Appwrite\Extend\Exception as AppwriteException; use Appwrite\Utopia\Fetch\BodyMultipart; -use Exception; +use Executor\Exception as ExecutorException; +use Executor\Exception\Timeout as ExecutorTimeout; use Utopia\System\System; class Executor @@ -104,7 +104,7 @@ class Executor $status = $response['headers']['status-code']; if ($status >= 400) { $message = \is_string($response['body']) ? $response['body'] : $response['body']['message']; - throw new \Exception($message, $status); + throw new ExecutorException($message, $status); } return $response['body']; @@ -163,7 +163,7 @@ class Executor } if ($status >= 400) { - throw new \Exception($message, $status); + throw new ExecutorException($message, $status); } return $response['body']; @@ -247,7 +247,7 @@ class Executor $status = $response['headers']['status-code']; if ($status >= 400) { $message = \is_string($response['body']) ? $response['body'] : $response['body']['message']; - throw new \Exception($message, $status); + throw new ExecutorException($message, $status); } $headers = $response['body']['headers'] ?? []; @@ -281,7 +281,7 @@ class Executor $status = $response['headers']['status-code']; if ($status >= 400) { $message = \is_string($response['body']) ? $response['body'] : $response['body']['message']; - throw new \Exception($message, $status); + throw new ExecutorException($message, $status); } return $response['body']; @@ -401,7 +401,7 @@ class Executor $json = json_decode($responseBody, true); if ($json === null) { - throw new Exception('Failed to parse response: ' . $responseBody); + throw new ExecutorException('Failed to parse response: ' . $responseBody); } $responseBody = $json; @@ -412,9 +412,9 @@ class Executor if ($curlError) { if ($curlError == CURLE_OPERATION_TIMEDOUT) { - throw new AppwriteException(AppwriteException::FUNCTION_SYNCHRONOUS_TIMEOUT); + throw new ExecutorTimeout('Executor request timed out', $timeout); } - throw new Exception($curlErrorMessage . ' with status code ' . $responseStatus, $responseStatus); + throw new ExecutorException($curlErrorMessage . ' with status code ' . $responseStatus, $responseStatus); } $responseHeaders['status-code'] = $responseStatus; diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index f7bb54024d..ef42e99663 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -380,6 +380,7 @@ function computeFlow(ctx) { api('GET', '/functions/runtimes', null, ctx.sessionHeaders, [200], 'functions.runtimes.list'); api('GET', '/functions/specifications', null, ctx.apiHeaders, [200], 'functions.specifications.list'); const functionVariable = api('POST', `/functions/${functionId}/variables`, { + variableId: 'unique()', key: 'BENCHMARK', value: 'true', secret: false, diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index da788c3caa..160ee39e21 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -4163,4 +4163,72 @@ class AccountCustomClientTest extends Scope $this->assertEquals(401, $verification3['headers']['status-code']); } + + public function testRefreshEmailPasswordSession(): void + { + $email = uniqid() . 'user@localhost.test'; + + $account = $this->client->call(Client::METHOD_POST, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => 'password', + ]); + + $this->assertEquals(201, $account['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => $email, + 'password' => 'password', + ]); + + $this->assertEquals(201, $session['headers']['status-code']); + $this->assertNotEmpty($session['body']['$id']); + + $sessionId = $session['body']['$id']; + $cookie = 'a_session_' . $this->getProject()['$id'] . '=' .$session['cookies']['a_session_' . $this->getProject()['$id']]; + + $session = $this->client->call(Client::METHOD_GET, '/account/sessions/current', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => $cookie, + ])); + + $this->assertEquals(200, $session['headers']['status-code']); + $this->assertNotEmpty($session['body']['expire']); + $expiryBefore = $session['body']['expire']; + + \sleep(3); // Small delay to ensure expiry an expand + + $session = $this->client->call(Client::METHOD_PATCH, '/account/sessions/' . $sessionId, array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => $cookie, + ])); + + $this->assertEquals(200, $session['headers']['status-code']); + $this->assertNotEmpty($session['body']['expire']); + $expiryAfter = $session['body']['expire']; + + $this->assertGreaterThan(\strtotime($expiryBefore), \strtotime($expiryAfter)); + + $session = $this->client->call(Client::METHOD_GET, '/account/sessions/current', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => $cookie, + ])); + + $this->assertEquals(200, $session['headers']['status-code']); + $this->assertEquals(\strtotime($expiryAfter), \strtotime($session['body']['expire'])); + } } diff --git a/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php b/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php index 06044d9984..5d501486fd 100644 --- a/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php +++ b/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php @@ -7,8 +7,10 @@ use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideConsole; use Utopia\Console; +use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Role; +use Utopia\Database\Query; class FunctionsConsoleClientTest extends Scope { @@ -70,6 +72,7 @@ class FunctionsConsoleClientTest extends Scope $variable = $this->createVariable( $functionId, [ + 'variableId' => ID::unique(), 'key' => 'APP_TEST', 'value' => 'TESTINGVALUE', 'secret' => false @@ -82,6 +85,7 @@ class FunctionsConsoleClientTest extends Scope $secretVariable = $this->createVariable( $functionId, [ + 'variableId' => ID::unique(), 'key' => 'APP_TEST_1', 'value' => 'TESTINGVALUE_1', 'secret' => true @@ -196,6 +200,7 @@ class FunctionsConsoleClientTest extends Scope $variable = $this->createVariable( $functionId, [ + 'variableId' => ID::unique(), 'key' => 'APP_TEST', 'value' => 'TESTINGVALUE', 'secret' => false @@ -208,6 +213,7 @@ class FunctionsConsoleClientTest extends Scope $variable = $this->createVariable( $functionId, [ + 'variableId' => ID::unique(), 'key' => 'APP_TEST_1', 'value' => 'TESTINGVALUE_1', 'secret' => true @@ -226,6 +232,7 @@ class FunctionsConsoleClientTest extends Scope $variable = $this->createVariable( $functionId, [ + 'variableId' => ID::unique(), 'key' => 'APP_TEST', 'value' => 'ANOTHERTESTINGVALUE', 'secret' => false @@ -234,10 +241,47 @@ class FunctionsConsoleClientTest extends Scope $this->assertEquals(409, $variable['headers']['status-code']); + // Test for invalid variableId + $variable = $this->createVariable( + $functionId, + [ + 'variableId' => '!invalid-id!', + 'key' => 'INVALID_ID_KEY', + 'value' => 'value', + ] + ); + + $this->assertEquals(400, $variable['headers']['status-code']); + + // Test for duplicate variableId + $duplicateVariableId = ID::unique(); + $variable = $this->createVariable( + $functionId, + [ + 'variableId' => $duplicateVariableId, + 'key' => 'DUP_ID_KEY_1', + 'value' => 'value1', + ] + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + + $duplicate = $this->createVariable( + $functionId, + [ + 'variableId' => $duplicateVariableId, + 'key' => 'DUP_ID_KEY_2', + 'value' => 'value2', + ] + ); + + $this->assertEquals(409, $duplicate['headers']['status-code']); + // Test for invalid key $variable = $this->createVariable( $functionId, [ + 'variableId' => ID::unique(), 'key' => str_repeat("A", 256), 'value' => 'TESTINGVALUE' ] @@ -249,6 +293,7 @@ class FunctionsConsoleClientTest extends Scope $variable = $this->createVariable( $functionId, [ + 'variableId' => ID::unique(), 'key' => 'LONGKEY', 'value' => str_repeat("#", 8193), ] @@ -283,6 +328,150 @@ class FunctionsConsoleClientTest extends Scope */ } + public function testListVariablesWithLimit(): void + { + // Create a fresh function for this test + $function = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test List Variables With Limit', + 'execute' => [Role::user($this->getUser()['$id'])->toString()], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 10, + ]); + $this->assertEquals(201, $function['headers']['status-code']); + $functionId = $function['body']['$id']; + + $variable1 = $this->createVariable($functionId, [ + 'variableId' => ID::unique(), + 'key' => 'LIMIT_KEY_1', + 'value' => 'limit-value-1', + ]); + $this->assertEquals(201, $variable1['headers']['status-code']); + + $variable2 = $this->createVariable($functionId, [ + 'variableId' => ID::unique(), + 'key' => 'LIMIT_KEY_2', + 'value' => 'limit-value-2', + ]); + $this->assertEquals(201, $variable2['headers']['status-code']); + + // List with limit of 1 + $response = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/variables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::limit(1)->toString(), + ], + 'total' => true, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['variables']); + $this->assertGreaterThanOrEqual(2, $response['body']['total']); + + $this->cleanupFunction($functionId); + } + + public function testListVariablesWithoutTotal(): void + { + // Create a fresh function for this test + $function = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test List Variables Without Total', + 'execute' => [Role::user($this->getUser()['$id'])->toString()], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 10, + ]); + $this->assertEquals(201, $function['headers']['status-code']); + $functionId = $function['body']['$id']; + + $variable = $this->createVariable($functionId, [ + 'variableId' => ID::unique(), + 'key' => 'NO_TOTAL_KEY', + 'value' => 'no-total-value', + ]); + $this->assertEquals(201, $variable['headers']['status-code']); + + // List with total=false + $response = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/variables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'total' => false, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(0, $response['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($response['body']['variables'])); + + $this->cleanupFunction($functionId); + } + + public function testListVariablesCursorPagination(): void + { + // Create a fresh function for this test + $function = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test List Variables Cursor Pagination', + 'execute' => [Role::user($this->getUser()['$id'])->toString()], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 10, + ]); + $this->assertEquals(201, $function['headers']['status-code']); + $functionId = $function['body']['$id']; + + $variable1 = $this->createVariable($functionId, [ + 'variableId' => ID::unique(), + 'key' => 'CURSOR_KEY_1', + 'value' => 'cursor-value-1', + ]); + $this->assertEquals(201, $variable1['headers']['status-code']); + + $variable2 = $this->createVariable($functionId, [ + 'variableId' => ID::unique(), + 'key' => 'CURSOR_KEY_2', + 'value' => 'cursor-value-2', + ]); + $this->assertEquals(201, $variable2['headers']['status-code']); + + // Get first page with limit 1 + $page1 = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/variables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::limit(1)->toString(), + ], + 'total' => true, + ]); + + $this->assertEquals(200, $page1['headers']['status-code']); + $this->assertCount(1, $page1['body']['variables']); + $cursorId = $page1['body']['variables'][0]['$id']; + + // Get next page using cursor + $page2 = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/variables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::limit(1)->toString(), + Query::cursorAfter(new Document(['$id' => $cursorId]))->toString(), + ], + 'total' => true, + ]); + + $this->assertEquals(200, $page2['headers']['status-code']); + $this->assertCount(1, $page2['body']['variables']); + $this->assertNotEquals($cursorId, $page2['body']['variables'][0]['$id']); + + $this->cleanupFunction($functionId); + } + public function testGetVariable(): void { $data = $this->setupTestVariables(); @@ -337,6 +526,7 @@ class FunctionsConsoleClientTest extends Scope $functionId = $function['body']['$id']; $variable = $this->createVariable($functionId, [ + 'variableId' => ID::unique(), 'key' => 'APP_TEST', 'value' => 'TESTINGVALUE', 'secret' => false @@ -345,6 +535,7 @@ class FunctionsConsoleClientTest extends Scope $variableId = $variable['body']['$id']; $secretVariable = $this->createVariable($functionId, [ + 'variableId' => ID::unique(), 'key' => 'APP_TEST_1', 'value' => 'TESTINGVALUE_1', 'secret' => true @@ -457,6 +648,7 @@ class FunctionsConsoleClientTest extends Scope * Test for FAILURE */ + // Update with no parameters should fail with 400 $response = $this->client->call(Client::METHOD_PUT, '/functions/' . $functionId . '/variables/' . $variableId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -464,6 +656,7 @@ class FunctionsConsoleClientTest extends Scope $this->assertEquals(400, $response['headers']['status-code']); + // Update with only value should succeed $response = $this->client->call(Client::METHOD_PUT, '/functions/' . $functionId . '/variables/' . $variableId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -471,7 +664,7 @@ class FunctionsConsoleClientTest extends Scope 'value' => 'TESTINGVALUEUPDATED_2' ]); - $this->assertEquals(400, $response['headers']['status-code']); + $this->assertEquals(200, $response['headers']['status-code']); $longKey = str_repeat("A", 256); $response = $this->client->call(Client::METHOD_PUT, '/functions/' . $functionId . '/variables/' . $variableId, array_merge([ @@ -496,6 +689,110 @@ class FunctionsConsoleClientTest extends Scope $this->assertEquals(400, $response['headers']['status-code']); } + public function testUpdateVariableKey(): void + { + // Create a fresh function and variable for this test + $function = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test Update Variable Key', + 'execute' => [Role::user($this->getUser()['$id'])->toString()], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 10, + ]); + $this->assertEquals(201, $function['headers']['status-code']); + $functionId = $function['body']['$id']; + + $variable = $this->createVariable($functionId, [ + 'variableId' => ID::unique(), + 'key' => 'KEY_BEFORE', + 'value' => 'unchanged-value', + 'secret' => false + ]); + $this->assertEquals(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Update only key (key is nullable, but we provide a new key) + $response = $this->client->call(Client::METHOD_PUT, '/functions/' . $functionId . '/variables/' . $variableId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'key' => 'KEY_AFTER', + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('KEY_AFTER', $response['body']['key']); + $this->assertEquals('unchanged-value', $response['body']['value']); + + $this->cleanupFunction($functionId); + } + + public function testUpdateVariableValueOnly(): void + { + // Create a fresh function and variable for this test + $function = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test Update Variable Value', + 'execute' => [Role::user($this->getUser()['$id'])->toString()], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 10, + ]); + $this->assertEquals(201, $function['headers']['status-code']); + $functionId = $function['body']['$id']; + + $variable = $this->createVariable($functionId, [ + 'variableId' => ID::unique(), + 'key' => 'UNCHANGED_KEY', + 'value' => 'value-before', + 'secret' => false + ]); + $this->assertEquals(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Update only value + $response = $this->client->call(Client::METHOD_PUT, '/functions/' . $functionId . '/variables/' . $variableId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'value' => 'value-after', + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('UNCHANGED_KEY', $response['body']['key']); + $this->assertEquals('value-after', $response['body']['value']); + + $this->cleanupFunction($functionId); + } + + public function testUpdateVariableNotFound(): void + { + // Create a fresh function for this test + $function = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test Update Variable Not Found', + 'execute' => [Role::user($this->getUser()['$id'])->toString()], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 10, + ]); + $this->assertEquals(201, $function['headers']['status-code']); + $functionId = $function['body']['$id']; + + $response = $this->client->call(Client::METHOD_PUT, '/functions/' . $functionId . '/variables/non-existent-id', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'key' => 'NEW_KEY', + 'value' => 'new-value', + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + $this->assertEquals('variable_not_found', $response['body']['type']); + + $this->cleanupFunction($functionId); + } + public function testDeleteVariable(): void { // Create a fresh function and variables for this test since it deletes them @@ -512,6 +809,7 @@ class FunctionsConsoleClientTest extends Scope $functionId = $function['body']['$id']; $variable = $this->createVariable($functionId, [ + 'variableId' => ID::unique(), 'key' => 'APP_TEST', 'value' => 'TESTINGVALUE', 'secret' => false @@ -520,6 +818,7 @@ class FunctionsConsoleClientTest extends Scope $variableId = $variable['body']['$id']; $secretVariable = $this->createVariable($functionId, [ + 'variableId' => ID::unique(), 'key' => 'APP_TEST_1', 'value' => 'TESTINGVALUE_1', 'secret' => true @@ -585,6 +884,7 @@ class FunctionsConsoleClientTest extends Scope // create variable $variable = $this->createVariable($functionId, [ + 'variableId' => ID::unique(), 'key' => 'CUSTOM_VARIABLE', 'value' => 'a_secret_value', 'secret' => true, diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 899c0ff71f..f08b711fb2 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -53,14 +53,17 @@ class FunctionsCustomServerTest extends Scope $functionId = $function['body']['$id'] ?? ''; $variable = $this->createVariable($functionId, [ + 'variableId' => 'unique()', 'key' => 'funcKey1', 'value' => 'funcValue1', ]); $variable2 = $this->createVariable($functionId, [ + 'variableId' => 'unique()', 'key' => 'funcKey2', 'value' => 'funcValue2', ]); $variable3 = $this->createVariable($functionId, [ + 'variableId' => 'unique()', 'key' => 'funcKey3', 'value' => 'funcValue3', ]); @@ -109,6 +112,7 @@ class FunctionsCustomServerTest extends Scope // Create a variable for later tests $variable = $this->createVariable($functionId, [ + 'variableId' => 'unique()', 'key' => 'GLOBAL_VARIABLE', 'value' => 'Global Variable Value', ]); @@ -278,14 +282,17 @@ class FunctionsCustomServerTest extends Scope $this->assertEquals(10, $function['body']['timeout']); $variable = $this->createVariable($functionId, [ + 'variableId' => 'unique()', 'key' => 'funcKey1', 'value' => 'funcValue1', ]); $variable2 = $this->createVariable($functionId, [ + 'variableId' => 'unique()', 'key' => 'funcKey2', 'value' => 'funcValue2', ]); $variable3 = $this->createVariable($functionId, [ + 'variableId' => 'unique()', 'key' => 'funcKey3', 'value' => 'funcValue3', ]); @@ -521,6 +528,7 @@ class FunctionsCustomServerTest extends Scope // Create a variable for later tests $variable = $this->createVariable($functionId, [ + 'variableId' => 'unique()', 'key' => 'GLOBAL_VARIABLE', 'value' => 'Global Variable Value', ]); @@ -2011,6 +2019,7 @@ class FunctionsCustomServerTest extends Scope ]); $variable = $this->createVariable($functionId, [ + 'variableId' => 'unique()', 'key' => 'CUSTOM_VARIABLE', 'value' => 'variable' ]); diff --git a/tests/e2e/Services/GraphQL/Base.php b/tests/e2e/Services/GraphQL/Base.php index c42679018e..9010490cc9 100644 --- a/tests/e2e/Services/GraphQL/Base.php +++ b/tests/e2e/Services/GraphQL/Base.php @@ -4,6 +4,7 @@ namespace Tests\E2E\Services\GraphQL; use CURLFile; use Utopia\Console; +use Utopia\Image\Image; trait Base { @@ -516,6 +517,21 @@ trait Base } '; + protected function assertFilePreviewResponse(array $file): void + { + $this->assertEquals(200, $file['headers']['status-code']); + $this->assertEquals('image/png', $file['headers']['content-type']); + $this->assertNotEmpty($file['body']); + + $image = new Image($file['body']); + $dimensions = \getimagesizefromstring($file['body']); + + $this->assertNotEmpty($image->output('png')); + $this->assertIsArray($dimensions); + $this->assertEquals(100, $dimensions[0]); + $this->assertEquals(100, $dimensions[1]); + } + public function getQuery(string $name): string { switch ($name) { @@ -2388,8 +2404,8 @@ trait Base } }'; case self::GET_FILE_PREVIEW: - return 'query getFilePreview($bucketId: String!, $fileId: String!) { - storageGetFilePreview(bucketId: $bucketId, fileId: $fileId) { + return 'query getFilePreview($bucketId: String!, $fileId: String!, $width: Int, $height: Int) { + storageGetFilePreview(bucketId: $bucketId, fileId: $fileId, width: $width, height: $height) { status } }'; diff --git a/tests/e2e/Services/GraphQL/FunctionsClientTest.php b/tests/e2e/Services/GraphQL/FunctionsClientTest.php index ed436ad075..e8e033f353 100644 --- a/tests/e2e/Services/GraphQL/FunctionsClientTest.php +++ b/tests/e2e/Services/GraphQL/FunctionsClientTest.php @@ -55,10 +55,10 @@ class FunctionsClientTest extends Scope $query = ' mutation createVariables($functionId: String!) { - var1: functionsCreateVariable(functionId: $functionId, key: "name", value: "John Doe") { + var1: functionsCreateVariable(functionId: $functionId, variableId: "unique()", key: "name", value: "John Doe") { _id } - var2: functionsCreateVariable(functionId: $functionId, key: "age", value: "42") { + var2: functionsCreateVariable(functionId: $functionId, variableId: "unique()", key: "age", value: "42") { _id } } diff --git a/tests/e2e/Services/GraphQL/FunctionsServerTest.php b/tests/e2e/Services/GraphQL/FunctionsServerTest.php index 572fde49bf..95b52bcbe3 100644 --- a/tests/e2e/Services/GraphQL/FunctionsServerTest.php +++ b/tests/e2e/Services/GraphQL/FunctionsServerTest.php @@ -55,10 +55,10 @@ class FunctionsServerTest extends Scope $query = ' mutation createVariables($functionId: String!) { - var1: functionsCreateVariable(functionId: $functionId, key: "name", value: "John Doe") { + var1: functionsCreateVariable(functionId: $functionId, variableId: "unique()", key: "name", value: "John Doe") { _id } - var2: functionsCreateVariable(functionId: $functionId, key: "age", value: "42") { + var2: functionsCreateVariable(functionId: $functionId, variableId: "unique()", key: "age", value: "42") { _id } } diff --git a/tests/e2e/Services/GraphQL/StorageClientTest.php b/tests/e2e/Services/GraphQL/StorageClientTest.php index dd89819c34..9cdf523a0a 100644 --- a/tests/e2e/Services/GraphQL/StorageClientTest.php +++ b/tests/e2e/Services/GraphQL/StorageClientTest.php @@ -200,7 +200,7 @@ class StorageClientTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - $this->assertEquals(46719, \strlen($file['body'])); + $this->assertFilePreviewResponse($file); return $file; } diff --git a/tests/e2e/Services/GraphQL/StorageServerTest.php b/tests/e2e/Services/GraphQL/StorageServerTest.php index 1377ef9207..7808c50be6 100644 --- a/tests/e2e/Services/GraphQL/StorageServerTest.php +++ b/tests/e2e/Services/GraphQL/StorageServerTest.php @@ -262,7 +262,7 @@ class StorageServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - $this->assertEquals(46719, \strlen($file['body'])); + $this->assertFilePreviewResponse($file); return $file; } diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 0d38ef77d8..8dd5b2fef6 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -761,6 +761,1275 @@ trait MigrationsBase self::$cachedTableData = []; } + /** Rows under all three modes; schema tolerance lets every run hit 'completed'. */ + public function testAppwriteMigrationRowsOnDuplicate(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + + $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ + 'rowId' => ID::unique(), + 'data' => ['name' => 'Original'], + ]); + $this->assertEquals(201, $row['headers']['status-code']); + $rowId = $row['body']['$id']; + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + ]; + + // First migration: destination is empty, strict completion expected. + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + // Mutate destination row to prove onDuplicate=skip preserves it. + $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders, [ + 'data' => ['name' => 'Mutated'], + ]); + $this->assertEquals(200, $mutate['headers']['status-code']); + $this->assertEquals('Mutated', $mutate['body']['name']); + + // Re-migration with onDuplicate=skip — completion is strict because + // DestinationAppwrite tolerates existing schema resources. + $skipResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'skip', + ]); + $this->assertEquals('completed', $skipResult['status']); + + $rowAfterSkip = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); + $this->assertEquals(200, $rowAfterSkip['headers']['status-code']); + $this->assertEquals('Mutated', $rowAfterSkip['body']['name'], 'onDuplicate=skip must not overwrite destination row'); + + // Re-migration with onDuplicate=overwrite — strict completion; destination + // row restored to source value. + $overwriteResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'overwrite', + ]); + $this->assertEquals('completed', $overwriteResult['status']); + + $rowAfterOverwrite = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); + $this->assertEquals(200, $rowAfterOverwrite['headers']['status-code']); + $this->assertEquals('Original', $rowAfterOverwrite['body']['name'], 'onDuplicate=overwrite must restore source value'); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + + /** Unchanged source under Skip/Overwrite is a no-op — every resource Tolerated. */ + public function testAppwriteMigrationReRunIsIdempotent(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + + // Seed two rows on source so the row-level tolerance is exercised too. + foreach (['row-a', 'row-b'] as $rowId) { + $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ + 'rowId' => $rowId, + 'data' => ['name' => 'Seeded ' . $rowId], + ]); + $this->assertEquals(201, $row['headers']['status-code']); + } + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + ]; + + // First migration: fresh destination. + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + // Re-run under Skip: nothing on source has changed. Destination + // schema + rows are already correct — expect clean completion. + $reRunSkip = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'skip', + ]); + $this->assertEquals('completed', $reRunSkip['status']); + + // Re-run under Overwrite: same unchanged source. Schema tolerance path + // fires for each resource; rows go through DB-native upsert. + $reRunOverwrite = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'overwrite', + ]); + $this->assertEquals('completed', $reRunOverwrite['status']); + + foreach (['row-a', 'row-b'] as $rowId) { + $check = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); + $this->assertEquals(200, $check['headers']['status-code']); + $this->assertEquals('Seeded ' . $rowId, $check['body']['name']); + } + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + + /** Overwrite reconciles container drift via UpdateInPlace; children (rows) preserved. */ + public function testAppwriteMigrationOverwriteUpdatesContainerMetadata(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + $rowId = 'persist-me'; + + $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ + 'rowId' => $rowId, + 'data' => ['name' => 'SeedRow'], + ]); + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + ]; + + // First migration — dest empty, strict completion. + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + // `_updatedAt` is stored at second granularity (strtotime) — ensure + // the source edits below produce a strictly-newer timestamp than + // dest's first-migration timestamp. + sleep(1); + + // Mutate source: rename database + toggle table enabled. + $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId, $sourceHeaders, [ + 'name' => 'Renamed Source DB', + ]); + $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $sourceHeaders, [ + 'name' => 'Renamed Source Table', + 'permissions' => [Permission::read(Role::any())], + 'rowSecurity' => true, + 'enabled' => false, + ]); + + // Overwrite re-migration: UpdateInPlace path fires for database + table. + $overwriteResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'overwrite', + ]); + $this->assertEquals('completed', $overwriteResult['status']); + + // Assert dest database metadata reflects source's new values. + $destDb = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId, $destHeaders); + $this->assertEquals(200, $destDb['headers']['status-code']); + $this->assertEquals('Renamed Source DB', $destDb['body']['name']); + + // Assert dest table metadata reflects source's new values. + $destTable = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders); + $this->assertEquals(200, $destTable['headers']['status-code']); + $this->assertEquals('Renamed Source Table', $destTable['body']['name']); + $this->assertFalse($destTable['body']['enabled'], 'Overwrite must propagate source enabled=false'); + $this->assertTrue($destTable['body']['documentSecurity'] ?? $destTable['body']['rowSecurity'], 'Overwrite must propagate source rowSecurity=true'); + + // Child row untouched — UpdateInPlace only rewrites container metadata. + $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); + $this->assertEquals(200, $row['headers']['status-code']); + $this->assertEquals('SeedRow', $row['body']['name'], 'Overwrite must not touch child rows when updating container metadata'); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + + /** Skip preserves dest container drift even when source has diverged. */ + public function testAppwriteMigrationSkipPreservesContainerDrift(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + ]; + + // First migration: dest gets whatever source had. + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + sleep(1); + + // Mutate dest: ops tightens permissions and renames the table for + // its production-specific branding. + $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders, [ + 'name' => 'Dest-Managed Table', + 'permissions' => [Permission::read(Role::users())], + 'rowSecurity' => false, + 'enabled' => true, + ]); + + // Also mutate source so the second run has a real divergence. + $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $sourceHeaders, [ + 'name' => 'Source Renamed', + 'permissions' => [Permission::read(Role::any())], + 'rowSecurity' => true, + 'enabled' => false, + ]); + + // Skip re-migration: must tolerate existing destination — no update. + $skipResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'skip', + ]); + $this->assertEquals('completed', $skipResult['status']); + + // Dest kept its tightened values. + $destTable = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders); + $this->assertEquals(200, $destTable['headers']['status-code']); + $this->assertEquals('Dest-Managed Table', $destTable['body']['name'], 'Skip must not propagate source name over dest drift'); + $this->assertTrue($destTable['body']['enabled'], 'Skip must preserve dest enabled flag'); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + + /** Overwrite drops dest columns source no longer declares; cleanup runs before rows land. */ + public function testAppwriteMigrationOverwriteDropsOrphanColumn(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + ]; + + // First migration: dest mirrors source (one column 'name'). + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + // Add an orphan column directly on destination (not on source). + // Simulates the post-rename state: source dropped a column, dest + // still has it — or a dest-only column added by a separate app. + $orphanResp = $this->client->call( + Client::METHOD_POST, + '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', + $destHeaders, + [ + 'key' => 'orphan_col', + 'size' => 50, + 'required' => false, + ] + ); + $this->assertEquals(202, $orphanResp['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/orphan_col', $destHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + }, 5000, 500); + + // Seed a row on source so per-table orphan cleanup fires inside + // createRecord (before rows land), not just at end of run. + $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ + 'rowId' => ID::unique(), + 'data' => ['name' => 'seed'], + ]); + + // Overwrite re-migration: orphan_col must be dropped from dest. + $overwriteResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'overwrite', + ]); + $this->assertEquals('completed', $overwriteResult['status']); + + // Orphan column dropped. + $orphanCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/orphan_col', $destHeaders); + $this->assertEquals(404, $orphanCheck['headers']['status-code'], 'Overwrite must drop destination column source no longer declares'); + + // Source's column preserved. + $nameCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); + $this->assertEquals(200, $nameCheck['headers']['status-code'], 'Overwrite must preserve columns source declared'); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + + /** Skip preserves orphan columns; cleanup is Overwrite-only. */ + public function testAppwriteMigrationSkipKeepsOrphanColumn(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + ]; + + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + $orphanResp = $this->client->call( + Client::METHOD_POST, + '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', + $destHeaders, + [ + 'key' => 'dest_only_col', + 'size' => 50, + 'required' => false, + ] + ); + $this->assertEquals(202, $orphanResp['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/dest_only_col', $destHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + }, 5000, 500); + + $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ + 'rowId' => ID::unique(), + 'data' => ['name' => 'seed'], + ]); + + // Skip re-migration: orphan column must NOT be dropped. + $skipResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'skip', + ]); + $this->assertEquals('completed', $skipResult['status']); + + $orphanCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/dest_only_col', $destHeaders); + $this->assertEquals(200, $orphanCheck['headers']['status-code'], 'Skip must preserve destination columns, including orphans'); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + + /** SDK-reachable attribute change propagates via updateAttributeInPlace; row data preserved. */ + public function testAppwriteMigrationOverwriteUpdatesAttributeInPlace(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + $rowId = 'persist-on-inplace'; + + // Seed a row that proves drop+recreate didn't happen — recreate would + // have wiped this column's data on the destination. + $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ + 'rowId' => $rowId, + 'data' => ['name' => 'SeedRow'], + ]); + $this->assertEquals(201, $row['headers']['status-code']); + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + ]; + + // First migration — dest gets the column as required:true. + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + $beforeUpdate = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); + $this->assertEquals(200, $beforeUpdate['headers']['status-code']); + $this->assertTrue($beforeUpdate['body']['required']); + + // _updatedAt has second granularity; ensure source's PATCH produces a + // strictly-newer timestamp than the dest's first-migration value. + sleep(1); + + // SDK-reachable change set: required true→false, default null→'unknown'. + // Both fields are supported by PATCH /columns/string/:key — must route + // through updateAttributeInPlace, not DropAndRecreate. + $patch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $sourceHeaders, [ + 'required' => false, + 'default' => 'unknown', + ]); + $this->assertEquals(200, $patch['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + $this->assertFalse($r['body']['required']); + $this->assertEquals('unknown', $r['body']['default']); + }, 5000, 500); + + $overwriteResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'overwrite', + ]); + $this->assertEquals('completed', $overwriteResult['status']); + + $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + $this->assertFalse($r['body']['required'], 'updateAttributeInPlace must propagate source required=false'); + $this->assertEquals('unknown', $r['body']['default'], 'updateAttributeInPlace must propagate source default'); + }, 10000, 500); + + // Pre-existing row preserved — proof that the path was UpdateInPlace + // and not DropAndRecreate (which would have nulled this column). + $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); + $this->assertEquals(200, $rowAfter['headers']['status-code']); + $this->assertEquals('SeedRow', $rowAfter['body']['name'], 'updateAttributeInPlace must not touch row data'); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + + /** Skip preserves dest attribute drift; leaf-level analog of the container drift test. */ + public function testAppwriteMigrationSkipPreservesAttributeDrift(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + ]; + + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + sleep(1); + + // Dest divergence: ops loosens the column for a production-only need. + $destPatch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $destHeaders, [ + 'required' => false, + 'default' => 'dest-default', + ]); + $this->assertEquals(200, $destPatch['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + $this->assertFalse($r['body']['required']); + }, 5000, 500); + + sleep(1); + + // Source advances strictly later (and to a different value). Under + // Overwrite this would propagate to dest; under Skip it must not. + $sourcePatch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $sourceHeaders, [ + 'required' => true, + 'default' => null, + ]); + $this->assertEquals(200, $sourcePatch['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + $this->assertTrue($r['body']['required']); + }, 5000, 500); + + $skipResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'skip', + ]); + $this->assertEquals('completed', $skipResult['status']); + + $destAttr = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); + $this->assertEquals(200, $destAttr['headers']['status-code']); + $this->assertFalse($destAttr['body']['required'], 'Skip must not propagate source required over dest drift'); + $this->assertEquals('dest-default', $destAttr['body']['default'], 'Skip must preserve dest default'); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + + /** Two-way onDelete change updates in place on both sides; partner meta refreshed by hand. */ + public function testAppwriteMigrationOverwriteUpdatesRelationshipOnDeleteInPlace(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $databaseId = ID::unique(); + $createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [ + 'databaseId' => $databaseId, + 'name' => 'Rel In-Place DB', + ]); + $this->assertEquals(201, $createDb['headers']['status-code']); + + foreach (['parents', 'children'] as $tbl) { + $createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [ + 'tableId' => $tbl, + 'name' => $tbl, + ]); + $this->assertEquals(201, $createTable['headers']['status-code']); + } + + // Two-way: parents.kids ↔ children.parent. Required to hit the in-place path. + $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [ + 'relatedTableId' => 'children', + 'type' => Database::RELATION_ONE_TO_MANY, + 'twoWay' => true, + 'key' => 'kids', + 'twoWayKey' => 'parent', + 'onDelete' => Database::RELATION_MUTATE_CASCADE, + ]); + $this->assertEquals(202, $createRel['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $r['body']['onDelete']); + }, 10000, 500); + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + ]; + + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + // Both sides land on dest with onDelete=cascade. + $this->assertEventually(function () use ($databaseId, $destHeaders) { + $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); + $this->assertEquals(200, $parent['headers']['status-code']); + $this->assertEquals('available', $parent['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $parent['body']['onDelete']); + + $child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders); + $this->assertEquals(200, $child['headers']['status-code']); + $this->assertEquals('available', $child['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $child['body']['onDelete']); + }, 10000, 500); + + sleep(1); + + // SDK-reachable: PATCH /columns/:key/relationship accepts onDelete. + $patch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids/relationship', $sourceHeaders, [ + 'onDelete' => Database::RELATION_MUTATE_RESTRICT, + ]); + $this->assertEquals(200, $patch['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete']); + }, 5000, 500); + + $overwriteResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'overwrite', + ]); + $this->assertEquals('completed', $overwriteResult['status']); + + // Both sides on dest must reflect onDelete=restrict. Asserting the + // partner side is the regression guard for the previously-missed + // partner meta refresh in updateRelationshipInPlace. + $this->assertEventually(function () use ($databaseId, $destHeaders) { + $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); + $this->assertEquals(200, $parent['headers']['status-code']); + $this->assertEquals('available', $parent['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $parent['body']['onDelete'], 'parent-side onDelete must reflect source'); + $this->assertEquals(Database::RELATION_ONE_TO_MANY, $parent['body']['relationType'], 'In-place update must not change relationType'); + $this->assertTrue($parent['body']['twoWay'], 'In-place update must not change twoWay'); + + $child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders); + $this->assertEquals(200, $child['headers']['status-code']); + $this->assertEquals('available', $child['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $child['body']['onDelete'], 'partner-side onDelete must reflect source after in-place update'); + }, 10000, 500); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + + /** Two-way recreate with same spec: spec-match guard tolerates parent; pair-key dedup tolerates partner. Both sides + child rows preserved. */ + public function testAppwriteMigrationOverwriteTwoWayRecreateSkipsPartnerSide(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $databaseId = ID::unique(); + $createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [ + 'databaseId' => $databaseId, + 'name' => 'Two-Way Recreate DB', + ]); + $this->assertEquals(201, $createDb['headers']['status-code']); + + foreach (['parents', 'children'] as $tbl) { + $createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [ + 'tableId' => $tbl, + 'name' => $tbl, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $this->assertEquals(201, $createTable['headers']['status-code']); + } + + // Add a non-relationship column on parents so we can POST a row with + // non-empty data. tablesdb POST /rows rejects empty data arrays in + // 1.9.x (Create.php:161 — getSupportForEmptyDocument() defaults false). + $createLabel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/string', $sourceHeaders, [ + 'key' => 'label', + 'size' => 32, + 'required' => false, + ]); + $this->assertEquals(202, $createLabel['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/label', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + }, 10000, 500); + + $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [ + 'relatedTableId' => 'children', + 'type' => Database::RELATION_ONE_TO_MANY, + 'twoWay' => true, + 'key' => 'kids', + 'twoWayKey' => 'parent', + 'onDelete' => Database::RELATION_MUTATE_CASCADE, + ]); + $this->assertEquals(202, $createRel['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + }, 10000, 500); + + $parentRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/rows', $sourceHeaders, [ + 'rowId' => 'parent-1', + 'data' => ['label' => 'p1'], + ]); + $this->assertEquals(201, $parentRow['headers']['status-code']); + $childRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/children/rows', $sourceHeaders, [ + 'rowId' => 'child-1', + 'data' => ['parent' => 'parent-1'], + ]); + $this->assertEquals(201, $childRow['headers']['status-code']); + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + ]; + + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + // Recreate the relationship on source so its createdAt advances past + // dest's stored value — forces SchemaAction::DropAndRecreate on the + // parent side, which is the path the partner-side dedup guards. + sleep(1); + $deleteRel = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); + $this->assertEquals(204, $deleteRel['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); + $this->assertEquals(404, $r['headers']['status-code']); + }, 10000, 500); + + sleep(1); + $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [ + 'relatedTableId' => 'children', + 'type' => Database::RELATION_ONE_TO_MANY, + 'twoWay' => true, + 'key' => 'kids', + 'twoWayKey' => 'parent', + 'onDelete' => Database::RELATION_MUTATE_CASCADE, + ]); + $this->assertEquals(202, $recreate['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + }, 10000, 500); + + // Child-row's relationship was wiped by the source-side delete. Re-link. + $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/children/rows/child-1', $sourceHeaders, [ + 'data' => ['parent' => 'parent-1'], + ]); + $this->assertEquals(200, $relink['headers']['status-code']); + + $overwriteResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'overwrite', + ]); + $this->assertEquals('completed', $overwriteResult['status']); + + $this->assertEventually(function () use ($databaseId, $destHeaders) { + $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); + $this->assertEquals(200, $parent['headers']['status-code']); + $this->assertEquals('available', $parent['body']['status']); + + $child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders); + $this->assertEquals(200, $child['headers']['status-code']); + $this->assertEquals('available', $child['body']['status']); + }, 10000, 500); + + // Both rows survive the re-migration. If the partner-side dedup were + // missing and the partner pass re-fired DropAndRecreate, the partner + // (children) table's row would have been wiped before the row pass. + $destChild = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/rows/child-1', $destHeaders); + $this->assertEquals(200, $destChild['headers']['status-code'], 'partner-table row must survive two-way recreate re-migration'); + $this->assertEquals('parent-1', $destChild['body']['parent']['$id'] ?? $destChild['body']['parent'], 'partner-table row relationship must point to the migrated parent'); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + + /** One-way + onDelete change falls through to DropAndRecreate (in-place gated off for one-way). */ + public function testAppwriteMigrationOverwriteOneWayRelationshipDropAndRecreate(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $databaseId = ID::unique(); + $createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [ + 'databaseId' => $databaseId, + 'name' => 'One-Way DropAndRecreate DB', + ]); + $this->assertEquals(201, $createDb['headers']['status-code']); + + foreach (['parents', 'children'] as $tbl) { + $createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [ + 'tableId' => $tbl, + 'name' => $tbl, + ]); + $this->assertEquals(201, $createTable['headers']['status-code']); + } + + $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [ + 'relatedTableId' => 'children', + 'type' => Database::RELATION_ONE_TO_MANY, + 'twoWay' => false, + 'key' => 'kids', + 'onDelete' => Database::RELATION_MUTATE_CASCADE, + ]); + $this->assertEquals(202, $createRel['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + }, 10000, 500); + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + ]; + + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + $this->assertEventually(function () use ($databaseId, $destHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $r['body']['onDelete']); + }, 10000, 500); + + sleep(1); + + $patch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids/relationship', $sourceHeaders, [ + 'onDelete' => Database::RELATION_MUTATE_RESTRICT, + ]); + $this->assertEquals(200, $patch['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); + $this->assertEquals('available', $r['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete']); + }, 5000, 500); + + $overwriteResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'overwrite', + ]); + $this->assertEquals('completed', $overwriteResult['status']); + + $this->assertEventually(function () use ($databaseId, $destHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete'], 'one-way DropAndRecreate must propagate source onDelete'); + $this->assertEquals(Database::RELATION_ONE_TO_MANY, $r['body']['relationType'], 'DropAndRecreate must preserve relationType'); + $this->assertFalse($r['body']['twoWay'], 'DropAndRecreate must preserve twoWay=false'); + }, 10000, 500); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + + /** Recreate with non-SDK spec change (array toggle): updateAttributeInPlace bails → drop+recreate; row pass refills. */ + public function testAppwriteMigrationOverwriteAttributeRecreateDropsAndRecreates(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + $rowId = 'row-after-recreate'; + + $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ + 'rowId' => $rowId, + 'data' => ['name' => 'before-recreate'], + ]); + $this->assertEquals(201, $row['headers']['status-code']); + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + ]; + + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + sleep(1); + + // Drop + recreate the column on source. createdAt advances → re-migration + // must take the createdAt-diff DropAndRecreate path on dest. + $delete = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); + $this->assertEquals(204, $delete['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); + $this->assertEquals(404, $r['headers']['status-code']); + }, 10000, 500); + + // Recreate with `array: true` — a non-SDK change (`array` is in + // ATTRIBUTE_NON_SDK_FIELDS). Forces updateAttributeInPlace to bail + // and the caller to fall through to drop+recreate, which is what + // this test pins. + $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $sourceHeaders, [ + 'key' => 'name', + 'size' => 100, + 'required' => false, + 'array' => true, + ]); + $this->assertEquals(202, $recreate['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + }, 10000, 500); + + // Source row's data was nulled by the source-side delete. Set a list value (column is array=true now). + $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $sourceHeaders, [ + 'data' => ['name' => ['after-recreate']], + ]); + $this->assertEquals(200, $relink['headers']['status-code']); + + $overwriteResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'overwrite', + ]); + $this->assertEquals('completed', $overwriteResult['status']); + + $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) { + $col = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); + $this->assertEquals(200, $col['headers']['status-code']); + $this->assertEquals('available', $col['body']['status']); + $this->assertTrue($col['body']['array'], 'recreated column must reflect the new spec (array=true)'); + $this->assertFalse($col['body']['required']); + }, 10000, 500); + + $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); + $this->assertEquals(200, $rowAfter['headers']['status-code']); + $this->assertEquals(['after-recreate'], $rowAfter['body']['name'], 'row pass must repopulate the recreated column with source value'); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + + /** Source drops+recreates with SAME spec: spec-match guard forces Tolerate; dest meta untouched. */ + public function testAppwriteMigrationOverwriteSameSpecRecreateTolerates(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + $rowId = 'row-spec-match'; + + $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ + 'rowId' => $rowId, + 'data' => ['name' => 'before-recreate'], + ]); + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + ]; + + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + $destBefore = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); + $this->assertEquals(200, $destBefore['headers']['status-code']); + $destCreatedAtBefore = $destBefore['body']['$createdAt']; + + sleep(1); + + // Drop + recreate with the EXACT same spec as setupMigrationTable + // (size=100, required=true). Source's $createdAt advances but the + // spec is identical → spec-match guard must force Tolerate. + $delete = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); + $this->assertEquals(204, $delete['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); + $this->assertEquals(404, $r['headers']['status-code']); + }, 10000, 500); + + $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $sourceHeaders, [ + 'key' => 'name', + 'size' => 100, + 'required' => true, + ]); + $this->assertEquals(202, $recreate['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + }, 10000, 500); + + $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $sourceHeaders, [ + 'data' => ['name' => 'after-recreate'], + ]); + $this->assertEquals(200, $relink['headers']['status-code']); + + $overwriteResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'overwrite', + ]); + $this->assertEquals('completed', $overwriteResult['status']); + + // Spec-match guard fired → dest column's $createdAt stayed at the + // first-migration value. If DropAndRecreate had run, $createdAt + // would have been bumped to source's NEW createdAt. + $destAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); + $this->assertEquals(200, $destAfter['headers']['status-code']); + $this->assertEquals($destCreatedAtBefore, $destAfter['body']['$createdAt'], 'spec-match guard must keep dest column meta untouched'); + $this->assertEquals(100, $destAfter['body']['size']); + $this->assertTrue($destAfter['body']['required']); + + // Row pass under Overwrite still propagated source's new row value. + $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); + $this->assertEquals(200, $rowAfter['headers']['status-code']); + $this->assertEquals('after-recreate', $rowAfter['body']['name']); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + /** * Storage */ @@ -1096,6 +2365,7 @@ trait MigrationsBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], + 'x-appwrite-response-format' => '1.9.3' ], [ 'key' => 'TEST_VAR', 'value' => 'test_value', @@ -1482,6 +2752,260 @@ trait MigrationsBase }, 10_000, 500); } + /** + * Set up a database + table + bucket + uploaded CSV for the skip/overwrite tests. + * Returns [$databaseId, $tableId, $bucketId, $fileId, $firstRowId, $firstRowName, $firstRowAge]. + * + * @return array{string,string,string,string,string,string,int} + */ + private function prepareCsvImportFixture(string $testLabel): array + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]; + + // database + $response = $this->client->call(Client::METHOD_POST, '/databases', $headers, [ + 'databaseId' => ID::unique(), + 'name' => 'Test DB ' . $testLabel, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + $databaseId = $response['body']['$id']; + + // table + $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $headers, [ + 'name' => 'Test table ' . $testLabel, + 'tableId' => ID::unique(), + ]); + $this->assertEquals(201, $response['headers']['status-code']); + $tableId = $response['body']['$id']; + + // columns: name, age (match documents.csv fixture) + $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $headers, [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', $headers, [ + 'key' => 'age', + 'min' => 18, + 'max' => 65, + 'required' => true, + ]); + $this->assertEquals(202, $response['headers']['status-code']); + + // Columns are created async (202). Wait for both to be `available` + // before proceeding so the migration worker doesn't race the schema. + foreach (['name', 'age'] as $column) { + $this->assertEventually(function () use ($databaseId, $tableId, $column, $headers) { + $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/' . $column, $headers); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('available', $response['body']['status']); + }, 5000, 500); + } + + // bucket + $response = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [ + 'bucketId' => ID::unique(), + 'name' => 'Bucket ' . $testLabel, + 'maximumFileSize' => 2000000, + 'allowedFileExtensions' => ['csv'], + ]); + $this->assertEquals(201, $response['headers']['status-code']); + $bucketId = $response['body']['$id']; + + // upload documents.csv (100 rows with $id, name, age columns) + $response = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/csv/documents.csv'), 'text/csv', 'documents.csv'), + ]); + $this->assertEquals(201, $response['headers']['status-code']); + $fileId = $response['body']['$id']; + + // first row in documents.csv: hxfcwpcas5xokpwe,Diamond Mendez,56 + return [$databaseId, $tableId, $bucketId, $fileId, 'hxfcwpcas5xokpwe', 'Diamond Mendez', 56]; + } + + /** + * onDuplicate=skip on re-import: duplicates are silently no-op'd, existing rows preserved unchanged. + */ + public function testCreateCSVImportSkipDuplicates(): void + { + [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareCsvImportFixture('skip'); + + // First import: 100 rows created + $first = $this->performCsvMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + ]); + $this->assertEventually(function () use ($first) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); + }, 10_000, 500); + + // Mutate one row so we can prove skip does NOT overwrite it + $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'data' => ['age' => 22], + ]); + $this->assertEquals(200, $mutate['headers']['status-code']); + $this->assertEquals(22, $mutate['body']['age']); + + // Second import with onDuplicate=skip: no errors, mutated row preserved + $second = $this->performCsvMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + 'onDuplicate' => 'skip', + ]); + $this->assertEventually(function () use ($second) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + }, 10_000, 500); + + // Mutated row kept its mutated value (not overwritten by CSV's original age) + $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $row['headers']['status-code']); + $this->assertEquals($originalName, $row['body']['name']); + $this->assertEquals(22, $row['body']['age'], 'onDuplicate=skip must not overwrite mutated row'); + + // Row count still 100 (no duplicates created) + $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::limit(150)->toString()], + ]); + $this->assertEquals(100, $rows['body']['total']); + } + + /** + * onDuplicate=overwrite on re-import: existing rows are replaced with imported values. + */ + public function testCreateCSVImportOverwrite(): void + { + [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareCsvImportFixture('overwrite'); + + // First import: 100 rows created + $first = $this->performCsvMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + ]); + $this->assertEventually(function () use ($first) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); + }, 10_000, 500); + + // Mutate one row so we can prove overwrite restores it to the CSV's original value + $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'data' => ['age' => 22], + ]); + $this->assertEquals(200, $mutate['headers']['status-code']); + $this->assertEquals(22, $mutate['body']['age']); + + // Second import with onDuplicate=overwrite: mutated row restored to CSV value + $second = $this->performCsvMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + 'onDuplicate' => 'overwrite', + ]); + $this->assertEventually(function () use ($second) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + }, 10_000, 500); + + // Mutated row is back to CSV's original age (proving overwrite actually replaced the row) + $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $row['headers']['status-code']); + $this->assertEquals($originalName, $row['body']['name']); + $this->assertEquals($originalAge, $row['body']['age'], 'onDuplicate=overwrite must restore row to imported value'); + + // Row count still 100 (no duplicates created) + $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::limit(150)->toString()], + ]); + $this->assertEquals(100, $rows['body']['total']); + } + + /** + * Default behavior (neither flag): re-import of duplicate ids fails with DuplicateException. + * Regression guard so the skip/overwrite additions don't silently change the default. + */ + public function testCreateCSVImportDefaultFailsOnDuplicate(): void + { + [$databaseId, $tableId, $bucketId, $fileId] = $this->prepareCsvImportFixture('default'); + + // First import: succeeds + $first = $this->performCsvMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + ]); + $this->assertEventually(function () use ($first) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + }, 10_000, 500); + + // Second import with no flags: should fail on duplicate ids + $second = $this->performCsvMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + ]); + $this->assertEventually(function () use ($second) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('finished', $migration['body']['stage']); + $this->assertEquals('failed', $migration['body']['status']); + $this->assertNotEmpty($migration['body']['errors']); + }, 60_000, 500); + } + private function performCsvMigration(array $body): array { return $this->client->call(Client::METHOD_POST, '/migrations/csv', [ @@ -1491,6 +3015,246 @@ trait MigrationsBase ], $body); } + /** + * Set up a database + table + bucket + uploaded JSON for the skip/overwrite tests. + * Mirrors prepareCsvImportFixture but uploads documents.json instead. + * + * @return array{string,string,string,string,string,string,int} + */ + private function prepareJsonImportFixture(string $testLabel): array + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]; + + // database + $response = $this->client->call(Client::METHOD_POST, '/databases', $headers, [ + 'databaseId' => ID::unique(), + 'name' => 'Test JSON DB ' . $testLabel, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + $databaseId = $response['body']['$id']; + + // table + $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $headers, [ + 'name' => 'Test JSON table ' . $testLabel, + 'tableId' => ID::unique(), + ]); + $this->assertEquals(201, $response['headers']['status-code']); + $tableId = $response['body']['$id']; + + // columns: name, age (match documents.json fixture) + $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $headers, [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', $headers, [ + 'key' => 'age', + 'min' => 18, + 'max' => 65, + 'required' => true, + ]); + $this->assertEquals(202, $response['headers']['status-code']); + + foreach (['name', 'age'] as $column) { + $this->assertEventually(function () use ($databaseId, $tableId, $column, $headers) { + $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/' . $column, $headers); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('available', $response['body']['status']); + }, 5000, 500); + } + + // bucket + $response = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [ + 'bucketId' => ID::unique(), + 'name' => 'JSON Bucket ' . $testLabel, + 'maximumFileSize' => 2000000, + 'allowedFileExtensions' => ['json'], + ]); + $this->assertEquals(201, $response['headers']['status-code']); + $bucketId = $response['body']['$id']; + + // upload documents.json (same row shape as documents.csv) + $response = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/json/documents.json'), 'application/json', 'documents.json'), + ]); + $this->assertEquals(201, $response['headers']['status-code']); + $fileId = $response['body']['$id']; + + // first row in documents.json: hxfcwpcas5xokpwe, Diamond Mendez, 56 + return [$databaseId, $tableId, $bucketId, $fileId, 'hxfcwpcas5xokpwe', 'Diamond Mendez', 56]; + } + + /** + * onDuplicate=skip on JSON re-import: duplicates silently no-op, existing rows preserved unchanged. + */ + public function testCreateJSONImportSkipDuplicates(): void + { + [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareJsonImportFixture('skip'); + + $first = $this->performJsonMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + ]); + $this->assertEventually(function () use ($first) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); + }, 10_000, 500); + + // Mutate one row so we can prove skip does NOT overwrite it + $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'data' => ['age' => 22], + ]); + $this->assertEquals(200, $mutate['headers']['status-code']); + $this->assertEquals(22, $mutate['body']['age']); + + $second = $this->performJsonMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + 'onDuplicate' => 'skip', + ]); + $this->assertEventually(function () use ($second) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + }, 10_000, 500); + + $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $row['headers']['status-code']); + $this->assertEquals($originalName, $row['body']['name']); + $this->assertEquals(22, $row['body']['age'], 'onDuplicate=skip must not overwrite mutated row'); + + $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::limit(150)->toString()], + ]); + $this->assertEquals(100, $rows['body']['total']); + } + + /** + * onDuplicate=overwrite on JSON re-import: existing rows replaced with imported values. + */ + public function testCreateJSONImportOverwrite(): void + { + [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareJsonImportFixture('overwrite'); + + $first = $this->performJsonMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + ]); + $this->assertEventually(function () use ($first) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); + }, 10_000, 500); + + $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'data' => ['age' => 22], + ]); + $this->assertEquals(200, $mutate['headers']['status-code']); + $this->assertEquals(22, $mutate['body']['age']); + + $second = $this->performJsonMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + 'onDuplicate' => 'overwrite', + ]); + $this->assertEventually(function () use ($second) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + }, 10_000, 500); + + $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $row['headers']['status-code']); + $this->assertEquals($originalName, $row['body']['name']); + $this->assertEquals($originalAge, $row['body']['age'], 'onDuplicate=overwrite must restore row to imported value'); + + $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::limit(150)->toString()], + ]); + $this->assertEquals(100, $rows['body']['total']); + } + + /** + * Default (no onDuplicate) on JSON re-import: regression guard, must fail on duplicate ids. + */ + public function testCreateJSONImportDefaultFailsOnDuplicate(): void + { + [$databaseId, $tableId, $bucketId, $fileId] = $this->prepareJsonImportFixture('default'); + + $first = $this->performJsonMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + ]); + $this->assertEventually(function () use ($first) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + }, 10_000, 500); + + $second = $this->performJsonMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + ]); + $this->assertEventually(function () use ($second) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('finished', $migration['body']['stage']); + $this->assertEquals('failed', $migration['body']['status']); + $this->assertNotEmpty($migration['body']['errors']); + }, 60_000, 500); + } + /** * Test CSV export with email notification */ diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php index a55cc559b2..47d92a2a58 100644 --- a/tests/e2e/Services/Project/OAuth2Base.php +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -1651,8 +1651,8 @@ trait OAuth2Base $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']); + $this->assertArrayHasKey('tokenURL', $response['body']); + $this->assertArrayHasKey('userInfoURL', $response['body']); // Cleanup $this->updateOAuth2('oidc', [ @@ -1660,8 +1660,8 @@ trait OAuth2Base 'clientSecret' => '', 'wellKnownURL' => '', 'authorizationURL' => '', - 'tokenUrl' => '', - 'userInfoUrl' => '', + 'tokenURL' => '', + 'userInfoURL' => '', 'enabled' => false, ]); } @@ -1672,15 +1672,15 @@ trait OAuth2Base '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', + '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']); + $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', [ @@ -1688,8 +1688,8 @@ trait OAuth2Base 'clientSecret' => '', 'wellKnownURL' => '', 'authorizationURL' => '', - 'tokenUrl' => '', - 'userInfoUrl' => '', + 'tokenURL' => '', + 'userInfoURL' => '', 'enabled' => false, ]); } @@ -1701,8 +1701,8 @@ trait OAuth2Base 'clientSecret' => '', 'wellKnownURL' => '', 'authorizationURL' => '', - 'tokenUrl' => '', - 'userInfoUrl' => '', + 'tokenURL' => '', + 'userInfoURL' => '', 'enabled' => false, ]); @@ -1731,8 +1731,8 @@ trait OAuth2Base 'clientSecret' => '', 'wellKnownURL' => '', 'authorizationURL' => '', - 'tokenUrl' => '', - 'userInfoUrl' => '', + 'tokenURL' => '', + 'userInfoURL' => '', 'enabled' => false, ]); @@ -1740,7 +1740,7 @@ trait OAuth2Base 'clientId' => 'oidc-partial', 'clientSecret' => 'oidc-partial-secret', 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', - 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'tokenURL' => 'https://idp.example.com/oauth2/token', 'enabled' => true, ]); @@ -1753,8 +1753,8 @@ trait OAuth2Base 'clientSecret' => '', 'wellKnownURL' => '', 'authorizationURL' => '', - 'tokenUrl' => '', - 'userInfoUrl' => '', + 'tokenURL' => '', + 'userInfoURL' => '', 'enabled' => false, ]); } @@ -1785,8 +1785,8 @@ trait OAuth2Base 'clientSecret' => '', 'wellKnownURL' => '', 'authorizationURL' => '', - 'tokenUrl' => '', - 'userInfoUrl' => '', + 'tokenURL' => '', + 'userInfoURL' => '', 'enabled' => false, ]); } @@ -1816,8 +1816,8 @@ trait OAuth2Base 'clientSecret' => '', 'wellKnownURL' => '', 'authorizationURL' => '', - 'tokenUrl' => '', - 'userInfoUrl' => '', + 'tokenURL' => '', + 'userInfoURL' => '', 'enabled' => false, ]); } @@ -1831,8 +1831,8 @@ trait OAuth2Base 'clientSecret' => '', 'wellKnownURL' => '', 'authorizationURL' => '', - 'tokenUrl' => '', - 'userInfoUrl' => '', + 'tokenURL' => '', + 'userInfoURL' => '', 'enabled' => false, ]); @@ -1841,7 +1841,7 @@ trait OAuth2Base 'clientId' => 'oidc-split-discovery', 'clientSecret' => 'oidc-split-discovery-secret', 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', - 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'tokenURL' => 'https://idp.example.com/oauth2/token', 'enabled' => false, ]); @@ -1849,19 +1849,19 @@ trait OAuth2Base // 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', + '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). + // 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']); + $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', [ @@ -1869,8 +1869,8 @@ trait OAuth2Base 'clientSecret' => '', 'wellKnownURL' => '', 'authorizationURL' => '', - 'tokenUrl' => '', - 'userInfoUrl' => '', + 'tokenURL' => '', + 'userInfoURL' => '', 'enabled' => false, ]); } @@ -1883,8 +1883,8 @@ trait OAuth2Base 'clientSecret' => 'oidc-clear-then-enable-secret', 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', 'authorizationURL' => '', - 'tokenUrl' => '', - 'userInfoUrl' => '', + 'tokenURL' => '', + 'userInfoURL' => '', 'enabled' => false, ]); @@ -1907,8 +1907,8 @@ trait OAuth2Base 'clientSecret' => '', 'wellKnownURL' => '', 'authorizationURL' => '', - 'tokenUrl' => '', - 'userInfoUrl' => '', + 'tokenURL' => '', + 'userInfoURL' => '', 'enabled' => false, ]); } @@ -1929,16 +1929,16 @@ trait OAuth2Base $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', + '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']); + $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', [ @@ -1946,8 +1946,8 @@ trait OAuth2Base 'clientSecret' => '', 'wellKnownURL' => '', 'authorizationURL' => '', - 'tokenUrl' => '', - 'userInfoUrl' => '', + 'tokenURL' => '', + 'userInfoURL' => '', 'enabled' => false, ]); } @@ -1961,23 +1961,23 @@ trait OAuth2Base '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', + 'tokenURL' => 'https://idp.example.com/oauth2/token', + 'userInfoURL' => 'https://idp.example.com/oauth2/userinfo', 'enabled' => false, ]); $response = $this->updateOAuth2('oidc', [ 'wellKnownURL' => '', 'authorizationURL' => '', - 'tokenUrl' => '', - 'userInfoUrl' => '', + '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']); + $this->assertSame('', $response['body']['tokenURL']); + $this->assertSame('', $response['body']['userInfoURL']); // Cleanup $this->updateOAuth2('oidc', [ @@ -1987,6 +1987,96 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2OidcBackwardCompatibleResponseFormat(): void + { + // Reset to clean state + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenURL' => '', + 'userInfoURL' => '', + 'enabled' => false, + ]); + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.3', + ]; + $headers = \array_merge($headers, $this->getHeaders()); + + // Update using OLD param names (aliases must still work) + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/oauth2/oidc', + $headers, + [ + 'clientId' => 'oidc-compat-client', + 'clientSecret' => 'oidc-compat-secret', + 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'userInfoUrl' => 'https://idp.example.com/oauth2/userinfo', + 'enabled' => false, + ], + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('tokenUrl', $response['body']); + $this->assertArrayHasKey('userInfoUrl', $response['body']); + $this->assertArrayNotHasKey('tokenURL', $response['body']); + $this->assertArrayNotHasKey('userInfoURL', $response['body']); + $this->assertSame('https://idp.example.com/oauth2/token', $response['body']['tokenUrl']); + $this->assertSame('https://idp.example.com/oauth2/userinfo', $response['body']['userInfoUrl']); + + // GET with 1.9.3 format must also return old param names + $get = $this->client->call( + Client::METHOD_GET, + '/project/oauth2/oidc', + $headers, + ); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertArrayHasKey('tokenUrl', $get['body']); + $this->assertArrayHasKey('userInfoUrl', $get['body']); + $this->assertArrayNotHasKey('tokenURL', $get['body']); + $this->assertArrayNotHasKey('userInfoURL', $get['body']); + $this->assertSame('https://idp.example.com/oauth2/token', $get['body']['tokenUrl']); + $this->assertSame('https://idp.example.com/oauth2/userinfo', $get['body']['userInfoUrl']); + + // LIST with 1.9.3 format must also return old param names for OIDC + $list = $this->client->call( + Client::METHOD_GET, + '/project/oauth2', + $headers, + ); + + $this->assertSame(200, $list['headers']['status-code']); + $oidcEntry = null; + foreach ($list['body']['providers'] as $provider) { + if ($provider['$id'] === 'oidc') { + $oidcEntry = $provider; + break; + } + } + $this->assertNotNull($oidcEntry, 'OIDC provider missing from listOAuth2Providers response'); + $this->assertArrayHasKey('tokenUrl', $oidcEntry); + $this->assertArrayHasKey('userInfoUrl', $oidcEntry); + $this->assertArrayNotHasKey('tokenURL', $oidcEntry); + $this->assertArrayNotHasKey('userInfoURL', $oidcEntry); + $this->assertSame('https://idp.example.com/oauth2/token', $oidcEntry['tokenUrl']); + $this->assertSame('https://idp.example.com/oauth2/userinfo', $oidcEntry['userInfoUrl']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'tokenURL' => '', + 'userInfoURL' => '', + 'enabled' => false, + ]); + } + // ========================================================================= // Update Okta (clientId + clientSecret + optional domain/authServer) // ========================================================================= diff --git a/tests/e2e/Services/ProjectWebhooks/WebhooksCustomServerTest.php b/tests/e2e/Services/ProjectWebhooks/WebhooksCustomServerTest.php index 9085733b70..eb08da56f2 100644 --- a/tests/e2e/Services/ProjectWebhooks/WebhooksCustomServerTest.php +++ b/tests/e2e/Services/ProjectWebhooks/WebhooksCustomServerTest.php @@ -89,6 +89,7 @@ class WebhooksCustomServerTest extends Scope $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/variables', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.3', ], $this->getHeaders()), [ 'key' => 'key1', 'value' => 'value1', @@ -699,6 +700,7 @@ class WebhooksCustomServerTest extends Scope $variable = $this->client->call(Client::METHOD_POST, '/functions/' . $id . '/variables', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.3', ], $this->getHeaders()), [ 'key' => 'key1', 'value' => 'value1', diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 6936de9aff..8b0c1af57f 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -787,12 +787,240 @@ class ProjectsConsoleClientTest extends Scope 'projectId' => ID::unique(), 'name' => 'Project Test', 'teamId' => $team['body']['$id'], - 'region' => System::getEnv('_APP_REGION', 'default') + 'region' => System::getEnv('_APP_REGION', 'default'), + 'description' => 'My description', + 'logo' => 'https://google.com/logo.png', + 'url' => 'https://myapp.com/', + 'legalName' => 'Legal company', + 'legalCountry' => 'Slovakia', + 'legalState' => 'Custom state', + 'legalCity' => 'Košice', + 'legalAddress' => 'Main street 32', + 'legalTaxId' => 'TAXID_123456' ]); $this->assertEquals(201, $response['headers']['status-code']); $id = $response['body']['$id']; + // Increase ping 3x + for ($i = 0; $i < 3; $i++) { + $response = $this->client->call( + Client::METHOD_GET, + '/ping', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + ], $this->getHeaders()), + ); + $this->assertEquals(200, $response['headers']['status-code']); + } + + // Configure SMTP + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), + [ + 'enabled' => true, + 'senderName' => 'Custom sender', + 'senderEmail' => 'email@custom.com', + 'host' => 'maildev', + 'port' => 1025, + 'replyToEmail' => 'replyto@custom.com', + 'replyToName' => 'Reply sender', + ], + ); + $this->assertEquals(200, $response['headers']['status-code']); + + // Add mock numbers + $response = $this->client->call(Client::METHOD_POST, '/project/mock-phones', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'number' => '+421123456789', + 'otp' => '123456' + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + // Add labels + $response = $this->client->call(Client::METHOD_PUT, '/project/labels', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'labels' => ['custom1', 'custom2'] + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + // Create dev keys + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/dev-keys', array_merge([ + 'content-type' => 'application/json', + ], $this->getHeaders()), [ + 'name' => 'Custom key 1', + 'expire' => '2099-05-07 09:23:30.713', + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/dev-keys', array_merge([ + 'content-type' => 'application/json', + ], $this->getHeaders()), [ + 'name' => 'Custom key 2', + 'expire' => '2099-05-07 11:23:30.713' + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/project/mock-phones', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'number' => '+420987654321', + 'otp' => '654321' + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + // Setup custom values for project policies + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'duration' => 135 + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'total' => 54 + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'total' => 7 + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'total' => 9 + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'enabled' => true + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'enabled' => true + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'enabled' => true + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'enabled' => true + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + // Create webhook + $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'webhookId' => 'unique()', + 'name' => 'Webhook Test', + 'events' => ['users.*.create', 'users.*.update.email'], + 'url' => 'https://appwrite.io', + 'tls' => true, + 'authUsername' => 'username', + 'authPassword' => 'password', + ]); + $this->assertEquals(201, $webhook['headers']['status-code']); + + // Create API key + $key = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'keyId' => ID::unique(), + 'name' => 'Key Test', + 'scopes' => ['teams.read', 'teams.write'], + ]); + $this->assertEquals(201, $key['headers']['status-code']); + + // Create platform + $platform = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'platformId' => ID::unique(), + 'type' => 'web', + 'name' => 'Web App', + 'hostname' => 'localhost', + ]); + $this->assertEquals(201, $platform['headers']['status-code']); + + // Configure OAuth provider + $oauth = $this->client->call(Client::METHOD_PATCH, '/project/oauth2/github', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'clientId' => 'github-client-id', + 'clientSecret' => 'github-client-secret', + 'enabled' => false, + ]); + $this->assertEquals(200, $oauth['headers']['status-code']); + /** * Test for SUCCESS */ @@ -802,10 +1030,452 @@ class ProjectsConsoleClientTest extends Scope ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']); $this->assertEquals($id, $response['body']['$id']); $this->assertEquals('Project Test', $response['body']['name']); + $this->assertIsString($response['body']['$createdAt']); + $this->assertNotEmpty($response['body']['$createdAt']); + $this->assertNotFalse(\strtotime($response['body']['$createdAt'])); + + $this->assertIsString($response['body']['$updatedAt']); + $this->assertNotEmpty($response['body']['$updatedAt']); + $this->assertNotFalse(\strtotime($response['body']['$updatedAt'])); + + $this->assertEquals('My description', $response['body']['description']); + $this->assertEquals($team['body']['$id'], $response['body']['teamId']); + $this->assertEquals('active', $response['body']['status']); + $this->assertEquals('https://google.com/logo.png', $response['body']['logo']); + $this->assertEquals('https://myapp.com/', $response['body']['url']); + $this->assertEquals('Legal company', $response['body']['legalName']); + $this->assertEquals('Slovakia', $response['body']['legalCountry']); + $this->assertEquals('Custom state', $response['body']['legalState']); + $this->assertEquals('Košice', $response['body']['legalCity']); + $this->assertEquals('Main street 32', $response['body']['legalAddress']); + $this->assertEquals('TAXID_123456', $response['body']['legalTaxId']); + $this->assertEquals(135, $response['body']['authDuration']); + $this->assertEquals(54, $response['body']['authLimit']); + $this->assertEquals(7, $response['body']['authSessionsLimit']); + $this->assertEquals(9, $response['body']['authPasswordHistory']); + $this->assertTrue($response['body']['authPasswordDictionary']); + $this->assertTrue($response['body']['authPersonalDataCheck']); + $this->assertFalse($response['body']['authDisposableEmails']); + $this->assertFalse($response['body']['authCanonicalEmails']); + $this->assertFalse($response['body']['authFreeEmails']); + $this->assertTrue($response['body']['authSessionAlerts']); + $this->assertTrue($response['body']['authMembershipsUserName']); + $this->assertTrue($response['body']['authMembershipsUserEmail']); + $this->assertTrue($response['body']['authMembershipsMfa']); + $this->assertTrue($response['body']['authMembershipsUserId']); + $this->assertTrue($response['body']['authMembershipsUserPhone']); + $this->assertTrue($response['body']['authInvalidateSessions']); + $this->assertTrue($response['body']['smtpEnabled']); + $this->assertSame('Custom sender', $response['body']['smtpSenderName']); + $this->assertSame('email@custom.com', $response['body']['smtpSenderEmail']); + $this->assertSame('Reply sender', $response['body']['smtpReplyToName']); + $this->assertSame('replyto@custom.com', $response['body']['smtpReplyToEmail']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + $this->assertSame('', $response['body']['smtpUsername']); + $this->assertSame('', $response['body']['smtpPassword']); // Write only + $this->assertSame('', $response['body']['smtpSecure']); + $this->assertSame(3, $response['body']['pingCount']); + + $this->assertIsString($response['body']['pingedAt']); + $this->assertNotEmpty($response['body']['pingedAt']); + $this->assertNotFalse(\strtotime($response['body']['pingedAt'])); + + $this->assertCount(2, $response['body']['labels']); + $this->assertEquals('custom1', $response['body']['labels'][0]); + $this->assertEquals('custom2', $response['body']['labels'][1]); + + $this->assertCount(2, $response['body']['devKeys']); + $this->assertEquals('Custom key 1', $response['body']['devKeys'][0]['name']); + $this->assertEquals('Custom key 2', $response['body']['devKeys'][1]['name']); + $this->assertEquals('2099-05-07T09:23:30.713+00:00', $response['body']['devKeys'][0]['expire']); + $this->assertEquals('2099-05-07T11:23:30.713+00:00', $response['body']['devKeys'][1]['expire']); + + foreach ($response['body']['devKeys'] as $devKey) { + $this->assertIsString($devKey['$id']); + $this->assertNotEmpty($devKey['$id']); + + $this->assertIsString($devKey['secret']); + $this->assertNotEmpty($devKey['secret']); + + $this->assertIsString($devKey['accessedAt']); + $this->assertEmpty($devKey['accessedAt']); + + $this->assertIsString($devKey['$createdAt']); + $this->assertNotEmpty($devKey['$createdAt']); + $this->assertNotFalse(\strtotime($devKey['$createdAt'])); + + $this->assertIsString($devKey['$updatedAt']); + $this->assertNotEmpty($devKey['$updatedAt']); + $this->assertNotFalse(\strtotime($devKey['$updatedAt'])); + + $this->assertIsArray($devKey['sdks']); + $this->assertCount(0, $devKey['sdks']); + } + + $this->assertCount(2, $response['body']['authMockNumbers']); + $this->assertEquals('+421123456789', $response['body']['authMockNumbers'][0]['phone']); + $this->assertEquals('+420987654321', $response['body']['authMockNumbers'][1]['phone']); + $this->assertEquals('123456', $response['body']['authMockNumbers'][0]['otp']); + $this->assertEquals('654321', $response['body']['authMockNumbers'][1]['otp']); + + foreach ($response['body']['authMockNumbers'] as $mockNumber) { + $this->assertIsString($mockNumber['$createdAt']); + $this->assertNotEmpty($mockNumber['$createdAt']); + $this->assertNotFalse(\strtotime($mockNumber['$createdAt'])); + + $this->assertIsString($mockNumber['$updatedAt']); + $this->assertNotEmpty($mockNumber['$updatedAt']); + $this->assertNotFalse(\strtotime($mockNumber['$updatedAt'])); + + $this->assertIsString($mockNumber['phone']); + $this->assertNotEmpty($mockNumber['phone']); + + $this->assertIsString($mockNumber['otp']); + $this->assertNotEmpty($mockNumber['otp']); + } + + $this->assertIsArray($response['body']['oAuthProviders']); + $this->assertGreaterThan(0, count($response['body']['oAuthProviders'])); + + $githubProvider = null; + foreach ($response['body']['oAuthProviders'] as $provider) { + $this->assertIsString($provider['key']); + $this->assertNotEmpty($provider['key']); + + $this->assertIsString($provider['name']); + $this->assertIsString($provider['appId']); + $this->assertIsString($provider['secret']); + $this->assertIsBool($provider['enabled']); + + if ($provider['key'] === 'github') { + $githubProvider = $provider; + } + } + + $this->assertNotNull($githubProvider, 'GitHub provider not found'); + $this->assertEquals('github-client-id', $githubProvider['appId']); + $this->assertEquals('', $githubProvider['secret']); // Write only + $this->assertEquals(false, $githubProvider['enabled']); + + $this->assertIsArray($response['body']['platforms']); + $this->assertCount(1, $response['body']['platforms']); + $this->assertIsString($response['body']['platforms'][0]['$id']); + $this->assertNotEmpty($response['body']['platforms'][0]['$id']); + $this->assertEquals('Web App', $response['body']['platforms'][0]['name']); + $this->assertEquals('web', $response['body']['platforms'][0]['type']); + $this->assertEquals('localhost', $response['body']['platforms'][0]['hostname']); + + $this->assertIsString($response['body']['platforms'][0]['$createdAt']); + $this->assertNotEmpty($response['body']['platforms'][0]['$createdAt']); + $this->assertNotFalse(\strtotime($response['body']['platforms'][0]['$createdAt'])); + + $this->assertIsString($response['body']['platforms'][0]['$updatedAt']); + $this->assertNotEmpty($response['body']['platforms'][0]['$updatedAt']); + $this->assertNotFalse(\strtotime($response['body']['platforms'][0]['$updatedAt'])); + + $this->assertArrayHasKey('webhooks', $response['body']); + $this->assertIsArray($response['body']['webhooks']); + $this->assertCount(1, $response['body']['webhooks']); + $this->assertIsString($response['body']['webhooks'][0]['$id']); + $this->assertNotEmpty($response['body']['webhooks'][0]['$id']); + $this->assertEquals('Webhook Test', $response['body']['webhooks'][0]['name']); + $this->assertEquals('https://appwrite.io', $response['body']['webhooks'][0]['url']); + $this->assertContains('users.*.create', $response['body']['webhooks'][0]['events']); + $this->assertContains('users.*.update.email', $response['body']['webhooks'][0]['events']); + $this->assertCount(2, $response['body']['webhooks'][0]['events']); + $this->assertTrue($response['body']['webhooks'][0]['tls']); + $this->assertEquals('username', $response['body']['webhooks'][0]['authUsername']); + $this->assertEquals('password', $response['body']['webhooks'][0]['authPassword']); + $this->assertTrue($response['body']['webhooks'][0]['enabled']); + $this->assertIsString($response['body']['webhooks'][0]['secret']); + $this->assertNotEmpty($response['body']['webhooks'][0]['secret']); + $this->assertIsString($response['body']['webhooks'][0]['$createdAt']); + $this->assertNotEmpty($response['body']['webhooks'][0]['$createdAt']); + $this->assertNotFalse(\strtotime($response['body']['webhooks'][0]['$createdAt'])); + $this->assertIsString($response['body']['webhooks'][0]['$updatedAt']); + $this->assertNotEmpty($response['body']['webhooks'][0]['$updatedAt']); + $this->assertNotFalse(\strtotime($response['body']['webhooks'][0]['$updatedAt'])); + + $this->assertArrayHasKey('keys', $response['body']); + $this->assertIsArray($response['body']['keys']); + $this->assertCount(1, $response['body']['keys']); + $this->assertIsString($response['body']['keys'][0]['$id']); + $this->assertNotEmpty($response['body']['keys'][0]['$id']); + $this->assertEquals('Key Test', $response['body']['keys'][0]['name']); + $this->assertContains('teams.read', $response['body']['keys'][0]['scopes']); + $this->assertContains('teams.write', $response['body']['keys'][0]['scopes']); + $this->assertCount(2, $response['body']['keys'][0]['scopes']); + $this->assertNotEmpty($response['body']['keys'][0]['secret']); + $this->assertEmpty($response['body']['keys'][0]['accessedAt']); + $this->assertIsArray($response['body']['keys'][0]['sdks']); + $this->assertCount(0, $response['body']['keys'][0]['sdks']); + $this->assertIsString($response['body']['keys'][0]['$createdAt']); + $this->assertNotEmpty($response['body']['keys'][0]['$createdAt']); + $this->assertNotFalse(\strtotime($response['body']['keys'][0]['$createdAt'])); + $this->assertIsString($response['body']['keys'][0]['$updatedAt']); + $this->assertNotEmpty($response['body']['keys'][0]['$updatedAt']); + $this->assertNotFalse(\strtotime($response['body']['keys'][0]['$updatedAt'])); + + $authsKeys = [ + 'authEmailPassword', + 'authUsersAuthMagicURL', + 'authEmailOtp', + 'authAnonymous', + 'authInvites', + 'authJWT', + 'authPhone', + ]; + foreach ($authsKeys as $authsKey) { + $this->assertTrue($response['body'][$authsKey], 'Auth method should be enabled: ' . $authsKey); + } + + $serviceKeys = [ + 'serviceStatusForAccount', + 'serviceStatusForAvatars', + 'serviceStatusForDatabases', + 'serviceStatusForTablesdb', + 'serviceStatusForLocale', + 'serviceStatusForHealth', + 'serviceStatusForProject', + 'serviceStatusForStorage', + 'serviceStatusForTeams', + 'serviceStatusForUsers', + 'serviceStatusForVcs', + 'serviceStatusForSites', + 'serviceStatusForFunctions', + 'serviceStatusForProxy', + 'serviceStatusForGraphql', + 'serviceStatusForMigrations', + 'serviceStatusForMessaging', + ]; + foreach ($serviceKeys as $serviceKey) { + $this->assertTrue($response['body'][$serviceKey], 'Service should be enabled: ' . $serviceKey); + } + + $protocolKeys = [ + 'protocolStatusForRest', + 'protocolStatusForGraphql', + 'protocolStatusForWebsocket', + ]; + foreach ($protocolKeys as $protocolKey) { + $this->assertTrue($response['body'][$protocolKey], 'Protocol should be enabled: ' . $protocolKey); + } + + // Ensure booleans can be falsy + + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'enabled' => false + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'enabled' => false + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'enabled' => false + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'enabled' => false + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + // Toggle auth methods, services, protocols + + $authMethods = ['email-password', 'magic-url', 'email-otp', 'anonymous', 'invites', 'jwt', 'phone']; + foreach ($authMethods as $authMethod) { + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/auth-methods/' . $authMethod, + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), + [ + 'enabled' => false, + ], + ); + $this->assertEquals(200, $response['headers']['status-code']); + } + + $protocols = ['rest', 'graphql', 'websocket']; + foreach ($protocols as $protocol) { + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/protocols/' . $protocol, + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), + [ + 'enabled' => false, + ], + ); + $this->assertEquals(200, $response['headers']['status-code']); + } + + $services = [ + 'account', + 'avatars', + 'databases', + 'tablesdb', + 'locale', + 'health', + 'project', + 'storage', + 'teams', + 'users', + 'vcs', + 'sites', + 'functions', + 'proxy', + 'graphql', + 'migrations', + 'messaging', + ]; + + foreach ($services as $service) { + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/services/' . $service, + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), + [ + 'enabled' => false, + ], + ); + $this->assertEquals(200, $response['headers']['status-code']); + } + + // Configure SMTP + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), + [ + 'enabled' => false, + 'host' => 'customhost.com', + 'port' => 4444, + 'username' => 'myuser', + 'password' => 'mypassword', + 'secure' => 'ssl', + ], + ); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + + $this->assertFalse($response['body']['authPasswordDictionary']); + $this->assertFalse($response['body']['authPersonalDataCheck']); + $this->assertFalse($response['body']['authSessionAlerts']); + $this->assertFalse($response['body']['authMembershipsUserName']); + $this->assertFalse($response['body']['authMembershipsUserEmail']); + $this->assertFalse($response['body']['authMembershipsMfa']); + $this->assertFalse($response['body']['authMembershipsUserId']); + $this->assertFalse($response['body']['authMembershipsUserPhone']); + $this->assertFalse($response['body']['authInvalidateSessions']); + $this->assertFalse($response['body']['smtpEnabled']); + $this->assertSame('customhost.com', $response['body']['smtpHost']); + $this->assertSame(4444, $response['body']['smtpPort']); + $this->assertSame('myuser', $response['body']['smtpUsername']); + $this->assertSame('', $response['body']['smtpPassword']); // Write only + $this->assertSame('ssl', $response['body']['smtpSecure']); + + $authsKeys = [ + 'authEmailPassword', + 'authUsersAuthMagicURL', + 'authEmailOtp', + 'authAnonymous', + 'authInvites', + 'authJWT', + 'authPhone', + ]; + foreach ($authsKeys as $authsKey) { + $this->assertFalse($response['body'][$authsKey], 'Auth method should be disabled: ' . $authsKey); + } + + $serviceKeys = [ + 'serviceStatusForAccount', + 'serviceStatusForAvatars', + 'serviceStatusForDatabases', + 'serviceStatusForTablesdb', + 'serviceStatusForLocale', + 'serviceStatusForHealth', + 'serviceStatusForProject', + 'serviceStatusForStorage', + 'serviceStatusForTeams', + 'serviceStatusForUsers', + 'serviceStatusForVcs', + 'serviceStatusForSites', + 'serviceStatusForFunctions', + 'serviceStatusForProxy', + 'serviceStatusForGraphql', + 'serviceStatusForMigrations', + 'serviceStatusForMessaging', + ]; + foreach ($serviceKeys as $serviceKey) { + $this->assertFalse($response['body'][$serviceKey], 'Service should be disabled: ' . $serviceKey); + } + + $protocolKeys = [ + 'protocolStatusForRest', + 'protocolStatusForGraphql', + 'protocolStatusForWebsocket', + ]; + foreach ($protocolKeys as $protocolKey) { + $this->assertFalse($response['body'][$protocolKey], 'Protocol should be disabled: ' . $protocolKey); + } + /** * Test for FAILURE */ @@ -6813,6 +7483,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-mode' => 'admin', 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, ], [ + 'variableId' => $variableId, 'key' => 'APP_TEST_' . $variableId, 'value' => 'TESTINGVALUE', 'secret' => false @@ -6832,6 +7503,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-mode' => 'admin', 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, ], [ + 'variableId' => $variableId, 'key' => 'APP_TEST_' . $variableId, 'value' => 'TESTINGVALUE', 'secret' => false diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 7d9257c699..2beae74d3e 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -104,14 +104,17 @@ class SitesCustomServerTest extends Scope $this->assertEquals('./', $site['body']['outputDirectory']); $variable = $this->createVariable($siteId, [ + 'variableId' => ID::unique(), 'key' => 'siteKey1', 'value' => 'siteValue1', ]); $variable2 = $this->createVariable($siteId, [ + 'variableId' => ID::unique(), 'key' => 'siteKey2', 'value' => 'siteValue2', ]); $variable3 = $this->createVariable($siteId, [ + 'variableId' => ID::unique(), 'key' => 'siteKey3', 'value' => 'siteValue3', ]); @@ -211,6 +214,7 @@ class SitesCustomServerTest extends Scope $this->assertEquals('Test Site', $site['body']['name']); $variable = $this->createVariable($siteId, [ + 'variableId' => ID::unique(), 'key' => 'siteKey1', 'value' => 'siteValue1', 'secret' => false, @@ -223,6 +227,7 @@ class SitesCustomServerTest extends Scope $this->assertEquals(false, $variable['body']['secret']); $variable2 = $this->createVariable($siteId, [ + 'variableId' => ID::unique(), 'key' => 'siteKey2', 'value' => 'siteValue2', 'secret' => false, @@ -235,6 +240,7 @@ class SitesCustomServerTest extends Scope $this->assertEquals(false, $variable2['body']['secret']); $secretVariable = $this->createVariable($siteId, [ + 'variableId' => ID::unique(), 'key' => 'siteKey3', 'value' => 'siteValue3', 'secret' => true, @@ -330,6 +336,316 @@ class SitesCustomServerTest extends Scope $this->cleanupSite($siteId); } + public function testListVariablesWithLimit(): void + { + $site = $this->createSite([ + 'buildRuntime' => 'node-22', + 'fallbackFile' => '', + 'framework' => 'other', + 'name' => 'Test List Variables Limit', + 'outputDirectory' => './', + 'siteId' => ID::unique() + ]); + $siteId = $site['body']['$id'] ?? ''; + $this->assertEquals(201, $site['headers']['status-code']); + + $variable1 = $this->createVariable($siteId, [ + 'variableId' => ID::unique(), + 'key' => 'LIMIT_KEY_1', + 'value' => 'limit-value-1', + ]); + $this->assertEquals(201, $variable1['headers']['status-code']); + + $variable2 = $this->createVariable($siteId, [ + 'variableId' => ID::unique(), + 'key' => 'LIMIT_KEY_2', + 'value' => 'limit-value-2', + ]); + $this->assertEquals(201, $variable2['headers']['status-code']); + + // List with limit of 1 + $response = $this->listVariables($siteId, [ + 'queries' => [ + Query::limit(1)->toString(), + ], + 'total' => true, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['variables']); + $this->assertGreaterThanOrEqual(2, $response['body']['total']); + + $this->cleanupSite($siteId); + } + + public function testListVariablesWithoutTotal(): void + { + $site = $this->createSite([ + 'buildRuntime' => 'node-22', + 'fallbackFile' => '', + 'framework' => 'other', + 'name' => 'Test List Variables No Total', + 'outputDirectory' => './', + 'siteId' => ID::unique() + ]); + $siteId = $site['body']['$id'] ?? ''; + $this->assertEquals(201, $site['headers']['status-code']); + + $variable = $this->createVariable($siteId, [ + 'variableId' => ID::unique(), + 'key' => 'NO_TOTAL_KEY', + 'value' => 'no-total-value', + ]); + $this->assertEquals(201, $variable['headers']['status-code']); + + // List with total=false + $response = $this->listVariables($siteId, [ + 'total' => false, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(0, $response['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($response['body']['variables'])); + + $this->cleanupSite($siteId); + } + + public function testListVariablesCursorPagination(): void + { + $site = $this->createSite([ + 'buildRuntime' => 'node-22', + 'fallbackFile' => '', + 'framework' => 'other', + 'name' => 'Test List Variables Cursor', + 'outputDirectory' => './', + 'siteId' => ID::unique() + ]); + $siteId = $site['body']['$id'] ?? ''; + $this->assertEquals(201, $site['headers']['status-code']); + + $variable1 = $this->createVariable($siteId, [ + 'variableId' => ID::unique(), + 'key' => 'CURSOR_KEY_1', + 'value' => 'cursor-value-1', + ]); + $this->assertEquals(201, $variable1['headers']['status-code']); + + $variable2 = $this->createVariable($siteId, [ + 'variableId' => ID::unique(), + 'key' => 'CURSOR_KEY_2', + 'value' => 'cursor-value-2', + ]); + $this->assertEquals(201, $variable2['headers']['status-code']); + + // Get first page with limit 1 + $page1 = $this->listVariables($siteId, [ + 'queries' => [ + Query::limit(1)->toString(), + ], + 'total' => true, + ]); + + $this->assertEquals(200, $page1['headers']['status-code']); + $this->assertCount(1, $page1['body']['variables']); + $cursorId = $page1['body']['variables'][0]['$id']; + + // Get next page using cursor + $page2 = $this->listVariables($siteId, [ + 'queries' => [ + Query::limit(1)->toString(), + Query::cursorAfter(new Document(['$id' => $cursorId]))->toString(), + ], + 'total' => true, + ]); + + $this->assertEquals(200, $page2['headers']['status-code']); + $this->assertCount(1, $page2['body']['variables']); + $this->assertNotEquals($cursorId, $page2['body']['variables'][0]['$id']); + + $this->cleanupSite($siteId); + } + + public function testUpdateVariableKey(): void + { + $site = $this->createSite([ + 'buildRuntime' => 'node-22', + 'fallbackFile' => '', + 'framework' => 'other', + 'name' => 'Test Update Variable Key', + 'outputDirectory' => './', + 'siteId' => ID::unique() + ]); + $siteId = $site['body']['$id'] ?? ''; + $this->assertEquals(201, $site['headers']['status-code']); + + $variable = $this->createVariable($siteId, [ + 'variableId' => ID::unique(), + 'key' => 'KEY_BEFORE', + 'value' => 'unchanged-value', + 'secret' => false + ]); + $this->assertEquals(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Update only key + $response = $this->updateVariable($siteId, $variableId, [ + 'key' => 'KEY_AFTER', + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('KEY_AFTER', $response['body']['key']); + $this->assertEquals('unchanged-value', $response['body']['value']); + + $this->cleanupSite($siteId); + } + + public function testUpdateVariableValueOnly(): void + { + $site = $this->createSite([ + 'buildRuntime' => 'node-22', + 'fallbackFile' => '', + 'framework' => 'other', + 'name' => 'Test Update Variable Value', + 'outputDirectory' => './', + 'siteId' => ID::unique() + ]); + $siteId = $site['body']['$id'] ?? ''; + $this->assertEquals(201, $site['headers']['status-code']); + + $variable = $this->createVariable($siteId, [ + 'variableId' => ID::unique(), + 'key' => 'UNCHANGED_KEY', + 'value' => 'value-before', + 'secret' => false + ]); + $this->assertEquals(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Update only value + $response = $this->updateVariable($siteId, $variableId, [ + 'value' => 'value-after', + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('UNCHANGED_KEY', $response['body']['key']); + $this->assertEquals('value-after', $response['body']['value']); + + $this->cleanupSite($siteId); + } + + public function testUpdateVariableNoOp(): void + { + $site = $this->createSite([ + 'buildRuntime' => 'node-22', + 'fallbackFile' => '', + 'framework' => 'other', + 'name' => 'Test Update Variable NoOp', + 'outputDirectory' => './', + 'siteId' => ID::unique() + ]); + $siteId = $site['body']['$id'] ?? ''; + $this->assertEquals(201, $site['headers']['status-code']); + + $variable = $this->createVariable($siteId, [ + 'variableId' => ID::unique(), + 'key' => 'NOOP_KEY', + 'value' => 'noop-value', + 'secret' => false + ]); + $this->assertEquals(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Update with no parameters should fail with 400 + $response = $this->updateVariable($siteId, $variableId, []); + + $this->assertEquals(400, $response['headers']['status-code']); + + $this->cleanupSite($siteId); + } + + public function testUpdateVariableNotFound(): void + { + $site = $this->createSite([ + 'buildRuntime' => 'node-22', + 'fallbackFile' => '', + 'framework' => 'other', + 'name' => 'Test Update Variable Not Found', + 'outputDirectory' => './', + 'siteId' => ID::unique() + ]); + $siteId = $site['body']['$id'] ?? ''; + $this->assertEquals(201, $site['headers']['status-code']); + + $response = $this->updateVariable($siteId, 'non-existent-id', [ + 'key' => 'NEW_KEY', + 'value' => 'new-value', + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + $this->assertEquals('variable_not_found', $response['body']['type']); + + $this->cleanupSite($siteId); + } + + public function testCreateVariableInvalidId(): void + { + $site = $this->createSite([ + 'buildRuntime' => 'node-22', + 'fallbackFile' => '', + 'framework' => 'other', + 'name' => 'Test Invalid Variable ID', + 'outputDirectory' => './', + 'siteId' => ID::unique() + ]); + $siteId = $site['body']['$id'] ?? ''; + $this->assertEquals(201, $site['headers']['status-code']); + + $variable = $this->createVariable($siteId, [ + 'variableId' => '!invalid-id!', + 'key' => 'INVALID_ID_KEY', + 'value' => 'value', + ]); + + $this->assertEquals(400, $variable['headers']['status-code']); + + $this->cleanupSite($siteId); + } + + public function testCreateVariableDuplicateId(): void + { + $site = $this->createSite([ + 'buildRuntime' => 'node-22', + 'fallbackFile' => '', + 'framework' => 'other', + 'name' => 'Test Duplicate Variable ID', + 'outputDirectory' => './', + 'siteId' => ID::unique() + ]); + $siteId = $site['body']['$id'] ?? ''; + $this->assertEquals(201, $site['headers']['status-code']); + + $variableId = ID::unique(); + + $variable = $this->createVariable($siteId, [ + 'variableId' => $variableId, + 'key' => 'DUP_ID_KEY_1', + 'value' => 'value1', + ]); + $this->assertEquals(201, $variable['headers']['status-code']); + + // Attempt to create with same ID + $duplicate = $this->createVariable($siteId, [ + 'variableId' => $variableId, + 'key' => 'DUP_ID_KEY_2', + 'value' => 'value2', + ]); + + $this->assertEquals(409, $duplicate['headers']['status-code']); + $this->assertEquals('variable_already_exists', $duplicate['body']['type']); + + $this->cleanupSite($siteId); + } + // This is first Sites test with Proxy // If this fails, it may not be related to variables; but Router flow failing public function testVariablesE2E(): void @@ -351,6 +667,7 @@ class SitesCustomServerTest extends Scope $domain = $this->setupSiteDomain($siteId); $secretVariable = $this->createVariable($siteId, [ + 'variableId' => ID::unique(), 'key' => 'name', 'value' => 'Appwrite', ]); diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index 29f7d70435..5e09031a9c 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -957,6 +957,68 @@ trait StorageBase $this->assertNotEquals($imageBefore->getImageBlob(), $imageAfter->getImageBlob()); } + public function testFilePreviewCacheControlOnCacheHit(): void + { + $data = $this->setupBucketFile(); + $bucketId = $data['bucketId']; + $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'), + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $this->assertEquals(201, $file['headers']['status-code']); + $this->assertNotEmpty($file['body']['$id']); + + $fileId = $file['body']['$id']; + $params = [ + 'width' => 123, + 'height' => 45, + 'output' => 'png', + 'quality' => 80, + ]; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()); + + $preview = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', + $headers, + $params + ); + + $this->assertEquals(200, $preview['headers']['status-code']); + $this->assertEquals('image/png', $preview['headers']['content-type']); + $this->assertEquals('private, max-age=2592000', $preview['headers']['cache-control']); + $this->assertEquals('miss', $preview['headers']['x-appwrite-cache']); + $this->assertNotEmpty($preview['body']); + + $cachedPreview = []; + $this->assertEventually(function () use (&$cachedPreview, $bucketId, $fileId, $headers, $params) { + $cachedPreview = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', + $headers, + $params + ); + + $this->assertEquals('hit', $cachedPreview['headers']['x-appwrite-cache']); + }); + + $this->assertEquals(200, $cachedPreview['headers']['status-code']); + $this->assertEquals('image/png', $cachedPreview['headers']['content-type']); + $this->assertStringStartsWith('private, max-age=', $cachedPreview['headers']['cache-control']); + $this->assertEquals($preview['body'], $cachedPreview['body']); + } + public function testFilePreviewZstdCompression(): void { $data = $this->setupZstdCompressionBucket(); diff --git a/tests/e2e/Services/VCS/VCSConsoleClientTest.php b/tests/e2e/Services/VCS/VCSConsoleClientTest.php index 854e7110f1..23007339de 100644 --- a/tests/e2e/Services/VCS/VCSConsoleClientTest.php +++ b/tests/e2e/Services/VCS/VCSConsoleClientTest.php @@ -513,6 +513,59 @@ class VCSConsoleClientTest extends Scope $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'main'); $this->assertEquals($repositoryBranches['body']['branches'][1]['name'], 'test'); + $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'search' => 'tes', + ]); + + $this->assertEquals(200, $repositoryBranches['headers']['status-code']); + $this->assertEquals($repositoryBranches['body']['total'], 1); + $this->assertCount(1, $repositoryBranches['body']['branches']); + $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'test'); + + $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::limit(1)->toString(), + Query::offset(1)->toString(), + ], + ]); + + $this->assertEquals(200, $repositoryBranches['headers']['status-code']); + $this->assertEquals($repositoryBranches['body']['total'], 2); + $this->assertCount(1, $repositoryBranches['body']['branches']); + $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'test'); + + $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::limit(1)->toString(), + Query::cursorAfter(new \Utopia\Database\Document(['$id' => 'main']))->toString(), + ], + ]); + + $this->assertEquals(200, $repositoryBranches['headers']['status-code']); + $this->assertEquals($repositoryBranches['body']['total'], 2); + $this->assertCount(1, $repositoryBranches['body']['branches']); + $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'test'); + + $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::limit(1)->toString(), + Query::cursorBefore(new \Utopia\Database\Document(['$id' => 'test']))->toString(), + ], + ]); + + $this->assertEquals(200, $repositoryBranches['headers']['status-code']); + $this->assertEquals($repositoryBranches['body']['total'], 2); + $this->assertCount(1, $repositoryBranches['body']['branches']); + $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'main'); + /** * Test for FAILURE */ @@ -522,6 +575,16 @@ class VCSConsoleClientTest extends Scope ], $this->getHeaders())); $this->assertEquals(404, $repositoryBranches['headers']['status-code']); + + $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::cursorAfter(new \Utopia\Database\Document(['$id' => 'missing-branch']))->toString(), + ], + ]); + + $this->assertEquals(400, $repositoryBranches['headers']['status-code']); } public function testCreateFunctionUsingVCS(): void