diff --git a/.github/workflows/ai-moderator.yml b/.github/workflows/ai-moderator.yml index d0b180985f..483f3dbeee 100644 --- a/.github/workflows/ai-moderator.yml +++ b/.github/workflows/ai-moderator.yml @@ -5,8 +5,6 @@ on: types: [opened, edited] issue_comment: types: [created, edited] - pull_request: - types: [opened, edited] pull_request_review: types: [submitted, edited] pull_request_review_comment: diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml deleted file mode 100644 index b7b4fa0d2f..0000000000 --- a/.github/workflows/benchmark.yml +++ /dev/null @@ -1,123 +0,0 @@ -name: Benchmark -concurrency: - group: '${{ github.workflow }}-${{ github.ref }}' - cancel-in-progress: true -env: - COMPOSE_FILE: docker-compose.yml - IMAGE: appwrite-dev - CACHE_KEY: 'appwrite-dev-${{ github.event.pull_request.head.sha }}' -'on': - - pull_request -jobs: - setup: - name: Setup & Build Appwrite Image - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - submodules: recursive - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Build Appwrite - uses: docker/build-push-action@v6 - with: - context: . - push: false - tags: '${{ env.IMAGE }}' - load: true - cache-from: type=gha - cache-to: 'type=gha,mode=max' - outputs: 'type=docker,dest=/tmp/${{ env.IMAGE }}.tar' - target: development - build-args: | - DEBUG=false - TESTING=true - VERSION=dev - - name: Cache Docker Image - uses: actions/cache@v4 - with: - key: '${{ env.CACHE_KEY }}' - path: '/tmp/${{ env.IMAGE }}.tar' - benchmarking: - name: Benchmark - runs-on: ubuntu-latest - needs: setup - permissions: - pull-requests: write - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - name: Load Cache - uses: actions/cache@v4 - with: - key: '${{ env.CACHE_KEY }}' - path: '/tmp/${{ env.IMAGE }}.tar' - fail-on-cache-miss: true - - name: Load and Start Appwrite - run: | - sed -i 's/traefik/localhost/g' .env - docker load --input /tmp/${{ env.IMAGE }}.tar - docker compose up -d - sleep 10 - - name: Install Oha - run: | - echo "deb [signed-by=/usr/share/keyrings/azlux-archive-keyring.gpg] http://packages.azlux.fr/debian/ stable main" | sudo tee /etc/apt/sources.list.d/azlux.list - sudo wget -O /usr/share/keyrings/azlux-archive-keyring.gpg https://azlux.fr/repo.gpg - sudo apt update - sudo apt install oha - oha --version - - name: Benchmark PR - run: 'oha -z 180s http://localhost/v1/health/version --output-format json > benchmark.json' - - name: Cleaning - run: docker compose down -v - - name: Installing latest version - run: | - rm docker-compose.yml - rm .env - curl https://appwrite.io/install/compose -o docker-compose.yml - curl https://appwrite.io/install/env -o .env - sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env - docker compose up -d - sleep 10 - - name: Benchmark Latest - run: oha -z 180s http://localhost/v1/health/version --output-format json > benchmark-latest.json - - name: Prepare comment - run: | - echo '## :sparkles: Benchmark results' > benchmark.txt - echo ' ' >> benchmark.txt - echo "- Requests per second: $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt - echo "- Requests with 200 status code: $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt - echo "- P99 latency: $(jq -r '.latencyPercentiles.p99' benchmark.json )" >> benchmark.txt - echo " " >> benchmark.txt - echo " " >> benchmark.txt - echo "## :zap: Benchmark Comparison" >> benchmark.txt - echo " " >> benchmark.txt - echo "| Metric | This PR | Latest version | " >> benchmark.txt - echo "| --- | --- | --- | " >> benchmark.txt - echo "| RPS | $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.summary.requestsPerSec|tonumber|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt - echo "| 200 | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt - echo "| P99 | $(jq -r '.latencyPercentiles.p99' benchmark.json ) | $(jq -r '.latencyPercentiles.p99' benchmark-latest.json ) | " >> benchmark.txt - - name: Save results - uses: actions/upload-artifact@v6 - if: '${{ !cancelled() }}' - with: - name: benchmark.json - path: benchmark.json - retention-days: 7 - - name: Find Comment - if: github.event.pull_request.head.repo.full_name == github.repository - uses: peter-evans/find-comment@v3 - id: fc - with: - issue-number: '${{ github.event.pull_request.number }}' - comment-author: 'github-actions[bot]' - body-includes: Benchmark results - - name: Comment on PR - if: github.event.pull_request.head.repo.full_name == github.repository - uses: peter-evans/create-or-update-comment@v4 - with: - comment-id: '${{ steps.fc.outputs.comment-id }}' - issue-number: '${{ github.event.pull_request.number }}' - body-path: benchmark.txt - edit-mode: replace diff --git a/.github/workflows/check-dependencies.yml b/.github/workflows/check-dependencies.yml deleted file mode 100644 index 17caf3aa6b..0000000000 --- a/.github/workflows/check-dependencies.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Check dependencies - -# Adapted from https://google.github.io/osv-scanner/github-action/#scan-on-pull-request - -on: - pull_request: - branches: [main, 1.*.x] - merge_group: - branches: [main, 1.*.x] - -permissions: - # Require writing security events to upload SARIF file to security tab - security-events: write - # Only need to read contents - contents: read - -jobs: - scan-pr: - uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@v1.7.1" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..3a6ae039f0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,714 @@ +name: CI + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + COMPOSE_FILE: docker-compose.yml + IMAGE: appwrite-dev + +on: + pull_request: + workflow_dispatch: + inputs: + response_format: + description: 'Response format version to test (e.g., 1.5.0, 1.4.0)' + required: false + type: string + default: '' + +jobs: + dependencies: + name: Checks / Dependencies + if: github.event_name == 'pull_request' + permissions: + actions: read + security-events: write + contents: read + uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@v2.3.3" + + security: + name: Checks / Image + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: 'recursive' + + - name: Build the Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: false + load: true + tags: pr_image:${{ github.sha }} + target: production + + - name: Run Trivy vulnerability scanner on image + uses: aquasecurity/trivy-action@0.35.0 + with: + image-ref: 'pr_image:${{ github.sha }}' + format: 'sarif' + output: 'trivy-image-results.sarif' + severity: 'CRITICAL,HIGH' + + - name: Run Trivy vulnerability scanner on source code + uses: aquasecurity/trivy-action@0.35.0 + with: + scan-type: 'fs' + scan-ref: '.' + format: 'sarif' + output: 'trivy-fs-results.sarif' + severity: 'CRITICAL,HIGH' + skip-setup-trivy: true + + - name: Upload image scan results + uses: github/codeql-action/upload-sarif@v4 + 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 + if: always() && hashFiles('trivy-fs-results.sarif') != '' + with: + sarif_file: 'trivy-fs-results.sarif' + category: 'trivy-source' + + composer: + name: Checks / Composer + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer:v2 + coverage: none + + - name: Validate + run: composer validate + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --ignore-platform-reqs + + - name: Audit + env: + COMPOSER_NO_AUDIT: 0 + run: composer audit + + format: + name: Checks / Format + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 2 + + - run: git checkout HEAD^2 + if: github.event_name == 'pull_request' + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer:v2 + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --ignore-platform-reqs + + - name: Run Linter + run: composer lint + + analyze: + name: Checks / Analyze + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer:v2 + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --ignore-platform-reqs + + - name: Run PHPStan + run: composer analyze + + locale: + name: Checks / Locale + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Run Locale check + run: node .github/workflows/static-analysis/locale/index.js + + matrix: + name: Tests / Matrix + runs-on: ubuntu-latest + outputs: + databases: ${{ steps.generate.outputs.databases }} + modes: ${{ steps.generate.outputs.modes }} + steps: + - name: Generate matrix + id: generate + uses: actions/github-script@v8 + with: + script: | + const allDatabases = ['MariaDB', 'PostgreSQL', 'MongoDB']; + const allModes = ['dedicated', 'shared_v1', 'shared_v2']; + + const defaultDatabases = ['MongoDB']; + const defaultModes = ['dedicated']; + + const pr = context.payload.pull_request; + if (!pr) { + core.setOutput('databases', JSON.stringify(allDatabases)); + core.setOutput('modes', JSON.stringify(allModes)); + return; + } + + const getContent = (ref) => github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: 'composer.lock', + ref, + }); + + const getDbVersion = (lock) => lock.packages?.find(p => p.name === 'utopia-php/database')?.version; + + const [{ data: base }, { data: head }] = await Promise.all([ + getContent(pr.base.sha), + getContent(pr.head.sha), + ]); + + const decode = (content) => JSON.parse(Buffer.from(content, 'base64').toString()); + const databaseChanged = getDbVersion(decode(base.content)) !== getDbVersion(decode(head.content)); + + core.setOutput('databases', JSON.stringify(databaseChanged ? allDatabases : defaultDatabases)); + core.setOutput('modes', JSON.stringify(databaseChanged ? allModes : defaultModes)); + + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build Appwrite + uses: docker/build-push-action@v6 + with: + context: . + push: false + tags: ${{ env.IMAGE }} + load: true + cache-from: type=gha + cache-to: type=gha,mode=max + outputs: type=docker,dest=/tmp/${{ env.IMAGE }}.tar + target: development + build-args: | + DEBUG=false + TESTING=true + VERSION=dev + + - name: Upload Docker Image + uses: actions/upload-artifact@v7 + with: + name: ${{ env.IMAGE }} + path: /tmp/${{ env.IMAGE }}.tar + retention-days: 1 + + unit: + name: Tests / Unit + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + pull-requests: write + steps: + - name: checkout + uses: actions/checkout@v6 + + - name: Download Docker Image + uses: actions/download-artifact@v7 + with: + name: ${{ env.IMAGE }} + path: /tmp + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Load and Start Appwrite + timeout-minutes: 5 + run: | + docker load --input /tmp/${{ env.IMAGE }}.tar + docker compose pull --quiet --ignore-buildable + docker compose up -d --quiet-pull --wait + + - name: Environment Variables + run: docker compose exec -T appwrite vars + + - name: Run Unit Tests + uses: itznotabug/php-retry@v3 + with: + max_attempts: 2 + retry_wait_seconds: 60 + timeout_minutes: 15 + job_id: ${{ job.check_run_id }} + github_token: ${{ secrets.GITHUB_TOKEN }} + test_dir: tests/unit + command: >- + docker compose exec + -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" + appwrite test /usr/src/code/tests/unit + + e2e_general: + name: Tests / E2E / General + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + pull-requests: write + steps: + - name: checkout + uses: actions/checkout@v6 + + - name: Download Docker Image + uses: actions/download-artifact@v7 + with: + name: ${{ env.IMAGE }} + path: /tmp + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Load and Start Appwrite + timeout-minutes: 5 + run: | + docker load --input /tmp/${{ env.IMAGE }}.tar + docker compose pull --quiet --ignore-buildable + docker compose up -d --quiet-pull --wait + + - name: Wait for Open Runtimes + timeout-minutes: 3 + run: | + while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do + echo "Waiting for Executor to come online" + sleep 1 + done + + - name: Run General Tests + uses: itznotabug/php-retry@v3 + with: + max_attempts: 2 + retry_wait_seconds: 60 + timeout_minutes: 15 + job_id: ${{ job.check_run_id }} + github_token: ${{ secrets.GITHUB_TOKEN }} + test_dir: tests/e2e/General + command: >- + docker compose exec -T + -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" + appwrite test /usr/src/code/tests/e2e/General + + - name: Failure Logs + if: failure() + run: | + echo "=== Appwrite Logs ===" + docker compose logs + + e2e_service: + name: Tests / E2E / ${{ matrix.database }} (${{ matrix.mode }}) / ${{ matrix.service }} + runs-on: ${{ matrix.runner || 'ubuntu-latest' }} + needs: [build, matrix] + permissions: + contents: read + pull-requests: write + strategy: + fail-fast: false + matrix: + database: ${{ fromJSON(needs.matrix.outputs.databases) }} + mode: ${{ fromJSON(needs.matrix.outputs.modes) }} + service: [ + Account, + Avatars, + Console, + Databases, + TablesDB, + Functions, + FunctionsSchedule, + GraphQL, + Health, + Locale, + Projects, + Realtime, + Sites, + Proxy, + Storage, + Tokens, + Teams, + Users, + ProjectWebhooks, + Webhooks, + VCS, + Messaging, + Migrations + ] + include: + - service: Databases + runner: blacksmith-4vcpu-ubuntu-2404 + - service: Sites + runner: blacksmith-4vcpu-ubuntu-2404 + - service: Functions + runner: blacksmith-4vcpu-ubuntu-2404 + - service: Avatars + runner: blacksmith-4vcpu-ubuntu-2404 + - service: Realtime + runner: blacksmith-4vcpu-ubuntu-2404 + - service: TablesDB + runner: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Download Docker Image + uses: actions/download-artifact@v7 + with: + name: ${{ env.IMAGE }} + path: /tmp + + - name: Set database environment + run: | + if [ "${{ matrix.database }}" = "MariaDB" ]; then + echo "COMPOSE_PROFILES=mariadb" >> $GITHUB_ENV + echo "_APP_DB_ADAPTER=mariadb" >> $GITHUB_ENV + echo "_APP_DB_HOST=mariadb" >> $GITHUB_ENV + echo "_APP_DB_PORT=3306" >> $GITHUB_ENV + elif [ "${{ matrix.database }}" = "MongoDB" ]; then + echo "COMPOSE_PROFILES=mongodb" >> $GITHUB_ENV + echo "_APP_DB_ADAPTER=mongodb" >> $GITHUB_ENV + echo "_APP_DB_HOST=mongodb" >> $GITHUB_ENV + echo "_APP_DB_PORT=27017" >> $GITHUB_ENV + elif [ "${{ matrix.database }}" = "PostgreSQL" ]; then + echo "COMPOSE_PROFILES=postgresql" >> $GITHUB_ENV + echo "_APP_DB_ADAPTER=postgresql" >> $GITHUB_ENV + echo "_APP_DB_HOST=postgresql" >> $GITHUB_ENV + echo "_APP_DB_PORT=5432" >> $GITHUB_ENV + fi + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Load and Start Appwrite + timeout-minutes: 5 + env: + _APP_BROWSER_HOST: http://invalid-browser/v1 + _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }} + _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }} + run: | + docker load --input /tmp/${{ env.IMAGE }}.tar + docker compose pull --quiet --ignore-buildable + docker compose up -d --quiet-pull --wait + + - name: Wait for Open Runtimes + timeout-minutes: 3 + run: | + while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do + echo "Waiting for Executor to come online" + sleep 1 + done + + - name: Run tests + uses: itznotabug/php-retry@v3 + with: + max_attempts: 2 + retry_wait_seconds: 60 + timeout_minutes: 20 + job_id: ${{ job.check_run_id }} + github_token: ${{ secrets.GITHUB_TOKEN }} + test_dir: tests/e2e/Services/${{ matrix.service }} + command: | + SERVICE_PATH="/usr/src/code/tests/e2e/Services/${{ matrix.service }}" + + # Services that rely on sequential test method execution (shared static state) + FUNCTIONAL_FLAG="--functional" + case "${{ matrix.service }}" in + Databases|TablesDB|Functions|Realtime) FUNCTIONAL_FLAG="" ;; + esac + + docker compose exec -T \ + -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ + appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml + + - name: Failure Logs + if: failure() + run: | + echo "=== Appwrite Logs ===" + docker compose logs + + e2e_abuse: + name: Tests / E2E / Abuse (${{ matrix.mode }}) + runs-on: ubuntu-latest + needs: [build, matrix] + permissions: + contents: read + pull-requests: write + strategy: + fail-fast: false + matrix: + mode: ${{ fromJSON(needs.matrix.outputs.modes) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Download Docker Image + uses: actions/download-artifact@v7 + with: + name: ${{ env.IMAGE }} + path: /tmp + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Load and Start Appwrite + timeout-minutes: 5 + env: + _APP_OPTIONS_ABUSE: enabled + _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }} + _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }} + run: | + docker load --input /tmp/${{ env.IMAGE }}.tar + docker compose pull --quiet --ignore-buildable + docker compose up -d --quiet-pull --wait + + - name: Run tests + uses: itznotabug/php-retry@v3 + with: + max_attempts: 2 + retry_wait_seconds: 60 + timeout_minutes: 15 + job_id: ${{ job.check_run_id }} + github_token: ${{ secrets.GITHUB_TOKEN }} + test_dir: tests/e2e + command: >- + docker compose exec -T + -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" + appwrite test /usr/src/code/tests/e2e --group=abuseEnabled + + - name: Failure Logs + if: failure() + run: | + echo "=== Appwrite Logs ===" + docker compose logs + + e2e_screenshots: + name: Tests / E2E / Screenshots (${{ matrix.mode }}) + runs-on: ubuntu-latest + needs: [build, matrix] + permissions: + contents: read + pull-requests: write + strategy: + fail-fast: false + matrix: + mode: ${{ fromJSON(needs.matrix.outputs.modes) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Download Docker Image + uses: actions/download-artifact@v7 + with: + name: ${{ env.IMAGE }} + path: /tmp + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Load and Start Appwrite + timeout-minutes: 5 + env: + _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }} + _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }} + run: | + docker load --input /tmp/${{ env.IMAGE }}.tar + docker compose pull --quiet --ignore-buildable + docker compose up -d --quiet-pull --wait + + - name: Wait for Open Runtimes + timeout-minutes: 3 + run: | + while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do + echo "Waiting for Executor to come online" + sleep 1 + done + + - name: Run tests + uses: itznotabug/php-retry@v3 + with: + max_attempts: 2 + retry_wait_seconds: 60 + timeout_minutes: 15 + job_id: ${{ job.check_run_id }} + github_token: ${{ secrets.GITHUB_TOKEN }} + test_dir: tests/e2e/Services/Sites + command: >- + docker compose exec -T + -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" + appwrite test /usr/src/code/tests/e2e/Services/Sites --group=screenshots + + - name: Failure Logs + if: failure() + run: | + echo "=== Appwrite Logs ===" + docker compose logs + + benchmark: + name: Benchmark + runs-on: ubuntu-latest + needs: build + permissions: + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Download Docker Image + uses: actions/download-artifact@v7 + with: + name: ${{ env.IMAGE }} + path: /tmp + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Load and Start Appwrite + run: | + sed -i 's/traefik/localhost/g' .env + docker load --input /tmp/${{ env.IMAGE }}.tar + docker compose up -d + sleep 10 + + - name: Install Oha + run: | + echo "deb [signed-by=/usr/share/keyrings/azlux-archive-keyring.gpg] http://packages.azlux.fr/debian/ stable main" | sudo tee /etc/apt/sources.list.d/azlux.list + sudo wget -O /usr/share/keyrings/azlux-archive-keyring.gpg https://azlux.fr/repo.gpg + sudo apt update + sudo apt install oha + oha --version + + - name: Benchmark PR + run: 'oha -z 180s http://localhost/v1/health/version --output-format json > benchmark.json' + + - name: Cleaning + run: docker compose down -v + + - name: Installing latest version + run: | + rm docker-compose.yml + rm .env + curl https://appwrite.io/install/compose -o docker-compose.yml + curl https://appwrite.io/install/env -o .env + sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env + docker compose up -d + sleep 10 + + - name: Benchmark Latest + run: oha -z 180s http://localhost/v1/health/version --output-format json > benchmark-latest.json + + - name: Prepare comment + run: | + echo '## :sparkles: Benchmark results' > benchmark.txt + echo ' ' >> benchmark.txt + echo "- Requests per second: $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt + echo "- Requests with 200 status code: $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt + echo "- P99 latency: $(jq -r '.latencyPercentiles.p99' benchmark.json )" >> benchmark.txt + echo " " >> benchmark.txt + echo " " >> benchmark.txt + echo "## :zap: Benchmark Comparison" >> benchmark.txt + echo " " >> benchmark.txt + echo "| Metric | This PR | Latest version | " >> benchmark.txt + echo "| --- | --- | --- | " >> benchmark.txt + echo "| RPS | $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.summary.requestsPerSec|tonumber|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt + echo "| 200 | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt + echo "| P99 | $(jq -r '.latencyPercentiles.p99' benchmark.json ) | $(jq -r '.latencyPercentiles.p99' benchmark-latest.json ) | " >> benchmark.txt + + - name: Save results + uses: actions/upload-artifact@v7 + if: ${{ !cancelled() }} + with: + name: benchmark.json + path: benchmark.json + retention-days: 7 + + - name: Find Comment + if: github.event.pull_request.head.repo.full_name == github.repository + uses: peter-evans/find-comment@v3 + id: fc + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: Benchmark results + + - name: Comment on PR + if: github.event.pull_request.head.repo.full_name == github.repository + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ steps.fc.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body-path: benchmark.txt + edit-mode: replace diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml deleted file mode 100644 index f4ae5df1ce..0000000000 --- a/.github/workflows/linter.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: "Linter" - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -on: [pull_request] -jobs: - lint: - name: Linter - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 2 - - - run: git checkout HEAD^2 - - - name: Validate composer.json and composer.lock - run: | - docker run --rm -v $PWD:/app composer:2.8 sh -c \ - "composer validate" - - name: Run Linter - run: | - docker run --rm -v $PWD:/app composer:2.8 sh -c \ - "composer install --profile --ignore-platform-reqs && composer lint" diff --git a/.github/workflows/pr-scan.yml b/.github/workflows/pr-scan.yml deleted file mode 100644 index 51f3460d03..0000000000 --- a/.github/workflows/pr-scan.yml +++ /dev/null @@ -1,106 +0,0 @@ -name: PR Security Scan -on: - pull_request_target: - types: [opened, synchronize, reopened] - -jobs: - scan: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - name: Check out code - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - submodules: 'recursive' - - - name: Build the Docker image - uses: docker/build-push-action@v6 - with: - context: . - push: false - load: true - tags: pr_image:${{ github.sha }} - target: production - - - name: Run Trivy vulnerability scanner on image - uses: aquasecurity/trivy-action@0.20.0 - with: - image-ref: 'pr_image:${{ github.sha }}' - format: 'json' - output: 'trivy-image-results.json' - severity: 'CRITICAL,HIGH' - - - name: Run Trivy vulnerability scanner on source code - uses: aquasecurity/trivy-action@0.20.0 - with: - scan-type: 'fs' - scan-ref: '.' - format: 'json' - output: 'trivy-fs-results.json' - severity: 'CRITICAL,HIGH' - - - name: Process Trivy scan results - id: process-results - uses: actions/github-script@v8 - with: - script: | - const fs = require('fs'); - let commentBody = '## Security Scan Results for PR\n\n'; - - function processResults(results, title) { - let sectionBody = `### ${title}\n\n`; - if (results.Results && results.Results.some(result => result.Vulnerabilities && result.Vulnerabilities.length > 0)) { - sectionBody += '| Package | Version | Vulnerability | Severity |\n'; - sectionBody += '|---------|---------|----------------|----------|\n'; - - const uniqueVulns = new Set(); - results.Results.forEach(result => { - if (result.Vulnerabilities) { - result.Vulnerabilities.forEach(vuln => { - const vulnKey = `${vuln.PkgName}-${vuln.InstalledVersion}-${vuln.VulnerabilityID}`; - if (!uniqueVulns.has(vulnKey)) { - uniqueVulns.add(vulnKey); - sectionBody += `| ${vuln.PkgName} | ${vuln.InstalledVersion} | [${vuln.VulnerabilityID}](https://nvd.nist.gov/vuln/detail/${vuln.VulnerabilityID}) | ${vuln.Severity} |\n`; - } - }); - } - }); - } else { - sectionBody += '🎉 No vulnerabilities found!\n'; - } - return sectionBody; - } - - try { - const imageResults = JSON.parse(fs.readFileSync('trivy-image-results.json', 'utf8')); - const fsResults = JSON.parse(fs.readFileSync('trivy-fs-results.json', 'utf8')); - - commentBody += processResults(imageResults, "Docker Image Scan Results"); - commentBody += '\n'; - commentBody += processResults(fsResults, "Source Code Scan Results"); - - } catch (error) { - commentBody += `There was an error while running the security scan: ${error.message}\n`; - commentBody += 'Please contact the core team for assistance.'; - } - - core.setOutput('comment-body', commentBody); - - name: Find Comment - uses: peter-evans/find-comment@v3 - id: fc - with: - issue-number: ${{ github.event.pull_request.number }} - comment-author: 'github-actions[bot]' - body-includes: Security Scan Results for PR - - - name: Create or update comment - uses: peter-evans/create-or-update-comment@v3 - with: - issue-number: ${{ github.event.pull_request.number }} - comment-id: ${{ steps.fc.outputs.comment-id }} - body: ${{ steps.process-results.outputs.comment-body }} - edit-mode: replace diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 5987eeeb0c..6e4a8ba73b 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@v10 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/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml deleted file mode 100644 index a0dc38b3b4..0000000000 --- a/.github/workflows/static-analysis.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: "Static code analysis" - -on: [pull_request] -jobs: - lint: - name: CodeQL - runs-on: ubuntu-latest - - steps: - - name: Check out the repo - uses: actions/checkout@v6 - - - name: Run CodeQL - run: | - docker run --rm -v $PWD:/app composer:2.8 sh -c \ - "composer install --profile --ignore-platform-reqs && composer check" - - - name: Run Locale check - run: | - docker run --rm -v $PWD:/app node:24-alpine sh -c \ - "cd /app/.github/workflows/static-analysis/locale && node index.js" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index 1b112eebbd..0000000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,678 +0,0 @@ -name: "Tests" - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - COMPOSE_FILE: docker-compose.yml - IMAGE: appwrite-dev - CACHE_KEY: appwrite-dev-${{ github.event.pull_request.head.sha }} - -on: - pull_request: - workflow_dispatch: - inputs: - response_format: - description: 'Response format version to test (e.g., 1.5.0, 1.4.0)' - required: false - type: string - default: '' - -jobs: - check_database_changes: - name: Check if utopia-php/database changed - runs-on: ubuntu-latest - outputs: - database_changed: ${{ steps.check.outputs.database_changed }} - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Fetch base branch - run: git fetch origin ${{ github.event.pull_request.base.ref }} - - - name: Check for utopia-php/database changes - id: check - run: | - if git diff origin/${{ github.event.pull_request.base.ref }} HEAD -- composer.lock | grep -q '"name": "utopia-php/database"'; then - echo "Database version changed, going to run all mode tests." - echo "database_changed=true" >> "$GITHUB_ENV" - echo "database_changed=true" >> "$GITHUB_OUTPUT" - else - echo "database_changed=false" >> "$GITHUB_ENV" - echo "database_changed=false" >> "$GITHUB_OUTPUT" - fi - - setup: - name: Setup & Build Appwrite Image - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - submodules: recursive - - - name: Login to Docker Hub - uses: docker/login-action@v4 - with: - username: ${{ vars.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Build Appwrite - uses: docker/build-push-action@v6 - with: - context: . - push: false - tags: ${{ env.IMAGE }} - load: true - cache-from: type=gha - cache-to: type=gha,mode=max - outputs: type=docker,dest=/tmp/${{ env.IMAGE }}.tar - target: development - build-args: | - DEBUG=false - TESTING=true - VERSION=dev - - - name: Cache Docker Image - uses: actions/cache@v5 - with: - key: ${{ env.CACHE_KEY }} - path: /tmp/${{ env.IMAGE }}.tar - - unit_test: - name: Unit Test - runs-on: ubuntu-latest - needs: setup - permissions: - contents: read - pull-requests: write - - steps: - - name: checkout - uses: actions/checkout@v6 - - - name: Load Cache - uses: actions/cache@v5 - with: - key: ${{ env.CACHE_KEY }} - path: /tmp/${{ env.IMAGE }}.tar - fail-on-cache-miss: true - - - name: Login to Docker Hub - uses: docker/login-action@v4 - with: - username: ${{ vars.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Load and Start Appwrite - timeout-minutes: 5 - run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - docker compose pull --quiet --ignore-buildable - docker compose up -d --quiet-pull --wait - - - name: Environment Variables - run: docker compose exec -T appwrite vars - - - name: Run Unit Tests - uses: itznotabug/php-retry@v3 - with: - max_attempts: 2 - retry_wait_seconds: 60 - timeout_minutes: 15 - job_id: ${{ job.check_run_id }} - github_token: ${{ secrets.GITHUB_TOKEN }} - test_dir: tests/unit - command: >- - docker compose exec - -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" - appwrite test /usr/src/code/tests/unit - - e2e_general_test: - name: E2E General Test - runs-on: ubuntu-latest - needs: setup - permissions: - contents: read - pull-requests: write - steps: - - name: checkout - uses: actions/checkout@v6 - - - name: Load Cache - uses: actions/cache@v5 - with: - key: ${{ env.CACHE_KEY }} - path: /tmp/${{ env.IMAGE }}.tar - fail-on-cache-miss: true - - - name: Login to Docker Hub - uses: docker/login-action@v4 - with: - username: ${{ vars.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Load and Start Appwrite - timeout-minutes: 5 - run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - docker compose pull --quiet --ignore-buildable - docker compose up -d --quiet-pull --wait - - - name: Wait for Open Runtimes - timeout-minutes: 3 - run: | - while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do - echo "Waiting for Executor to come online" - sleep 1 - done - - - name: Run General Tests - uses: itznotabug/php-retry@v3 - with: - max_attempts: 2 - retry_wait_seconds: 60 - timeout_minutes: 15 - job_id: ${{ job.check_run_id }} - github_token: ${{ secrets.GITHUB_TOKEN }} - test_dir: tests/e2e/General - command: >- - docker compose exec -T - -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" - appwrite test /usr/src/code/tests/e2e/General - - - name: Failure Logs - if: failure() - run: | - echo "=== Appwrite Logs ===" - docker compose logs - - e2e_service_test: - name: E2E Service Test - runs-on: ubuntu-latest - needs: setup - permissions: - contents: read - pull-requests: write - strategy: - fail-fast: false - matrix: - db_adapter: [ - MARIADB, - POSTGRESQL, - MONGODB - ] - service: [ - Account, - Avatars, - Console, - Databases, - Functions, - FunctionsSchedule, - GraphQL, - Health, - Locale, - Projects, - Realtime, - Sites, - Proxy, - Storage, - Tokens, - Teams, - Users, - Webhooks, - VCS, - Messaging, - Migrations - ] - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Load Cache - uses: actions/cache@v5 - with: - key: ${{ env.CACHE_KEY }} - path: /tmp/${{ env.IMAGE }}.tar - fail-on-cache-miss: true - - - name: Set DB Adapter environment - id: set-db-env - run: | - DB_ADAPTER_LOWER=$(echo "${{ matrix.db_adapter }}" | tr 'A-Z' 'a-z') - echo "COMPOSE_PROFILES=${DB_ADAPTER_LOWER}" >> $GITHUB_ENV - - if [ "${{ matrix.db_adapter }}" = "MARIADB" ]; then - echo "_APP_DB_ADAPTER=mariadb" >> $GITHUB_ENV - echo "_APP_DB_HOST=mariadb" >> $GITHUB_ENV - echo "_APP_DB_PORT=3306" >> $GITHUB_ENV - elif [ "${{ matrix.db_adapter }}" = "MONGODB" ]; then - echo "_APP_DB_ADAPTER=mongodb" >> $GITHUB_ENV - echo "_APP_DB_HOST=mongodb" >> $GITHUB_ENV - echo "_APP_DB_PORT=27017" >> $GITHUB_ENV - elif [ "${{ matrix.db_adapter }}" = "POSTGRESQL" ]; then - echo "_APP_DB_ADAPTER=postgresql" >> $GITHUB_ENV - echo "_APP_DB_HOST=postgresql" >> $GITHUB_ENV - echo "_APP_DB_PORT=5432" >> $GITHUB_ENV - fi - - - name: Login to Docker Hub - uses: docker/login-action@v4 - with: - username: ${{ vars.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Load and Start Appwrite - timeout-minutes: 5 - env: - _APP_BROWSER_HOST: http://invalid-browser/v1 - _APP_DATABASE_SHARED_TABLES: "" - _APP_DATABASE_SHARED_TABLES_V1: "" - run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - docker compose pull --quiet --ignore-buildable - docker compose up -d --quiet-pull --wait - - - name: Wait for Open Runtimes - timeout-minutes: 3 - run: | - while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do - echo "Waiting for Executor to come online" - sleep 1 - done - - - name: Run ${{ matrix.service }} tests with Project table mode - uses: itznotabug/php-retry@v3 - with: - max_attempts: 2 - retry_wait_seconds: 60 - timeout_minutes: 20 - job_id: ${{ job.check_run_id }} - github_token: ${{ secrets.GITHUB_TOKEN }} - test_dir: tests/e2e/Services/${{ matrix.service }} - command: | - SERVICE_PATH="/usr/src/code/tests/e2e/Services/${{ matrix.service }}" - - # Services that rely on sequential test method execution (shared static state) - FUNCTIONAL_FLAG="--functional" - case "${{ matrix.service }}" in - Databases|Functions|Realtime) FUNCTIONAL_FLAG="" ;; - esac - - docker compose exec -T \ - -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ - appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --exclude-group ciIgnore --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml - - - name: Failure Logs - if: failure() - run: | - echo "=== Appwrite Logs ===" - docker compose logs - - e2e_shared_mode_test: - name: E2E Shared Mode Service Test - runs-on: ubuntu-latest - needs: [ setup, check_database_changes ] - if: needs.check_database_changes.outputs.database_changed == 'true' - permissions: - contents: read - pull-requests: write - strategy: - fail-fast: false - matrix: - service: - [ - Account, - Avatars, - Console, - Databases, - Functions, - FunctionsSchedule, - GraphQL, - Health, - Locale, - Projects, - Realtime, - Sites, - Proxy, - Storage, - Teams, - Users, - Webhooks, - VCS, - Messaging, - Migrations, - Tokens - ] - tables-mode: [ - 'Shared V1', - 'Shared V2', - ] - - steps: - - name: checkout - uses: actions/checkout@v6 - - - name: Load Cache - uses: actions/cache@v5 - with: - key: ${{ env.CACHE_KEY }} - path: /tmp/${{ env.IMAGE }}.tar - fail-on-cache-miss: true - - - name: Login to Docker Hub - uses: docker/login-action@v4 - with: - username: ${{ vars.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Load and Start Appwrite - timeout-minutes: 5 - env: - _APP_DATABASE_SHARED_TABLES: database_db_main - _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.tables-mode == 'Shared V1' && 'database_db_main' || '' }} - run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - docker compose pull --quiet --ignore-buildable - docker compose up -d --quiet-pull --wait - - - name: Wait for Open Runtimes - timeout-minutes: 3 - run: | - while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do - echo "Waiting for Executor to come online" - sleep 1 - done - - - name: Run ${{ matrix.service }} tests with ${{ matrix.tables-mode }} table mode - uses: itznotabug/php-retry@v3 - with: - max_attempts: 2 - retry_wait_seconds: 60 - timeout_minutes: 20 - job_id: ${{ job.check_run_id }} - github_token: ${{ secrets.GITHUB_TOKEN }} - test_dir: tests/e2e/Services/${{ matrix.service }} - command: | - SERVICE_PATH="/usr/src/code/tests/e2e/Services/${{ matrix.service }}" - - # Services that rely on sequential test method execution (shared static state) - FUNCTIONAL_FLAG="--functional" - case "${{ matrix.service }}" in - Databases|Functions|Realtime) FUNCTIONAL_FLAG="" ;; - esac - - docker compose exec -T \ - -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ - appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --exclude-group ciIgnore --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml - - - name: Failure Logs - if: failure() - run: | - echo "=== Appwrite Worker Builds Logs ===" - docker compose logs appwrite-worker-builds - echo "=== OpenRuntimes Executor Logs ===" - docker compose logs openruntimes-executor - - e2e_abuse_enabled: - name: E2E Service Test (Abuse) - runs-on: ubuntu-latest - needs: setup - permissions: - contents: read - pull-requests: write - steps: - - name: checkout - uses: actions/checkout@v6 - - - name: Load Cache - uses: actions/cache@v5 - with: - key: ${{ env.CACHE_KEY }} - path: /tmp/${{ env.IMAGE }}.tar - fail-on-cache-miss: true - - - name: Login to Docker Hub - uses: docker/login-action@v4 - with: - username: ${{ vars.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Load and Start Appwrite - timeout-minutes: 5 - env: - _APP_OPTIONS_ABUSE: enabled - _APP_DATABASE_SHARED_TABLES: "" - _APP_DATABASE_SHARED_TABLES_V1: "" - run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - docker compose pull --quiet --ignore-buildable - docker compose up -d --quiet-pull --wait - - - name: Run abuse-enabled tests in dedicated table mode - uses: itznotabug/php-retry@v3 - with: - max_attempts: 2 - retry_wait_seconds: 60 - timeout_minutes: 15 - job_id: ${{ job.check_run_id }} - github_token: ${{ secrets.GITHUB_TOKEN }} - test_dir: tests/e2e - command: >- - docker compose exec -T - -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" - appwrite test /usr/src/code/tests/e2e --group=abuseEnabled - - - name: Failure Logs - if: failure() - run: | - echo "=== Appwrite Worker Builds Logs ===" - docker compose logs appwrite-worker-builds - echo "=== OpenRuntimes Executor Logs ===" - docker compose logs openruntimes-executor - - e2e_abuse_enabled_shared_mode: - name: E2E Shared Mode Service Test (Abuse) - runs-on: ubuntu-latest - needs: [ setup, check_database_changes ] - if: needs.check_database_changes.outputs.database_changed == 'true' - permissions: - contents: read - pull-requests: write - strategy: - fail-fast: false - matrix: - tables-mode: [ - 'Shared V1', - 'Shared V2', - ] - steps: - - name: checkout - uses: actions/checkout@v6 - - - name: Load Cache - uses: actions/cache@v5 - with: - key: ${{ env.CACHE_KEY }} - path: /tmp/${{ env.IMAGE }}.tar - fail-on-cache-miss: true - - - name: Login to Docker Hub - uses: docker/login-action@v4 - with: - username: ${{ vars.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Load and Start Appwrite - timeout-minutes: 5 - env: - _APP_OPTIONS_ABUSE: enabled - _APP_DATABASE_SHARED_TABLES: database_db_main - _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.tables-mode == 'Shared V1' && 'database_db_main' || '' }} - run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - docker compose pull --quiet --ignore-buildable - docker compose up -d --quiet-pull --wait - - - name: Run abuse-enabled tests in ${{ matrix.tables-mode }} table mode - uses: itznotabug/php-retry@v3 - with: - max_attempts: 2 - retry_wait_seconds: 60 - timeout_minutes: 15 - job_id: ${{ job.check_run_id }} - github_token: ${{ secrets.GITHUB_TOKEN }} - test_dir: tests/e2e - command: >- - docker compose exec -T - -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" - appwrite test /usr/src/code/tests/e2e --group=abuseEnabled - - - name: Failure Logs - if: failure() - run: | - echo "=== Appwrite Worker Builds Logs ===" - docker compose logs appwrite-worker-builds - echo "=== OpenRuntimes Executor Logs ===" - docker compose logs openruntimes-executor - - e2e_screenshots: - name: E2E Service Test (Site Screenshots) - runs-on: ubuntu-latest - needs: setup - permissions: - contents: read - pull-requests: write - steps: - - name: checkout - uses: actions/checkout@v6 - - - name: Load Cache - uses: actions/cache@v5 - with: - key: ${{ env.CACHE_KEY }} - path: /tmp/${{ env.IMAGE }}.tar - fail-on-cache-miss: true - - - name: Login to Docker Hub - uses: docker/login-action@v4 - with: - username: ${{ vars.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Load and Start Appwrite - timeout-minutes: 5 - env: - _APP_DATABASE_SHARED_TABLES: "" - _APP_DATABASE_SHARED_TABLES_V1: "" - run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - docker compose pull --quiet --ignore-buildable - docker compose up -d --quiet-pull --wait - - - name: Wait for Open Runtimes - timeout-minutes: 3 - run: | - while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do - echo "Waiting for Executor to come online" - sleep 1 - done - - - name: Run Site tests with browser connected in dedicated table mode - uses: itznotabug/php-retry@v3 - with: - max_attempts: 2 - retry_wait_seconds: 60 - timeout_minutes: 15 - job_id: ${{ job.check_run_id }} - github_token: ${{ secrets.GITHUB_TOKEN }} - test_dir: tests/e2e/Services/Sites - command: >- - docker compose exec -T - -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" - appwrite test /usr/src/code/tests/e2e/Services/Sites --group=screenshots - - - name: Failure Logs - if: failure() - run: | - echo "=== Appwrite Worker Builds Logs ===" - docker compose logs appwrite-worker-builds - echo "=== OpenRuntimes Executor Logs ===" - docker compose logs openruntimes-executor - - e2e_screenshots_shared_mode: - name: E2E Shared Mode Service Test (Site Screenshots) - runs-on: ubuntu-latest - needs: [ setup, check_database_changes ] - if: needs.check_database_changes.outputs.database_changed == 'true' - permissions: - contents: read - pull-requests: write - strategy: - fail-fast: false - matrix: - tables-mode: [ - 'Shared V1', - 'Shared V2', - ] - steps: - - name: checkout - uses: actions/checkout@v6 - - - name: Load Cache - uses: actions/cache@v5 - with: - key: ${{ env.CACHE_KEY }} - path: /tmp/${{ env.IMAGE }}.tar - fail-on-cache-miss: true - - - name: Login to Docker Hub - uses: docker/login-action@v4 - with: - username: ${{ vars.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Load and Start Appwrite - timeout-minutes: 5 - env: - _APP_DATABASE_SHARED_TABLES: database_db_main - _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.tables-mode == 'Shared V1' && 'database_db_main' || '' }} - run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - docker compose pull --quiet --ignore-buildable - docker compose up -d --quiet-pull --wait - - - name: Wait for Open Runtimes - timeout-minutes: 3 - run: | - while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do - echo "Waiting for Executor to come online" - sleep 1 - done - - - name: Run Site tests with browser connected in ${{ matrix.tables-mode }} table mode - uses: itznotabug/php-retry@v3 - with: - max_attempts: 2 - retry_wait_seconds: 60 - timeout_minutes: 15 - job_id: ${{ job.check_run_id }} - github_token: ${{ secrets.GITHUB_TOKEN }} - test_dir: tests/e2e/Services/Sites - command: >- - docker compose exec -T - -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" - appwrite test /usr/src/code/tests/e2e/Services/Sites --group=screenshots - - - name: Failure Logs - if: failure() - run: | - echo "=== Appwrite Worker Builds Logs ===" - docker compose logs appwrite-worker-builds - echo "=== OpenRuntimes Executor Logs ===" - docker compose logs openruntimes-executor diff --git a/.gitignore b/.gitignore index 6aac1bbbf4..d6e138a382 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ appwrite.config.json /app/config/specs/ /docs/examples/ .phpunit.cache +playwright-report +test-results +docker-compose.web-installer.yml +.env.web-installer +docker-compose.web-installer.yml.**.backup +tests/playwright/screenshots diff --git a/CHANGES.md b/CHANGES.md index e6dd04b556..548c0d72b0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,100 +1,133 @@ -# Version 1.8.1 +# Version 1.9.0 ## What's Changed ### Notable changes -* Add branch deployments support in [#10486](https://github.com/appwrite/appwrite/pull/10486) -* Add TanStack Start sites support in [#10681](https://github.com/appwrite/appwrite/pull/10681) -* Add Next.js standalone support in [#10747](https://github.com/appwrite/appwrite/pull/10747) -* Add Resend integration in [#10690](https://github.com/appwrite/appwrite/pull/10690) -* Add option to enable/disable image transformations per-bucket in [#10722](https://github.com/appwrite/appwrite/pull/10722) -* Add operators support in [#10735](https://github.com/appwrite/appwrite/pull/10735) and [#10800](https://github.com/appwrite/appwrite/pull/10800) -* Add function and sites stats in [#10786](https://github.com/appwrite/appwrite/pull/10786) -* Add disable count feature in [#10668](https://github.com/appwrite/appwrite/pull/10668) -* Add ElevenLabs site template in [#10782](https://github.com/appwrite/appwrite/pull/10782) -* Add suggested environment variables in [#10795](https://github.com/appwrite/appwrite/pull/10795) -* Update GeoDB database in [#10890](https://github.com/appwrite/appwrite/pull/10890) -* Update Flutter default build runtime in [#10807](https://github.com/appwrite/appwrite/pull/10807) -* Upgrade runtimes in [#10804](https://github.com/appwrite/appwrite/pull/10804) +* Add PostgreSQL database adapter in [#9772](https://github.com/appwrite/appwrite/pull/9772) and [#11293](https://github.com/appwrite/appwrite/pull/11293) +* Add MongoDB support in [#11312](https://github.com/appwrite/appwrite/pull/11312) +* Add new webhooks API in [#11033](https://github.com/appwrite/appwrite/pull/11033) and [#11566](https://github.com/appwrite/appwrite/pull/11566) +* Add schedules API endpoints in [#11331](https://github.com/appwrite/appwrite/pull/11331) +* Add project labels in [#11056](https://github.com/appwrite/appwrite/pull/11056) and project status attribute in [#11291](https://github.com/appwrite/appwrite/pull/11291) +* Add resource-based API key structure in [#11003](https://github.com/appwrite/appwrite/pull/11003) with custom ID support in [#11277](https://github.com/appwrite/appwrite/pull/11277) and list queries in [#11278](https://github.com/appwrite/appwrite/pull/11278) +* Add string types (varchar, text, mediumtext, longtext) for attributes in [#11174](https://github.com/appwrite/appwrite/pull/11174) +* Add encrypt parameter to string attribute types in [#11334](https://github.com/appwrite/appwrite/pull/11334) +* Add int64 format support for integer attributes in [#11123](https://github.com/appwrite/appwrite/pull/11123) +* Add collection and row storage size in [#11254](https://github.com/appwrite/appwrite/pull/11254) and [#11069](https://github.com/appwrite/appwrite/pull/11069) +* Add totalSize on list responses in [#11102](https://github.com/appwrite/appwrite/pull/11102) +* Add custom start command for sites and functions in [#10842](https://github.com/appwrite/appwrite/pull/10842) +* Add separate build/runtime specifications in [#10849](https://github.com/appwrite/appwrite/pull/10849) +* Add deployment retention for sites and functions in [#10959](https://github.com/appwrite/appwrite/pull/10959) +* Add auto-delete old deployments in [#10959](https://github.com/appwrite/appwrite/pull/10959) +* Add custom JWT duration in [#11009](https://github.com/appwrite/appwrite/pull/11009) +* Add multiple application domains support in [#10911](https://github.com/appwrite/appwrite/pull/10911) +* Add GraphQL introspection in [#11159](https://github.com/appwrite/appwrite/pull/11159) +* Add realtime query subscriptions in [#11202](https://github.com/appwrite/appwrite/pull/11202) and [#11237](https://github.com/appwrite/appwrite/pull/11237) +* Add realtime metrics for connections, messages, and bandwidth in [#11438](https://github.com/appwrite/appwrite/pull/11438) and [#11488](https://github.com/appwrite/appwrite/pull/11488) +* Add messaging resource migration support in [#11495](https://github.com/appwrite/appwrite/pull/11495) +* Add cached documents list in [#10832](https://github.com/appwrite/appwrite/pull/10832) +* Add project queries support in [#10990](https://github.com/appwrite/appwrite/pull/10990) +* Add batch document creation in [#10894](https://github.com/appwrite/appwrite/pull/10894) +* Add async screenshots in [#11110](https://github.com/appwrite/appwrite/pull/11110) +* Add new file parameters (encryption, compression) in [#11135](https://github.com/appwrite/appwrite/pull/11135) +* Add new site templates in [#10031](https://github.com/appwrite/appwrite/pull/10031) +* Add VCS repository authorized field in [#11421](https://github.com/appwrite/appwrite/pull/11421) +* Add trusted console projects in [#11248](https://github.com/appwrite/appwrite/pull/11248) + +### Refactoring + +* Refactor to Utopia Platform modules architecture in [#11035](https://github.com/appwrite/appwrite/pull/11035), [#11049](https://github.com/appwrite/appwrite/pull/11049), [#11057](https://github.com/appwrite/appwrite/pull/11057), [#11103](https://github.com/appwrite/appwrite/pull/11103), [#11208](https://github.com/appwrite/appwrite/pull/11208), and [#11398](https://github.com/appwrite/appwrite/pull/11398) +* Refactor auth to single instance in [#10872](https://github.com/appwrite/appwrite/pull/10872) and [#11130](https://github.com/appwrite/appwrite/pull/11130) +* Refactor usage metrics to stateless publisher pattern in [#11449](https://github.com/appwrite/appwrite/pull/11449) +* Refactor messaging and queue in [#10961](https://github.com/appwrite/appwrite/pull/10961) +* Refactor functions schedule in [#10913](https://github.com/appwrite/appwrite/pull/10913) +* Refactor make Bus dispatch synchronous in [#11449](https://github.com/appwrite/appwrite/pull/11449) +* Remove proxy container in [#11039](https://github.com/appwrite/appwrite/pull/11039) + +### Performance + +* Optimize updateDocument() calls to use sparse documents in [#11465](https://github.com/appwrite/appwrite/pull/11465) +* Optimize Dockerfile in [#10947](https://github.com/appwrite/appwrite/pull/10947) +* Improve domain caching in [#11346](https://github.com/appwrite/appwrite/pull/11346) +* Improve memory usage in [#11345](https://github.com/appwrite/appwrite/pull/11345) +* Fix memory leak in [#11067](https://github.com/appwrite/appwrite/pull/11067) and [#11241](https://github.com/appwrite/appwrite/pull/11241) +* Improve realtime performance in [#11251](https://github.com/appwrite/appwrite/pull/11251) +* Enable SMTP keep-alive to reuse connections across mail jobs in [#11496](https://github.com/appwrite/appwrite/pull/11496) ### Fixes -* Fix duplicate document error while creating file in [#10891](https://github.com/appwrite/appwrite/pull/10891) -* Fix "Update external deployment (authorize)" throwing 500 error due to invalid query in [#10888](https://github.com/appwrite/appwrite/pull/10888) -* Fix error setting user password in [#10889](https://github.com/appwrite/appwrite/pull/10889) -* Fix error generating email MFA challenges in [#10884](https://github.com/appwrite/appwrite/pull/10884) -* Fix file token expiry in [#10877](https://github.com/appwrite/appwrite/pull/10877) -* Fix TanStack Nitro default in [#10860](https://github.com/appwrite/appwrite/pull/10860) -* Fix TanStack builds in [#10767](https://github.com/appwrite/appwrite/pull/10767) -* Fix nullable validation in [#10819](https://github.com/appwrite/appwrite/pull/10819) and [#10778](https://github.com/appwrite/appwrite/pull/10778) -* Fix WebP library in [#10738](https://github.com/appwrite/appwrite/pull/10738) -* Fix batch writes in [#10812](https://github.com/appwrite/appwrite/pull/10812) -* Fix error handler error in [#10719](https://github.com/appwrite/appwrite/pull/10719) -* Fix Next 16 compatibility in [#10713](https://github.com/appwrite/appwrite/pull/10713) -* Fix stats usage memory leak in [#10683](https://github.com/appwrite/appwrite/pull/10683) -* Fix author URL in template deployments in [#10535](https://github.com/appwrite/appwrite/pull/10535) -* Fix VCS lock deletion in [#10691](https://github.com/appwrite/appwrite/pull/10691) +* Fix blocked user/resource errors from 401 to 403 in [#11469](https://github.com/appwrite/appwrite/pull/11469) +* Fix OAuth for custom domains in [#10967](https://github.com/appwrite/appwrite/pull/10967) and [#11269](https://github.com/appwrite/appwrite/pull/11269) +* Fix OAuth redirect custom scheme in [#11292](https://github.com/appwrite/appwrite/pull/11292) +* Fix OAuth verified emails in [#10986](https://github.com/appwrite/appwrite/pull/10986) +* Fix MFA recovery code validation in [#10925](https://github.com/appwrite/appwrite/pull/10925) +* Fix users allow updating phone number to empty in [#11521](https://github.com/appwrite/appwrite/pull/11521) +* Fix users optional name error in [#11413](https://github.com/appwrite/appwrite/pull/11413) +* Fix file permissions in [#11026](https://github.com/appwrite/appwrite/pull/11026) +* Fix bulk insert webhook validation in [#11022](https://github.com/appwrite/appwrite/pull/11022) +* Fix execution status update in [#11134](https://github.com/appwrite/appwrite/pull/11134) +* Fix execution timeout status in [#11400](https://github.com/appwrite/appwrite/pull/11400) +* Fix CORS wildcard in [#10956](https://github.com/appwrite/appwrite/pull/10956) +* Fix preflight requests in [#10943](https://github.com/appwrite/appwrite/pull/10943) +* Fix SMTP auth check in [#10939](https://github.com/appwrite/appwrite/pull/10939) +* Fix scheduled executions trigger in [#10922](https://github.com/appwrite/appwrite/pull/10922) +* Fix schedule executions bug in [#10916](https://github.com/appwrite/appwrite/pull/10916) +* Fix deployment enum missing canceled value in [#11179](https://github.com/appwrite/appwrite/pull/11179) +* Fix invalid chunk total in [#11270](https://github.com/appwrite/appwrite/pull/11270) +* Fix sites domains in [#11240](https://github.com/appwrite/appwrite/pull/11240) and [#11355](https://github.com/appwrite/appwrite/pull/11355) +* Fix rule domains in [#11355](https://github.com/appwrite/appwrite/pull/11355) and [#11276](https://github.com/appwrite/appwrite/pull/11276) +* Fix rules deletion in [#11575](https://github.com/appwrite/appwrite/pull/11575) +* Fix VCS template flow in [#11275](https://github.com/appwrite/appwrite/pull/11275) +* Fix VCS comment empty in [#11490](https://github.com/appwrite/appwrite/pull/11490) +* Fix DSN VCS error in [#11364](https://github.com/appwrite/appwrite/pull/11364) +* Fix email URL params encoding in [#11369](https://github.com/appwrite/appwrite/pull/11369) +* Fix missing email warning in [#11378](https://github.com/appwrite/appwrite/pull/11378) +* Fix race condition in builds worker in [#11336](https://github.com/appwrite/appwrite/pull/11336) +* Fix realtime regions in [#11414](https://github.com/appwrite/appwrite/pull/11414) +* Fix realtime errors in [#11573](https://github.com/appwrite/appwrite/pull/11573) +* Fix realtime TablesDB channels in [#11404](https://github.com/appwrite/appwrite/pull/11404) and [#11430](https://github.com/appwrite/appwrite/pull/11430) +* Fix database shared table reconciliation in [#11578](https://github.com/appwrite/appwrite/pull/11578) +* Fix PostgreSQL race condition in shared mode project creation in [#11536](https://github.com/appwrite/appwrite/pull/11536) +* Fix compression enabled env in [#11171](https://github.com/appwrite/appwrite/pull/11171) +* Fix deletes bug in [#10965](https://github.com/appwrite/appwrite/pull/10965) +* Fix devkey scopes in [#10984](https://github.com/appwrite/appwrite/pull/10984) +* Fix phone auth limit in [#11143](https://github.com/appwrite/appwrite/pull/11143) +* Fix relationship document ID validation in [#11193](https://github.com/appwrite/appwrite/pull/11193) +* Fix stale project overwrites OAuth in [#11461](https://github.com/appwrite/appwrite/pull/11461) +* Fix storage health error swallowing in [#11492](https://github.com/appwrite/appwrite/pull/11492) +* Fix Origin validator type error in [#11297](https://github.com/appwrite/appwrite/pull/11297) +* Fix getScreenshot image format in [#11017](https://github.com/appwrite/appwrite/pull/11017) +* Fix migration error handling in [#11457](https://github.com/appwrite/appwrite/pull/11457) +* Fix deprecation warnings in [#11227](https://github.com/appwrite/appwrite/pull/11227) + +### Installer + +* New installer UI in [#11175](https://github.com/appwrite/appwrite/pull/11175) and [#11247](https://github.com/appwrite/appwrite/pull/11247) ### Miscellaneous -* Add CSV export functionality in [#10546](https://github.com/appwrite/appwrite/pull/10546), [#10750](https://github.com/appwrite/appwrite/pull/10750), [#10813](https://github.com/appwrite/appwrite/pull/10813), and [#10847](https://github.com/appwrite/appwrite/pull/10847) -* Add JWT disposition in [#10867](https://github.com/appwrite/appwrite/pull/10867) -* Add screenshots endpoint in [#10675](https://github.com/appwrite/appwrite/pull/10675) -* Add screenshot endpoint stats in [#10706](https://github.com/appwrite/appwrite/pull/10706) -* Add users attributes in [#10688](https://github.com/appwrite/appwrite/pull/10688) -* Add max build duration environment variable in [#10674](https://github.com/appwrite/appwrite/pull/10674) -* Add custom realtime logger in [#10871](https://github.com/appwrite/appwrite/pull/10871) -* Add logs in [#10869](https://github.com/appwrite/appwrite/pull/10869) -* Improve MFA docs endpoint order in [#10793](https://github.com/appwrite/appwrite/pull/10793) -* Auth refactor in [#10758](https://github.com/appwrite/appwrite/pull/10758), [#10837](https://github.com/appwrite/appwrite/pull/10837), [#10682](https://github.com/appwrite/appwrite/pull/10682), and [#10667](https://github.com/appwrite/appwrite/pull/10667) -* Bump assistant to 0.8.4 in [#10887](https://github.com/appwrite/appwrite/pull/10887) -* Bump database to 3.1.5 in [#10766](https://github.com/appwrite/appwrite/pull/10766) -* Bump Utopia DNS in [#10761](https://github.com/appwrite/appwrite/pull/10761) -* Update domains to 0.8.3 in [#10658](https://github.com/appwrite/appwrite/pull/10658) -* Update domains to 0.9.1 in [#10678](https://github.com/appwrite/appwrite/pull/10678) -* Update Apple Swift to 13.3.0 in [#10679](https://github.com/appwrite/appwrite/pull/10679) -* Update Apple Swift in [#10663](https://github.com/appwrite/appwrite/pull/10663) -* Update CLI to 10.2.2 in [#10672](https://github.com/appwrite/appwrite/pull/10672) -* Update to CLI 12.0.0 in [#10853](https://github.com/appwrite/appwrite/pull/10853) -* Update docs examples to use Permission class in [#10707](https://github.com/appwrite/appwrite/pull/10707) -* Update SDK examples docs in [#10855](https://github.com/appwrite/appwrite/pull/10855) -* Release Python SDK in [#10762](https://github.com/appwrite/appwrite/pull/10762) -* Release Flutter 20.3.2 in [#10838](https://github.com/appwrite/appwrite/pull/10838) -* Release Flutter/Dart add screenshot examples in [#10811](https://github.com/appwrite/appwrite/pull/10811) -* Release PHP CLI in [#10791](https://github.com/appwrite/appwrite/pull/10791) -* Release SDKs in [#10817](https://github.com/appwrite/appwrite/pull/10817) -* Update SDKs in [#10694](https://github.com/appwrite/appwrite/pull/10694), [#10729](https://github.com/appwrite/appwrite/pull/10729), and [#10744](https://github.com/appwrite/appwrite/pull/10744) -* Update SDK generator in [#10743](https://github.com/appwrite/appwrite/pull/10743) -* Update database in [#10664](https://github.com/appwrite/appwrite/pull/10664) -* Update README file in [#10763](https://github.com/appwrite/appwrite/pull/10763) -* SDK release documentation in [#10745](https://github.com/appwrite/appwrite/pull/10745) -* SDK release runtime config in [#10765](https://github.com/appwrite/appwrite/pull/10765) -* Sync specs in [#10789](https://github.com/appwrite/appwrite/pull/10789) -* Sync 1.8.0 in [#10677](https://github.com/appwrite/appwrite/pull/10677) -* Add workflow for issue triage in [#10718](https://github.com/appwrite/appwrite/pull/10718) -* Add issue auto-labeler in [#10700](https://github.com/appwrite/appwrite/pull/10700) -* Add AI moderator repo in [#10717](https://github.com/appwrite/appwrite/pull/10717) -* Browser bump in [#10850](https://github.com/appwrite/appwrite/pull/10850) -* Template type enum override in [#10848](https://github.com/appwrite/appwrite/pull/10848) -* VCS reference type in [#10852](https://github.com/appwrite/appwrite/pull/10852) -* Index scope description in [#10851](https://github.com/appwrite/appwrite/pull/10851) -* Config for environment in [#10833](https://github.com/appwrite/appwrite/pull/10833) -* Format instance in [#10830](https://github.com/appwrite/appwrite/pull/10830) -* Replace sleep in webhooks service in [#10656](https://github.com/appwrite/appwrite/pull/10656) -* Update email composer in [#10720](https://github.com/appwrite/appwrite/pull/10720) -* Update facts on GitHub sites and functions in [#10593](https://github.com/appwrite/appwrite/pull/10593) and [#10771](https://github.com/appwrite/appwrite/pull/10771) -* Fix wrong user type in [#10875](https://github.com/appwrite/appwrite/pull/10875) -* Fix limit and offset computation in [#10880](https://github.com/appwrite/appwrite/pull/10880) -* Fix enum examples in [#10828](https://github.com/appwrite/appwrite/pull/10828) -* Fix response models multi-methods in [#10815](https://github.com/appwrite/appwrite/pull/10815) -* Fix undefined variable in [#10654](https://github.com/appwrite/appwrite/pull/10654) -* Fix undefined sequence in [#10652](https://github.com/appwrite/appwrite/pull/10652) -* Fix description in [#10702](https://github.com/appwrite/appwrite/pull/10702) -* Fix warning in builds worker in [#10705](https://github.com/appwrite/appwrite/pull/10705) -* Fix sites create deployment docs in [#10566](https://github.com/appwrite/appwrite/pull/10566) -* Fix test dependencies projects in [#10655](https://github.com/appwrite/appwrite/pull/10655) -* Fix list sites test in [#10726](https://github.com/appwrite/appwrite/pull/10726) +* Add audits upgrade in [#10953](https://github.com/appwrite/appwrite/pull/10953) +* Add graceful workers shutdown in [#11104](https://github.com/appwrite/appwrite/pull/11104) +* Add pool resilience in [#11139](https://github.com/appwrite/appwrite/pull/11139) +* Add function queue job TTL in [#11226](https://github.com/appwrite/appwrite/pull/11226) +* Add cleanup stale executions in [#11146](https://github.com/appwrite/appwrite/pull/11146) +* Add success abuse reset in [#11085](https://github.com/appwrite/appwrite/pull/11085) +* Add SMTP connection validation in [#11079](https://github.com/appwrite/appwrite/pull/11079) +* Add allow custom email sender in [#10945](https://github.com/appwrite/appwrite/pull/10945) +* Add array domains env support in [#11213](https://github.com/appwrite/appwrite/pull/11213) +* Add file create after success hook in [#11054](https://github.com/appwrite/appwrite/pull/11054) +* Add delete subscribers in [#11115](https://github.com/appwrite/appwrite/pull/11115) +* Add cursor plugin in [#11371](https://github.com/appwrite/appwrite/pull/11371) +* Add observability spans in [#11320](https://github.com/appwrite/appwrite/pull/11320), [#11306](https://github.com/appwrite/appwrite/pull/11306), and [#11228](https://github.com/appwrite/appwrite/pull/11228) +* Upgrade PHPStan to v2 with full codebase coverage in [#11550](https://github.com/appwrite/appwrite/pull/11550) +* Upgrade Traefik in [#11265](https://github.com/appwrite/appwrite/pull/11265) +* Upgrade utopia-php/queue in [#11239](https://github.com/appwrite/appwrite/pull/11239) +* Upgrade spomky-labs/otphp in [#11263](https://github.com/appwrite/appwrite/pull/11263) +* Bump utopia-php/database to stable 5.3.15 in [#11573](https://github.com/appwrite/appwrite/pull/11573) +* Bump utopia-php/migration to 1.6.3 in [#11443](https://github.com/appwrite/appwrite/pull/11443) +* Consolidate CI workflows in [#11531](https://github.com/appwrite/appwrite/pull/11531) and [#11551](https://github.com/appwrite/appwrite/pull/11551) +* Hide deprecated methods from docs in [#10933](https://github.com/appwrite/appwrite/pull/10933) +* Deprecate project-level attributes in [#11203](https://github.com/appwrite/appwrite/pull/11203) # Version 1.8.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ccc8e8372..5d7ab96a4f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -409,14 +409,16 @@ Next follow the appropriate steps below depending on whether you're adding the m **API** -In file `app/controllers/shared/api.php` On the database listener, add to an existing or create a new switch case. Add a call to the usage worker with your new metric const like so: +In file `app/controllers/shared/api.php` On the database listener, add to an existing or create a new switch case. Accumulate metrics in the usage context like so: ```php case $document->getCollection() === 'teams': - $queueForStatsUsage - ->addMetric(METRIC_TEAMS, $value); // per project + $usage->addMetric(METRIC_TEAMS, $value); // per project break; ``` + +The metrics will be automatically published by the shutdown hook at the end of the request. There is no need to manually trigger or publish. + There are cases when you need to handle metric that has a parent entity, like buckets. Files are linked to a parent bucket, you should verify you remove the files stats when you delete a bucket. @@ -425,14 +427,13 @@ In that case you need also to handle children removal using addReduce() method c ```php case $document->getCollection() === 'buckets': //buckets - $queueForStatsUsage - ->addMetric(METRIC_BUCKETS, $value); // per project + $usage->addMetric(METRIC_BUCKETS, $value); // per project if ($event === Database::EVENT_DOCUMENT_DELETE) { - $queueForStatsUsage + $usage ->addReduce($document); } break; - + ``` In addition, you will also need to add some logic to the `reduce()` method of the Usage worker located in `/src/Appwrite/Platform/Workers/Usage.php`, like so: @@ -460,8 +461,12 @@ case $document->getCollection() === 'buckets': **Background worker** -You need to inject the usage queue in the desired worker on the constructor method +You need to inject the usage context and publisher in the desired worker on the constructor method ```php +use Appwrite\Usage\Context; +use Appwrite\Event\Publisher\Usage as UsagePublisher; +use Appwrite\Event\Message\Usage as UsageMessage; + /** * @throws Exception */ @@ -474,24 +479,32 @@ public function __construct() ->inject('dbForProject') ->inject('queueForFunctions') ->inject('queueForEvents') - ->inject('queueForStatsUsage') + ->inject('usage') + ->inject('publisherForUsage') ->inject('log') - ->callback(fn (Message $message, Database $dbForProject, Func $queueForFunctions, Event $queueForEvents, StatsUsage $queueForStatsUsage, Log $log) => $this->action($message, $dbForProject, $queueForFunctions, $queueForEvents, $queueForStatsUsage, $log)); + ->callback(fn (Message $message, Database $dbForProject, Func $queueForFunctions, Event $queueForEvents, Context $usage, UsagePublisher $publisherForUsage, Log $log) => $this->action($message, $dbForProject, $queueForFunctions, $queueForEvents, $usage, $publisherForUsage, $log)); } ``` -and then trigger the queue with the new metric like so: +and then accumulate metrics, create a message, and publish like so: ```php -$queueForStatsUsage +$usage ->addMetric(METRIC_BUILDS, 1) ->addMetric(METRIC_BUILDS_STORAGE, $build->getAttribute('size', 0)) ->addMetric(METRIC_BUILDS_COMPUTE, (int)$build->getAttribute('duration', 0) * 1000) - ->addMetric(str_replace('{functionInternalId}', $function->getSequence(), METRIC_FUNCTION_ID_BUILDS), 1) + ->addMetric(str_replace('{functionInternalId}', $function->getSequence(), METRIC_FUNCTION_ID_BUILDS), 1) ->addMetric(str_replace('{functionInternalId}', $function->getSequence(), METRIC_FUNCTION_ID_BUILDS_STORAGE), $build->getAttribute('size', 0)) - ->addMetric(str_replace('{functionInternalId}', $function->getSequence(), METRIC_FUNCTION_ID_BUILDS_COMPUTE), (int)$build->getAttribute('duration', 0) * 1000) - ->setProject($project) - ->trigger(); + ->addMetric(str_replace('{functionInternalId}', $function->getSequence(), METRIC_FUNCTION_ID_BUILDS_COMPUTE), (int)$build->getAttribute('duration', 0) * 1000); + +// Publish the accumulated metrics (workers don't have shutdown hooks) +$message = new UsageMessage( + project: $project, + metrics: $usage->getMetrics(), + reduce: $usage->getReduce() +); +$publisherForUsage->enqueue($message); +$usage->reset(); ``` diff --git a/Dockerfile b/Dockerfile index 210c2bc3d9..7cb007c188 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist \ `if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi` -FROM appwrite/base:1.0.0 AS base +FROM appwrite/base:1.0.1 AS base LABEL maintainer="team@appwrite.io" @@ -121,5 +121,6 @@ RUN if [ "$DEBUG" = "true" ]; then \ fi EXPOSE 80 +EXPOSE 8080 CMD [ "php", "app/http.php" ] diff --git a/README-CN.md b/README-CN.md index afd7eca289..212b5bb08d 100644 --- a/README-CN.md +++ b/README-CN.md @@ -72,7 +72,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.8.1 + appwrite/appwrite:1.9.0 ``` ### Windows @@ -84,7 +84,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.8.1 + appwrite/appwrite:1.9.0 ``` #### PowerShell @@ -94,7 +94,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.8.1 + appwrite/appwrite:1.9.0 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index 4a71579207..457863d236 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.8.1 + appwrite/appwrite:1.9.0 ``` ### Windows @@ -87,7 +87,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.8.1 + appwrite/appwrite:1.9.0 ``` #### PowerShell @@ -97,7 +97,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.8.1 + appwrite/appwrite:1.9.0 ``` Once the Docker installation is complete, go to http://localhost to access the Appwrite console from your browser. Please note that on non-Linux native hosts, the server might take a few minutes to start after completing the installation. diff --git a/app/cli.php b/app/cli.php index 052643f004..b8721320be 100644 --- a/app/cli.php +++ b/app/cli.php @@ -4,11 +4,13 @@ require_once __DIR__ . '/init.php'; use Appwrite\Event\Certificate; use Appwrite\Event\Delete; +use Appwrite\Event\Event; use Appwrite\Event\Func; +use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\StatsResources; -use Appwrite\Event\StatsUsage; use Appwrite\Platform\Appwrite; use Appwrite\Runtimes\Runtimes; +use Appwrite\Usage\Context as UsageContext; use Appwrite\Utopia\Database\Documents\User; use Executor\Executor; use Swoole\Runtime; @@ -29,6 +31,7 @@ use Utopia\Platform\Service; use Utopia\Pools\Group; use Utopia\Queue\Broker\Pool as BrokerPool; use Utopia\Queue\Publisher; +use Utopia\Queue\Queue; use Utopia\Registry\Registry; use Utopia\System\System; use Utopia\Telemetry\Adapter\None as NoTelemetry; @@ -47,7 +50,7 @@ $platform = new Appwrite(); $args = $platform->getEnv('argv'); \array_shift($args); -if (!isset($args[0])) { +if (! isset($args[0])) { Console::error('Missing task name'); Console::exit(1); } @@ -85,6 +88,7 @@ $setResource('pools', function (Registry $register) { $setResource('authorization', function () { $authorization = new Authorization(); $authorization->disable(); + return $authorization; }, []); @@ -113,7 +117,7 @@ $setResource('dbForPlatform', function ($pools, $cache, $authorization) { $collections = Config::getParam('collections', [])['console']; $last = \array_key_last($collections); - if (!($dbForPlatform->exists($dbForPlatform->getDatabase(), $last))) { /** TODO cache ready variable using registry */ + if (! ($dbForPlatform->exists($dbForPlatform->getDatabase(), $last))) { /** TODO cache ready variable using registry */ throw new Exception('Tables not ready yet.'); } @@ -122,10 +126,10 @@ $setResource('dbForPlatform', function ($pools, $cache, $authorization) { Console::warning($err->getMessage()); sleep($sleep); } - } while ($attempts < $maxAttempts && !$ready); + } while ($attempts < $maxAttempts && ! $ready); - if (!$ready) { - throw new Exception("Console is not ready yet. Please try again later."); + if (! $ready) { + throw new Exception('Console is not ready yet. Please try again later.'); } return $dbForPlatform; @@ -163,7 +167,7 @@ $setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $c if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) - ->setTenant((int)$project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -184,7 +188,7 @@ $setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $c if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) - ->setTenant((int)$project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -208,7 +212,7 @@ $setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $a return function (?Document $project = null) use ($pools, $cache, $database, $authorization) { if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') { - $database->setTenant((int)$project->getSequence()); + $database->setTenant($project->getSequence()); return $database; } @@ -225,7 +229,7 @@ $setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $a // set tenant if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') { - $database->setTenant((int)$project->getSequence()); + $database->setTenant($project->getSequence()); } return $database; @@ -243,15 +247,16 @@ $setResource('publisherFunctions', function (BrokerPool $publisher) { $setResource('publisherMigrations', function (BrokerPool $publisher) { return $publisher; }, ['publisher']); -$setResource('publisherStatsUsage', function (BrokerPool $publisher) { - return $publisher; -}, ['publisher']); $setResource('publisherMessaging', function (BrokerPool $publisher) { return $publisher; }, ['publisher']); -$setResource('queueForStatsUsage', function (Publisher $publisher) { - return new StatsUsage($publisher); -}, ['publisher']); +$setResource('usage', function () { + return new UsageContext(); +}, []); +$setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher( + $publisher, + new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME)) +), ['publisher']); $setResource('queueForStatsResources', function (Publisher $publisher) { return new StatsResources($publisher); }, ['publisher']); diff --git a/app/config/collections/common.php b/app/config/collections/common.php index 1845ef8a42..80bb717423 100644 --- a/app/config/collections/common.php +++ b/app/config/collections/common.php @@ -419,6 +419,17 @@ return [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('impersonator'), + 'type' => Database::VAR_BOOLEAN, + 'signed' => true, + 'size' => 0, + 'format' => '', + 'filters' => [], + 'required' => false, + 'default' => false, + 'array' => false, + ], ], 'indexes' => [ [ @@ -491,6 +502,13 @@ return [ 'lengths' => [], 'orders' => [], ], + [ + '$id' => ID::custom('impersonator'), + 'type' => Database::INDEX_KEY, + 'attributes' => [ID::custom('impersonator')], + 'lengths' => [], + 'orders' => [], + ], ], ], diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php index 6c417ae145..55dceb9b40 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -788,6 +788,17 @@ return [ 'default' => null, 'filters' => [], ], + [ + 'array' => false, + '$id' => ID::custom('specification'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => false, + 'required' => false, + 'default' => APP_COMPUTE_SPECIFICATION_DEFAULT, + 'filters' => [], + ], [ 'array' => false, '$id' => ID::custom('buildSpecification'), @@ -1245,6 +1256,17 @@ return [ 'array' => false, 'filters' => [], ], + [ + 'array' => false, + '$id' => ID::custom('specification'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => false, + 'required' => false, + 'default' => APP_COMPUTE_SPECIFICATION_DEFAULT, + 'filters' => [], + ], [ 'array' => false, '$id' => ID::custom('buildSpecification'), diff --git a/app/config/cors.php b/app/config/cors.php index ef1adeb998..0454a24495 100644 --- a/app/config/cors.php +++ b/app/config/cors.php @@ -28,6 +28,9 @@ return [ 'X-Appwrite-Timestamp', 'X-Appwrite-Session', 'X-Appwrite-Platform', + 'X-Appwrite-Impersonate-User-Id', + 'X-Appwrite-Impersonate-User-Email', + 'X-Appwrite-Impersonate-User-Phone', // SDK generator 'X-SDK-Version', 'X-SDK-Name', diff --git a/app/config/errors.php b/app/config/errors.php index e8519fd797..278dbb3458 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1144,6 +1144,11 @@ return [ 'description' => 'Webhook with the requested ID could not be found.', 'code' => 404, ], + Exception::WEBHOOK_ALREADY_EXISTS => [ + 'name' => Exception::WEBHOOK_ALREADY_EXISTS, + 'description' => 'Webhook with the same ID already exists. Try again with a different ID.', + 'code' => 409, + ], Exception::KEY_NOT_FOUND => [ 'name' => Exception::KEY_NOT_FOUND, 'description' => 'Key with the requested ID could not be found.', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 0a3feba9e7..1f318b0376 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -172,4 +172,12 @@ return [ // List of publicly visible scopes 'tokens.write' => [ 'description' => 'Access to create, update, and delete your project\'s tokens', ], + "webhooks.read" => [ + "description" => + "Access to read project\'s webhooks", + ], + "webhooks.write" => [ + "description" => + "Access to create, update, and delete project\'s webhooks", + ], ]; diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index b58a9b4185..6d33b45f0b 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -14,10 +14,8 @@ use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\Hooks\Hooks; -use Appwrite\Network\Validator\Email as EmailValidator; use Appwrite\Network\Validator\Redirect; use Appwrite\OpenSSL\OpenSSL; use Appwrite\SDK\AuthType; @@ -28,6 +26,7 @@ use Appwrite\SDK\MethodType; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Template\Template; use Appwrite\URL\URL as URLParser; +use Appwrite\Usage\Context; use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Database\Validator\Queries\Identities; @@ -60,6 +59,7 @@ use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\UID; use Utopia\Emails\Email; +use Utopia\Emails\Validator\Email as EmailValidator; use Utopia\Http\Http; use Utopia\Locale\Locale; use Utopia\Storage\Validator\FileName; @@ -2801,12 +2801,12 @@ Http::post('/v1/account/tokens/phone') ->inject('queueForMessaging') ->inject('locale') ->inject('timelimit') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('plan') ->inject('store') ->inject('proofForCode') ->inject('authorization') - ->action(function (string $userId, string $phone, Request $request, Response $response, User $user, Document $project, array $platform, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Locale $locale, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, Store $store, ProofsCode $proofForCode, Authorization $authorization) { + ->action(function (string $userId, string $phone, Request $request, Response $response, User $user, Document $project, array $platform, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Locale $locale, callable $timelimit, Context $usage, array $plan, Store $store, ProofsCode $proofForCode, Authorization $authorization) { if (empty(System::getEnv('_APP_SMS_PROVIDER'))) { throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured'); } @@ -2955,16 +2955,12 @@ Http::post('/v1/account/tokens/phone') $countryCode = $helper->parse($phone)->getCountryCode(); if (!empty($countryCode)) { - $queueForStatsUsage - ->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1); + $usage->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1); } } catch (NumberParseException $e) { // Ignore invalid phone number for country code stats } - $queueForStatsUsage - ->addMetric(METRIC_AUTH_METHOD_PHONE, 1) - ->setProject($project) - ->trigger(); + $usage->addMetric(METRIC_AUTH_METHOD_PHONE, 1); } $token->setAttribute('secret', $secret); @@ -4199,11 +4195,11 @@ Http::post('/v1/account/verifications/phone') ->inject('project') ->inject('locale') ->inject('timelimit') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('plan') ->inject('proofForCode') ->inject('authorization') - ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Document $project, Locale $locale, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, ProofsCode $proofForCode, Authorization $authorization) { + ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Document $project, Locale $locale, callable $timelimit, Context $usage, array $plan, ProofsCode $proofForCode, Authorization $authorization) { if (empty(System::getEnv('_APP_SMS_PROVIDER'))) { throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured'); } @@ -4288,16 +4284,12 @@ Http::post('/v1/account/verifications/phone') $countryCode = $helper->parse($phone)->getCountryCode(); if (!empty($countryCode)) { - $queueForStatsUsage - ->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1); + $usage->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1); } } catch (NumberParseException $e) { // Ignore invalid phone number for country code stats } - $queueForStatsUsage - ->addMetric(METRIC_AUTH_METHOD_PHONE, 1) - ->setProject($project) - ->trigger(); + $usage->addMetric(METRIC_AUTH_METHOD_PHONE, 1); } $verification->setAttribute('secret', $secret); diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php index a52ec70b12..1ba5eb1119 100644 --- a/app/controllers/api/messaging.php +++ b/app/controllers/api/messaging.php @@ -8,7 +8,6 @@ use Appwrite\Event\Event; use Appwrite\Event\Messaging; use Appwrite\Extend\Exception; use Appwrite\Messaging\Status as MessageStatus; -use Appwrite\Network\Validator\Email; use Appwrite\Permission; use Appwrite\Role; use Appwrite\SDK\AuthType; @@ -43,6 +42,7 @@ use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\Roles; use Utopia\Database\Validator\UID; +use Utopia\Emails\Validator\Email; use Utopia\Http\Http; use Utopia\Locale\Locale; use Utopia\System\System; diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 24a1b28cdd..2fc20ba83f 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -4,10 +4,8 @@ use Ahc\Jwt\JWT; use Appwrite\Auth\Validator\MockNumber; use Appwrite\Event\Delete; use Appwrite\Event\Mail; -use Appwrite\Event\Validator\Event; use Appwrite\Extend\Exception; use Appwrite\Network\Platform; -use Appwrite\Network\Validator\Email; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; @@ -30,7 +28,7 @@ use Utopia\Database\Query; use Utopia\Database\Validator\Datetime as DatetimeValidator; use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; -use Utopia\Domains\Validator\PublicDomain; +use Utopia\Emails\Validator\Email; use Utopia\Http\Http; use Utopia\Locale\Locale; use Utopia\System\System; @@ -38,11 +36,9 @@ use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Hostname; use Utopia\Validator\Integer; -use Utopia\Validator\Multiple; use Utopia\Validator\Nullable; use Utopia\Validator\Range; use Utopia\Validator\Text; -use Utopia\Validator\URL; use Utopia\Validator\WhiteList; Http::init() @@ -773,312 +769,6 @@ Http::delete('/v1/projects/:projectId') $response->noContent(); }); -// Webhooks - -Http::post('/v1/projects/:projectId/webhooks') - ->desc('Create webhook') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'webhooks', - name: 'createWebhook', - description: '/docs/references/projects/create-webhook.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_WEBHOOK, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('name', null, new Text(128), 'Webhook name. Max length: 128 chars.') - ->param('enabled', true, new Boolean(true), 'Enable or disable a webhook.', true) - ->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') - ->param('url', '', fn ($request) => new Multiple([new URL(['http', 'https']), new PublicDomain()], Multiple::TYPE_STRING), 'Webhook URL.', false, ['request']) - ->param('security', false, new Boolean(true), 'Certificate verification, false for disabled or true for enabled.') - ->param('httpUser', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) - ->param('httpPass', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $name, bool $enabled, array $events, string $url, bool $security, string $httpUser, string $httpPass, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $security = (bool) filter_var($security, FILTER_VALIDATE_BOOLEAN); - - $webhook = new Document([ - '$id' => ID::unique(), - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'projectInternalId' => $project->getSequence(), - 'projectId' => $project->getId(), - 'name' => $name, - 'events' => $events, - 'url' => $url, - 'security' => $security, - 'httpUser' => $httpUser, - 'httpPass' => $httpPass, - 'signatureKey' => \bin2hex(\random_bytes(64)), - 'enabled' => $enabled, - ]); - - $webhook = $dbForPlatform->createDocument('webhooks', $webhook); - - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($webhook, Response::MODEL_WEBHOOK); - }); - -Http::get('/v1/projects/:projectId/webhooks') - ->desc('List webhooks') - ->groups(['api', 'projects']) - ->label('scope', 'projects.read') - ->label('sdk', new Method( - namespace: 'projects', - group: 'webhooks', - name: 'listWebhooks', - description: '/docs/references/projects/list-webhooks.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_WEBHOOK_LIST, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->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('dbForPlatform') - ->action(function (string $projectId, bool $includeTotal, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $webhooks = $dbForPlatform->find('webhooks', [ - Query::equal('projectInternalId', [$project->getSequence()]), - Query::limit(5000), - ]); - - $response->dynamic(new Document([ - 'webhooks' => $webhooks, - 'total' => $includeTotal ? count($webhooks) : 0, - ]), Response::MODEL_WEBHOOK_LIST); - }); - -Http::get('/v1/projects/:projectId/webhooks/:webhookId') - ->desc('Get webhook') - ->groups(['api', 'projects']) - ->label('scope', 'projects.read') - ->label('sdk', new Method( - namespace: 'projects', - group: 'webhooks', - name: 'getWebhook', - description: '/docs/references/projects/get-webhook.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_WEBHOOK, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook unique ID.', false, ['dbForPlatform']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $webhookId, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $webhook = $dbForPlatform->findOne('webhooks', [ - Query::equal('$id', [$webhookId]), - Query::equal('projectInternalId', [$project->getSequence()]), - ]); - - if ($webhook->isEmpty()) { - throw new Exception(Exception::WEBHOOK_NOT_FOUND); - } - - $response->dynamic($webhook, Response::MODEL_WEBHOOK); - }); - -Http::put('/v1/projects/:projectId/webhooks/:webhookId') - ->desc('Update webhook') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'webhooks', - name: 'updateWebhook', - description: '/docs/references/projects/update-webhook.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_WEBHOOK, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook unique ID.', false, ['dbForPlatform']) - ->param('name', null, new Text(128), 'Webhook name. Max length: 128 chars.') - ->param('enabled', true, new Boolean(true), 'Enable or disable a webhook.', true) - ->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') - ->param('url', '', fn ($request) => new Multiple([new URL(['http', 'https']), new PublicDomain()], Multiple::TYPE_STRING), 'Webhook URL.', false, ['request']) - ->param('security', false, new Boolean(true), 'Certificate verification, false for disabled or true for enabled.') - ->param('httpUser', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) - ->param('httpPass', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $webhookId, string $name, bool $enabled, array $events, string $url, bool $security, string $httpUser, string $httpPass, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $security = ($security === '1' || $security === 'true' || $security === 1 || $security === true); - - $webhook = $dbForPlatform->findOne('webhooks', [ - Query::equal('$id', [$webhookId]), - Query::equal('projectInternalId', [$project->getSequence()]), - ]); - - if ($webhook->isEmpty()) { - throw new Exception(Exception::WEBHOOK_NOT_FOUND); - } - - $webhook - ->setAttribute('name', $name) - ->setAttribute('events', $events) - ->setAttribute('url', $url) - ->setAttribute('security', $security) - ->setAttribute('httpUser', $httpUser) - ->setAttribute('httpPass', $httpPass) - ->setAttribute('enabled', $enabled); - - if ($enabled) { - $webhook->setAttribute('attempts', 0); - } - - $dbForPlatform->updateDocument('webhooks', $webhook->getId(), $webhook); - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - - $response->dynamic($webhook, Response::MODEL_WEBHOOK); - }); - -Http::patch('/v1/projects/:projectId/webhooks/:webhookId/signature') - ->desc('Update webhook signature key') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'webhooks', - name: 'updateWebhookSignature', - description: '/docs/references/projects/update-webhook-signature.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_WEBHOOK, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook unique ID.', false, ['dbForPlatform']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $webhookId, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $webhook = $dbForPlatform->findOne('webhooks', [ - Query::equal('$id', [$webhookId]), - Query::equal('projectInternalId', [$project->getSequence()]), - ]); - - if ($webhook->isEmpty()) { - throw new Exception(Exception::WEBHOOK_NOT_FOUND); - } - - $webhook->setAttribute('signatureKey', \bin2hex(\random_bytes(64))); - - $dbForPlatform->updateDocument('webhooks', $webhook->getId(), $webhook); - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - - $response->dynamic($webhook, Response::MODEL_WEBHOOK); - }); - -Http::delete('/v1/projects/:projectId/webhooks/:webhookId') - ->desc('Delete webhook') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'webhooks', - name: 'deleteWebhook', - description: '/docs/references/projects/delete-webhook.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook unique ID.', false, ['dbForPlatform']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $webhookId, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $webhook = $dbForPlatform->findOne('webhooks', [ - Query::equal('$id', [$webhookId]), - Query::equal('projectInternalId', [$project->getSequence()]), - ]); - - if ($webhook->isEmpty()) { - throw new Exception(Exception::WEBHOOK_NOT_FOUND); - } - - $dbForPlatform->deleteDocument('webhooks', $webhook->getId()); - - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - - $response->noContent(); - }); - // Keys Http::post('/v1/projects/:projectId/keys') diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 9d04018b10..3b21d4797d 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -15,7 +15,6 @@ use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Extend\Exception; use Appwrite\Hooks\Hooks; -use Appwrite\Network\Validator\Email as EmailValidator; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; @@ -60,6 +59,7 @@ use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\UID; use Utopia\Emails\Email; +use Utopia\Emails\Validator\Email as EmailValidator; use Utopia\Http\Http; use Utopia\Locale\Locale; use Utopia\System\System; @@ -1212,6 +1212,47 @@ Http::put('/v1/users/:userId/labels') $response->dynamic($user, Response::MODEL_USER); }); +Http::patch('/v1/users/:userId/impersonator') + ->desc('Update user impersonator capability') + ->groups(['api', 'users']) + ->label('event', 'users.[userId].update.impersonator') + ->label('scope', 'users.write') + ->label('audits.event', 'user.update') + ->label('audits.resource', 'user/{response.$id}') + ->label('sdk', new Method( + namespace: 'users', + group: 'users', + name: 'updateImpersonator', + description: '/docs/references/users/update-user-impersonator.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_USER, + ) + ] + )) + ->param('userId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'User ID.', false, ['dbForProject']) + ->param('impersonator', false, new Boolean(true), 'Whether the user can impersonate other users. When true, the user can browse project users to choose a target and can pass impersonation headers to act as that user. Internal audit logs still attribute impersonated actions to the original impersonator and store the target user details only in internal audit payload data.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->action(function (string $userId, bool $impersonator, Response $response, Database $dbForProject, Event $queueForEvents) { + + $user = $dbForProject->getDocument('users', $userId); + + if ($user->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND); + } + + $user = $dbForProject->updateDocument('users', $user->getId(), new Document(['impersonator' => $impersonator])); + + $queueForEvents + ->setParam('userId', $user->getId()); + + $response->dynamic($user, Response::MODEL_USER); + }); + Http::patch('/v1/users/:userId/verification/phone') ->desc('Update phone verification') ->groups(['api', 'users']) diff --git a/app/controllers/general.php b/app/controllers/general.php index c3081f675a..158844aede 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -586,7 +586,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S } if (!empty($deployment->getAttribute('startCommand', ''))) { - $startCommand = 'cd /usr/local/server/src/function/ && ' . $deployment->getAttribute('startCommand', ''); + $startCommand = 'cd /usr/local/server/src/function/ && ' . str_replace(['"', '`', '$'], ['\\"', '\\`', '\\$'], $deployment->getAttribute('startCommand', '')); } $runtimeEntrypoint = match ($version) { diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 29ccc90179..90ac1bc378 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -10,14 +10,16 @@ use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; +use Appwrite\Event\Message\Usage as UsageMessage; use Appwrite\Event\Messaging; +use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; -use Appwrite\Event\StatsUsage; use Appwrite\Event\Webhook; use Appwrite\Extend\Exception; use Appwrite\Extend\Exception as AppwriteException; use Appwrite\Functions\EventProcessor; use Appwrite\SDK\Method; +use Appwrite\Usage\Context; use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; @@ -53,7 +55,7 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar $replace = $parts[1] ?? ''; $params = match ($namespace) { - 'user' => (array)$user, + 'user' => (array) $user, 'request' => $requestParams, default => $responsePayload, }; @@ -61,13 +63,13 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar if (array_key_exists($replace, $params)) { $replacement = $params[$replace]; // Convert to string if it's not already a string - if (!is_string($replacement)) { + if (! is_string($replacement)) { if (is_array($replacement)) { $replacement = json_encode($replacement); } elseif (is_object($replacement) && method_exists($replacement, '__toString')) { - $replacement = (string)$replacement; + $replacement = (string) $replacement; } elseif (is_scalar($replacement)) { - $replacement = (string)$replacement; + $replacement = (string) $replacement; } else { throw new Exception(Exception::GENERAL_SERVER_ERROR, "The server encountered an error while parsing the label: $label. Please create an issue on GitHub to allow us to investigate further https://github.com/appwrite/appwrite/issues/new/choose"); } @@ -75,6 +77,7 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar $label = \str_replace($find, $replacement, $label); } } + return $label; }; @@ -160,7 +163,7 @@ Http::init() $scopes = $roles[$role]['scopes']; // Step 5: API Key Authentication - if (!empty($apiKey)) { + if (! empty($apiKey)) { // Check if key is expired if ($apiKey->isExpired()) { throw new Exception(Exception::PROJECT_KEY_EXPIRED); @@ -170,7 +173,6 @@ Http::init() $role = $apiKey->getRole(); $scopes = $apiKey->getScopes(); - // Handle special app role case if ($apiKey->getRole() === User::ROLE_APPS) { // Disable authorization checks for project API keys @@ -193,19 +195,19 @@ Http::init() // For standard keys, update last accessed time if (\in_array($apiKey->getType(), [API_KEY_STANDARD, API_KEY_ORGANIZATION, API_KEY_ACCOUNT])) { $dbKey = null; - if (!empty($apiKey->getProjectId())) { + if (! empty($apiKey->getProjectId())) { $dbKey = $project->find( key: 'secret', find: $request->getHeader('x-appwrite-key', ''), subject: 'keys' ); - } elseif (!empty($apiKey->getUserId())) { + } elseif (! empty($apiKey->getUserId())) { $dbKey = $user->find( key: 'secret', find: $request->getHeader('x-appwrite-key', ''), subject: 'keys' ); - } elseif (!empty($apiKey->getTeamId())) { + } elseif (! empty($apiKey->getTeamId())) { $dbKey = $team->find( key: 'secret', find: $request->getHeader('x-appwrite-key', ''), @@ -214,8 +216,6 @@ Http::init() } if (!$dbKey) { - \var_dump($apiKey); - \var_dump($request->getHeader('x-appwrite-key', '')); throw new Exception(Exception::USER_UNAUTHORIZED); } @@ -233,7 +233,7 @@ Http::init() if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) { $sdks = $dbKey->getAttribute('sdks', []); - if (!in_array($sdk, $sdks)) { + if (! in_array($sdk, $sdks)) { $sdks[] = $sdk; $updates->setAttribute('sdks', $sdks); @@ -241,14 +241,14 @@ Http::init() } } - if (!$updates->isEmpty()) { + if (! $updates->isEmpty()) { $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->updateDocument('keys', $dbKey->getId(), $updates)); - if (!empty($apiKey->getProjectId())) { + if (! empty($apiKey->getProjectId())) { $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); - } elseif (!empty($apiKey->getUserId())) { + } elseif (! empty($apiKey->getUserId())) { $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('users', $user->getId())); - } elseif (!empty($apiKey->getTeamId())) { + } elseif (! empty($apiKey->getTeamId())) { $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('teams', $team->getId())); } } @@ -285,7 +285,7 @@ Http::init() } } } // Admin User Authentication - elseif (($project->getId() === 'console' && !$team->isEmpty() && !$user->isEmpty()) || ($project->getId() !== 'console' && !$user->isEmpty() && $mode === APP_MODE_ADMIN)) { + elseif (($project->getId() === 'console' && ! $team->isEmpty() && ! $user->isEmpty()) || ($project->getId() !== 'console' && ! $user->isEmpty() && $mode === APP_MODE_ADMIN)) { $teamId = $team->getId(); $adminRoles = []; $memberships = $user->getAttribute('memberships', []); @@ -310,7 +310,7 @@ Http::init() // Useful for those who have project-specific roles but don't have team-wide role. $scopes = ['teams.read', 'projects.read']; foreach ($adminRoles as $adminRole) { - $isTeamWideRole = !str_starts_with($adminRole, 'project-'); + $isTeamWideRole = ! str_starts_with($adminRole, 'project-'); $isProjectSpecificRole = $projectId !== 'console' && str_starts_with($adminRole, 'project-' . $projectId); if ($isTeamWideRole || $isProjectSpecificRole) { @@ -338,6 +338,19 @@ Http::init() $scopes = \array_unique($scopes); + // Intentional: impersonators get users.read so they can discover a target user + // before impersonation starts, and keep that access while impersonating. + if ( + !$user->isEmpty() + && ( + $user->getAttribute('impersonator', false) + || $user->getAttribute('impersonatorUserId') + ) + ) { + $scopes[] = 'users.read'; + $scopes = \array_unique($scopes); + } + $authorization->addRole($role); foreach ($user->getRoles($authorization) as $authRole) { $authorization->addRole($authRole); @@ -348,18 +361,18 @@ Http::init() * But, for actions on resources (sites, functions, etc.) in a non-console project, we explicitly check * whether the admin user has necessary permission on the project (sites, functions, etc. don't have permissions associated to them). */ - if (empty($apiKey) && !$user->isEmpty() && $project->getId() !== 'console' && $mode === APP_MODE_ADMIN) { + if (empty($apiKey) && ! $user->isEmpty() && $project->getId() !== 'console' && $mode === APP_MODE_ADMIN) { $input = new Input(Database::PERMISSION_READ, $project->getPermissionsByType(Database::PERMISSION_READ)); $initialStatus = $authorization->getStatus(); $authorization->enable(); - if (!$authorization->isValid($input)) { + if (! $authorization->isValid($input)) { throw new Exception(Exception::PROJECT_NOT_FOUND); } $authorization->setStatus($initialStatus); } // Step 6: Update project and user last activity - if (!$project->isEmpty() && $project->getId() !== 'console') { + if (! $project->isEmpty() && $project->getId() !== 'console') { $accessedAt = $project->getAttribute('accessedAt', 0); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) { $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ @@ -368,12 +381,15 @@ Http::init() } } - if (!empty($user->getId())) { + if (! empty($user->getId())) { + $impersonatorUserId = $user->getAttribute('impersonatorUserId'); $accessedAt = $user->getAttribute('accessedAt', 0); - if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_USER_ACCESS)) > $accessedAt) { + + // Skip updating accessedAt for impersonated requests so we don't attribute activity to the target user. + if (! $impersonatorUserId && DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_USER_ACCESS)) > $accessedAt) { $user->setAttribute('accessedAt', DateTime::now()); - if ($project->getId() !== 'console' && APP_MODE_ADMIN !== $mode) { + if ($project->getId() !== 'console' && $mode !== APP_MODE_ADMIN) { $dbForProject->updateDocument('users', $user->getId(), new Document([ 'accessedAt' => $user->getAttribute('accessedAt') ])); @@ -397,26 +413,26 @@ Http::init() $method = $method[0]; } - if (!empty($method)) { + if (! empty($method)) { $namespace = $method->getNamespace(); if ( array_key_exists($namespace, $project->getAttribute('services', [])) - && !$project->getAttribute('services', [])[$namespace] - && !(User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles())) + && ! $project->getAttribute('services', [])[$namespace] + && ! (User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles())) ) { throw new Exception(Exception::GENERAL_SERVICE_DISABLED); } } // Step 9: Validate scope permissions - $allowed = (array)$route->getLabel('scope', 'none'); + $allowed = (array) $route->getLabel('scope', 'none'); if (empty(\array_intersect($allowed, $scopes))) { throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, $user->getAttribute('email', 'User') . ' (role: ' . \strtolower($roles[$role]['label']) . ') missing scopes (' . \json_encode($allowed) . ')'); } // Step 10: Check if user is blocked - if (false === $user->getAttribute('status')) { // Account is blocked + if ($user->getAttribute('status') === false) { // Account is blocked throw new Exception(Exception::USER_BLOCKED); } @@ -434,7 +450,7 @@ Http::init() $minimumFactors = ($mfaEnabled && $hasMoreFactors) ? 2 : 1; // Step 13: Handle Multi-Factor Authentication - if (!in_array('mfa', $route->getGroups())) { + if (! in_array('mfa', $route->getGroups())) { if ($session && \count($session->getAttribute('factors', [])) < $minimumFactors) { throw new Exception(Exception::USER_MORE_FACTORS_REQUIRED); } @@ -454,7 +470,7 @@ Http::init() ->inject('queueForDeletes') ->inject('queueForDatabase') ->inject('queueForBuilds') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('queueForFunctions') ->inject('queueForMails') ->inject('dbForProject') @@ -467,14 +483,14 @@ Http::init() ->inject('telemetry') ->inject('platform') ->inject('authorization') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, Document $user, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) { + ->action(function (Http $utopia, Request $request, Response $response, Document $project, Document $user, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) { $route = $utopia->getRoute(); if ( array_key_exists('rest', $project->getAttribute('apis', [])) - && !$project->getAttribute('apis', [])['rest'] - && !(User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles())) + && ! $project->getAttribute('apis', [])['rest'] + && ! (User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles())) ) { throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); } @@ -486,7 +502,7 @@ Http::init() $abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}'); $timeLimitArray = []; - $abuseKeyLabel = (!is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel; + $abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel; foreach ($abuseKeyLabel as $abuseKey) { $start = $request->getContentRangeStart(); @@ -499,7 +515,7 @@ Http::init() ->setParam('{ip}', $request->getIP()) ->setParam('{url}', $request->getHostname() . $route->getPath()) ->setParam('{method}', $request->getMethod()) - ->setParam('{chunkId}', (int)($start / ($end + 1 - $start))); + ->setParam('{chunkId}', (int) ($start / ($end + 1 - $start))); $timeLimitArray[] = $timeLimit; } @@ -511,7 +527,7 @@ Http::init() foreach ($timeLimitArray as $timeLimit) { foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys - if (!empty($value)) { + if (! empty($value)) { $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value); } } @@ -534,8 +550,8 @@ Http::init() if ( $enabled // Abuse is enabled - && !$isAppUser // User is not API key - && !$isPrivilegedUser // User is not an admin + && ! $isAppUser // User is not API key + && ! $isPrivilegedUser // User is not an admin && $devKey->isEmpty() // request doesn't not contain development key && $abuse->check() // Route is rate-limited ) { @@ -564,19 +580,13 @@ Http::init() ->setProject($project); /* If a session exists, use the user associated with the session */ - if (!$user->isEmpty()) { + if (! $user->isEmpty()) { $userClone = clone $user; // $user doesn't support `type` and can cause unintended effects. $userClone->setAttribute('type', ACTIVITY_TYPE_USER); $queueForAudits->setUser($userClone); } - if (!empty($apiKey) && !empty($apiKey->getDisabledMetrics())) { - foreach ($apiKey->getDisabledMetrics() as $key) { - $queueForStatsUsage->disableMetric($key); - } - } - /* Auto-set projects */ $queueForDeletes->setProject($project); $queueForDatabase->setProject($project); @@ -590,69 +600,64 @@ Http::init() $queueForBuilds->setPlatform($platform); $queueForMails->setPlatform($platform); - $useCache = $route->getLabel('cache', false); $storageCacheOperationsCounter = $telemetry->createCounter('storage.cache.operations.load'); if ($useCache) { $route = $utopia->match($request); $isImageTransformation = $route->getPath() === '/v1/storage/buckets/:bucketId/files/:fileId/preview'; - $isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && !User::isPrivileged($authorization->getRoles()); + $isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && ! User::isPrivileged($authorization->getRoles()); $key = $request->cacheIdentifier(); - $cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key)); + $cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key)); $cache = new Cache( new Filesystem(APP_STORAGE_CACHE . DIRECTORY_SEPARATOR . 'app-' . $project->getId()) ); $timestamp = 60 * 60 * 24 * 180; // Temporarily increase the TTL to 180 day to ensure files in the cache are still fetched. $data = $cache->load($key, $timestamp); - if (!empty($data) && !$cacheLog->isEmpty()) { - $usageMetric = $route->getLabel('usage.metric', null); - if ($usageMetric === METRIC_AVATARS_SCREENSHOTS_GENERATED) { - $queueForStatsUsage->disableMetric(METRIC_AVATARS_SCREENSHOTS_GENERATED); - } + if (! empty($data) && ! $cacheLog->isEmpty()) { $parts = explode('/', $cacheLog->getAttribute('resourceType', '')); $type = $parts[0] ?? null; - if ($type === 'bucket' && (!$isImageTransformation || !$isDisabled)) { + if ($type === 'bucket' && (! $isImageTransformation || ! $isDisabled)) { $bucketId = $parts[1] ?? null; $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isToken = !$resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence(); + $isToken = ! $resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence(); $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); - if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAppUser && !$isPrivilegedUser)) { + if ($bucket->isEmpty() || (! $bucket->getAttribute('enabled') && ! $isAppUser && ! $isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); } - if (!$bucket->getAttribute('transformations', true) && !$isAppUser && !$isPrivilegedUser) { + if (! $bucket->getAttribute('transformations', true) && ! $isAppUser && ! $isPrivilegedUser) { throw new Exception(Exception::STORAGE_BUCKET_TRANSFORMATIONS_DISABLED); } $fileSecurity = $bucket->getAttribute('fileSecurity', false); $valid = $authorization->isValid(new Input(Database::PERMISSION_READ, $bucket->getRead())); - if (!$fileSecurity && !$valid && !$isToken) { + if (! $fileSecurity && ! $valid && ! $isToken) { throw new Exception(Exception::USER_UNAUTHORIZED); } $parts = explode('/', $cacheLog->getAttribute('resource')); $fileId = $parts[1] ?? null; - if ($fileSecurity && !$valid && !$isToken) { + if ($fileSecurity && ! $valid && ! $isToken) { $file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId); } else { $file = $authorization->skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); } - if (!$resourceToken->isEmpty() && $resourceToken->getAttribute('fileInternalId') !== $file->getSequence()) { + if (! $resourceToken->isEmpty() && $resourceToken->getAttribute('fileInternalId') !== $file->getSequence()) { throw new Exception(Exception::USER_UNAUTHORIZED); } if ($file->isEmpty()) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } - //Do not update transformedAt if it's a console user - if (!User::isPrivileged($authorization->getRoles())) { + // Do not update transformedAt if it's a console user + if (! User::isPrivileged($authorization->getRoles())) { $transformedAt = $file->getAttribute('transformedAt', ''); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) { $file->setAttribute('transformedAt', DateTime::now()); @@ -668,7 +673,7 @@ Http::init() ->addHeader('X-Appwrite-Cache', 'hit') ->setContentType($cacheLog->getAttribute('mimeType')); $storageCacheOperationsCounter->add(1, ['result' => 'hit']); - if (!$isImageTransformation || !$isDisabled) { + if (! $isImageTransformation || ! $isDisabled) { $response->send($data); } } else { @@ -691,7 +696,7 @@ Http::init() return; } - if (!$user->isEmpty()) { + if (! $user->isEmpty()) { throw new Exception(Exception::USER_SESSION_ALREADY_EXISTS); } }); @@ -745,7 +750,8 @@ Http::shutdown() ->inject('user') ->inject('queueForEvents') ->inject('queueForAudits') - ->inject('queueForStatsUsage') + ->inject('usage') + ->inject('publisherForUsage') ->inject('queueForDeletes') ->inject('queueForDatabase') ->inject('queueForBuilds') @@ -758,11 +764,12 @@ Http::shutdown() ->inject('timelimit') ->inject('eventProcessor') ->inject('bus') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, StatsUsage $queueForStatsUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus) use ($parseLabel) { + ->inject('apiKey') + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, 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) use ($parseLabel) { $responsePayload = $response->getPayload(); - if (!empty($queueForEvents->getEvent())) { + if (! empty($queueForEvents->getEvent())) { if (empty($queueForEvents->getPayload())) { $queueForEvents->setPayload($responsePayload); } @@ -784,7 +791,7 @@ Http::shutdown() } // Only trigger functions if there are matching function events - if (!empty($functionsEvents)) { + if (! empty($functionsEvents)) { foreach ($generatedEvents as $event) { if (isset($functionsEvents[$event])) { $queueForFunctions @@ -796,7 +803,7 @@ Http::shutdown() } // Only trigger webhooks if there are matching webhook events - if (!empty($webhooksEvents)) { + if (! empty($webhooksEvents)) { foreach ($generatedEvents as $event) { if (isset($webhooksEvents[$event])) { $queueForWebhooks @@ -820,7 +827,7 @@ Http::shutdown() if ($abuseEnabled && \count($abuseResetCode) > 0 && \in_array($response->getStatusCode(), $abuseResetCode)) { $abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}'); - $abuseKeyLabel = (!is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel; + $abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel; foreach ($abuseKeyLabel as $abuseKey) { $start = $request->getContentRangeStart(); @@ -833,10 +840,10 @@ Http::shutdown() ->setParam('{ip}', $request->getIP()) ->setParam('{url}', $request->getHostname() . $route->getPath()) ->setParam('{method}', $request->getMethod()) - ->setParam('{chunkId}', (int)($start / ($end + 1 - $start))); + ->setParam('{chunkId}', (int) ($start / ($end + 1 - $start))); foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys - if (!empty($value)) { + if (! empty($value)) { $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value); } } @@ -850,14 +857,14 @@ Http::shutdown() * Audit labels */ $pattern = $route->getLabel('audits.resource', null); - if (!empty($pattern)) { + if (! empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); - if (!empty($resource) && $resource !== $pattern) { + if (! empty($resource) && $resource !== $pattern) { $queueForAudits->setResource($resource); } } - if (!$user->isEmpty()) { + if (! $user->isEmpty()) { $userClone = clone $user; // $user doesn't support `type` and can cause unintended effects. $userClone->setAttribute('type', ACTIVITY_TYPE_USER); @@ -883,13 +890,13 @@ Http::shutdown() $queueForAudits->setUser($user); } - if (!empty($queueForAudits->getResource()) && !$queueForAudits->getUser()->isEmpty()) { + if (! empty($queueForAudits->getResource()) && ! $queueForAudits->getUser()->isEmpty()) { /** * audits.payload is switched to default true * in order to auto audit payload for all endpoints */ $pattern = $route->getLabel('audits.payload', true); - if (!empty($pattern)) { + if (! empty($pattern)) { $queueForAudits->setPayload($responsePayload); } @@ -900,19 +907,19 @@ Http::shutdown() $queueForAudits->trigger(); } - if (!empty($queueForDeletes->getType())) { + if (! empty($queueForDeletes->getType())) { $queueForDeletes->trigger(); } - if (!empty($queueForDatabase->getType())) { + if (! empty($queueForDatabase->getType())) { $queueForDatabase->trigger(); } - if (!empty($queueForBuilds->getType())) { + if (! empty($queueForBuilds->getType())) { $queueForBuilds->trigger(); } - if (!empty($queueForMessaging->getType())) { + if (! empty($queueForMessaging->getType())) { $queueForMessaging->trigger(); } @@ -921,14 +928,14 @@ Http::shutdown() if ($useCache) { $resource = $resourceType = null; $data = $response->getPayload(); - if (!empty($data['payload'])) { + if (! empty($data['payload'])) { $pattern = $route->getLabel('cache.resource', null); - if (!empty($pattern)) { + if (! empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); } $pattern = $route->getLabel('cache.resourceType', null); - if (!empty($pattern)) { + if (! empty($pattern)) { $resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user); } @@ -938,7 +945,7 @@ Http::shutdown() $key = $request->cacheIdentifier(); $signature = md5($data['payload']); - $cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key)); + $cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key)); $accessedAt = $cacheLog->getAttribute('accessedAt', 0); $now = DateTime::now(); if ($cacheLog->isEmpty()) { @@ -971,7 +978,7 @@ Http::shutdown() } if ($project->getId() !== 'console') { - if (!User::isPrivileged($authorization->getRoles())) { + if (! User::isPrivileged($authorization->getRoles())) { $bus->dispatch(new RequestCompleted( project: $project->getArrayCopy(), request: $request, @@ -979,9 +986,32 @@ Http::shutdown() )); } - $queueForStatsUsage - ->setProject($project) - ->trigger(); + // Publish usage metrics if context has data + if (! $usage->isEmpty()) { + $metrics = $usage->getMetrics(); + + // Filter out API key disabled metrics using suffix pattern matching + $disabledMetrics = $apiKey?->getDisabledMetrics() ?? []; + if (! empty($disabledMetrics)) { + $metrics = array_values(array_filter($metrics, function ($metric) use ($disabledMetrics) { + foreach ($disabledMetrics as $pattern) { + if (str_ends_with($metric['key'], $pattern)) { + return false; + } + } + + return true; + })); + } + + $message = new UsageMessage( + project: $project, + metrics: $metrics, + reduce: $usage->getReduce() + ); + + $publisherForUsage->enqueue($message); + } } }); diff --git a/app/init/constants.php b/app/init/constants.php index 7a484c7f4e..c578bdbf9a 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -47,7 +47,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 = 4321; -const APP_VERSION_STABLE = '1.8.1'; +const APP_VERSION_STABLE = '1.9.0'; const APP_DATABASE_ATTRIBUTE_EMAIL = 'email'; const APP_DATABASE_ATTRIBUTE_ENUM = 'enum'; const APP_DATABASE_ATTRIBUTE_IP = 'ip'; diff --git a/app/init/database/formats.php b/app/init/database/formats.php index 6c73877576..29a4f0c7d4 100644 --- a/app/init/database/formats.php +++ b/app/init/database/formats.php @@ -1,9 +1,9 @@ $register); Http::setResource('locale', function () { $locale = new Locale(System::getEnv('_APP_LOCALE', 'en')); $locale->setFallback(System::getEnv('_APP_LOCALE', 'en')); + return $locale; }); @@ -108,9 +111,6 @@ Http::setResource('publisherFunctions', function (Publisher $publisher) { Http::setResource('publisherMigrations', function (Publisher $publisher) { return $publisher; }, ['publisher']); -Http::setResource('publisherStatsUsage', function (Publisher $publisher) { - return $publisher; -}, ['publisher']); Http::setResource('publisherMails', function (Publisher $publisher) { return $publisher; }, ['publisher']); @@ -150,9 +150,13 @@ Http::setResource('queueForWebhooks', function (Publisher $publisher) { Http::setResource('queueForRealtime', function () { return new Realtime(); }, []); -Http::setResource('queueForStatsUsage', function (Publisher $publisher) { - return new StatsUsage($publisher); -}, ['publisher']); +Http::setResource('usage', function () { + return new UsageContext(); +}, []); +Http::setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher( + $publisher, + new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME)) +), ['publisher']); Http::setResource('queueForAudits', function (Publisher $publisher) { return new AuditEvent($publisher); }, ['publisher']); @@ -186,14 +190,14 @@ Http::setResource('allowedHostnames', function (array $platform, Document $proje $allowed = [...($platform['hostnames'] ?? [])]; /* Add platform configured hostnames */ - if (!$project->isEmpty() && $project->getId() !== 'console') { + if (! $project->isEmpty() && $project->getId() !== 'console') { $platforms = $project->getAttribute('platforms', []); $hostnames = Platform::getHostnames($platforms); $allowed = [...$allowed, ...$hostnames]; } /* Add the request hostname if a dev key is found */ - if (!$devKey->isEmpty()) { + if (! $devKey->isEmpty()) { $allowed[] = $request->getHostname(); } @@ -211,12 +215,12 @@ Http::setResource('allowedHostnames', function (array $platform, Document $proje } /* Allow the request origin of rule */ - if (!$rule->isEmpty() && !empty($rule->getAttribute('domain', ''))) { + if (! $rule->isEmpty() && ! empty($rule->getAttribute('domain', ''))) { $allowed[] = $rule->getAttribute('domain', ''); } /* Allow the request origin if a dev key is found */ - if (!$devKey->isEmpty() && !empty($hostname)) { + if (! $devKey->isEmpty() && ! empty($hostname)) { $allowed[] = $hostname; } @@ -229,7 +233,7 @@ Http::setResource('allowedHostnames', function (array $platform, Document $proje Http::setResource('allowedSchemes', function (array $platform, Document $project) { $allowed = [...($platform['schemas'] ?? [])]; - if (!$project->isEmpty() && $project->getId() !== 'console') { + if (! $project->isEmpty() && $project->getId() !== 'console') { /* Add hardcoded schemes */ $allowed[] = 'exp'; $allowed[] = 'appwrite-callback-' . $project->getId(); @@ -273,7 +277,7 @@ Http::setResource('rule', function (Request $request, Database $dbForPlatform, D // Temporary implementation until custom wildcard domains are an official feature // Allow trusted projects; Used for Console (website) previews - if (!$permitsCurrentProject && !$rule->isEmpty() && !empty($rule->getAttribute('projectId', ''))) { + if (! $permitsCurrentProject && ! $rule->isEmpty() && ! empty($rule->getAttribute('projectId', ''))) { $trustedProjects = []; foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) { if (empty($trustedProject)) { @@ -286,7 +290,7 @@ Http::setResource('rule', function (Request $request, Database $dbForPlatform, D } } - if (!$permitsCurrentProject) { + if (! $permitsCurrentProject) { return new Document(); } @@ -309,16 +313,18 @@ Http::setResource('cors', function (array $allowedHostnames) { }, ['allowedHostnames']); Http::setResource('originValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) { - if (!$devKey->isEmpty()) { + if (! $devKey->isEmpty()) { return new URL(); } + return new Origin($allowedHostnames, $allowedSchemes); }, ['devKey', 'allowedHostnames', 'allowedSchemes']); Http::setResource('redirectValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) { - if (!$devKey->isEmpty()) { + if (! $devKey->isEmpty()) { return new URL(); } + return new Redirect($allowedHostnames, $allowedSchemes); }, ['devKey', 'allowedHostnames', 'allowedSchemes']); @@ -342,12 +348,11 @@ Http::setResource('user', function (string $mode, Document $project, Document $c * overwriting the previous value. * 7. If account API key is passed, use user of the account API key as long as user ID header matches too */ - $authorization->setDefaultStatus(true); $store->setKey('a_session_' . $project->getId()); - if (APP_MODE_ADMIN === $mode) { + if ($mode === APP_MODE_ADMIN) { $store->setKey('a_session_' . $console->getId()); } @@ -362,7 +367,7 @@ Http::setResource('user', function (string $mode, Document $project, Document $c if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) { $sessionHeader = $request->getHeader('x-appwrite-session', ''); - if (!empty($sessionHeader)) { + if (! empty($sessionHeader)) { $store->decode($sessionHeader); } } @@ -382,14 +387,14 @@ Http::setResource('user', function (string $mode, Document $project, Document $c } $user = null; - if (APP_MODE_ADMIN === $mode) { + if ($mode === APP_MODE_ADMIN) { /** @var User $user */ $user = $dbForPlatform->getDocument('users', $store->getProperty('id', '')); } else { if ($project->isEmpty()) { $user = new User([]); } else { - if (!empty($store->getProperty('id', ''))) { + if (! empty($store->getProperty('id', ''))) { if ($project->getId() === 'console') { /** @var User $user */ $user = $dbForPlatform->getDocument('users', $store->getProperty('id', '')); @@ -402,16 +407,16 @@ Http::setResource('user', function (string $mode, Document $project, Document $c } if ( - !$user || + ! $user || $user->isEmpty() // Check a document has been found in the DB - || !$user->sessionVerify($store->getProperty('secret', ''), $proofForToken) + || ! $user->sessionVerify($store->getProperty('secret', ''), $proofForToken) ) { // Validate user has valid login token $user = new User([]); } $authJWT = $request->getHeader('x-appwrite-jwt', ''); - if (!empty($authJWT) && !$project->isEmpty()) { // JWT authentication - if (!$user->isEmpty()) { + if (! empty($authJWT) && ! $project->isEmpty()) { // JWT authentication + if (! $user->isEmpty()) { throw new Exception(Exception::USER_JWT_AND_COOKIE_SET); } @@ -423,7 +428,7 @@ Http::setResource('user', function (string $mode, Document $project, Document $c } $jwtUserId = $payload['userId'] ?? ''; - if (!empty($jwtUserId)) { + if (! empty($jwtUserId)) { if ($mode === APP_MODE_ADMIN) { $user = $dbForPlatform->getDocument('users', $jwtUserId); } else { @@ -431,7 +436,7 @@ Http::setResource('user', function (string $mode, Document $project, Document $c } } $jwtSessionId = $payload['sessionId'] ?? ''; - if (!empty($jwtSessionId)) { + if (! empty($jwtSessionId)) { if (empty($user->find('$id', $jwtSessionId, 'sessions'))) { // Match JWT to active token $user = new User([]); } @@ -441,22 +446,22 @@ Http::setResource('user', function (string $mode, Document $project, Document $c // Account based on account API key $accountKey = $request->getHeader('x-appwrite-key', ''); $accountKeyUserId = $request->getHeader('x-appwrite-user', ''); - if (!empty($accountKeyUserId) && !empty($accountKey)) { - if (!$user->isEmpty()) { + if (! empty($accountKeyUserId) && ! empty($accountKey)) { + if (! $user->isEmpty()) { throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET); } $accountKeyUser = $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId)); - if (!$accountKeyUser->isEmpty()) { + if (! $accountKeyUser->isEmpty()) { $key = $accountKeyUser->find( key: 'secret', find: $accountKey, subject: 'keys' ); - if (!empty($key)) { + if (! empty($key)) { $expire = $key->getAttribute('expire'); - if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) { + if (! empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) { throw new Exception(Exception::ACCOUNT_KEY_EXPIRED); } @@ -465,23 +470,61 @@ Http::setResource('user', function (string $mode, Document $project, Document $c } } + // Impersonation: if current user has impersonator capability and headers are set, act as another user + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', ''); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); + if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { + $userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; + $targetUser = null; + if (!empty($impersonateUserId)) { + $targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->getDocument('users', $impersonateUserId)); + } elseif (!empty($impersonateEmail)) { + $targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->findOne('users', [Query::equal('email', [\strtolower($impersonateEmail)])])); + } elseif (!empty($impersonatePhone)) { + $targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->findOne('users', [Query::equal('phone', [$impersonatePhone])])); + } + if ($targetUser !== null && !$targetUser->isEmpty()) { + $impersonator = clone $user; + $user = clone $targetUser; + $user->setAttribute('impersonatorUserId', $impersonator->getId()); + $user->setAttribute('impersonatorUserInternalId', $impersonator->getSequence()); + $user->setAttribute('impersonatorUserName', $impersonator->getAttribute('name', '')); + $user->setAttribute('impersonatorUserEmail', $impersonator->getAttribute('email', '')); + $user->setAttribute('impersonatorAccessedAt', $impersonator->getAttribute('accessedAt', 0)); + } + } + $dbForProject->setMetadata('user', $user->getId()); $dbForPlatform->setMetadata('user', $user->getId()); return $user; }, ['mode', 'project', 'console', 'request', 'response', 'dbForProject', 'dbForPlatform', 'store', 'proofForToken', 'authorization']); -Http::setResource('project', function ($dbForPlatform, $request, $console, $authorization) { +Http::setResource('project', function ($dbForPlatform, $request, $console, $authorization, Http $utopia) { /** @var Appwrite\Utopia\Request $request */ /** @var Utopia\Database\Database $dbForPlatform */ /** @var Utopia\Database\Document $console */ - $projectId = $request->getParam('project', $request->getHeader('x-appwrite-project', '')); // Realtime channel "project" can send project=Query array - if (!\is_string($projectId)) { + if (! \is_string($projectId)) { $projectId = $request->getHeader('x-appwrite-project', ''); } + // Backwards compatibility for new services, originally project resources + // These endpoints moved from /v1/projects/:projectId/ to /v1/ + // When accessed via the old alias path, extract projectId from the URI + $deprecatedProjectPathPrefix = '/v1/projects/'; + $route = $utopia->match($request); + if (!empty($route)) { + $isDeprecatedAlias = \str_starts_with($request->getURI(), $deprecatedProjectPathPrefix) && + !\str_starts_with($route->getPath(), $deprecatedProjectPathPrefix); + + if ($isDeprecatedAlias) { + $projectId = \explode('/', $request->getURI(), 5)[3] ?? ''; + } + } + if (empty($projectId) || $projectId === 'console') { return $console; } @@ -489,7 +532,7 @@ Http::setResource('project', function ($dbForPlatform, $request, $console, $auth $project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId)); return $project; -}, ['dbForPlatform', 'request', 'console', 'authorization']); +}, ['dbForPlatform', 'request', 'console', 'authorization', 'utopia']); Http::setResource('session', function (User $user, Store $store, Token $proofForToken) { if ($user->isEmpty()) { @@ -499,7 +542,7 @@ Http::setResource('session', function (User $user, Store $store, Token $proofFor $sessions = $user->getAttribute('sessions', []); $sessionId = $user->sessionVerify($store->getProperty('secret', ''), $proofForToken); - if (!$sessionId) { + if (! $sessionId) { return; } foreach ($sessions as $session) { @@ -509,7 +552,6 @@ Http::setResource('session', function (User $user, Store $store, Token $proofFor } } - return; }, ['user', 'store', 'proofForToken']); Http::setResource('store', function (): Store { @@ -533,12 +575,14 @@ Http::setResource('proofForPassword', function (): Password { Http::setResource('proofForToken', function (): Token { $token = new Token(); $token->setHash(new Sha()); + return $token; }); Http::setResource('proofForCode', function (): Code { $code = new Code(); $code->setHash(new Sha()); + return $code; }); @@ -550,7 +594,7 @@ Http::setResource('authorization', function () { return new Authorization(); }, []); -Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, StatsUsage $queueForStatsUsage, Authorization $authorization) { +Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, UsageContext $usage, Authorization $authorization) { if ($project->isEmpty() || $project->getId() === 'console') { return $dbForPlatform; } @@ -584,7 +628,7 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) - ->setTenant((int) $project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -615,9 +659,8 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor ->from($queueForEvents) ->trigger(); - /** Trigger webhooks events only if a project has them enabled */ - if (!empty($project->getAttribute('webhooks'))) { + if (! empty($project->getAttribute('webhooks'))) { $queueForWebhooks ->from($queueForEvents) ->trigger(); @@ -636,7 +679,6 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor */ $functionsEventsCacheListener = function (string $event, Document $document, Document $project, Database $dbForProject) { - if ($document->getCollection() !== 'functions') { return; } @@ -658,7 +700,7 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor $dbForProject->getCache()->purge($cacheKey); }; - $usageDatabaseListener = function (string $event, Document $document, StatsUsage $queueForStatsUsage) { + $usageDatabaseListener = function (string $event, Document $document, UsageContext $usage) { $value = 1; switch ($event) { @@ -678,81 +720,78 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor switch (true) { case $document->getCollection() === 'teams': - $queueForStatsUsage->addMetric(METRIC_TEAMS, $value); // per project + $usage->addMetric(METRIC_TEAMS, $value); // per project break; case $document->getCollection() === 'users': - $queueForStatsUsage->addMetric(METRIC_USERS, $value); // per project + $usage->addMetric(METRIC_USERS, $value); // per project if ($event === Database::EVENT_DOCUMENT_DELETE) { - $queueForStatsUsage->addReduce($document); + $usage->addReduce($document); } break; case $document->getCollection() === 'sessions': // sessions - $queueForStatsUsage->addMetric(METRIC_SESSIONS, $value); //per project + $usage->addMetric(METRIC_SESSIONS, $value); // per project break; case $document->getCollection() === 'databases': // databases - $queueForStatsUsage->addMetric(METRIC_DATABASES, $value); // per project + $usage->addMetric(METRIC_DATABASES, $value); // per project if ($event === Database::EVENT_DOCUMENT_DELETE) { - $queueForStatsUsage->addReduce($document); + $usage->addReduce($document); } break; - case str_starts_with($document->getCollection(), 'database_') && !str_contains($document->getCollection(), 'collection'): //collections + case str_starts_with($document->getCollection(), 'database_') && ! str_contains($document->getCollection(), 'collection'): // collections $parts = explode('_', $document->getCollection()); $databaseInternalId = $parts[1] ?? 0; - $queueForStatsUsage + $usage ->addMetric(METRIC_COLLECTIONS, $value) // per project ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_COLLECTIONS), $value); if ($event === Database::EVENT_DOCUMENT_DELETE) { - $queueForStatsUsage->addReduce($document); + $usage->addReduce($document); } break; - case str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_'): //documents + case str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_'): // documents $parts = explode('_', $document->getCollection()); - $databaseInternalId = $parts[1] ?? 0; + $databaseInternalId = $parts[1] ?? 0; $collectionInternalId = $parts[3] ?? 0; - $queueForStatsUsage + $usage ->addMetric(METRIC_DOCUMENTS, $value) // per project ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_DOCUMENTS), $value) // per database ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS), $value); // per collection break; - case $document->getCollection() === 'buckets': //buckets - $queueForStatsUsage - ->addMetric(METRIC_BUCKETS, $value); // per project + case $document->getCollection() === 'buckets': // buckets + $usage->addMetric(METRIC_BUCKETS, $value); // per project if ($event === Database::EVENT_DOCUMENT_DELETE) { - $queueForStatsUsage + $usage ->addReduce($document); } break; case str_starts_with($document->getCollection(), 'bucket_'): // files $parts = explode('_', $document->getCollection()); - $bucketInternalId = $parts[1]; - $queueForStatsUsage + $bucketInternalId = $parts[1]; + $usage ->addMetric(METRIC_FILES, $value) // per project ->addMetric(METRIC_FILES_STORAGE, $document->getAttribute('sizeOriginal') * $value) // per project ->addMetric(str_replace('{bucketInternalId}', $bucketInternalId, METRIC_BUCKET_ID_FILES), $value) // per bucket ->addMetric(str_replace('{bucketInternalId}', $bucketInternalId, METRIC_BUCKET_ID_FILES_STORAGE), $document->getAttribute('sizeOriginal') * $value); // per bucket break; case $document->getCollection() === 'functions': - $queueForStatsUsage - ->addMetric(METRIC_FUNCTIONS, $value); // per project + $usage->addMetric(METRIC_FUNCTIONS, $value); // per project if ($event === Database::EVENT_DOCUMENT_DELETE) { - $queueForStatsUsage + $usage ->addReduce($document); } break; case $document->getCollection() === 'sites': - $queueForStatsUsage - ->addMetric(METRIC_SITES, $value); // per project + $usage->addMetric(METRIC_SITES, $value); // per project if ($event === Database::EVENT_DOCUMENT_DELETE) { - $queueForStatsUsage + $usage ->addReduce($document); } break; case $document->getCollection() === 'deployments': - $queueForStatsUsage + $usage ->addMetric(METRIC_DEPLOYMENTS, $value) // per project ->addMetric(METRIC_DEPLOYMENTS_STORAGE, $document->getAttribute('size') * $value) // per project ->addMetric(str_replace(['{resourceType}'], [$document->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_DEPLOYMENTS), $value) // per function @@ -772,30 +811,27 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor $queueForWebhooks = new Webhook($publisherWebhooks); $queueForRealtime = new Realtime(); - $database - ->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForStatsUsage)) - ->on(Database::EVENT_DOCUMENT_DELETE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForStatsUsage)) - ->on(Database::EVENT_DOCUMENTS_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForStatsUsage)) - ->on(Database::EVENT_DOCUMENTS_DELETE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForStatsUsage)) - ->on(Database::EVENT_DOCUMENTS_UPSERT, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForStatsUsage)) - ->on(Database::EVENT_DOCUMENT_CREATE, 'create-trigger-events', fn ($event, $document) => $eventDatabaseListener( - $project, - $document, - $response, - $queueForEventsClone->from($queueForEvents), - $queueForFunctions->from($queueForEvents), - $queueForWebhooks->from($queueForEvents), - $queueForRealtime->from($queueForEvents) - )) - ->on(Database::EVENT_DOCUMENT_CREATE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database)) - ->on(Database::EVENT_DOCUMENT_UPDATE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database)) - ->on(Database::EVENT_DOCUMENT_DELETE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database)) - ; - + ->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage)) + ->on(Database::EVENT_DOCUMENT_DELETE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage)) + ->on(Database::EVENT_DOCUMENTS_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage)) + ->on(Database::EVENT_DOCUMENTS_DELETE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage)) + ->on(Database::EVENT_DOCUMENTS_UPSERT, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage)) + ->on(Database::EVENT_DOCUMENT_CREATE, 'create-trigger-events', fn ($event, $document) => $eventDatabaseListener( + $project, + $document, + $response, + $queueForEventsClone->from($queueForEvents), + $queueForFunctions->from($queueForEvents), + $queueForWebhooks->from($queueForEvents), + $queueForRealtime->from($queueForEvents) + )) + ->on(Database::EVENT_DOCUMENT_CREATE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database)) + ->on(Database::EVENT_DOCUMENT_UPDATE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database)) + ->on(Database::EVENT_DOCUMENT_DELETE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database)); return $database; -}, ['pools', 'dbForPlatform', 'cache', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'queueForFunctions', 'queueForWebhooks', 'queueForRealtime', 'queueForStatsUsage', 'authorization']); +}, ['pools', 'dbForPlatform', 'cache', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'queueForFunctions', 'queueForWebhooks', 'queueForRealtime', 'usage', 'authorization']); Http::setResource('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) { @@ -844,15 +880,14 @@ Http::setResource('getProjectDB', function (Group $pools, Database $dbForPlatfor ->setMetadata('project', $project->getId()) ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API) ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES) - ->setDocumentType('users', User::class) - ; + ->setDocumentType('users', User::class); $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) - ->setTenant((int) $project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -865,6 +900,7 @@ Http::setResource('getProjectDB', function (Group $pools, Database $dbForPlatfor if (isset($databases[$dsn->getHost()])) { $database = $databases[$dsn->getHost()]; $configure($database); + return $database; } @@ -882,7 +918,7 @@ Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorizati return function (?Document $project = null) use ($pools, $cache, $authorization, &$database) { if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') { - $database->setTenant((int) $project->getSequence()); + $database->setTenant($project->getSequence()); return $database; } @@ -899,7 +935,7 @@ Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorizati // set tenant if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') { - $database->setTenant((int) $project->getSequence()); + $database->setTenant($project->getSequence()); } return $database; @@ -908,6 +944,7 @@ Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorizati Http::setResource('audit', function ($dbForProject) { $adapter = new AdapterDatabase($dbForProject); + return new Audit($adapter); }, ['dbForProject']); @@ -923,6 +960,7 @@ Http::setResource('cache', function (Group $pools, Telemetry $telemetry) { $cache = new Cache(new Sharding($adapters)); $cache->setTelemetry($telemetry); + return $cache; }, ['pools', 'telemetry']); @@ -968,9 +1006,9 @@ Http::setResource('deviceForBuilds', function ($project, Telemetry $telemetry) { function getDevice(string $root, string $connection = ''): Device { - $connection = !empty($connection) ? $connection : System::getEnv('_APP_CONNECTIONS_STORAGE', ''); + $connection = ! empty($connection) ? $connection : System::getEnv('_APP_CONNECTIONS_STORAGE', ''); - if (!empty($connection)) { + if (! empty($connection)) { $acl = 'private'; $device = Storage::DEVICE_LOCAL; $accessKey = ''; @@ -992,8 +1030,9 @@ function getDevice(string $root, string $connection = ''): Device switch ($device) { case Storage::DEVICE_S3: - if (!empty($url)) { - $bucketRoot = (!empty($bucket) ? $bucket . '/' : '') . \ltrim($root, '/'); + if (! empty($url)) { + $bucketRoot = (! empty($bucket) ? $bucket . '/' : '') . \ltrim($root, '/'); + return new S3($bucketRoot, $accessKey, $accessSecret, $url, $region, $acl); } else { return new AWS($root, $accessKey, $accessSecret, $bucket, $region, $acl); @@ -1002,6 +1041,7 @@ function getDevice(string $root, string $connection = ''): Device case STORAGE::DEVICE_DO_SPACES: $device = new DOSpaces($root, $accessKey, $accessSecret, $bucket, $region, $acl); $device->setHttpVersion(S3::HTTP_VERSION_1_1); + return $device; case Storage::DEVICE_BACKBLAZE: return new Backblaze($root, $accessKey, $accessSecret, $bucket, $region, $acl); @@ -1025,8 +1065,9 @@ function getDevice(string $root, string $connection = ''): Device $s3Bucket = System::getEnv('_APP_STORAGE_S3_BUCKET', ''); $s3Acl = 'private'; $s3EndpointUrl = System::getEnv('_APP_STORAGE_S3_ENDPOINT', ''); - if (!empty($s3EndpointUrl)) { - $bucketRoot = (!empty($s3Bucket) ? $s3Bucket . '/' : '') . \ltrim($root, '/'); + if (! empty($s3EndpointUrl)) { + $bucketRoot = (! empty($s3Bucket) ? $s3Bucket . '/' : '') . \ltrim($root, '/'); + return new S3($bucketRoot, $s3AccessKey, $s3SecretKey, $s3EndpointUrl, $s3Region, $s3Acl); } else { return new AWS($root, $s3AccessKey, $s3SecretKey, $s3Bucket, $s3Region, $s3Acl); @@ -1040,6 +1081,7 @@ function getDevice(string $root, string $connection = ''): Device $doSpacesAcl = 'private'; $device = new DOSpaces($root, $doSpacesAccessKey, $doSpacesSecretKey, $doSpacesBucket, $doSpacesRegion, $doSpacesAcl); $device->setHttpVersion(S3::HTTP_VERSION_1_1); + return $device; case Storage::DEVICE_BACKBLAZE: $backblazeAccessKey = System::getEnv('_APP_STORAGE_BACKBLAZE_ACCESS_KEY', ''); @@ -1047,6 +1089,7 @@ function getDevice(string $root, string $connection = ''): Device $backblazeRegion = System::getEnv('_APP_STORAGE_BACKBLAZE_REGION', ''); $backblazeBucket = System::getEnv('_APP_STORAGE_BACKBLAZE_BUCKET', ''); $backblazeAcl = 'private'; + return new Backblaze($root, $backblazeAccessKey, $backblazeSecretKey, $backblazeBucket, $backblazeRegion, $backblazeAcl); case Storage::DEVICE_LINODE: $linodeAccessKey = System::getEnv('_APP_STORAGE_LINODE_ACCESS_KEY', ''); @@ -1054,6 +1097,7 @@ function getDevice(string $root, string $connection = ''): Device $linodeRegion = System::getEnv('_APP_STORAGE_LINODE_REGION', ''); $linodeBucket = System::getEnv('_APP_STORAGE_LINODE_BUCKET', ''); $linodeAcl = 'private'; + return new Linode($root, $linodeAccessKey, $linodeSecretKey, $linodeBucket, $linodeRegion, $linodeAcl); case Storage::DEVICE_WASABI: $wasabiAccessKey = System::getEnv('_APP_STORAGE_WASABI_ACCESS_KEY', ''); @@ -1061,21 +1105,27 @@ function getDevice(string $root, string $connection = ''): Device $wasabiRegion = System::getEnv('_APP_STORAGE_WASABI_REGION', ''); $wasabiBucket = System::getEnv('_APP_STORAGE_WASABI_BUCKET', ''); $wasabiAcl = 'private'; + return new Wasabi($root, $wasabiAccessKey, $wasabiSecretKey, $wasabiBucket, $wasabiRegion, $wasabiAcl); } } } -Http::setResource('mode', function ($request) { - /** @var Appwrite\Utopia\Request $request */ - +Http::setResource('mode', function (Request $request, Document $project) { /** * Defines the mode for the request: * - 'default' => Requests for Client and Server Side * - 'admin' => Request from the Console on non-console projects */ - return $request->getParam('mode', $request->getHeader('x-appwrite-mode', APP_MODE_DEFAULT)); -}, ['request']); + $mode = $request->getParam('mode', $request->getHeader('x-appwrite-mode', APP_MODE_DEFAULT)); + + $projectId = $request->getParam('project', $request->getHeader('x-appwrite-project', '')); + if (!empty($projectId) && $project->getId() !== $projectId) { + $mode = APP_MODE_ADMIN; + } + + return $mode; +}, ['request', 'project']); Http::setResource('geodb', function ($register) { /** @var Utopia\Registry\Registry $register */ @@ -1087,7 +1137,6 @@ Http::setResource('passwordsDictionary', function ($register) { return $register->get('passwordsDictionary'); }, ['register']); - Http::setResource('servers', function () { $platforms = Config::getParam('sdks'); $server = $platforms[APP_SDK_PLATFORM_SERVER]; @@ -1195,16 +1244,17 @@ Http::setResource('gitHub', function (Cache $cache) { }, ['cache']); Http::setResource('requestTimestamp', function ($request) { - //TODO: Move this to the Request class itself + // TODO: Move this to the Request class itself $timestampHeader = $request->getHeader('x-appwrite-timestamp'); $requestTimestamp = null; - if (!empty($timestampHeader)) { + if (! empty($timestampHeader)) { try { $requestTimestamp = new \DateTime($timestampHeader); } catch (\Throwable $e) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid X-Appwrite-Timestamp header value'); } } + return $requestTimestamp; }, ['request']); @@ -1221,13 +1271,13 @@ Http::setResource('devKey', function (Request $request, Document $project, array // Check if given key match project's development keys $key = $project->find('secret', $devKey, 'devKeys'); - if (!$key) { + if (! $key) { return new Document([]); } // check expiration $expire = $key->getAttribute('expire'); - if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) { + if (! empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) { return new Document([]); } @@ -1248,7 +1298,7 @@ Http::setResource('devKey', function (Request $request, Document $project, array if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) { $sdks = $key->getAttribute('sdks', []); - if (!in_array($sdk, $sdks)) { + if (! in_array($sdk, $sdks)) { $sdks[] = $sdk; $key->setAttribute('sdks', $sdks); @@ -1271,7 +1321,7 @@ Http::setResource('team', function (Document $project, Database $dbForPlatform, $teamInternalId = $project->getAttribute('teamInternalId', ''); } else { $route = $utopia->match($request); - $path = !empty($route) ? $route->getPath() : $request->getURI(); + $path = ! empty($route) ? $route->getPath() : $request->getURI(); $orgHeader = $request->getHeader('x-appwrite-organization', ''); if (str_starts_with($path, '/v1/projects/:projectId')) { $uri = $request->getURI(); @@ -1286,8 +1336,9 @@ Http::setResource('team', function (Document $project, Database $dbForPlatform, } $team = $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $teamId)); + return $team; - } elseif (!empty($orgHeader)) { + } elseif (! empty($orgHeader)) { return $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $orgHeader)); } } @@ -1317,13 +1368,13 @@ Http::setResource('previewHostname', function (Request $request, ?Key $apiKey) { if (Http::isDevelopment()) { $allowed = true; - } elseif (!\is_null($apiKey) && $apiKey->getHostnameOverride() === true) { + } elseif (! \is_null($apiKey) && $apiKey->getHostnameOverride() === true) { $allowed = true; } if ($allowed) { $host = $request->getQuery('appwrite-hostname', $request->getHeader('x-appwrite-hostname', '')) ?? ''; - if (!empty($host)) { + if (! empty($host)) { return $host; } } @@ -1344,19 +1395,19 @@ Http::setResource('apiKey', function (Request $request, Document $project, Docum $organizationHeader = $request->getHeader('x-appwrite-organization'); $projectHeader = $request->getHeader('x-appwrite-project'); - if (!empty($key->getProjectId())) { + if (! empty($key->getProjectId())) { if (empty($projectHeader) || $projectHeader !== $key->getProjectId()) { throw new Exception(Exception::PROJECT_ID_MISSING); } } - if (!empty($key->getUserId())) { + if (! empty($key->getUserId())) { if (empty($userHeader) || $userHeader !== $key->getUserId()) { throw new Exception(Exception::USER_ID_MISSING); } } - if (!empty($key->getTeamId())) { + if (! empty($key->getTeamId())) { if (empty($organizationHeader) || $organizationHeader !== $key->getTeamId()) { throw new Exception(Exception::ORGANIZATION_ID_MISSING); } @@ -1370,7 +1421,7 @@ Http::setResource('executor', fn () => new Executor()); Http::setResource('resourceToken', function ($project, $dbForProject, $request, Authorization $authorization) { $tokenJWT = $request->getParam('token'); - if (!empty($tokenJWT) && !$project->isEmpty()) { // JWT authentication + if (! empty($tokenJWT) && ! $project->isEmpty()) { // JWT authentication // Use a large but reasonable maxAge to avoid auto-exp when token has no expiry $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), RESOURCE_TOKEN_ALGORITHM, RESOURCE_TOKEN_MAX_AGE, RESOURCE_TOKEN_LEEWAY); // Instantiate with key, algo, maxAge and leeway. @@ -1430,6 +1481,7 @@ Http::setResource('resourceToken', function ($project, $dbForProject, $request, default => throw new Exception(Exception::TOKEN_RESOURCE_TYPE_INVALID), }; } + return new Document([]); }, ['project', 'dbForProject', 'request', 'authorization']); diff --git a/app/realtime.php b/app/realtime.php index 5addb2a78f..d3305ca7f8 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -50,6 +50,16 @@ require_once __DIR__ . '/init.php'; Runtime::enableCoroutine(SWOOLE_HOOK_ALL); +// Log uncaught exceptions in one line instead of relying on Swoole's full backtrace dump +set_exception_handler(function (\Throwable $e) { + Console::error(sprintf( + 'Realtime uncaught exception: %s in %s:%d', + $e->getMessage(), + $e->getFile(), + $e->getLine() + )); +}); + // Allows overriding if (!function_exists('getConsoleDB')) { function getConsoleDB(): Database @@ -115,7 +125,7 @@ if (!function_exists('getProjectDB')) { if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) - ->setTenant((int)$project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -985,15 +995,21 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re }); $server->onClose(function (int $connection) use ($realtime, $stats, $register) { - if (array_key_exists($connection, $realtime->connections)) { - $stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal'); - $register->get('telemetry.connectionCounter')->add(-1); + try { + if (array_key_exists($connection, $realtime->connections)) { + $stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal'); + $register->get('telemetry.connectionCounter')->add(-1); - $projectId = $realtime->connections[$connection]['projectId']; + $projectId = $realtime->connections[$connection]['projectId']; - triggerStats([ - METRIC_REALTIME_CONNECTIONS => -1, - ], $projectId); + triggerStats([ + METRIC_REALTIME_CONNECTIONS => -1, + ], $projectId); + } + } catch (\Throwable $th) { + // Log only; do not rethrow. If we let this bubble, Swoole dumps full coroutine + // backtraces and unsubscribe() below would never run (connection cleanup would fail). + Console::error('Realtime onClose error: ' . $th->getMessage()); } $realtime->unsubscribe($connection); diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index a62f6f7e8c..0f4df352bd 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -12,8 +12,12 @@ $version = $this->getParam('version', ''); $organization = $this->getParam('organization', ''); $image = $this->getParam('image', ''); $enableAssistant = $this->getParam('enableAssistant', false); -$dbService = $this->getParam('database'); - +$dbService = $this->getParam('database', 'mongodb'); +$allowedDbServices = ['mariadb', 'mongodb', 'postgresql']; +if (!\in_array($dbService, $allowedDbServices, true)) { + $dbService = 'mongodb'; +} +$hostPath = rtrim($this->getParam('hostPath', ''), '/'); ?>services: traefik: image: traefik:3.6 @@ -63,6 +67,9 @@ $dbService = $this->getParam('database'); - traefik.http.routers.appwrite_api_https.service=appwrite_api - traefik.http.routers.appwrite_api_https.tls=true volumes: + + - ":/usr/src/code:rw" + - appwrite-uploads:/storage/uploads:rw - appwrite-imports:/storage/imports:rw - appwrite-cache:/storage/cache:rw @@ -72,8 +79,10 @@ $dbService = $this->getParam('database'); - appwrite-sites:/storage/sites:rw - appwrite-builds:/storage/builds:rw depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy # - clamav environment: - _APP_ENV @@ -227,8 +236,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -258,8 +269,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -286,8 +299,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -316,8 +331,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy volumes: - appwrite-uploads:/storage/uploads:rw - appwrite-cache:/storage/cache:rw @@ -381,8 +398,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -409,8 +428,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy volumes: - appwrite-functions:/storage/functions:rw - appwrite-sites:/storage/sites:rw @@ -479,8 +500,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy volumes: - appwrite-config:/storage/config:rw - appwrite-certificates:/storage/certificates:rw @@ -518,9 +541,12 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - - - openruntimes-executor + redis: + condition: service_healthy + : + condition: service_healthy + openruntimes-executor: + condition: service_started environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -559,8 +585,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -598,8 +626,10 @@ $dbService = $this->getParam('database'); volumes: - appwrite-uploads:/storage/uploads:rw depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -652,7 +682,8 @@ $dbService = $this->getParam('database'); volumes: - appwrite-imports:/storage/imports:rw depends_on: - - + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -688,8 +719,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -730,8 +763,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -761,8 +796,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -791,8 +828,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - redis - - + redis: + condition: service_healthy + : + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -821,8 +860,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - - - redis + : + condition: service_healthy + redis: + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -848,8 +889,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - - - redis + : + condition: service_healthy + redis: + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -875,8 +918,10 @@ $dbService = $this->getParam('database'); networks: - appwrite depends_on: - - - - redis + : + condition: service_healthy + redis: + condition: service_healthy environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -966,7 +1011,6 @@ $dbService = $this->getParam('database'); - OPR_EXECUTOR_STORAGE_WASABI_BUCKET=$_APP_STORAGE_WASABI_BUCKET - mariadb: image: mariadb:10.11 container_name: appwrite-mariadb @@ -982,6 +1026,12 @@ $dbService = $this->getParam('database'); - MYSQL_PASSWORD=${_APP_DB_PASS} - MARIADB_AUTO_UPGRADE=1 command: 'mysqld --innodb-flush-method=fsync' + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 10s + retries: 10 + start_period: 30s @@ -1059,18 +1109,24 @@ $dbService = $this->getParam('database'); postgresql: - image: postgres:18 + image: appwrite/postgres:0.1.0 container_name: appwrite-postgresql restart: unless-stopped networks: - appwrite volumes: - - appwrite-postgresql:/var/lib/postgresql/data:rw + - appwrite-postgresql:/var/lib/postgresql:rw environment: - POSTGRES_DB=${_APP_DB_SCHEMA} - POSTGRES_USER=${_APP_DB_USER} - POSTGRES_PASSWORD=${_APP_DB_PASS} command: "postgres" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${_APP_DB_USER} -d ${_APP_DB_SCHEMA}"] + interval: 10s + timeout: 10s + retries: 10 + start_period: 30s @@ -1088,6 +1144,12 @@ $dbService = $this->getParam('database'); - appwrite volumes: - appwrite-redis:/data:rw + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s # clamav: # image: appwrite/clamav:1.2.0 @@ -1114,6 +1176,7 @@ volumes: appwrite-mongodb: appwrite-mongodb-keyfile: + appwrite-mongodb-config: appwrite-redis: appwrite-cache: diff --git a/app/views/install/env.phtml b/app/views/install/env.phtml index c3ebb6f918..51bda7a6fb 100644 --- a/app/views/install/env.phtml +++ b/app/views/install/env.phtml @@ -3,6 +3,10 @@ $vars = $this->getParam('vars'); foreach ($vars as $key => $value) { - echo $key.'='.$value."\n"; + if ($value === null || $value === '') { + echo $key . "=\n"; + } else { + echo $key . '="' . addcslashes((string) $value, '"\\$`') . '"' . "\n"; + } } ?> \ No newline at end of file diff --git a/app/views/install/installer.phtml b/app/views/install/installer.phtml new file mode 100644 index 0000000000..05bc1b80ed --- /dev/null +++ b/app/views/install/installer.phtml @@ -0,0 +1,147 @@ + + + + + + + <?php echo $isUpgrade ? 'Appwrite Update' : 'Appwrite Installation'; ?> + + + + + + + + + + + + + + + + + data-locked-database="" + + data-default-http-port="" + data-default-https-port="" + data-default-app-domain="" + data-default-email-certificates="" + data-default-secret-key="" + data-default-assistant-openai-key="" + data-default-database="" + data-enabled-databases="" + + data-dev-mode="true" + +> + + +
+
+
+
+ +
+
+
+
+
+ + + + + +
+
+
+
+
+ +
+
+
+ +
+ +
+ + + +
+ + diff --git a/app/views/install/installer/css/styles.css b/app/views/install/installer/css/styles.css new file mode 100644 index 0000000000..b1d8fe5089 --- /dev/null +++ b/app/views/install/installer/css/styles.css @@ -0,0 +1,1786 @@ +/* Installer Styles - Pink v2 tokens and components */ + +:root { + /* Neutral colors */ + --neutral-0: rgba(255, 255, 255, 1); + --neutral-25: rgba(250, 250, 251, 1); + --neutral-40: rgba(244, 244, 247, 1); + --neutral-50: rgba(237, 237, 240, 1); + --neutral-100: rgba(228, 228, 231, 1); + --neutral-200: rgba(216, 216, 219, 1); + --neutral-300: rgba(173, 173, 176, 1); + --neutral-400: rgba(151, 151, 155, 1); + --neutral-500: rgba(129, 129, 134, 1); + --neutral-600: rgba(108, 108, 113, 1); + --neutral-700: rgba(86, 86, 92, 1); + --neutral-750: rgba(65, 65, 70, 1); + --neutral-800: rgba(45, 45, 49, 1); + --neutral-850: rgba(29, 29, 33, 1); + --neutral-900: rgba(25, 25, 28, 1); + --neutral-250: rgba(195, 195, 198, 1); + + /* Warning colors */ + --web-orange-200: rgba(255, 213, 194, 1); + --web-orange-500: rgba(254, 124, 67, 1); + --web-orange-700: rgba(97, 37, 10, 1); + + /* Error colors */ + --web-red-500: rgba(255, 69, 58, 1); + --web-red-700: rgba(179, 18, 18, 1); + + /* Brand colors */ + --brand-pink-500: rgba(253, 54, 110, 1); + --brand-pink-600: rgba(202, 43, 88, 1); + --brand-pink-700: rgba(152, 32, 66, 1); + + /* Background colors */ + --bgcolor-neutral-default: var(--neutral-25); + --bgcolor-neutral-primary: var(--neutral-0); + --bgcolor-neutral-secondary: var(--neutral-40); + --bgcolor-neutral-tertiary: var(--neutral-50); + --bgcolor-neutral-invert-weaker: var(--neutral-500); + --bgcolor-neutral-invert-weak: var(--neutral-700); + --bgcolor-accent: var(--brand-pink-500); + --bgcolor-accent-secondary: var(--brand-pink-600); + --bgcolor-accent-tertiary: var(--brand-pink-700); + --bgcolor-success-weak: rgba(16, 185, 129, 0.16); + --bgcolor-warning-weaker: rgba(254, 124, 67, 0.04); + --bgcolor-warning-weak: rgba(254, 124, 67, 0.16); + --bgcolor-error: var(--web-red-500); + --bgcolor-error-weaker: rgba(255, 69, 58, 0.04); + + /* Foreground colors */ + --fgcolor-neutral-primary: var(--neutral-800); + --fgcolor-neutral-secondary: var(--neutral-700); + --fgcolor-neutral-tertiary: var(--neutral-400); + --fgcolor-neutral-weak: var(--neutral-200); + --fgcolor-accent: var(--brand-pink-500); + --fgcolor-on-accent: var(--neutral-0); + --fgcolor-on-invert: var(--neutral-25); + --fgcolor-on-success-weak: rgba(10, 113, 79, 1); + --fgcolor-warning: rgba(97, 37, 10, 1); + --fgcolor-on-warning-weak: var(--web-orange-700); + --fgcolor-error: var(--web-red-700); + --fgcolor-on-error: var(--neutral-0); + + /* Border colors */ + --border-neutral: var(--neutral-50); + --border-neutral-strong: var(--neutral-200); + --border-neutral-stronger: var(--neutral-500); + --border-focus: var(--neutral-300); + --border-accent: var(--brand-pink-500); + --border-warning-weak: rgba(254, 124, 67, 0.32); + --border-error: var(--web-red-500); + --border-error-weak: rgba(255, 69, 58, 0.32); + + /* Overlay colors */ + --overlay-neutral-hover: rgba(25, 25, 28, 0.03); + --overlay-neutral-pressed: rgba(25, 25, 28, 0.04); + --overlay-scrim: rgba(25, 25, 28, 0.8); + + /* Icon sizes */ + --icon-size-s: var(--base-16); + + /* Typography */ + --font-family-brand: 'Aeonik Pro'; + --font-family-sansserif: 'Inter'; + --font-family-code: 'Fira Code'; + --sans-fallbacks: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --mono-fallbacks: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace; + + --font-size-xs: 12px; + --font-size-s: 14px; + --font-size-m: 16px; + --font-size-l: 20px; + --label-line-height: 19.6px; + + /* Motion */ + --duration-fast: 150ms; + --duration-short: 160ms; + --duration-medium: 200ms; + --duration-extended: 250ms; + --duration-slow: 300ms; + --ease-standard: ease; + --ease-emphasized: cubic-bezier(0.32, 0.72, 0, 1); + --ease-in-out: ease-in-out; + --ease-out: ease-out; + + /* Spacing */ + --base-0: 0; + --base-2: 2px; + --base-4: 4px; + --base-6: 6px; + --base-8: 8px; + --base-10: 10px; + --base-12: 12px; + --base-16: 16px; + --base-20: 20px; + --base-24: 24px; + --base-32: 32px; + --base-40: 40px; + --base-48: 48px; + + /* Component sizing */ + --button-min-width-s: 60px; + + --space-0: var(--base-0); + --space-1: var(--base-2); + --space-2: var(--base-4); + --space-3: var(--base-6); + --space-4: var(--base-8); + --space-5: var(--base-10); + --space-6: var(--base-12); + --space-7: var(--base-16); + --space-8: var(--base-20); + --space-9: var(--base-24); + --space-10: var(--base-32); + --space-11: var(--base-40); + --space-12: var(--base-48); + + /* Gap scale (Pink tokens) */ + --gap-none: var(--base-0); + --gap-xxxs: var(--base-2); + --gap-xxs: var(--base-4); + --gap-xs: var(--base-6); + --gap-s: var(--base-8); + --gap-m: var(--base-12); + --gap-l: var(--base-16); + --gap-xl: var(--base-20); + --gap-xxl: var(--base-32); + --gap-xxxl: var(--base-40); + + /* Border radius */ + --border-radius-s: 8px; + --border-radius-xs: 6px; + --border-radius-m: 12px; + --border-radius-l: 16px; + + /* Border widths */ + --border-width-s: 1px; + --border-width-l: 2px; + + /* Installer layout vars */ + --step-min-height: auto; + --divider-gap-top: var(--gap-xl); + + /* Animation vars */ + --spinner-rotation: 0deg; + + /* Legacy aliases */ + --bgColor-neutral-default: var(--bgcolor-neutral-default); + --bgColor-neutral-primary: var(--bgcolor-neutral-primary); + --bgColor-accent: var(--bgcolor-accent); + --fgColor-neutral-primary: var(--fgcolor-neutral-primary); + --fgColor-neutral-secondary: var(--fgcolor-neutral-secondary); + --fgColor-neutral-tertiary: var(--fgcolor-neutral-tertiary); + --fgColor-neutral-weak: var(--fgcolor-neutral-weak); + --fgColor-accent: var(--fgcolor-accent); + --fgColor-on-accent: var(--fgcolor-on-accent); + + color-scheme: light dark; +} + +@media (prefers-color-scheme: dark) { + :root { + --bgcolor-neutral-default: var(--neutral-900); + --bgcolor-neutral-primary: var(--neutral-850); + --bgcolor-neutral-secondary: var(--neutral-800); + --bgcolor-neutral-tertiary: var(--neutral-800); + --bgcolor-neutral-invert-weaker: var(--neutral-400); + --bgcolor-neutral-invert-weak: var(--neutral-300); + --bgcolor-success-weak: rgba(16, 185, 129, 0.12); + --bgcolor-warning-weaker: rgba(254, 124, 67, 0.08); + --bgcolor-warning-weak: rgba(254, 124, 67, 0.12); + --bgcolor-error-weaker: rgba(255, 69, 58, 0.08); + + --fgcolor-neutral-primary: var(--neutral-50); + --fgcolor-neutral-secondary: var(--neutral-250); + --fgcolor-neutral-tertiary: var(--neutral-500); + --fgcolor-neutral-weak: var(--neutral-600); + --fgcolor-on-accent: var(--neutral-0); + --fgcolor-on-invert: var(--neutral-900); + --fgcolor-on-success-weak: rgba(52, 211, 153, 1); + --fgcolor-warning: var(--web-orange-500); + --fgcolor-on-warning-weak: var(--web-orange-500); + --fgcolor-error: var(--web-red-500); + --fgcolor-on-error: var(--neutral-0); + + --border-neutral: var(--neutral-800); + --border-neutral-strong: var(--neutral-750); + --border-neutral-stronger: var(--neutral-600); + --border-focus: var(--neutral-600); + + --overlay-neutral-hover: rgba(255, 255, 255, 0.04); + --overlay-neutral-pressed: rgba(255, 255, 255, 0.08); + --overlay-scrim: rgba(0, 0, 0, 0.48); + } +} + +.installer-toast-stack { + position: fixed; + top: var(--base-12); + right: var(--base-12); + display: flex; + flex-direction: column; + gap: var(--space-4); + z-index: 60; + pointer-events: none; +} + +.installer-toast { + inline-size: 24rem; + display: inline-flex; + align-items: start; + justify-content: space-between; + gap: var(--space-6); + padding: var(--space-4); + border-radius: var(--border-radius-m); + border: var(--border-width-s) solid var(--border-neutral); + background: var(--bgcolor-neutral-primary); + box-shadow: + 0 2px 12px 0 rgba(0, 0, 0, 0.02), + 0 6px 8px 0 rgba(0, 0, 0, 0.02); + pointer-events: auto; + opacity: 1; + transform: translate3d(0, 0, 0); + will-change: transform, opacity; + transition: + transform 400ms cubic-bezier(0.33, 1, 0.68, 1), + opacity 400ms cubic-bezier(0.33, 1, 0.68, 1); +} + +.installer-toast.is-entering { + opacity: 0; + transform: translate3d(50px, 0, 0); +} + +.installer-toast.is-leaving { + opacity: 0; + transform: translate3d(50px, 0, 0); + pointer-events: none; +} + +.installer-toast-content { + display: flex; + align-items: start; + gap: var(--space-6); +} + +.installer-toast-body { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-block: auto; +} + +.installer-toast-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--border-radius-s); + background: var(--bgcolor-neutral-invert-weaker); + color: var(--fgcolor-on-invert); + flex-shrink: 0; +} + +.installer-toast-icon svg { + width: 16px; + height: 16px; + display: block; +} + +.installer-toast-icon[data-status='error'] { + background: var(--bgcolor-error); + color: var(--fgcolor-on-error); +} + +.installer-toast-close { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-3); + border: var(--border-width-s) solid transparent; + border-radius: var(--border-radius-s); + background: transparent; + color: var(--fgcolor-neutral-tertiary); + cursor: pointer; + transition: all 0.15s ease-in-out; +} + +.installer-toast-close svg { + width: 16px; + height: 16px; + display: block; +} + +.installer-toast-close:hover { + color: var(--fgcolor-neutral-secondary); + background: var(--overlay-neutral-hover); +} + +.installer-toast-close:active { + color: var(--fgcolor-neutral-secondary); + background: var(--overlay-neutral-pressed); +} + +.installer-toast-close:focus-visible { + outline: var(--border-width-l) solid var(--border-focus); + outline-offset: var(--border-width-s); +} + +.is-hidden { + display: none !important; +} + +@media (min-width: 768px) { + .installer-toast-stack { + top: var(--base-24); + right: var(--base-24); + } +} + +@media (max-width: 640px) { + .installer-toast-stack { + left: 0; + right: 0; + top: var(--base-12); + padding: 0 var(--space-4); + align-items: stretch; + } + + .installer-toast { + inline-size: 100%; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: var(--fgcolor-neutral-primary); + background: var(--bgcolor-neutral-default); + font-family: var(--font-family-sansserif), var(--sans-fallbacks), sans-serif; +} + +.installer-page { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + padding: var(--space-7); + gap: var(--gap-l); + background: var(--bgcolor-neutral-default); + position: relative; + overflow: hidden; +} + +.installer-main { + flex: 1; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + position: relative; + z-index: 1; + min-height: 0; +} + +.installer-backdrop { + position: fixed; + inset: 0; + opacity: 0; + pointer-events: none; + transition: opacity var(--duration-medium) var(--ease-standard); + z-index: 0; +} + +.installer-page[data-step='5'] .installer-backdrop { + opacity: 1; +} + +.installer-gradients { + top: 34%; + left: 17%; + width: 409px; + height: 194px; + opacity: 0.32; + position: absolute; + pointer-events: none; + transform: translateY(-50%) rotate(-25deg); +} + +.installer-blob { + position: absolute; + left: 0; + top: 0; + --blob-x: 0; + --blob-y: 0; + transform: translate(var(--blob-x), var(--blob-y)) scale(1); + transform-origin: center; + animation: none; +} + +.installer-blob.blob-one { + --blob-x: 268.3px; + --blob-y: 108.3px; +} + +.installer-blob.blob-two { + --blob-x: 101.3px; + --blob-y: 101.3px; +} + +.installer-card { + width: 100%; + max-width: 500px; + max-height: 100%; + padding: var(--space-8); + background: var(--bgcolor-neutral-primary); + border-radius: var(--border-radius-l); + outline: var(--border-width-s) solid var(--border-neutral); + outline-offset: -1px; + display: flex; + flex-direction: column; + gap: 0; + transition: opacity var(--duration-medium) var(--ease-standard); + overflow: hidden; +} + +.installer-page[data-step='5'] .installer-card { + opacity: 0; + pointer-events: none; +} + +.installer-page[data-install-locked='true'] .step-indicators { + opacity: 0.4; +} + +.selector-card.is-disabled { + cursor: default; + pointer-events: auto; +} + +.selector-card.is-disabled .selector-content, +.selector-card.is-disabled .selector-icon { + opacity: 0.4; +} + +.installer-step { + display: grid; + position: relative; + width: 100%; + min-height: var(--step-min-height, auto); + flex: 1 1 auto; + overflow: hidden; +} + +.action-shell { + display: flex; + flex-direction: column; + width: 100%; + margin-top: auto; + flex-shrink: 0; +} + +.install-screen { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity var(--duration-medium) var(--ease-standard); + z-index: 1; +} + +.installer-page[data-step='5'] .install-screen { + opacity: 1; + pointer-events: auto; +} + +.install-screen-content { + width: 100%; + max-width: 500px; +} + +.step-panel { + grid-area: 1 / 1; + width: 100%; + opacity: 1; + transition: opacity var(--duration-medium) var(--ease-standard); + will-change: opacity; +} + +.step-panel:not(.is-measure) { + height: 100%; + overflow-y: auto; + scrollbar-gutter: stable; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.step-panel:not(.is-measure)::-webkit-scrollbar { + width: 0; + height: 0; +} + +.step-panel.is-entering { + opacity: 0; + pointer-events: none; +} + +.step-panel.is-exiting { + opacity: 0; + pointer-events: none; +} + +.step-panel.is-measure { + position: absolute; + top: 0; + left: 0; + width: 100%; + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.stack-none { + display: flex; + flex-direction: column; + gap: var(--gap-none); +} + +.stack-xxxs { + display: flex; + flex-direction: column; + gap: var(--gap-xxxs); +} + +.stack-xxs { + display: flex; + flex-direction: column; + gap: var(--gap-xxs); +} + +.stack-xs { + display: flex; + flex-direction: column; + gap: var(--gap-xs); +} + +.stack-s { + display: flex; + flex-direction: column; + gap: var(--gap-s); +} + +.stack-m { + display: flex; + flex-direction: column; + gap: var(--gap-m); +} + +.stack-l { + display: flex; + flex-direction: column; + gap: var(--gap-l); +} + +.stack-xl { + display: flex; + flex-direction: column; + gap: var(--gap-xl); +} + +.stack-xxl { + display: flex; + flex-direction: column; + gap: var(--gap-xxl); +} + +.stack-xxxl { + display: flex; + flex-direction: column; + gap: var(--gap-xxxl); +} + +.install-layout { + width: 100%; +} + +.install-card { + width: 100%; + padding: var(--space-2); + background: var(--bgcolor-neutral-default); + border-radius: var(--border-radius-l); + border: var(--border-width-s) solid var(--border-neutral); +} + +.install-panel { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--gap-xxxs); + transition: height 0.3s cubic-bezier(0.32, 0.72, 0, 1); +} + +.install-header { + padding: var(--space-4); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--gap-xxxs); +} + +.install-list { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0; +} + +.install-row { + display: flex; + flex-direction: column; + align-items: stretch; + padding: 0; + background: var(--bgcolor-neutral-primary); + border: var(--border-width-s) solid var(--border-neutral); + border-radius: 0; + overflow: hidden; + opacity: 1; + transform: translateY(0); + transition: opacity 0.2s ease, + transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); + will-change: transform, opacity; +} + +.install-row-main { + display: flex; + align-items: center; + gap: var(--gap-s); + height: 44px; + padding: 0 var(--space-6); + transition: height 0.3s cubic-bezier(0.32, 0.72, 0, 1), + opacity 0.2s ease, + transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); +} + +.install-row-label { + display: flex; + align-items: center; + gap: var(--gap-s); + min-width: 0; + min-height: 0; + padding-block-start: 0; +} + +.install-text { + min-width: 0; + display: inline-flex; + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.install-text.is-enter { + opacity: 0; + transform: translateY(10px); +} + +.install-row-toggle { + margin-left: auto; + width: 32px; + height: 32px; + border: none; + border-radius: var(--border-radius-xs); + background: transparent; + color: var(--fgcolor-neutral-secondary); + display: none; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: background-color var(--duration-short) var(--ease-standard), + color var(--duration-short) var(--ease-standard), + transform var(--duration-short) var(--ease-standard); +} + +.install-row-toggle svg { + width: 20px; + height: 20px; +} + + +.install-row-details { + max-height: none; + opacity: 0; + overflow: hidden; + display: grid; + grid-template-rows: 0fr; + padding: 0; + border: none; + transition: grid-template-rows var(--duration-medium) var(--ease-standard), + opacity var(--duration-medium) var(--ease-standard); +} + +.install-row:first-child { + border-top-left-radius: var(--border-radius-m); + border-top-right-radius: var(--border-radius-m); +} + +.install-row + .install-row { + margin-top: -1px; +} + +.install-row:last-child { + border-bottom-left-radius: var(--border-radius-m); + border-bottom-right-radius: var(--border-radius-m); +} + +.install-row.is-entering { + opacity: 0; + transform: translateY(-20px); +} + +.install-row.is-entering .install-row-main { + height: 0; + opacity: 0; + transform: translateY(-20px); +} + +.install-icon { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + position: relative; + overflow: hidden; +} + +.install-icon-spinner, +.install-icon-check { + display: inline-flex; + align-items: center; + justify-content: center; + position: absolute; + inset: 0; + transition: opacity 200ms cubic-bezier(0.32, 0.72, 0, 1); +} + +.install-icon-error { + display: inline-flex; + align-items: center; + justify-content: center; + position: absolute; + inset: 0; + opacity: 0; + transition: opacity 200ms cubic-bezier(0.32, 0.72, 0, 1); + color: var(--fgcolor-error); +} + +.install-icon-check { + opacity: 0; +} + +.install-icon-spinner svg { + animation: none; + transform: rotate(var(--spinner-rotation)); +} + +.install-row[data-status='completed'] .install-icon-spinner { + opacity: 0; +} + +.install-row[data-status='completed'] .install-icon-spinner svg { + animation: none; +} + +.install-row[data-status='completed'] .install-icon-check { + opacity: 1; +} + +/* Keep the checkmark subtle: rely on container fade only (no stroke drawing). */ + +.install-row[data-status='error'] { + height: auto; + overflow: hidden; +} + +.install-row[data-status='error'] .install-icon-spinner, +.install-row[data-status='error'] .install-icon-check { + opacity: 0; +} + +.install-row[data-status='error'] .install-icon-error { + opacity: 1; +} + +.install-row[data-status='error'] .install-row-toggle { + opacity: 1; + pointer-events: auto; + display: inline-flex; +} + +.install-row[data-status='error'] { + cursor: pointer; +} + +.install-row[data-status='error'] .button { + cursor: pointer; +} + +.install-row.is-open .install-row-toggle { + transform: rotate(180deg); +} + +.install-row.is-open .install-row-details { + grid-template-rows: 1fr; + opacity: 1; +} + +.install-row-details-inner { + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 0; + border-top: var(--border-width-s) solid var(--border-neutral); + border-left: none; + border-right: none; + border-radius: 0; + background: var(--bgcolor-neutral-primary); +} + +.install-error-code { + margin: 0; + padding: var(--space-4) var(--space-6); + width: 100%; + background: var(--bgcolor-neutral-default); + border: none; + border-radius: 0; + font-family: var(--font-family-code), var(--mono-fallbacks), monospace; + font-size: var(--font-size-xs); + line-height: 140%; + letter-spacing: 0; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: break-word; + max-height: 160px; + overflow-y: auto; + overflow-x: auto; + color: var(--fgcolor-neutral-primary); + scrollbar-width: none; + -ms-overflow-style: none; +} + +.install-error-code::-webkit-scrollbar { + width: 0; + height: 0; +} + + +.install-error-actions { + padding: var(--space-3) var(--space-6); + background: var(--bgcolor-neutral-primary); + border-top: var(--border-width-s) solid var(--border-neutral); + border-radius: 0 0 var(--border-radius-m) var(--border-radius-m); + display: flex; + justify-content: flex-end; + flex-direction: row; + gap: var(--gap-m); +} + +.install-error-details .button { + align-self: center; + margin-top: 0; +} + +@keyframes install-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.typography-title-s { + font-family: var(--font-family-brand), var(--sans-fallbacks), sans-serif; + font-size: var(--font-size-l); + font-weight: 400; + line-height: 130%; + letter-spacing: -0.144px; +} + +.typography-title-s, +.typography-text-m-400, +.typography-text-m-500 { + margin: 0; +} + +.typography-text-m-400 { + font-family: var(--font-family-brand), var(--sans-fallbacks), sans-serif; + font-size: var(--font-size-s); + font-weight: 400; + line-height: 140%; + letter-spacing: -0.063px; +} + +.typography-text-m-500 { + font-family: var(--font-family-brand), var(--sans-fallbacks), sans-serif; + font-size: var(--font-size-s); + font-weight: 500; + line-height: 140%; + letter-spacing: -0.063px; +} + +.typography-text-xs-400 { + font-family: var(--font-family-sansserif), var(--sans-fallbacks), sans-serif; + font-size: var(--font-size-xs); + font-weight: 400; + line-height: 130%; + letter-spacing: -0.12px; +} + +.typography-text-xs-500 { + font-family: var(--font-family-sansserif), var(--sans-fallbacks), sans-serif; + font-size: var(--font-size-xs); + font-weight: 500; + line-height: 130%; + letter-spacing: -0.12px; +} + +.typography-caption-400 { + font-family: var(--font-family-sansserif), var(--sans-fallbacks), sans-serif; + font-size: var(--font-size-xs); + font-weight: 400; + line-height: 140%; + letter-spacing: -0.063px; +} + +.typography-caption-500 { + font-family: var(--font-family-sansserif), var(--sans-fallbacks), sans-serif; + font-size: var(--font-size-xs); + font-weight: 500; + line-height: 140%; + letter-spacing: -0.063px; +} + +.label-text { + display: flex; + align-items: center; + gap: var(--space-1); +} + +.label-optional { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 var(--space-1); +} + +.label-info-button { + width: 16px; + height: 16px; + padding: 0; + border: none; + background: transparent; + color: var(--fgcolor-neutral-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: default; +} + +.label-info-button svg { + width: 16px; + height: 16px; +} + +.text-neutral-primary { + color: var(--fgcolor-neutral-primary); +} + +.text-neutral-secondary { + color: var(--fgcolor-neutral-secondary); +} + +.text-neutral-tertiary { + color: var(--fgcolor-neutral-tertiary); +} + +.text-warning { + color: var(--fgcolor-warning); +} + +.text-error { + color: var(--fgcolor-error); +} + +.text-on-success-weak { + color: var(--fgcolor-on-success-weak); +} + +.text-on-invert { + color: var(--fgcolor-on-invert); +} + +.input-group { + width: 100%; +} + +.field-error { + display: flex; + align-items: flex-start; + gap: var(--space-3); + color: var(--fgcolor-error); + max-height: 0; + opacity: 0; + overflow: hidden; + /*padding-inline-start: var(--space-2);*/ + transition: max-height var(--duration-extended) var(--ease-standard), + opacity var(--duration-extended) var(--ease-standard); +} + +.field-error-icon { + display: flex; + flex-shrink: 0; + align-items: center; +} + +.field-error-icon svg { + width: 1rem; + height: 1rem; +} + +.field-error.is-visible { + opacity: 1; + max-height: 64px; +} + +.field-helper { + display: flex; + align-items: flex-start; + gap: var(--space-3); + color: var(--fgcolor-neutral-secondary); +} + +.field-helper-icon { + display: flex; + flex-shrink: 0; + align-items: center; +} + +.field-helper-icon svg { + width: 1rem; + height: 1rem; +} + +.input-field { + width: 100%; + padding: var(--space-3) var(--space-6); + background: var(--bgcolor-neutral-default); + border: var(--border-width-s) solid var(--border-neutral); + outline: none; + border-radius: var(--border-radius-s); + transition: all var(--duration-fast) var(--ease-in-out); +} + +.input-field:focus { + border-color: var(--border-focus); + box-shadow: inset 0 0 0 1px var(--border-focus); +} + +.input-field.is-error { + border-color: var(--border-error); + box-shadow: none; +} + +.input-field.is-error:focus { + border-color: var(--border-error); + box-shadow: inset 0 0 0 1px var(--border-error); +} + +.input-field::placeholder { + color: var(--fgcolor-neutral-tertiary); +} + +.input-field[type='number'] { + appearance: textfield; + -moz-appearance: textfield; +} + +.input-field[type='number']::-webkit-outer-spin-button, +.input-field[type='number']::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.selector-group { + display: flex; + gap: var(--gap-m); + width: 100%; + position: relative; + overflow: visible; +} + +.selector-card { + position: relative; + flex: 1; + min-height: 52px; + display: flex; + align-items: center; + gap: var(--gap-s); + padding: var(--space-4) var(--space-6); + background: var(--bgcolor-neutral-default); + border: none; + box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral); + border-radius: var(--border-radius-s); + cursor: pointer; + overflow: hidden; + transition: all var(--duration-fast) var(--ease-in-out); +} + +.selector-card.has-tooltip { + overflow: visible; +} + +.selector-card::before { + content: ''; + position: absolute; + inset: 0; + background: var(--overlay-neutral-hover); + opacity: 0; + transition: opacity var(--duration-fast) var(--ease-in-out); +} + +.selector-card:hover::before, +.selector-card.selected::before { + opacity: 1; +} + +.selector-card.is-disabled::before, +.selector-card.is-disabled:hover::before { + opacity: 0; +} + +.selector-group.is-locked .selector-card { + cursor: default; +} + +.selector-group.is-locked .selector-card:hover::before { + opacity: 0; +} + +.selector-card.has-tooltip .tooltip { + top: calc(100% + 6px); + bottom: auto; + transform: translateX(-50%) translateY(-8px); +} + +.selector-card.has-tooltip { + z-index: 0; +} + +.selector-card.has-tooltip:hover { + z-index: 3; +} + +.selector-card.has-tooltip:hover .tooltip, +.selector-card.has-tooltip:focus-within .tooltip { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); + transition: opacity var(--duration-short) var(--ease-standard), + transform var(--duration-short) var(--ease-standard), + visibility 0s; +} + +.tooltip-db-locked { + width: 193px; + max-width: none; + min-width: 193px; + text-align: center; +} +.selector-card.selected { + box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral-stronger); +} + +.selector-card:focus-within { + outline: none; + box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral), + inset 0 0 0 var(--border-width-l) var(--border-focus); +} + +.selector-card.selected:focus-within { + box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral-stronger), + inset 0 0 0 var(--border-width-l) var(--border-focus); +} + +.selector-content { + position: relative; + display: flex; + flex-direction: column; + gap: 0; + flex: 1; +} + +.selector-icon { + position: relative; + width: 32px; + height: 32px; + flex-shrink: 0; +} + +.accordion { + display: flex; + flex-direction: column; + gap: 0; +} + +.accordion-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--gap-s); + width: 100%; + padding: 0; + border: none; + border-radius: var(--border-radius-s); + background: transparent; + cursor: pointer; + transition: background var(--duration-fast) var(--ease-in-out); + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + letter-spacing: inherit; +} + +.accordion-toggle:focus-visible { + outline: var(--border-width-l) solid var(--border-focus); +} + +.accordion-chevron { + width: 20px; + height: 20px; + transition: transform var(--duration-slow) var(--ease-in-out); + color: var(--fgcolor-neutral-tertiary); +} + +.accordion-chevron[data-open='true'] { + transform: rotate(180deg); +} + +.accordion-content { + display: flex; + flex-direction: column; + gap: var(--gap-l); + width: 100%; + max-height: 0; + opacity: 0; + overflow: hidden; + transition: max-height var(--duration-medium) var(--ease-out), + opacity var(--duration-medium) var(--ease-out); +} + +.accordion-content.open { + opacity: 1; + overflow: visible; + margin-top: var(--gap-m); +} + +.divider { + width: 100%; + height: 1px; + background: var(--border-neutral); + margin-top: var(--divider-gap-top, var(--gap-xl)); + margin-bottom: var(--gap-xl); +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--gap-xs); + padding: var(--space-3) var(--space-5); + min-height: var(--space-10); + min-width: var(--button-min-width-s); + border-radius: var(--border-radius-s); + border: none; + box-shadow: inset 0 0 0 var(--border-width-s) transparent; + cursor: pointer; + transition: all var(--duration-fast) var(--ease-in-out); + outline-offset: var(--border-width-s); + background: transparent; + color: var(--fgcolor-neutral-secondary); +} + +.button-text { + display: inline-flex; + align-items: center; +} + +.button:focus-visible { + outline: var(--border-width-l) solid var(--border-focus); +} + +.button.primary { + background: var(--bgcolor-accent); + box-shadow: inset 0 0 0 var(--border-width-s) var(--bgcolor-accent); + color: var(--fgcolor-on-accent); +} + +.button.primary:hover { + background: var(--bgcolor-accent-secondary); + box-shadow: inset 0 0 0 var(--border-width-s) var(--bgcolor-accent-secondary); +} + +.button.primary:active { + background: var(--bgcolor-accent-tertiary); + box-shadow: inset 0 0 0 var(--border-width-s) var(--bgcolor-accent-tertiary); +} + +.button.primary:disabled { + background: var(--bgcolor-neutral-invert-weaker); + box-shadow: inset 0 0 0 var(--border-width-s) var(--bgcolor-neutral-invert-weaker); +} + +.button.secondary { + background: var(--bgcolor-neutral-primary); + box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral); + color: var(--fgcolor-neutral-primary); +} + +.button.secondary:hover { + background: var(--bgcolor-neutral-secondary); +} + +.button.secondary:active { + background: var(--bgcolor-neutral-tertiary); +} + +.button.secondary:disabled { + box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral-strong); +} + +.button:disabled { + opacity: 0.4; + pointer-events: none; +} + +.action-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--gap-xl); + width: 100%; + flex-wrap: nowrap; +} + +.step-indicators { + display: flex; + align-items: center; + line-height: 0; +} + +.step-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; + color: var(--fgcolor-neutral-weak); +} + +.step-indicator svg { + display: block; +} + +.step-indicator .indicator-active { + display: none; +} + +.step-indicator.is-active .indicator-active { + display: inline-flex; +} + +.step-indicator.is-active .indicator-inactive { + display: none; +} + +.step-indicator.is-hidden { + display: none; +} + +.step-layout { + display: flex; + flex-direction: column; + gap: 0; +} + +.inline-alert { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--gap-m); + width: 100%; + padding: var(--space-6); + background: var(--bgcolor-neutral-default); + border-radius: var(--border-radius-s); + outline: var(--border-width-s) solid var(--border-neutral-strong); + outline-offset: -1px; + --alert-primary-color: var(--fgcolor-neutral-secondary); +} + +.inline-alert--warning { + background: var(--bgcolor-warning-weaker); + outline-color: var(--border-warning-weak); + --alert-primary-color: var(--fgcolor-warning); +} + +.inline-alert-content { + display: inline-flex; + align-items: flex-start; + gap: var(--gap-s); + align-self: stretch; +} + +.inline-alert-icon { + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--alert-primary-color); + flex-shrink: 0; +} + +.inline-alert-text { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.input-action { + display: flex; + align-items: center; + gap: var(--gap-l); + width: 100%; + padding: var(--space-3) var(--space-5) var(--space-3) var(--space-6); + background: var(--bgcolor-neutral-default); + border-radius: var(--border-radius-s); + border: var(--border-width-s) solid var(--border-neutral); + outline: none; + transition: all var(--duration-fast) var(--ease-in-out); +} + +.input-action:focus-within { + border-color: var(--border-focus); + box-shadow: inset 0 0 0 1px var(--border-focus); +} + +.input-action.is-error { + border-color: var(--border-error); + box-shadow: none; +} + +.input-action.is-error:focus-within { + border-color: var(--border-error); + box-shadow: inset 0 0 0 1px var(--border-error); +} + +.input-action-input { + flex: 1; + border: none; + background: transparent; + padding: 0; + outline: none; +} + +.input-action-buttons { + display: flex; + align-items: center; + gap: var(--gap-m); +} + +.tooltip-wrapper { + position: relative; + display: inline-flex; +} + +.tooltip { + position: absolute; + left: 50%; + bottom: calc(100% + 6px); + transform: translateX(-50%) translateY(8px); + display: inline-flex; + align-items: center; + justify-content: center; + width: max-content; + max-width: 11.25rem; + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-s); + background: var(--bgcolor-neutral-invert-weak); + color: var(--fgcolor-on-invert); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity var(--duration-short) var(--ease-standard), + transform var(--duration-short) var(--ease-standard), + visibility 0s linear var(--duration-short); + z-index: 5; +} + +.tooltip.is-open { + opacity: 1; + visibility: visible; + transform: translateY(0); + transition: opacity var(--duration-short) var(--ease-standard), + transform var(--duration-short) var(--ease-standard), + visibility 0s; +} + +.tooltip-portal { + position: fixed; + left: 0; + top: 0; + bottom: auto; + transform: translateY(8px); +} + +.tooltip-assistant { + width: 246px; + max-width: 246px; + text-align: left; +} + +.tooltip-wrapper:hover .tooltip, +.tooltip-wrapper:focus-within .tooltip, +.tooltip-wrapper.is-open .tooltip { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); + transition: opacity var(--duration-short) var(--ease-standard), + transform var(--duration-short) var(--ease-standard), + visibility 0s; +} + +.input-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: transparent; + color: var(--fgcolor-neutral-secondary); + cursor: pointer; + border-radius: var(--border-radius-xs); +} + +.input-icon-button svg { + width: 16px; + height: 16px; + display: block; +} + +.input-icon-button:hover { + background: var(--overlay-neutral-hover); +} + +.input-icon-button:active { + background: var(--overlay-neutral-pressed); +} + +.password-toggle-icon { + display: inline-flex; +} + +.password-toggle [data-password-icon="hide"] { + display: none; +} + +.password-toggle.is-visible [data-password-icon="show"] { + display: none; +} + +.password-toggle.is-visible [data-password-icon="hide"] { + display: inline-flex; +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-3); + background: var(--bgcolor-neutral-primary); + border-radius: var(--border-radius-s); + outline: var(--border-width-s) solid var(--border-neutral-strong); + outline-offset: -1px; + border: none; + color: var(--fgcolor-neutral-tertiary); + cursor: pointer; +} + +.icon-button.is-rotating svg { + animation: installer-rotate-once 0.35s linear; +} + +.input-row { + display: flex; + align-items: flex-start; + gap: var(--space-4); + width: 100%; +} + +.input-row .input-group { + flex: 1; +} + +.input-row .button { + margin-top: calc(var(--label-line-height) + var(--space-3)); +} + +.input-row .icon-button { + margin-top: calc(var(--label-line-height) + var(--space-3)); +} + +.review-card { + width: 100%; + padding: var(--space-6); + background: var(--bgcolor-neutral-default); + border-radius: var(--border-radius-m); + outline: var(--border-width-s) solid var(--border-neutral); + outline-offset: -1px; + display: flex; + flex-direction: column; + gap: var(--space-5); +} + +.review-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--gap-m); +} + +.review-label { + text-align: right; +} + +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-1) var(--space-2); + border-radius: 6px; +} + +.badge-success { + background: var(--bgcolor-success-weak); + color: var(--fgcolor-on-success-weak); +} + +.badge-warning { + background: var(--bgcolor-warning-weak); + color: var(--fgcolor-on-warning-weak); +} + +.badge-neutral { + background: var(--bgcolor-neutral-tertiary); + color: var(--fgcolor-neutral-secondary); +} + +.installer-footer { + width: 100%; + display: flex; + justify-content: center; + padding-bottom: var(--space-7); + position: relative; + z-index: 1; +} + +.appwrite-logo { + width: 131px; + height: 25px; + position: relative; +} + +.appwrite-logo svg { + width: 100%; + height: 100%; + display: block; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + animation: none !important; + } +} + +@keyframes installer-rotate-once { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 640px) { + .installer-page { + padding: var(--space-6); + gap: var(--gap-m); + } + + .installer-card { + padding: var(--space-6); + } + + .installer-step { + min-height: 0; + } + + .selector-group { + flex-direction: column; + } + + .input-row { + flex-direction: column; + align-items: stretch; + } + + .input-row .button { + align-self: flex-start; + margin-top: 0; + } + + .input-row .icon-button { + margin-top: 0; + } + + .step-layout[data-step='2'] .input-row { + flex-direction: row; + align-items: flex-end; + } + + .action-bar { + gap: var(--gap-s); + } +} diff --git a/app/views/install/installer/icons/appwrite-logo.svg b/app/views/install/installer/icons/appwrite-logo.svg new file mode 100644 index 0000000000..af9378f8fc --- /dev/null +++ b/app/views/install/installer/icons/appwrite-logo.svg @@ -0,0 +1,13 @@ + diff --git a/app/views/install/installer/icons/appwrite-mark.svg b/app/views/install/installer/icons/appwrite-mark.svg new file mode 100644 index 0000000000..7dff2b5fc4 --- /dev/null +++ b/app/views/install/installer/icons/appwrite-mark.svg @@ -0,0 +1,4 @@ + diff --git a/app/views/install/installer/icons/chevron-down.svg b/app/views/install/installer/icons/chevron-down.svg new file mode 100644 index 0000000000..74d9c14cfd --- /dev/null +++ b/app/views/install/installer/icons/chevron-down.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/copy.svg b/app/views/install/installer/icons/copy.svg new file mode 100644 index 0000000000..008d89098a --- /dev/null +++ b/app/views/install/installer/icons/copy.svg @@ -0,0 +1,4 @@ + diff --git a/app/views/install/installer/icons/exclamation-circle.svg b/app/views/install/installer/icons/exclamation-circle.svg new file mode 100644 index 0000000000..3416951e48 --- /dev/null +++ b/app/views/install/installer/icons/exclamation-circle.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/eye-off.svg b/app/views/install/installer/icons/eye-off.svg new file mode 100644 index 0000000000..9c0b0a063b --- /dev/null +++ b/app/views/install/installer/icons/eye-off.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/views/install/installer/icons/eye.svg b/app/views/install/installer/icons/eye.svg new file mode 100644 index 0000000000..897b425bf3 --- /dev/null +++ b/app/views/install/installer/icons/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/views/install/installer/icons/indicator-active.svg b/app/views/install/installer/icons/indicator-active.svg new file mode 100644 index 0000000000..e620bdf6e9 --- /dev/null +++ b/app/views/install/installer/icons/indicator-active.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/indicator-inactive.svg b/app/views/install/installer/icons/indicator-inactive.svg new file mode 100644 index 0000000000..550101da9b --- /dev/null +++ b/app/views/install/installer/icons/indicator-inactive.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/info.svg b/app/views/install/installer/icons/info.svg new file mode 100644 index 0000000000..6f0524fb49 --- /dev/null +++ b/app/views/install/installer/icons/info.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/install-bg-1.svg b/app/views/install/installer/icons/install-bg-1.svg new file mode 100644 index 0000000000..7291974bee --- /dev/null +++ b/app/views/install/installer/icons/install-bg-1.svg @@ -0,0 +1,16 @@ + diff --git a/app/views/install/installer/icons/install-bg-2.svg b/app/views/install/installer/icons/install-bg-2.svg new file mode 100644 index 0000000000..9e49ddb99f --- /dev/null +++ b/app/views/install/installer/icons/install-bg-2.svg @@ -0,0 +1,16 @@ + diff --git a/app/views/install/installer/icons/install-check.svg b/app/views/install/installer/icons/install-check.svg new file mode 100644 index 0000000000..7d43d007c6 --- /dev/null +++ b/app/views/install/installer/icons/install-check.svg @@ -0,0 +1,4 @@ + diff --git a/app/views/install/installer/icons/install-spinner.svg b/app/views/install/installer/icons/install-spinner.svg new file mode 100644 index 0000000000..543d2c4b22 --- /dev/null +++ b/app/views/install/installer/icons/install-spinner.svg @@ -0,0 +1,4 @@ + diff --git a/app/views/install/installer/icons/lock.svg b/app/views/install/installer/icons/lock.svg new file mode 100644 index 0000000000..6a87c21345 --- /dev/null +++ b/app/views/install/installer/icons/lock.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/mariadb.svg b/app/views/install/installer/icons/mariadb.svg new file mode 100644 index 0000000000..921fbbb0dc --- /dev/null +++ b/app/views/install/installer/icons/mariadb.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/views/install/installer/icons/mongodb.svg b/app/views/install/installer/icons/mongodb.svg new file mode 100644 index 0000000000..5470eccb38 --- /dev/null +++ b/app/views/install/installer/icons/mongodb.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/views/install/installer/icons/postgresql.svg b/app/views/install/installer/icons/postgresql.svg new file mode 100644 index 0000000000..f891b75c37 --- /dev/null +++ b/app/views/install/installer/icons/postgresql.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/views/install/installer/icons/refresh.svg b/app/views/install/installer/icons/refresh.svg new file mode 100644 index 0000000000..56f70b425a --- /dev/null +++ b/app/views/install/installer/icons/refresh.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/icons/step-dot.svg b/app/views/install/installer/icons/step-dot.svg new file mode 100644 index 0000000000..490a039d20 --- /dev/null +++ b/app/views/install/installer/icons/step-dot.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/views/install/installer/icons/warning.svg b/app/views/install/installer/icons/warning.svg new file mode 100644 index 0000000000..131b29171c --- /dev/null +++ b/app/views/install/installer/icons/warning.svg @@ -0,0 +1,3 @@ + diff --git a/app/views/install/installer/js/constants.js b/app/views/install/installer/js/constants.js new file mode 100644 index 0000000000..940c0bc780 --- /dev/null +++ b/app/views/install/installer/js/constants.js @@ -0,0 +1,11 @@ +(() => { + window.InstallerConstants = Object.freeze({ + stepTransitionMs: 260, + errorClearMs: 180, + installPollIntervalMs: 4000, + installFallbackDelayMs: 12000, + redirectDelayMs: 2500, + progressTransitionDelayMs: 320, + progressCompleteDelayMs: 140, + }); +})(); diff --git a/app/views/install/installer/js/installer.js b/app/views/install/installer/js/installer.js new file mode 100644 index 0000000000..463b7f6221 --- /dev/null +++ b/app/views/install/installer/js/installer.js @@ -0,0 +1,450 @@ +(() => { + const stepContainer = document.querySelector('.installer-step'); + const installerCard = document.querySelector('.installer-card'); + const backButton = document.querySelector('[data-action="back"]'); + const nextButton = document.querySelector('[data-action="next"]'); + const installScreen = document.querySelector('.install-screen-content'); + const indicatorNodes = Array.from(document.querySelectorAll('.step-indicator')); + const STEP_TRANSITION_TIMEOUT = window.InstallerConstants?.stepTransitionMs ?? 260; + + if (!stepContainer || !installerCard) return; + + const { validateInstallRequest } = window.InstallerStepsProgress || {}; + + const isUpgrade = document.body?.dataset.upgrade === 'true'; + const stepFlow = isUpgrade ? [1, 4, 5] : [1, 2, 3, 4, 5]; + const cardSteps = stepFlow.filter((step) => step !== 5); + + const normalizeStep = (step) => { + const numeric = clampStep(step); + if (stepFlow.includes(numeric)) return numeric; + if (numeric <= stepFlow[0]) return stepFlow[0]; + for (let i = 0; i < stepFlow.length; i += 1) { + if (numeric < stepFlow[i]) { + return stepFlow[i]; + } + } + return stepFlow[stepFlow.length - 1]; + }; + + const buildStepConfig = () => { + const config = {}; + stepFlow.forEach((step, index) => { + if (step === 5) { + config[step] = { back: { target: null }, next: { target: null } }; + return; + } + const prev = stepFlow[index - 1] ?? null; + const next = stepFlow[index + 1] ?? null; + const label = next === 5 ? (isUpgrade ? 'Update' : 'Install') : 'Next'; + config[step] = { + back: { target: prev }, + next: { label, target: next } + }; + }); + return config; + }; + + const STEP_CONFIG = buildStepConfig(); + + const stepCache = new Map(); + let maxStepHeight = 0; + let isTransitioning = false; + let pendingStep = null; + let pendingPushState = false; + + const clampStep = (step) => Math.max(1, Math.min(5, step)); + const isInstallLocked = () => Boolean(window.InstallerSteps?.isInstallLocked?.()); + + const scrollToFirstError = (panel) => { + if (!panel) return; + const getErrorNode = () => panel.querySelector('.field-error.is-visible') + || panel.querySelector('.field-error') + || panel.querySelector('.input-field.is-error, .input-action.is-error'); + const container = panel.closest('.step-panel') || panel; + const attemptScroll = () => { + const target = getErrorNode(); + if (!target || typeof target.getBoundingClientRect !== 'function') return false; + const targetRect = target.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const targetTop = targetRect.top - containerRect.top + container.scrollTop; + const targetBottom = targetTop + targetRect.height; + const viewTop = container.scrollTop; + const viewBottom = viewTop + containerRect.height; + const padding = 12; + + let nextScrollTop = viewTop; + if (targetTop < viewTop + padding) { + nextScrollTop = Math.max(0, targetTop - padding); + } else if (targetBottom > viewBottom - padding) { + nextScrollTop = Math.max(0, targetBottom - containerRect.height + padding); + } + + if (Math.abs(nextScrollTop - viewTop) < 1) { + return false; + } + + container.scrollTo({ top: nextScrollTop, behavior: 'smooth' }); + return true; + }; + + let remaining = 20; + let lastScrollTop = -1; + const settle = () => { + if (remaining <= 0) return; + const moved = attemptScroll(); + remaining -= 1; + const currentTop = container.scrollTop; + const delta = Math.abs(currentTop - lastScrollTop); + lastScrollTop = currentTop; + if (!moved && delta < 0.5) { + return; + } + requestAnimationFrame(settle); + }; + requestAnimationFrame(settle); + }; + + const getStepFromUrl = () => { + const url = new URL(window.location.href); + const step = Number(url.searchParams.get('step') || 1); + return normalizeStep(Number.isNaN(step) ? 1 : step); + }; + + const buildStepUrl = (step) => { + const url = new URL(window.location.href); + url.searchParams.set('step', step); + return url; + }; + + const setStepInUrl = (step, pushState) => { + const url = new URL(window.location.href); + url.searchParams.set('step', step); + + if (pushState) { + window.history.pushState({ step }, '', url.toString()); + } + + return url; + }; + + const updateActionBar = (step) => { + const config = STEP_CONFIG[step] || STEP_CONFIG[1]; + if (!backButton || !nextButton) return; + const locked = isInstallLocked(); + + const setButtonLabel = (button, label) => { + if (!button) return; + let text = button.querySelector('.button-text'); + if (!text) { + text = document.createElement('span'); + text.className = 'button-text typography-text-m-500'; + button.textContent = ''; + button.appendChild(text); + } + text.textContent = label; + }; + + if (!locked && config.back?.target) { + backButton.disabled = false; + backButton.setAttribute('data-step-target', String(config.back.target)); + } else { + backButton.disabled = true; + backButton.removeAttribute('data-step-target'); + } + setButtonLabel(backButton, 'Back'); + + if (!locked && config.next?.target) { + setButtonLabel(nextButton, config.next?.label || 'Next'); + nextButton.setAttribute('data-step-target', String(config.next?.target || 1)); + nextButton.disabled = false; + } else { + setButtonLabel(nextButton, config.next?.label || 'Next'); + nextButton.removeAttribute('data-step-target'); + nextButton.disabled = true; + } + + indicatorNodes.forEach((node, index) => { + const isVisible = index < cardSteps.length; + node.classList.toggle('is-hidden', !isVisible); + if (!isVisible) { + node.classList.remove('is-active'); + return; + } + node.classList.toggle('is-active', cardSteps[index] === step); + }); + + installerCard.setAttribute('data-step', String(step)); + document.body.dataset.step = String(step); + if (locked) { + document.body.dataset.installLocked = 'true'; + } else { + delete document.body.dataset.installLocked; + } + }; + + const measureStepHeight = (panel) => { + if (!panel) return; + const height = panel.getBoundingClientRect().height; + if (!height) return; + maxStepHeight = Math.max(maxStepHeight, height); + stepContainer.style.setProperty('--step-min-height', `${maxStepHeight}px`); + }; + + const runStepInit = (step, rootElement) => { + if (!window.InstallerSteps || typeof window.InstallerSteps.initStep !== 'function') return; + const root = rootElement || stepContainer; + window.InstallerSteps.initStep(step, root); + updateActionBar(step); + }; + + const fetchStepHtml = (step, url) => { + if (stepCache.has(step)) { + return Promise.resolve(stepCache.get(step)); + } + + const fetchUrl = new URL(url); + fetchUrl.searchParams.set('partial', '1'); + + return fetch(fetchUrl.toString(), { + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then((response) => { + if (!response.ok) { + throw new Error('Failed to load step'); + } + return response.text(); + }) + .then((html) => { + stepCache.set(step, html); + return html; + }); + }; + + const preloadSteps = (steps) => { + const current = getStepFromUrl(); + const targets = steps.filter((step) => step !== current); + + return Promise.all( + targets.map((step) => { + const url = buildStepUrl(step); + return fetchStepHtml(step, url) + .then((html) => { + const panel = document.createElement('div'); + panel.className = 'step-panel is-measure'; + panel.innerHTML = html; + stepContainer.appendChild(panel); + panel.getBoundingClientRect(); + measureStepHeight(panel); + panel.remove(); + }) + .catch(() => null); + }) + ); + }; + + const swapPanels = (step, html, onDone) => { + const activePanel = stepContainer.querySelector('.step-panel'); + + const measurePanel = document.createElement('div'); + measurePanel.className = 'step-panel is-measure'; + measurePanel.innerHTML = html; + stepContainer.appendChild(measurePanel); + measurePanel.getBoundingClientRect(); + measureStepHeight(measurePanel); + measurePanel.remove(); + + const newPanel = document.createElement('div'); + newPanel.className = 'step-panel is-entering'; + newPanel.innerHTML = html; + stepContainer.appendChild(newPanel); + runStepInit(step, newPanel); + + newPanel.getBoundingClientRect(); + + requestAnimationFrame(() => { + newPanel.classList.remove('is-entering'); + newPanel.classList.add('is-active'); + if (activePanel) { + activePanel.classList.add('is-exiting'); + } + }); + + const finalize = () => { + if (activePanel && activePanel.parentNode) { + activePanel.parentNode.removeChild(activePanel); + } + newPanel.classList.remove('is-entering'); + if (typeof onDone === 'function') { + onDone(); + } + }; + + const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (prefersReduced) { + finalize(); + return; + } + + let finished = false; + const finishOnce = () => { + if (finished) return; + finished = true; + finalize(); + }; + + newPanel.addEventListener( + 'transitionend', + (event) => { + if (event.propertyName === 'opacity') { + finishOnce(); + } + }, + { once: true } + ); + + setTimeout(finishOnce, STEP_TRANSITION_TIMEOUT); + }; + + const showInstallScreen = (step, html) => { + if (!installScreen) return; + installScreen.innerHTML = html; + runStepInit(step, installScreen); + }; + + const hideInstallScreen = () => { + if (!installScreen) return; + installScreen.innerHTML = ''; + }; + + const loadStep = (step, pushState) => { + const targetStep = normalizeStep(Number(step)); + const currentStep = getStepFromUrl(); + if (targetStep === currentStep && pushState) return; + + isTransitioning = true; + const url = setStepInUrl(targetStep, pushState); + + fetchStepHtml(targetStep, url) + .then((html) => { + if (targetStep === 5) { + showInstallScreen(targetStep, html); + isTransitioning = false; + if (pendingStep !== null && pendingStep !== targetStep) { + const nextStep = pendingStep; + const nextPushState = pendingPushState; + pendingStep = null; + pendingPushState = false; + loadStep(nextStep, nextPushState); + return; + } + pendingStep = null; + pendingPushState = false; + return; + } + + hideInstallScreen(); + swapPanels(targetStep, html, () => { + isTransitioning = false; + if (pendingStep !== null && pendingStep !== targetStep) { + const nextStep = pendingStep; + const nextPushState = pendingPushState; + pendingStep = null; + pendingPushState = false; + loadStep(nextStep, nextPushState); + return; + } + pendingStep = null; + pendingPushState = false; + }); + }) + .catch(() => { + isTransitioning = false; + window.location.href = url.toString(); + }); + }; + + const requestStep = (step, pushState) => { + const targetStep = normalizeStep(Number(step)); + if (isInstallLocked() && targetStep !== 5) { + loadStep(5, true); + return; + } + if (isTransitioning) { + pendingStep = targetStep; + pendingPushState = pendingPushState || pushState; + return; + } + loadStep(targetStep, pushState); + }; + + document.addEventListener('click', async (event) => { + const button = event.target.closest('[data-step-target]'); + if (!button || button.disabled) return; + event.preventDefault(); + const target = button.getAttribute('data-step-target'); + if (!target) return; + const action = button.getAttribute('data-action'); + if (action === 'next') { + const currentStep = getStepFromUrl(); + const panel = stepContainer.querySelector('.step-panel') || stepContainer; + const validator = window.InstallerSteps?.validateStep; + if (typeof validator === 'function') { + const valid = validator(currentStep, panel); + if (!valid) { + scrollToFirstError(panel); + return; + } + } + } + if (action === 'next' && String(target) === '5' && typeof validateInstallRequest === 'function') { + const isValid = await validateInstallRequest(); + if (!isValid) { + return; + } + } + if (isInstallLocked() && Number(target) !== 5) { + requestStep(5, true); + return; + } + requestStep(target, true); + }); + + window.addEventListener('popstate', (event) => { + const step = event.state?.step || getStepFromUrl(); + if (isInstallLocked() && Number(step) !== 5) { + requestStep(5, false); + return; + } + requestStep(step, false); + }); + + document.addEventListener('DOMContentLoaded', () => { + let step = getStepFromUrl(); + if (isInstallLocked() && step !== 5) { + const url = buildStepUrl(5); + window.history.replaceState({ step: 5 }, '', url.toString()); + step = 5; + } else { + const url = buildStepUrl(step); + window.history.replaceState({ step }, '', url.toString()); + } + const activePanel = stepContainer.querySelector('.step-panel') || stepContainer; + runStepInit(step, activePanel); + measureStepHeight(activePanel); + if (step === 5 && installScreen) { + runStepInit(step, installScreen); + } + const preload = () => { + measureStepHeight(activePanel); + preloadSteps(cardSteps); + }; + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(preload).catch(preload); + } else { + preload(); + } + }); +})(); diff --git a/app/views/install/installer/js/modules/context.js b/app/views/install/installer/js/modules/context.js new file mode 100644 index 0000000000..c531ecddce --- /dev/null +++ b/app/views/install/installer/js/modules/context.js @@ -0,0 +1,111 @@ +(() => { + const getBodyDataset = () => document.body?.dataset ?? {}; + const isUpgradeMode = () => getBodyDataset().upgrade === 'true'; + const getLockedDatabase = () => getBodyDataset().lockedDatabase || ''; + const getEnabledDatabases = () => { + const raw = getBodyDataset().enabledDatabases; + if (!raw) return ['mongodb', 'mariadb', 'postgresql']; + try { return JSON.parse(raw); } catch (e) { return ['mongodb', 'mariadb', 'postgresql']; } + }; + + const STEP_IDS = Object.freeze({ + CONFIG_FILES: 'config-files', + DOCKER_COMPOSE: 'docker-compose', + ENV_VARS: 'env-vars', + DOCKER_CONTAINERS: 'docker-containers', + ACCOUNT_SETUP: 'account-setup' + }); + + const STATUS = Object.freeze({ + IN_PROGRESS: 'in-progress', + COMPLETED: 'completed', + ERROR: 'error' + }); + + const SSE_EVENTS = Object.freeze({ + PING: 'ping', + INSTALL_ID: 'install-id', + PROGRESS: 'progress', + DONE: 'done', + ERROR: 'error' + }); + + const buildInstallationSteps = (upgrade) => (upgrade ? [ + { + id: STEP_IDS.CONFIG_FILES, + inProgress: 'Updating configuration files...', + done: 'Configuration files updated' + }, + { + id: STEP_IDS.DOCKER_COMPOSE, + inProgress: 'Updating Docker Compose file...', + done: 'Docker Compose file updated' + }, + { + id: STEP_IDS.ENV_VARS, + inProgress: 'Updating environment variables...', + done: 'Environment variables updated' + }, + { + id: STEP_IDS.DOCKER_CONTAINERS, + inProgress: 'Restarting Docker containers...', + done: 'Docker containers restarted' + } + ] : [ + { + id: STEP_IDS.CONFIG_FILES, + inProgress: 'Creating configuration files...', + done: 'Configuration files created' + }, + { + id: STEP_IDS.DOCKER_COMPOSE, + inProgress: 'Generating Docker Compose file...', + done: 'Docker Compose file generated' + }, + { + id: STEP_IDS.ENV_VARS, + inProgress: 'Configuring environment variables...', + done: 'Environment variables configured' + }, + { + id: STEP_IDS.DOCKER_CONTAINERS, + inProgress: 'Starting Docker containers...', + done: 'Docker containers started' + }, + { + id: STEP_IDS.ACCOUNT_SETUP, + inProgress: 'Creating Appwrite account...', + done: 'Appwrite account created (redirecting...)' + } + ]); + + const INSTALLATION_STEPS = buildInstallationSteps(isUpgradeMode()); + const CONSTANTS = window.InstallerConstants || {}; + const TIMINGS = { + errorClear: CONSTANTS.errorClearMs ?? 180, + installPollInterval: CONSTANTS.installPollIntervalMs ?? 4000, + installFallbackDelay: CONSTANTS.installFallbackDelayMs ?? 12000, + redirectDelay: CONSTANTS.redirectDelayMs ?? 500, + progressTransitionDelay: CONSTANTS.progressTransitionDelayMs ?? 140, + progressCompleteDelay: CONSTANTS.progressCompleteDelayMs ?? 120 + }; + + const clampStep = (step) => { + const numeric = Number(step); + if (Number.isNaN(numeric)) return 1; + return Math.max(1, Math.min(5, numeric)); + }; + + window.InstallerStepsContext = Object.freeze({ + getBodyDataset, + isUpgradeMode, + getLockedDatabase, + getEnabledDatabases, + STEP_IDS, + STATUS, + SSE_EVENTS, + INSTALLATION_STEPS, + TIMINGS, + clampStep + }); +})(); diff --git a/app/views/install/installer/js/modules/progress.js b/app/views/install/installer/js/modules/progress.js new file mode 100644 index 0000000000..7f7b23e3fc --- /dev/null +++ b/app/views/install/installer/js/modules/progress.js @@ -0,0 +1,903 @@ +(() => { + const { + INSTALLATION_STEPS, + TIMINGS, + getBodyDataset, + isUpgradeMode, + STEP_IDS, + STATUS, + SSE_EVENTS + } = window.InstallerStepsContext; + const { + formState, + applyLockPayload, + applyBodyDefaults, + setInstallLock, + getInstallLock, + clearInstallLock, + isInstallLocked, + syncInstallLockFlag, + getStoredInstallId, + storeInstallId, + clearInstallId + } = window.InstallerStepsState || {}; + const { extractHostname, isLocalHost } = window.InstallerStepsValidation || {}; + const { generateSecretKey } = window.InstallerStepsUI || {}; + const { showToast } = window.InstallerToast || {}; + + let activeInstall = null; + let unloadGuard = null; + let sseSessionDetails = null; + const csrfToken = document.querySelector('meta[name="appwrite-installer-csrf"]')?.getAttribute('content') || ''; + + const withCsrfHeader = (headers = {}) => { + if (!csrfToken) { + return headers; + } + return { ...headers, 'X-Appwrite-Installer-CSRF': csrfToken }; + }; + + const showCsrfToast = () => { + showToast?.({ + status: 'error', + title: 'Session expired', + description: 'Refresh the page and try again.', + dismissible: true + }); + }; + + const validateInstallRequest = async () => { + try { + const response = await fetch('/install/validate', { + method: 'POST', + headers: withCsrfHeader({ + 'Content-Type': 'application/json' + }) + }); + if (!response.ok) { + showCsrfToast(); + return false; + } + const data = await response.json().catch(() => ({})); + if (!data?.success) { + showCsrfToast(); + return false; + } + return true; + } catch (error) { + showCsrfToast(); + return false; + } + }; + + const setUnloadGuard = (enabled) => { + if (!enabled && unloadGuard) { + window.removeEventListener('beforeunload', unloadGuard); + unloadGuard = null; + return; + } + + if (enabled && !unloadGuard) { + unloadGuard = (event) => { + event.preventDefault(); + event.returnValue = ''; + return ''; + }; + window.addEventListener('beforeunload', unloadGuard); + } + }; + + const cleanupInstallFlow = () => { + if (activeInstall?.controller) { + activeInstall.controller.abort(); + if (activeInstall.pollTimer) { + clearInterval(activeInstall.pollTimer); + } + if (activeInstall.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + activeInstall = null; + } + stopSyncedSpinnerRotation(); + setUnloadGuard(false); + }; + + const getStepDefinition = (id) => INSTALLATION_STEPS.find((step) => step.id === id); + + const getProgressLabel = (step, status, message) => { + if (!step) return message || ''; + if (status === STATUS.ERROR) { + const normalized = normalizeInstallError(message || ''); + return normalized.summary || 'Installation failed.'; + } + if (status === STATUS.COMPLETED) return step.done; + return step.inProgress; + }; + + const updateInstallRow = (row, step, status, message) => { + if (!row || !step) return; + row.dataset.status = status; + row.dataset.step = step.id; + if (status !== STATUS.ERROR) { + row.classList.remove('is-open'); + const toggle = row.querySelector('[data-install-toggle]'); + if (toggle) { + toggle.setAttribute('aria-expanded', 'false'); + } + } + const label = getProgressLabel(step, status, message); + const text = row.querySelector('[data-install-text]'); + if (text) { + if (text.textContent !== label) { + text.classList.remove('is-enter'); + text.textContent = label; + text.classList.add('is-enter'); + requestAnimationFrame(() => { + text.classList.remove('is-enter'); + }); + } + } + + // Show/hide "Navigate to Console" button for account setup errors + const consoleBtn = row.querySelector('[data-install-console]'); + if (consoleBtn) { + const shouldShow = step.id === STEP_IDS.ACCOUNT_SETUP && status === STATUS.ERROR; + consoleBtn.classList.toggle('is-hidden', !shouldShow); + } + }; + + const normalizeInstallError = (message) => { + const text = String(message || '').trim(); + if (!text) { + return { summary: '', details: '' }; + } + const colonIndex = text.indexOf(':'); + if (colonIndex > 0 && colonIndex < 80) { + const summary = text.slice(0, colonIndex).trim(); + const details = text.slice(colonIndex + 1).trim(); + return { summary, details }; + } + if (text.length > 180) { + return { summary: text.slice(0, 180).trim() + '…', details: text }; + } + return { summary: text, details: '' }; + }; + + let spinnerAnimationFrame = null; + const stopSyncedSpinnerRotation = () => { + if (spinnerAnimationFrame) { + cancelAnimationFrame(spinnerAnimationFrame); + spinnerAnimationFrame = null; + } + }; + + const startSyncedSpinnerRotation = (container) => { + stopSyncedSpinnerRotation(); + if (!container) return; + let startTime = null; + const animate = (timestamp) => { + if (!startTime) startTime = timestamp; + const elapsed = timestamp - startTime; + const rotation = ((elapsed / 1000) * 360 * 1.5) % 360; + container.style.setProperty('--spinner-rotation', `${rotation}deg`); + spinnerAnimationFrame = requestAnimationFrame(animate); + }; + spinnerAnimationFrame = requestAnimationFrame(animate); + }; + + const updateInstallErrorDetails = (row, error) => { + if (!row) return; + const traceNode = row.querySelector('[data-install-trace]'); + const normalized = normalizeInstallError(error?.message || ''); + const output = error?.output || ''; + const trace = error?.trace || ''; + const detailChunks = []; + if (normalized.details) detailChunks.push(normalized.details); + if (output) detailChunks.push(output); + if (trace) detailChunks.push(trace); + const detailText = detailChunks.join('\n\n'); + + if (traceNode) { + traceNode.textContent = detailText; + traceNode.style.display = detailText ? 'block' : 'none'; + } + }; + + const createInstallRow = (template, step) => { + const fragment = template.content.cloneNode(true); + const row = fragment.querySelector('.install-row'); + if (!row) return null; + const toggle = row.querySelector('[data-install-toggle]'); + const setOpenState = (isOpen) => { + row.classList.toggle('is-open', isOpen); + if (toggle) { + toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); + } + }; + const toggleRow = () => { + if (!row.dataset.status || row.dataset.status !== STATUS?.ERROR) { + return; + } + setOpenState(!row.classList.contains('is-open')); + }; + row.addEventListener('click', (event) => { + if (event.target.closest('[data-install-retry]')) { + return; + } + if (event.target.closest('[data-install-toggle]')) { + return; + } + if (event.target.closest('.install-row-details')) { + return; + } + toggleRow(); + }); + if (toggle) { + toggle.addEventListener('click', (event) => { + event.stopPropagation(); + toggleRow(); + }); + } + updateInstallRow(row, step, STATUS.IN_PROGRESS); + return row; + }; + + const generateInstallId = () => { + if (window.crypto?.randomUUID) { + return window.crypto.randomUUID(); + } + const bytes = new Uint8Array(16); + window.crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + }; + + const buildRedirectUrl = () => { + const dataset = getBodyDataset?.() ?? {}; + const rawDomain = (formState?.appDomain || dataset.defaultAppDomain || '').trim(); + if (!rawDomain) return ''; + const httpPort = (formState?.httpPort || dataset.defaultHttpPort || '').trim(); + const httpsPort = (formState?.httpsPort || dataset.defaultHttpsPort || '').trim(); + const hasPort = rawDomain.includes(':') || rawDomain.startsWith('['); + let host = rawDomain; + const hostForProtocol = extractHostname?.(rawDomain); + const normalizedHost = hostForProtocol?.toLowerCase?.() ?? ''; + if (hostForProtocol === '0.0.0.0') { + host = rawDomain.replace('0.0.0.0', 'localhost'); + } else if (normalizedHost === 'traefik') { + host = rawDomain.replace(hostForProtocol, 'localhost'); + } + let protocol = 'http'; + let port = httpPort; + if (httpsPort && httpsPort !== '0' && !isLocalHost?.(normalizedHost)) { + protocol = 'https'; + port = httpsPort; + } + if (!hasPort && port && ((protocol === 'http' && port !== '80') || (protocol === 'https' && port !== '443'))) { + host = `${host}:${port}`; + } + return `${protocol}://${host}`; + }; + + const redirectToApp = () => { + const url = buildRedirectUrl(); + if (!url) return; + // Fire-and-forget: tell the installer server it can shut down + fetch('/install/shutdown', { method: 'POST', headers: withCsrfHeader() }).catch(() => {}); + window.location.href = url; + }; + + const notifyInstallComplete = (installId, session) => { + if (!installId) return Promise.resolve(); + const payload = { installId }; + const sessionSecret = session?.sessionSecret || session?.secret; + const sessionId = session?.sessionId || session?.id; + const sessionExpire = session?.sessionExpire || session?.expire; + if (sessionSecret) { + payload.sessionSecret = sessionSecret; + } + if (sessionId) { + payload.sessionId = sessionId; + } + if (sessionExpire) { + payload.sessionExpire = sessionExpire; + } + return fetch('/install/complete', { + method: 'POST', + headers: withCsrfHeader({ + 'Content-Type': 'application/json' + }), + body: JSON.stringify(payload) + }).catch(() => {}); + }; + + const buildInstallPayload = (installId) => { + const normalizedSecret = (formState?.opensslKey || '').trim(); + if (!normalizedSecret && generateSecretKey && !isUpgradeMode?.()) { + formState.opensslKey = generateSecretKey(); + } + const normalizedDomain = (formState?.appDomain || '').trim() || 'localhost'; + const normalizedHttpPort = (formState?.httpPort || '').trim() || '80'; + const normalizedHttpsPort = (formState?.httpsPort || '').trim() || '443'; + const normalizedEmail = (formState?.emailCertificates || '').trim(); + const normalizedAssistantKey = (formState?.assistantOpenAIKey || '').trim(); + const normalizedAccountEmail = (formState?.accountEmail || '').trim(); + const normalizedAccountPassword = (formState?.accountPassword || '').trim(); + + return { + installId, + httpPort: normalizedHttpPort, + httpsPort: normalizedHttpsPort, + database: formState?.database || 'mongodb', + appDomain: normalizedDomain, + emailCertificates: normalizedEmail, + opensslKey: (formState?.opensslKey || '').trim(), + assistantOpenAIKey: normalizedAssistantKey, + accountEmail: normalizedAccountEmail, + accountPassword: normalizedAccountPassword + }; + }; + + const fetchInstallStatus = async (installId) => { + if (!installId) return null; + const response = await fetch(`/install/status?installId=${encodeURIComponent(installId)}`, { + cache: 'no-store' + }); + if (!response.ok) return null; + const json = await response.json(); + return json.progress || null; + }; + + const readEventStream = async (stream, onEvent) => { + const reader = stream.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + + try { + const processEvent = (rawEvent) => { + if (!rawEvent) return; + const lines = rawEvent.split('\n'); + let eventName = 'message'; + let data = ''; + + lines.forEach((line) => { + if (line.startsWith('event:')) { + eventName = line.replace('event:', '').trim(); + } else if (line.startsWith('data:')) { + data += line.replace('data:', '').trim(); + } + }); + + if (data) { + try { + const parsed = JSON.parse(data); + onEvent(eventName, parsed); + } catch (error) { + onEvent(eventName, { message: data }); + } + } + }; + + while (true) { + const { value, done } = await reader.read(); + if (done) { + buffer = buffer.replace(/\r\n/g, '\n'); + if (buffer.trim()) { + processEvent(buffer); + } + break; + } + buffer += decoder.decode(value, { stream: true }); + buffer = buffer.replace(/\r\n/g, '\n'); + let separatorIndex = buffer.indexOf('\n\n'); + + while (separatorIndex !== -1) { + const rawEvent = buffer.slice(0, separatorIndex); + buffer = buffer.slice(separatorIndex + 2); + processEvent(rawEvent); + separatorIndex = buffer.indexOf('\n\n'); + } + } + } finally { + try { + reader.releaseLock(); + } catch (error) {} + } + }; + + const initStep5 = (root) => { + if (!root) return; + + if (activeInstall?.controller) { + activeInstall.controller.abort(); + } + if (activeInstall?.pollTimer) { + clearInterval(activeInstall.pollTimer); + } + if (activeInstall?.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + activeInstall = null; + + const list = root.querySelector('[data-install-list]'); + const template = root.querySelector('#install-row-template'); + if (!list || !template) return; + startSyncedSpinnerRotation(list); + + list.innerHTML = ''; + const rowsById = new Map(); + const progressState = new Map(); + syncInstallLockFlag?.(); + applyLockPayload?.(); + applyBodyDefaults?.(); + + const ensureRow = (step) => { + if (!step) return null; + if (rowsById.has(step.id)) { + return rowsById.get(step.id); + } + const row = createInstallRow(template, step); + if (!row) return null; + row.classList.add('is-entering'); + list.appendChild(row); + row.getBoundingClientRect(); + requestAnimationFrame(() => { + row.classList.remove('is-entering'); + }); + rowsById.set(step.id, row); + return row; + }; + + const installPanel = root.querySelector('.install-panel'); + let panelHeightCleanup = null; + const animatePanelHeight = (mutate) => { + if (!installPanel) { + mutate(); + return; + } + if (panelHeightCleanup) { + panelHeightCleanup(); + panelHeightCleanup = null; + } + const currentHeight = installPanel.getBoundingClientRect().height; + installPanel.style.height = `${currentHeight}px`; + installPanel.getBoundingClientRect(); + mutate(); + const nextHeight = installPanel.getBoundingClientRect().height; + if (currentHeight === nextHeight) { + installPanel.style.height = ''; + return; + } + installPanel.style.height = `${currentHeight}px`; + installPanel.getBoundingClientRect(); + installPanel.style.height = `${nextHeight}px`; + const cleanup = () => { + installPanel.style.height = ''; + installPanel.removeEventListener('transitionend', onEnd); + }; + const onEnd = (event) => { + if (event.propertyName === 'height') { + cleanup(); + } + }; + panelHeightCleanup = cleanup; + installPanel.addEventListener('transitionend', onEnd); + }; + + const renderProgress = () => { + animatePanelHeight(() => { + const visibleSteps = []; + for (const step of INSTALLATION_STEPS) { + const state = progressState.get(step.id); + if (!state) break; + visibleSteps.push(step); + } + + visibleSteps.forEach((step) => { + const state = progressState.get(step.id); + if (!state) return; + const row = ensureRow(step); + if (row) { + updateInstallRow(row, step, state.status || STATUS.IN_PROGRESS, state.message); + if (state.status === STATUS?.ERROR) { + updateInstallErrorDetails(row, { + message: state.message, + trace: state.details?.trace, + output: state.details?.output + }); + } + } + }); + }); + }; + + const firstStep = INSTALLATION_STEPS[0]; + if (firstStep) { + progressState.set(firstStep.id, { + status: STATUS.IN_PROGRESS, + message: firstStep.inProgress + }); + } + renderProgress(); + + const applyProgress = (payload) => { + const step = getStepDefinition(payload.step) || { + id: payload.step, + inProgress: payload.message || payload.step, + done: payload.message || payload.step + }; + if (step.id === STEP_IDS.ACCOUNT_SETUP && payload.details?.sessionSecret) { + sseSessionDetails = payload.details; + } + progressState.set(step.id, { + status: payload.status || STATUS.IN_PROGRESS, + message: payload.message, + details: payload.details + }); + renderProgress(); + if (activeInstall) { + activeInstall.lastEventAt = Date.now(); + if (payload.status === STATUS.ERROR) { + if (activeInstall.pollTimer) { + clearInterval(activeInstall.pollTimer); + activeInstall.pollTimer = null; + } + if (activeInstall.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + activeInstall.fallbackTimer = null; + } + } + } + scheduleFallback(); + }; + + const handleProgress = (payload) => { + if (!payload || !payload.step) return; + + const existingState = progressState.get(payload.step); + if (existingState && existingState.status === STATUS.COMPLETED && payload.status === STATUS.IN_PROGRESS) { + return; + } + + const step = getStepDefinition(payload.step) || { + id: payload.step, + inProgress: payload.message || payload.step, + done: payload.message || payload.step + }; + if (payload.status === STATUS.IN_PROGRESS) { + const currentIndex = INSTALLATION_STEPS.findIndex((candidate) => candidate.id === step.id); + if (currentIndex > 0) { + for (let i = 0; i < currentIndex; i += 1) { + const previousStep = INSTALLATION_STEPS[i]; + const previousState = progressState.get(previousStep.id); + if (previousState && previousState.status !== STATUS.COMPLETED) { + progressState.set(previousStep.id, { + status: STATUS.COMPLETED, + message: previousStep.done, + details: previousState.details + }); + } + } + } + } + applyProgress(payload); + }; + + const applySnapshot = (snapshot) => { + if (!snapshot || !snapshot.steps) return; + INSTALLATION_STEPS.forEach((step) => { + const detail = snapshot.steps[step.id]; + if (!detail) return; + progressState.set(step.id, { + status: detail.status, + message: detail.message, + details: snapshot.details?.[step.id] + }); + }); + renderProgress(); + }; + + const checkAllCompleted = () => { + const allDone = INSTALLATION_STEPS.every((step) => { + const state = progressState.get(step.id); + return state && state.status === STATUS.COMPLETED; + }); + if (!allDone) return; + const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP); + const sessionDetails = sseSessionDetails || accountState?.details; + finalizeInstall(); + notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => { + setTimeout(() => redirectToApp(), TIMINGS?.redirectDelay ?? 0); + }); + }; + + const startPolling = () => { + if (!activeInstall || activeInstall.pollTimer) return; + activeInstall.pollTimer = setInterval(async () => { + if (!activeInstall || activeInstall.completed) return; + const snapshot = await fetchInstallStatus(activeInstall.installId); + if (snapshot) { + applySnapshot(snapshot); + checkAllCompleted(); + } + }, TIMINGS?.installPollInterval ?? 0); + }; + + const scheduleFallback = () => { + if (!activeInstall) return; + if (activeInstall.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + activeInstall.fallbackTimer = setTimeout(() => { + if (!activeInstall) return; + startPolling(); + }, TIMINGS?.installFallbackDelay ?? 0); + }; + + const finalizeInstall = () => { + if (!activeInstall) return; + activeInstall.completed = true; + if (activeInstall.pollTimer) { + clearInterval(activeInstall.pollTimer); + } + if (activeInstall.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + stopSyncedSpinnerRotation(); + setUnloadGuard(false); + }; + + const startInstallStream = async (installId, options = {}) => { + const isValid = await validateInstallRequest(); + if (!isValid) { + return; + } + activeInstall = { + installId, + controller: new AbortController(), + lastEventAt: Date.now(), + pollTimer: null, + fallbackTimer: null, + completed: false + }; + + const payload = buildInstallPayload(installId); + if (options.retryStep) { + payload.retryStep = options.retryStep; + } + setInstallLock?.(installId, payload); + setUnloadGuard(true); + + try { + scheduleFallback(); + const response = await fetch('/install', { + method: 'POST', + headers: withCsrfHeader({ + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream' + }), + body: JSON.stringify(payload), + signal: activeInstall.controller.signal + }); + + if (!response.ok || !response.body) { + let errorMessage = null; + try { + const contentType = response.headers.get('Content-Type') || ''; + if (contentType.includes('application/json')) { + const data = await response.json(); + errorMessage = data?.message || null; + } + } catch (error) { + errorMessage = null; + } + if (errorMessage) { + handleProgress({ + step: STEP_IDS.CONFIG_FILES, + status: STATUS.ERROR, + message: errorMessage + }); + finalizeInstall(); + return; + } + startPolling(); + return; + } + + await readEventStream(response.body, (event, data) => { + if (!activeInstall) return; + if (event === SSE_EVENTS.INSTALL_ID && data?.installId) { + activeInstall.installId = data.installId; + storeInstallId?.(data.installId); + return; + } + if (event === SSE_EVENTS.PROGRESS) { + handleProgress(data); + return; + } + if (event === SSE_EVENTS.DONE) { + // Mark every step as completed (preserving details + // from earlier progress events, e.g. session info). + INSTALLATION_STEPS.forEach((step) => { + const existing = progressState.get(step.id); + if (!existing || (existing.status !== STATUS.COMPLETED && existing.status !== STATUS.ERROR)) { + progressState.set(step.id, { + status: STATUS.COMPLETED, + message: step.done, + details: existing?.details + }); + } + }); + renderProgress(); + + // If any step ended in error (e.g. account creation + // failed), stay on the progress screen so the user can + // see the error and choose to retry or navigate to the + // console manually — don't auto-redirect. + const hasErrors = INSTALLATION_STEPS.some((step) => { + const state = progressState.get(step.id); + return state && state.status === STATUS.ERROR; + }); + + if (hasErrors) { + finalizeInstall(); + return; + } + + const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP); + const sessionDetails = sseSessionDetails || accountState?.details; + finalizeInstall(); + notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => { + setTimeout(() => redirectToApp(), TIMINGS?.redirectDelay ?? 0); + }); + return; + } + if (event === SSE_EVENTS.ERROR) { + if (data?.message) { + const existingError = Array.from(progressState.values()).some((state) => state?.status === STATUS.ERROR); + if (data.step || !existingError) { + let targetStep = data.step; + if (!targetStep) { + for (const candidate of INSTALLATION_STEPS) { + const state = progressState.get(candidate.id); + if (!state || state.status !== STATUS.COMPLETED) { + targetStep = candidate.id; + break; + } + } + } + handleProgress({ + step: targetStep || STEP_IDS.CONFIG_FILES, + status: STATUS.ERROR, + message: data.message, + details: data.details + }); + } + } + finalizeInstall(); + } + }); + if (activeInstall && !activeInstall.completed) { + // Stream ended without a "done" event (e.g. browser + // throttled the background tab). Check if we're done. + checkAllCompleted(); + if (!activeInstall?.completed) { + startPolling(); + } + } + } catch (error) { + if (!activeInstall || activeInstall.controller.signal.aborted) { + return; + } + startPolling(); + } + }; + + const resumeInstall = async (installId) => { + const snapshot = await fetchInstallStatus(installId); + if (!snapshot) return false; + activeInstall = { + installId, + controller: new AbortController(), + lastEventAt: Date.now(), + pollTimer: null, + fallbackTimer: null, + completed: false + }; + applySnapshot(snapshot); + startPolling(); + setUnloadGuard(true); + return true; + }; + + const resetProgressFrom = (stepId) => { + const index = INSTALLATION_STEPS.findIndex((step) => step.id === stepId); + if (index === -1) return; + INSTALLATION_STEPS.slice(index).forEach((step) => { + progressState.delete(step.id); + const row = rowsById.get(step.id); + if (row && row.parentNode) { + row.parentNode.removeChild(row); + } + rowsById.delete(step.id); + }); + }; + + const retryInstallStep = (stepId) => { + if (!stepId) return; + if (activeInstall?.controller) { + activeInstall.controller.abort(); + } + if (activeInstall?.pollTimer) { + clearInterval(activeInstall.pollTimer); + } + if (activeInstall?.fallbackTimer) { + clearTimeout(activeInstall.fallbackTimer); + } + + resetProgressFrom(stepId); + + const step = getStepDefinition(stepId); + progressState.set(stepId, { + status: STATUS.IN_PROGRESS, + message: step?.inProgress || 'Retrying...' + }); + + const row = ensureRow(step); + if (row) { + updateInstallRow(row, step, STATUS.IN_PROGRESS, step.inProgress || 'Retrying...'); + } + + const installId = activeInstall?.installId || getInstallLock?.()?.installId || generateInstallId(); + storeInstallId?.(installId); + startInstallStream(installId, { retryStep: stepId }); + }; + + list.addEventListener('click', (event) => { + const consoleButton = event.target.closest('[data-install-console]'); + const retryButton = event.target.closest('[data-install-retry]'); + + if (consoleButton) { + redirectToApp(); + return; + } + + if (retryButton) { + const row = retryButton.closest('.install-row'); + const stepId = row?.dataset.step; + retryInstallStep(stepId); + } + }); + + // When the user switches back to this tab, check if installation + // finished while the tab was in the background. + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && activeInstall && !activeInstall.completed) { + checkAllCompleted(); + } + }); + + const lock = getInstallLock?.(); + const existingInstallId = lock?.installId || getStoredInstallId?.(); + if (existingInstallId) { + resumeInstall(existingInstallId).then((resumed) => { + if (!resumed) { + clearInstallId?.(); + clearInstallLock?.(); + const newInstallId = generateInstallId(); + storeInstallId?.(newInstallId); + startInstallStream(newInstallId); + } + }); + } else { + const newInstallId = generateInstallId(); + storeInstallId?.(newInstallId); + startInstallStream(newInstallId); + } + }; + + window.InstallerStepsProgress = { + initStep5, + cleanupInstallFlow, + validateInstallRequest + }; +})(); diff --git a/app/views/install/installer/js/modules/state.js b/app/views/install/installer/js/modules/state.js new file mode 100644 index 0000000000..9fcf9969a8 --- /dev/null +++ b/app/views/install/installer/js/modules/state.js @@ -0,0 +1,158 @@ +(() => { + const { + getBodyDataset, + isUpgradeMode, + getLockedDatabase + } = window.InstallerStepsContext || {}; + + const INSTALL_LOCK_KEY = 'appwrite-install-lock'; + const INSTALL_ID_KEY = 'appwrite-install-id'; + + const formState = { + appDomain: null, + database: null, + httpPort: null, + httpsPort: null, + emailCertificates: null, + opensslKey: null, + assistantOpenAIKey: null, + accountEmail: null, + accountPassword: null + }; + + const dispatchStateChange = (key) => { + if (!key || typeof document === 'undefined') return; + try { + document.dispatchEvent(new CustomEvent('installer:state-change', { + detail: { key, value: formState[key] } + })); + } catch (error) {} + }; + + const setStateIfEmpty = (key, value) => { + if (value === null || value === undefined || value === '') return; + if (formState[key] === null || formState[key] === undefined || formState[key] === '') { + formState[key] = value; + } + }; + + const applyBodyDefaults = () => { + const data = getBodyDataset?.() ?? {}; + setStateIfEmpty('appDomain', data.defaultAppDomain); + setStateIfEmpty('httpPort', data.defaultHttpPort); + setStateIfEmpty('httpsPort', data.defaultHttpsPort); + setStateIfEmpty('emailCertificates', data.defaultEmailCertificates); + setStateIfEmpty('opensslKey', data.defaultSecretKey); + setStateIfEmpty('assistantOpenAIKey', data.defaultAssistantOpenaiKey); + if (data.lockedDatabase) { + formState.database = data.lockedDatabase; + } + if (!isUpgradeMode?.()) { + setStateIfEmpty('database', data.defaultDatabase); + } + }; + + const getInstallLock = () => { + try { + const raw = sessionStorage.getItem(INSTALL_LOCK_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return null; + return parsed; + } catch (error) { + return null; + } + }; + + const setInstallLock = (installId, payload) => { + const sanitizedPayload = payload ? { ...payload } : null; + if (sanitizedPayload) { + delete sanitizedPayload.opensslKey; + delete sanitizedPayload.accountPassword; + delete sanitizedPayload.assistantOpenAIKey; + } + const lock = { + installId, + payload: sanitizedPayload, + startedAt: Date.now() + }; + try { + sessionStorage.setItem(INSTALL_LOCK_KEY, JSON.stringify(lock)); + } catch (error) {} + if (document.body) { + document.body.dataset.installLocked = 'true'; + } + return lock; + }; + + const clearInstallLock = () => { + try { + sessionStorage.removeItem(INSTALL_LOCK_KEY); + } catch (error) {} + if (document.body) { + delete document.body.dataset.installLocked; + } + }; + + const isInstallLocked = () => { + return Boolean(getInstallLock()); + }; + + const syncInstallLockFlag = () => { + if (!document.body) return; + if (isInstallLocked()) { + document.body.dataset.installLocked = 'true'; + } else { + delete document.body.dataset.installLocked; + } + }; + + const applyLockPayload = () => { + const lock = getInstallLock(); + if (!lock || !lock.payload) return; + const payload = lock.payload; + setStateIfEmpty('appDomain', payload.appDomain); + setStateIfEmpty('database', payload.database); + setStateIfEmpty('httpPort', payload.httpPort); + setStateIfEmpty('httpsPort', payload.httpsPort); + setStateIfEmpty('emailCertificates', payload.emailCertificates); + setStateIfEmpty('accountEmail', payload.accountEmail); + }; + + const getStoredInstallId = () => { + try { + return sessionStorage.getItem(INSTALL_ID_KEY); + } catch (error) { + return null; + } + }; + + const storeInstallId = (installId) => { + try { + sessionStorage.setItem(INSTALL_ID_KEY, installId); + } catch (error) {} + }; + + const clearInstallId = () => { + try { + sessionStorage.removeItem(INSTALL_ID_KEY); + } catch (error) {} + }; + + window.InstallerStepsState = { + formState, + dispatchStateChange, + setStateIfEmpty, + applyBodyDefaults, + applyLockPayload, + getInstallLock, + setInstallLock, + clearInstallLock, + isInstallLocked, + syncInstallLockFlag, + getStoredInstallId, + storeInstallId, + clearInstallId, + getLockedDatabase: getLockedDatabase || (() => '') + }; +})(); diff --git a/app/views/install/installer/js/modules/toast.js b/app/views/install/installer/js/modules/toast.js new file mode 100644 index 0000000000..5a8eb55f41 --- /dev/null +++ b/app/views/install/installer/js/modules/toast.js @@ -0,0 +1,95 @@ +(() => { + const TOAST_STACK_ID = 'installer-toast-stack'; + const DEFAULT_TIMEOUT = 5000; + const MAX_TOASTS = 3; + const ICONS = { + error: '', + close: '' + }; + + const getStack = () => document.getElementById(TOAST_STACK_ID); + + const dismissToast = (toast) => { + if (!toast) return; + if (toast.classList.contains('is-leaving')) return; + toast.classList.add('is-leaving'); + const remove = () => toast.remove(); + toast.addEventListener('transitionend', remove, { once: true }); + setTimeout(remove, 450); + }; + + const showToast = ({ + title = '', + description = '', + status = 'error', + dismissible = true, + timeout = DEFAULT_TIMEOUT + } = {}) => { + const stack = getStack(); + if (!stack) return; + const visibleToasts = Array.from( + stack.querySelectorAll('.installer-toast:not(.is-leaving)') + ); + if (visibleToasts.length >= MAX_TOASTS) { + dismissToast(visibleToasts[0]); + } + + const toast = document.createElement('div'); + toast.className = 'installer-toast is-entering'; + toast.dataset.status = status; + toast.setAttribute('role', status === 'error' ? 'alert' : 'status'); + + const content = document.createElement('div'); + content.className = 'installer-toast-content'; + + const icon = document.createElement('span'); + icon.className = 'installer-toast-icon'; + icon.dataset.status = status; + icon.innerHTML = ICONS.error; + content.appendChild(icon); + + const body = document.createElement('section'); + body.className = 'installer-toast-body'; + + if (title) { + const titleNode = document.createElement('p'); + titleNode.className = 'installer-toast-title typography-text-m-500'; + titleNode.textContent = title; + body.appendChild(titleNode); + } + + if (description) { + const descNode = document.createElement('p'); + descNode.className = 'installer-toast-description typography-text-m-400'; + descNode.textContent = description; + body.appendChild(descNode); + } + + content.appendChild(body); + toast.appendChild(content); + + if (dismissible) { + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'installer-toast-close'; + close.setAttribute('aria-label', 'Dismiss notification'); + close.innerHTML = ICONS.close; + close.addEventListener('click', () => dismissToast(toast)); + toast.appendChild(close); + } + + stack.appendChild(toast); + toast.getBoundingClientRect(); + requestAnimationFrame(() => { + toast.classList.remove('is-entering'); + }); + + if (timeout > 0) { + setTimeout(() => dismissToast(toast), timeout); + } + }; + + window.InstallerToast = Object.freeze({ + showToast + }); +})(); diff --git a/app/views/install/installer/js/modules/ui.js b/app/views/install/installer/js/modules/ui.js new file mode 100644 index 0000000000..bde4cb7c44 --- /dev/null +++ b/app/views/install/installer/js/modules/ui.js @@ -0,0 +1,281 @@ +(() => { + const { TIMINGS } = window.InstallerStepsContext || {}; + const { formState } = window.InstallerStepsState || {}; + + const clearFieldErrors = (root) => { + if (!root) return; + root.querySelectorAll('.field-error').forEach((node) => { + node.classList.remove('is-visible'); + }); + root.querySelectorAll('.input-field.is-error, .input-action.is-error').forEach((node) => { + node.classList.remove('is-error'); + }); + root.querySelectorAll('.field-helper').forEach((helper) => { + helper.style.display = ''; + }); + }; + + const setFieldError = (input, message) => { + if (!input) return; + const group = input.closest('.input-group'); + if (!group) return; + let error = group.querySelector('.field-error'); + let errorText = error?.querySelector('.field-error-text'); + const hasSameMessage = Boolean(errorText && errorText.textContent === message); + const alreadyVisible = Boolean(error && error.classList.contains('is-visible')); + + if (hasSameMessage && alreadyVisible) { + return; + } + + if (!error) { + const template = document.getElementById('field-error-template'); + if (template && template.content) { + const fragment = template.content.cloneNode(true); + error = fragment.querySelector('.field-error'); + group.appendChild(fragment); + } + errorText = error?.querySelector('.field-error-text'); + } + if (errorText) { + errorText.textContent = message; + } + + if (!alreadyVisible) { + requestAnimationFrame(() => { + error.classList.add('is-visible'); + }); + } + + input.classList.add('is-error'); + const actionWrapper = input.closest('.input-action'); + if (actionWrapper) { + actionWrapper.classList.add('is-error'); + } + const helper = group.querySelector('.field-helper'); + if (helper) { + helper.style.display = 'none'; + } + }; + + const bindErrorClear = (input) => { + if (!input) return; + const handler = () => { + const group = input.closest('.input-group'); + const error = group?.querySelector('.field-error'); + if (error) { + error.classList.remove('is-visible'); + } + input.classList.remove('is-error'); + const actionWrapper = input.closest('.input-action'); + if (actionWrapper) { + actionWrapper.classList.remove('is-error'); + } + const helper = group?.querySelector('.field-helper'); + if (helper) { + helper.style.display = ''; + } + }; + input.addEventListener('input', handler); + input.addEventListener('change', handler); + }; + + const toDatabaseLabel = (value) => { + if (!value) return ''; + const lower = value.toLowerCase(); + if (lower === 'mariadb') return 'MariaDB'; + if (lower === 'postgresql') return 'PostgreSQL'; + return 'MongoDB'; + }; + + const updateDatabaseSelection = (radio, root) => { + if (!radio || !root) return; + const allOptions = root.querySelectorAll('.selector-card'); + allOptions.forEach((option) => option.classList.remove('selected')); + const selectedOption = radio.closest('.selector-card'); + if (selectedOption) { + selectedOption.classList.add('selected'); + } + }; + + const syncResetButton = (input, button) => { + const defaultValue = input.dataset.default ?? ''; + button.disabled = input.value === defaultValue; + }; + + const setupResetButtons = (root) => { + const inputs = root.querySelectorAll('.input-field[data-default]'); + inputs.forEach((input) => { + const button = root.querySelector(`[data-reset-target="${input.id}"]`); + if (!button) return; + + syncResetButton(input, button); + + input.addEventListener('input', () => syncResetButton(input, button)); + button.addEventListener('click', () => { + input.value = input.dataset.default ?? ''; + syncResetButton(input, button); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + }); + }; + + const toggleAccordion = (button) => { + const content = button.nextElementSibling; + const icon = button.querySelector('.accordion-chevron'); + const isOpen = button.classList.contains('is-open'); + + button.classList.toggle('is-open', !isOpen); + button.setAttribute('aria-expanded', String(!isOpen)); + + if (content) { + if (!isOpen) { + content.classList.add('open'); + content.style.maxHeight = `${content.scrollHeight}px`; + } else { + content.style.maxHeight = '0px'; + content.classList.remove('open'); + } + } + + if (icon) { + icon.setAttribute('data-open', String(!isOpen)); + } + }; + + const setupAccordion = (root) => { + const buttons = root.querySelectorAll('.accordion-toggle'); + buttons.forEach((button) => { + button.addEventListener('click', () => toggleAccordion(button)); + }); + }; + + const openAccordion = (root) => { + const toggle = root.querySelector('.accordion-toggle'); + const content = root.querySelector('.accordion-content'); + if (!toggle || !content) return; + if (!toggle.classList.contains('is-open')) { + toggle.classList.add('is-open'); + toggle.setAttribute('aria-expanded', 'true'); + content.classList.add('open'); + content.style.maxHeight = `${content.scrollHeight}px`; + } + }; + + const disableControls = (root) => { + const inputs = root.querySelectorAll('input, select, textarea'); + inputs.forEach((input) => { + if (input.type === 'radio' || input.type === 'checkbox') { + input.disabled = true; + } else { + input.readOnly = true; + input.setAttribute('aria-disabled', 'true'); + } + }); + + const buttons = root.querySelectorAll('button'); + buttons.forEach((button) => { + if (button.matches('[data-copy-target]')) return; + button.disabled = true; + button.setAttribute('aria-disabled', 'true'); + }); + + root.classList.add('is-locked'); + }; + + const generateSecretKey = () => { + const array = new Uint8Array(32); + window.crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); + }; + + const copyToClipboard = (value, input) => { + if (!value) return; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(value); + return; + } + if (input) { + input.select(); + document.execCommand('copy'); + input.setSelectionRange(0, 0); + return; + } + const textArea = document.createElement('textarea'); + textArea.value = value; + textArea.style.position = 'fixed'; + textArea.style.top = '-9999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand('copy'); + } catch (error) {} finally { + document.body.removeChild(textArea); + } + }; + + const setTooltipText = (wrapper, message) => { + if (!wrapper) return; + const tooltip = wrapper.querySelector('.tooltip'); + if (tooltip && message) { + tooltip.textContent = message; + } + }; + + const resetTooltipText = (wrapper) => { + if (!wrapper) return; + const defaultText = wrapper.dataset.tooltipDefault; + if (!defaultText) return; + setTooltipText(wrapper, defaultText); + }; + + const updateReviewSummary = (root) => { + if (!root) return; + const valueNodes = root.querySelectorAll('[data-review-value]'); + valueNodes.forEach((node) => { + const key = node.dataset.reviewValue; + if (!key) return; + let value = formState?.[key]; + if (key === 'database') { + value = toDatabaseLabel(formState?.database); + } + if (value) { + node.textContent = value; + } + }); + + const badge = root.querySelector('[data-review-badge]'); + if (badge) { + const hasKey = Boolean((formState?.opensslKey || '').trim()); + badge.textContent = hasKey ? 'Generated' : 'Missing'; + badge.classList.remove('badge-success', 'badge-warning'); + badge.classList.add(hasKey ? 'badge-success' : 'badge-warning'); + } + + const assistantBadge = root.querySelector('[data-review-assistant-badge]'); + if (assistantBadge) { + const hasAssistantKey = Boolean((formState?.assistantOpenAIKey || '').trim()); + assistantBadge.textContent = hasAssistantKey ? 'Enabled' : 'Disabled'; + assistantBadge.classList.remove('badge-success', 'badge-neutral'); + assistantBadge.classList.add(hasAssistantKey ? 'badge-success' : 'badge-neutral'); + } + }; + + window.InstallerStepsUI = { + clearFieldErrors, + setFieldError, + bindErrorClear, + toDatabaseLabel, + updateDatabaseSelection, + setupResetButtons, + setupAccordion, + openAccordion, + disableControls, + generateSecretKey, + copyToClipboard, + setTooltipText, + resetTooltipText, + updateReviewSummary + }; +})(); diff --git a/app/views/install/installer/js/modules/validation.js b/app/views/install/installer/js/modules/validation.js new file mode 100644 index 0000000000..13ab60ef4e --- /dev/null +++ b/app/views/install/installer/js/modules/validation.js @@ -0,0 +1,117 @@ +(() => { + const isValidEmail = (email) => { + if (!email) return false; + const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return re.test(email); + }; + + const isValidPort = (value) => { + const numeric = Number(value); + if (!Number.isInteger(numeric)) return false; + return numeric >= 1 && numeric <= 65535; + }; + + const isValidPassword = (value) => { + if (!value) return false; + return value.length >= 8 && /\S/.test(value); + }; + + const isValidIPv4 = (host) => { + if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return false; + return host.split('.').every((part) => { + const num = Number(part); + return num >= 0 && num <= 255; + }); + }; + + const isValidIPv6 = (host) => { + try { + const url = new URL(`http://[${host}]`); + return url.hostname.toLowerCase() === host.toLowerCase(); + } catch (error) { + return false; + } + }; + + const isValidHostnameLabel = (label) => { + if (!label || label.length > 63) return false; + if (label.startsWith('-') || label.endsWith('-')) return false; + return /^[a-zA-Z0-9-]+$/.test(label); + }; + + const isValidDomain = (host) => { + if (host.length > 253) return false; + const labels = host.split('.'); + return labels.every((label) => isValidHostnameLabel(label)); + }; + + const isValidHost = (host) => { + if (host === 'localhost') return true; + if (isValidIPv4(host)) return true; + if (isValidIPv6(host)) return true; + return isValidDomain(host); + }; + + const isValidHostnameInput = (value) => { + if (!value) return false; + const trimmed = value.trim(); + if (!trimmed) return false; + + let host = trimmed; + let port = null; + + if (trimmed.startsWith('[')) { + const match = trimmed.match(/^\[([^\]]+)\](?::(\d+))?$/); + if (!match) return false; + host = match[1] || ''; + port = match[2] || null; + } else { + const parts = trimmed.split(':'); + if (parts.length > 2) return false; + if (parts.length === 2) { + host = parts[0]; + port = parts[1]; + } + } + + if (port !== null && port !== '' && !isValidPort(port)) { + return false; + } + + return isValidHost(host); + }; + + const extractHostname = (value) => { + if (!value) return ''; + const trimmed = value.trim(); + if (trimmed.startsWith('[')) { + const end = trimmed.indexOf(']'); + if (end !== -1) { + return trimmed.slice(1, end); + } + return trimmed; + } + const colonCount = (trimmed.match(/:/g) || []).length; + if (colonCount === 1) { + return trimmed.split(':')[0]; + } + return trimmed; + }; + + const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']); + + const isLocalHost = (host) => { + if (!host) return false; + const normalized = host.toLowerCase(); + return LOCAL_HOSTS.has(normalized); + }; + + window.InstallerStepsValidation = { + isValidEmail, + isValidPort, + isValidPassword, + isValidHostnameInput, + extractHostname, + isLocalHost + }; +})(); diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js new file mode 100644 index 0000000000..2a71d075cc --- /dev/null +++ b/app/views/install/installer/js/steps.js @@ -0,0 +1,451 @@ +(() => { + const Context = window.InstallerStepsContext || {}; + const State = window.InstallerStepsState || {}; + const Validation = window.InstallerStepsValidation || {}; + const UI = window.InstallerStepsUI || {}; + const Progress = window.InstallerStepsProgress || {}; + const Tooltips = window.InstallerTooltips || null; + + const { + INSTALLATION_STEPS, + clampStep, + isUpgradeMode, + getEnabledDatabases + } = Context; + + const { + formState, + dispatchStateChange, + applyBodyDefaults, + applyLockPayload, + clearInstallLock, + clearInstallId, + isInstallLocked, + syncInstallLockFlag, + getInstallLock, + getLockedDatabase + } = State; + + const { + isValidEmail, + isValidPort, + isValidHostnameInput, + isValidPassword + } = Validation; + + const { + clearFieldErrors, + setFieldError, + bindErrorClear, + updateDatabaseSelection, + setupResetButtons, + setupAccordion, + openAccordion, + disableControls, + generateSecretKey, + copyToClipboard, + setTooltipText, + resetTooltipText, + updateReviewSummary + } = UI; + + let reviewListener = null; + + const bindInputToState = (input, key) => { + if (!input) return; + const update = () => { + formState[key] = input.value; + dispatchStateChange?.(key); + }; + input.addEventListener('input', update); + input.addEventListener('change', update); + update(); + }; + + const lockDatabaseSelection = (root, lockedDatabase) => { + if (lockedDatabase) { + const radios = root.querySelectorAll('input[name="database"]'); + radios.forEach((radio) => { + const isLockedChoice = radio.value === lockedDatabase; + const card = radio.closest('.selector-card'); + radio.disabled = !isLockedChoice; + if (card) { + card.classList.toggle('is-disabled', !isLockedChoice); + } + if (isLockedChoice) { + radio.checked = true; + updateDatabaseSelection?.(radio, root); + } + }); + } + }; + + const applyEnabledDatabases = (root) => { + const enabled = getEnabledDatabases?.() || []; + const radios = root.querySelectorAll('input[name="database"]'); + radios.forEach((radio) => { + if (!enabled.includes(radio.value)) { + const card = radio.closest('.selector-card'); + if (card) { + card.remove(); + } + } + }); + }; + + const bindDatabaseSelection = (root) => { + const radios = root.querySelectorAll('input[name="database"]'); + radios.forEach((radio) => { + radio.addEventListener('change', () => { + formState.database = radio.value; + updateDatabaseSelection?.(radio, root); + }); + }); + }; + + const hydrateStep1State = (root) => { + State.setStateIfEmpty?.('appDomain', root.querySelector('#hostname')?.value); + State.setStateIfEmpty?.('database', root.querySelector('input[name="database"]:checked')?.value); + State.setStateIfEmpty?.('httpPort', root.querySelector('#http-port')?.value); + State.setStateIfEmpty?.('httpsPort', root.querySelector('#https-port')?.value); + State.setStateIfEmpty?.('emailCertificates', root.querySelector('#ssl-email')?.value); + State.setStateIfEmpty?.('assistantOpenAIKey', root.querySelector('#assistant-openai-key')?.value); + }; + + const applyStep1State = (root) => { + const hostname = root.querySelector('#hostname'); + if (hostname && formState.appDomain) hostname.value = formState.appDomain; + + const httpPort = root.querySelector('#http-port'); + if (httpPort && formState.httpPort) httpPort.value = formState.httpPort; + + const httpsPort = root.querySelector('#https-port'); + if (httpsPort && formState.httpsPort) httpsPort.value = formState.httpsPort; + + const sslEmail = root.querySelector('#ssl-email'); + if (sslEmail && formState.emailCertificates) sslEmail.value = formState.emailCertificates; + + const assistantKey = root.querySelector('#assistant-openai-key'); + if (assistantKey && formState.assistantOpenAIKey) { + assistantKey.value = formState.assistantOpenAIKey; + } + + if (formState.database) { + const radio = root.querySelector(`input[name="database"][value="${formState.database}"]`); + if (radio) { + radio.checked = true; + updateDatabaseSelection?.(radio, root); + } + } + }; + + const initStep1 = (root) => { + if (!root) return; + syncInstallLockFlag?.(); + applyLockPayload?.(); + applyBodyDefaults?.(); + hydrateStep1State(root); + applyStep1State(root); + + if (isInstallLocked?.()) { + openAccordion?.(root); + disableControls?.(root); + return; + } + + applyEnabledDatabases(root); + + const lockedDatabase = getLockedDatabase?.() || ''; + if (lockedDatabase) { + lockDatabaseSelection(root, lockedDatabase); + } else { + bindDatabaseSelection(root); + } + + const hostname = root.querySelector('#hostname'); + const httpPort = root.querySelector('#http-port'); + const httpsPort = root.querySelector('#https-port'); + const sslEmail = root.querySelector('#ssl-email'); + const assistantKey = root.querySelector('#assistant-openai-key'); + + bindInputToState(hostname, 'appDomain'); + bindInputToState(httpPort, 'httpPort'); + bindInputToState(httpsPort, 'httpsPort'); + bindInputToState(sslEmail, 'emailCertificates'); + bindInputToState(assistantKey, 'assistantOpenAIKey'); + + bindErrorClear?.(hostname); + bindErrorClear?.(httpPort); + bindErrorClear?.(httpsPort); + bindErrorClear?.(sslEmail); + bindErrorClear?.(assistantKey); + + const checked = root.querySelector('input[name="database"]:checked'); + if (checked) { + updateDatabaseSelection?.(checked, root); + } + + setupResetButtons?.(root); + setupAccordion?.(root); + Tooltips?.setupTooltipPortals?.(root); + }; + + const hydrateStep2State = (root) => { + const value = root.querySelector('#secret-key')?.value; + if (formState.opensslKey) return; + if (value) { + formState.opensslKey = value; + } + }; + + const applyStep2State = (root) => { + const input = root.querySelector('#secret-key'); + if (input && formState.opensslKey) { + input.value = formState.opensslKey; + } + }; + + const initStep2 = (root) => { + if (!root) return; + syncInstallLockFlag?.(); + applyLockPayload?.(); + applyBodyDefaults?.(); + hydrateStep2State(root); + if (!isUpgradeMode?.() && (!formState.opensslKey || !formState.opensslKey.trim())) { + formState.opensslKey = generateSecretKey?.(); + dispatchStateChange?.('opensslKey'); + } + applyStep2State(root); + + const input = root.querySelector('#secret-key'); + if (input) { + bindInputToState(input, 'opensslKey'); + bindErrorClear?.(input); + } + + const copyButton = root.querySelector('[data-copy-target]'); + const tooltipWrapper = copyButton?.closest('.tooltip-wrapper'); + + if (tooltipWrapper) { + tooltipWrapper.addEventListener('mouseenter', () => resetTooltipText?.(tooltipWrapper)); + tooltipWrapper.addEventListener('focusin', () => resetTooltipText?.(tooltipWrapper)); + } + + if (copyButton) { + copyButton.addEventListener('click', () => { + const targetId = copyButton.getAttribute('data-copy-target'); + const targetInput = targetId ? root.querySelector(`#${targetId}`) : null; + const value = targetInput?.value || ''; + copyToClipboard?.(value, targetInput); + copyButton.blur(); + + if (tooltipWrapper) { + const successText = tooltipWrapper.dataset.tooltipSuccess || 'Copied'; + setTooltipText?.(tooltipWrapper, successText); + } + }); + } + + const regenerateButton = root.querySelector('[data-regenerate-target]'); + if (regenerateButton && !isInstallLocked?.()) { + regenerateButton.addEventListener('click', () => { + const targetId = regenerateButton.getAttribute('data-regenerate-target'); + const targetInput = targetId ? root.querySelector(`#${targetId}`) : null; + if (!targetInput) return; + regenerateButton.classList.remove('is-rotating'); + void regenerateButton.offsetWidth; + regenerateButton.classList.add('is-rotating'); + const handleAnimationEnd = () => { + regenerateButton.classList.remove('is-rotating'); + }; + regenerateButton.addEventListener('animationend', handleAnimationEnd, { once: true }); + targetInput.value = generateSecretKey?.(); + targetInput.dispatchEvent(new Event('input', { bubbles: true })); + }); + } + + if (isInstallLocked?.()) { + disableControls?.(root); + } + }; + + const hydrateStep3State = (root) => { + State.setStateIfEmpty?.('accountEmail', root.querySelector('#account-email')?.value); + State.setStateIfEmpty?.('accountPassword', root.querySelector('#account-password')?.value); + }; + + const applyStep3State = (root) => { + const email = root.querySelector('#account-email'); + if (email && formState.accountEmail) email.value = formState.accountEmail; + + const password = root.querySelector('#account-password'); + if (password && formState.accountPassword) password.value = formState.accountPassword; + }; + + const initStep3 = (root) => { + if (!root) return; + syncInstallLockFlag?.(); + applyLockPayload?.(); + applyBodyDefaults?.(); + hydrateStep3State(root); + applyStep3State(root); + + const email = root.querySelector('#account-email'); + const password = root.querySelector('#account-password'); + const passwordToggle = root.querySelector('[data-password-toggle="account-password"]'); + + bindInputToState(email, 'accountEmail'); + bindInputToState(password, 'accountPassword'); + + bindErrorClear?.(email); + bindErrorClear?.(password); + + if (password && passwordToggle) { + passwordToggle.addEventListener('click', () => { + const isVisible = passwordToggle.classList.toggle('is-visible'); + password.type = isVisible ? 'text' : 'password'; + passwordToggle.setAttribute('aria-label', isVisible ? 'Hide password' : 'Show password'); + }); + } + + if (isInstallLocked?.()) { + disableControls?.(root); + } + }; + + const initStep4 = (root) => { + if (!root) return; + syncInstallLockFlag?.(); + applyLockPayload?.(); + applyBodyDefaults?.(); + updateReviewSummary?.(root); + if (reviewListener) { + document.removeEventListener('installer:state-change', reviewListener); + } + reviewListener = () => updateReviewSummary?.(root); + document.addEventListener('installer:state-change', reviewListener); + if (isInstallLocked?.()) { + disableControls?.(root); + } + }; + + const initStep = (step, container) => { + if (!container) return; + const root = container.querySelector('.step-layout') || container; + const normalized = clampStep?.(step) ?? 1; + Tooltips?.cleanupTooltipPortals?.(); + if (normalized !== 4 && reviewListener) { + document.removeEventListener('installer:state-change', reviewListener); + reviewListener = null; + } + if (normalized !== 5) { + Progress.cleanupInstallFlow?.(); + } + if (normalized === 1) initStep1(root); + if (normalized === 2) initStep2(root); + if (normalized === 3) initStep3(root); + if (normalized === 4) initStep4(root); + if (normalized === 5) Progress.initStep5?.(root); + }; + + window.InstallerSteps = { + initStep1, + initStep2, + initStep3, + initStep4, + initStep5: Progress.initStep5, + installationSteps: INSTALLATION_STEPS || [], + isInstallLocked, + getInstallLock, + clearInstallLock, + initStep, + validateStep: (step, container) => { + const root = container?.querySelector('.step-layout') || container; + const normalized = clampStep?.(step) ?? 1; + if (normalized === 1) { + clearFieldErrors?.(root); + let valid = true; + const hostname = root?.querySelector('#hostname'); + const httpPort = root?.querySelector('#http-port'); + const httpsPort = root?.querySelector('#https-port'); + const sslEmail = root?.querySelector('#ssl-email'); + + if (!hostname || !hostname.value.trim()) { + setFieldError?.(hostname, 'Please enter your Appwrite hostname'); + valid = false; + } else if (!isValidHostnameInput?.(hostname.value.trim())) { + setFieldError?.(hostname, 'Please enter a valid hostname'); + valid = false; + } + + const parsePort = (input, label) => { + const value = input?.value; + if (!value || !isValidPort?.(value)) { + setFieldError?.(input, `Please enter a valid ${label} port (1-65535)`); + return false; + } + return true; + }; + + if (!parsePort(httpPort, 'HTTP')) valid = false; + if (!parsePort(httpsPort, 'HTTPS')) valid = false; + + if (!sslEmail || !sslEmail.value.trim()) { + setFieldError?.(sslEmail, 'Please enter an email address for SSL certificates'); + valid = false; + } else if (!isValidEmail?.(sslEmail.value.trim())) { + setFieldError?.(sslEmail, 'Please enter a valid email address'); + valid = false; + } + + if (!valid) { + openAccordion?.(root); + } + + return valid; + } + + if (normalized === 2) { + clearFieldErrors?.(root); + const secretKey = root?.querySelector('#secret-key'); + const secretValue = secretKey?.value.trim() || ''; + if (!secretKey || !secretValue) { + setFieldError?.(secretKey, 'Please enter or generate a secret API key'); + return false; + } + if (secretValue.length > 64) { + setFieldError?.(secretKey, 'Secret API key must be 1-64 characters'); + return false; + } + } + + if (normalized === 3) { + clearFieldErrors?.(root); + let valid = true; + const email = root?.querySelector('#account-email'); + const password = root?.querySelector('#account-password'); + + if (!email || !email.value.trim()) { + setFieldError?.(email, 'This field is required'); + valid = false; + } else if (!isValidEmail?.(email.value.trim())) { + setFieldError?.(email, 'Please enter a valid email address'); + valid = false; + } + + const passwordValue = password?.value ?? ''; + if (!password || !/\S/.test(passwordValue)) { + setFieldError?.(password, 'This field is required'); + valid = false; + } else if (!isValidPassword?.(passwordValue)) { + setFieldError?.(password, 'Password must be at least 8 characters long'); + valid = false; + } + + return valid; + } + + return true; + } + }; +})(); diff --git a/app/views/install/installer/js/tooltips.js b/app/views/install/installer/js/tooltips.js new file mode 100644 index 0000000000..96b637b9a3 --- /dev/null +++ b/app/views/install/installer/js/tooltips.js @@ -0,0 +1,77 @@ +(() => { + const tooltipPortals = new Set(); + + const positionTooltipPortal = (tooltip, anchor) => { + if (!tooltip || !anchor) return; + const rect = anchor.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + const offset = Number(tooltip.dataset.tooltipOffset || 6); + const padding = 8; + let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); + left = Math.max(padding, Math.min(left, window.innerWidth - tooltipRect.width - padding)); + const top = rect.bottom + offset; + tooltip.style.left = `${left}px`; + tooltip.style.top = `${top}px`; + }; + + const attachTooltipPortal = (tooltip) => { + if (!tooltip || tooltip.dataset.portalInitialized === 'true') return; + const anchor = tooltip.parentElement; + if (!anchor) return; + + tooltip.dataset.portalInitialized = 'true'; + tooltip.classList.add('tooltip-portal'); + document.body.appendChild(tooltip); + + const show = () => { + tooltip.classList.add('is-open'); + positionTooltipPortal(tooltip, anchor); + }; + const hide = () => { + tooltip.classList.remove('is-open'); + }; + const refresh = () => { + if (tooltip.classList.contains('is-open')) { + positionTooltipPortal(tooltip, anchor); + } + }; + + anchor.addEventListener('mouseenter', show); + anchor.addEventListener('mouseleave', hide); + anchor.addEventListener('focusin', show); + anchor.addEventListener('focusout', hide); + window.addEventListener('scroll', refresh, true); + window.addEventListener('resize', refresh); + + tooltipPortals.add({ + tooltip, + cleanup: () => { + anchor.removeEventListener('mouseenter', show); + anchor.removeEventListener('mouseleave', hide); + anchor.removeEventListener('focusin', show); + anchor.removeEventListener('focusout', hide); + window.removeEventListener('scroll', refresh, true); + window.removeEventListener('resize', refresh); + if (tooltip.parentElement) { + tooltip.parentElement.removeChild(tooltip); + } + } + }); + }; + + const setupTooltipPortals = (root) => { + if (!root) return; + const portalTooltips = root.querySelectorAll('.tooltip[data-tooltip-portal]'); + portalTooltips.forEach((tooltip) => attachTooltipPortal(tooltip)); + }; + + const cleanupTooltipPortals = () => { + tooltipPortals.forEach((entry) => entry.cleanup()); + tooltipPortals.clear(); + }; + + window.InstallerTooltips = { + setupTooltipPortals, + cleanupTooltipPortals + }; +})(); diff --git a/app/views/install/installer/templates/steps/step-1.phtml b/app/views/install/installer/templates/steps/step-1.phtml new file mode 100644 index 0000000000..8f4a726158 --- /dev/null +++ b/app/views/install/installer/templates/steps/step-1.phtml @@ -0,0 +1,194 @@ + +
+
+
+

+

+ +

+
+ +
+
+ + +
+ +
+ +
+ + + + + + + + + + + +
+
+ +
+ +
+
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+
+
+
+
+ +
diff --git a/app/views/install/installer/templates/steps/step-2.phtml b/app/views/install/installer/templates/steps/step-2.phtml new file mode 100644 index 0000000000..9b7eed11b1 --- /dev/null +++ b/app/views/install/installer/templates/steps/step-2.phtml @@ -0,0 +1,58 @@ + +
+
+
+

Secure your app

+

+ +

+
+ +
+
+
+ +
+
Save your key
+
You won't be able to see this key again. Copy it somewhere safe before continuing.
+
+
+
+ +
+
+ +
+ +
+ + + Copy + +
+
+
+ +
+
+
+ +
diff --git a/app/views/install/installer/templates/steps/step-3.phtml b/app/views/install/installer/templates/steps/step-3.phtml new file mode 100644 index 0000000000..8eaf0e044b --- /dev/null +++ b/app/views/install/installer/templates/steps/step-3.phtml @@ -0,0 +1,61 @@ + +
+
+
+

Create your account

+

+ Set up the email and password for your Appwrite account. You can use these + credentials to sign in later. +

+
+ +
+
+ + +
+ +
+ +
+ +
+ +
+
+
+ + + + Password must be at least 8 characters long +
+
+
+
+
diff --git a/app/views/install/installer/templates/steps/step-4.phtml b/app/views/install/installer/templates/steps/step-4.phtml new file mode 100644 index 0000000000..07dc865257 --- /dev/null +++ b/app/views/install/installer/templates/steps/step-4.phtml @@ -0,0 +1,76 @@ + 'MariaDB', + 'postgresql' => 'PostgreSQL', + default => 'MongoDB', +}; +$badgeLabel = $defaultSecretKey !== '' ? 'Generated' : 'Missing'; +$badgeClass = $defaultSecretKey !== '' ? 'badge-success' : 'badge-warning'; +?> +
+
+
+

+

+ +

+
+ +
+
+
+
+
+ +
+
Hostname
+
+
+
+ +
+
Database
+
+
+
+ +
+
HTTP port
+
+
+
+ +
+
HTTPS port
+
+
+
+ +
+
SSL certificate email
+
+
+ Disabled +
Appwrite Assistant
+
+
+ + + +
Secret API key
+
+
+
+
+
+ +
diff --git a/app/views/install/installer/templates/steps/step-5.phtml b/app/views/install/installer/templates/steps/step-5.phtml new file mode 100644 index 0000000000..8fa810b259 --- /dev/null +++ b/app/views/install/installer/templates/steps/step-5.phtml @@ -0,0 +1,53 @@ + +
+
+
+
+
+ +
+
+
+
+
+ + +
diff --git a/app/worker.php b/app/worker.php index 2ee1803ddc..840231f16c 100644 --- a/app/worker.php +++ b/app/worker.php @@ -13,11 +13,12 @@ use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; use Appwrite\Event\Migration; +use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; use Appwrite\Event\Screenshot; -use Appwrite\Event\StatsUsage; use Appwrite\Event\Webhook; use Appwrite\Platform\Appwrite; +use Appwrite\Usage\Context; use Appwrite\Utopia\Database\Documents\User; use Executor\Executor; use Swoole\Runtime; @@ -42,6 +43,7 @@ use Utopia\Pools\Group; use Utopia\Queue\Broker\Pool as BrokerPool; use Utopia\Queue\Message; use Utopia\Queue\Publisher; +use Utopia\Queue\Queue; use Utopia\Queue\Server; use Utopia\Registry\Registry; use Utopia\Storage\Device\Telemetry as TelemetryDevice; @@ -58,7 +60,8 @@ Server::setResource('register', fn () => $register); Server::setResource('authorization', function () { $authorization = new Authorization(); $authorization->disable(); - return $authorization; + + return $authorization; }, []); Server::setResource('dbForPlatform', function (Cache $cache, Registry $register, Authorization $authorization) { @@ -70,9 +73,7 @@ Server::setResource('dbForPlatform', function (Cache $cache, Registry $register, ->setDatabase(APP_DATABASE) ->setAuthorization($authorization) ->setNamespace('_console') - ->setDocumentType('users', User::class) - ; - + ->setDocumentType('users', User::class); return $dbForPlatform; }, ['cache', 'register', 'authorization']); @@ -111,7 +112,7 @@ Server::setResource('dbForProject', function (Cache $cache, Registry $register, if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) - ->setTenant((int)$project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -151,7 +152,7 @@ Server::setResource('getProjectDB', function (Group $pools, Database $dbForPlatf if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) - ->setTenant((int)$project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -173,7 +174,7 @@ Server::setResource('getProjectDB', function (Group $pools, Database $dbForPlatf if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) - ->setTenant((int)$project->getSequence()) + ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -193,9 +194,10 @@ Server::setResource('getProjectDB', function (Group $pools, Database $dbForPlatf Server::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) { $database = null; + return function (?Document $project = null) use ($pools, $cache, $database, $authorization) { if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') { - $database->setTenant((int)$project->getSequence()); + $database->setTenant($project->getSequence()); return $database; } @@ -210,9 +212,8 @@ Server::setResource('getLogsDB', function (Group $pools, Cache $cache, Authoriza ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER) ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES_WORKER); - // set tenant if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') { - $database->setTenant((int)$project->getSequence()); + $database->setTenant($project->getSequence()); } return $database; @@ -227,6 +228,7 @@ Server::setResource('auditRetention', function (Document $project) { if ($project->getId() === 'console') { return DateTime::addSeconds(new \DateTime(), -1 * System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE', 15778800)); // 6 months } + return DateTime::addSeconds(new \DateTime(), -1 * System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT', 1209600)); // 14 days }, ['project']); @@ -252,7 +254,7 @@ Server::setResource('redis', function () { $pass = System::getEnv('_APP_REDIS_PASS', ''); $redis = new \Redis(); - @$redis->pconnect($host, (int)$port); + @$redis->pconnect($host, (int) $port); if ($pass) { $redis->auth($pass); } @@ -269,7 +271,6 @@ Server::setResource('timelimit', function (\Redis $redis) { Server::setResource('log', fn () => new Log()); - Server::setResource('publisher', function (Group $pools) { return new BrokerPool(publisher: $pools->get('publisher')); }, ['pools']); @@ -286,10 +287,6 @@ Server::setResource('publisherMigrations', function (BrokerPool $publisher) { return $publisher; }, ['publisher']); -Server::setResource('publisherStatsUsage', function (BrokerPool $publisher) { - return $publisher; -}, ['publisher']); - Server::setResource('publisherMessaging', function (BrokerPool $publisher) { return $publisher; }, ['publisher']); @@ -310,9 +307,13 @@ Server::setResource('consumerStatsUsage', function (BrokerPool $consumer) { return $consumer; }, ['consumer']); -Server::setResource('queueForStatsUsage', function (Publisher $publisher) { - return new StatsUsage($publisher); -}, ['publisher']); +Server::setResource('usage', function () { + return new Context(); +}, []); +Server::setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher( + $publisher, + new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME)) +), ['publisher']); Server::setResource('queueForDatabase', function (Publisher $publisher) { return new EventDatabase($publisher); @@ -354,7 +355,6 @@ Server::setResource('queueForFunctions', function (Publisher $publisher) { return new Func($publisher); }, ['publisher']); - Server::setResource('queueForRealtime', function () { return new Realtime(); }, []); @@ -484,11 +484,13 @@ Server::setResource('getAudit', function (Database $dbForPlatform, callable $get return function (Document $project) use ($dbForPlatform, $getProjectDB) { if ($project->isEmpty() || $project->getId() === 'console') { $adapter = new AdapterDatabase($dbForPlatform); + return new UtopiaAudit($adapter); } $dbForProject = $getProjectDB($project); $adapter = new AdapterDatabase($dbForProject); + return new UtopiaAudit($adapter); }; }, ['dbForPlatform', 'getProjectDB']); @@ -505,7 +507,7 @@ $pools = $register->get('pools'); $platform = new Appwrite(); $args = $platform->getEnv('argv'); -if (!isset($args[1])) { +if (! isset($args[1])) { Console::error('Missing worker name'); Console::exit(1); } @@ -530,10 +532,10 @@ try { 'workersNum' => System::getEnv('_APP_WORKERS_NUM', 1), 'connection' => $pools->get('consumer')->pop()->getResource(), 'workerName' => strtolower($workerName) ?? null, - 'queueName' => $queueName + 'queueName' => $queueName, ]); } catch (\Throwable $e) { - Console::error($e->getMessage() . ', File: ' . $e->getFile() . ', Line: ' . $e->getLine()); + Console::error($e->getMessage() . ', File: ' . $e->getFile() . ', Line: ' . $e->getLine()); } $worker = $platform->getWorker(); @@ -550,11 +552,11 @@ $worker ->inject('pools') ->inject('project') ->inject('authorization') - ->action(function (Throwable $error, ?Logger $logger, Log $log, Group $pools, Document $project, Authorization $authorization) use ($worker, $queueName) { + ->action(function (Throwable $error, ?Logger $logger, Log $log, Group $pools, Document $project, Authorization $authorization) use ($queueName) { $version = System::getEnv('_APP_VERSION', 'UNKNOWN'); if ($logger) { - $log->setNamespace("appwrite-worker"); + $log->setNamespace('appwrite-worker'); $log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname())); $log->setVersion($version); $log->setType(Log::TYPE_ERROR); diff --git a/composer.json b/composer.json index abe51e500b..2448a55522 100644 --- a/composer.json +++ b/composer.json @@ -13,8 +13,11 @@ "test": "vendor/bin/phpunit", "lint": "vendor/bin/pint --test --config pint.json", "format": "vendor/bin/pint --config pint.json", + "analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G", "bench": "vendor/bin/phpbench run --report=benchmark", - "check": "./vendor/bin/phpstan analyse -c phpstan.neon" + "check": "./vendor/bin/phpstan analyse -c phpstan.neon", + "installer:clean": "php src/Appwrite/Platform/Installer/Server.php --clean", + "installer:dev": "docker compose build && composer installer:clean && php src/Appwrite/Platform/Installer/Server.php --docker" }, "autoload": { "psr-4": { @@ -76,6 +79,7 @@ "utopia-php/span": "1.1.*", "utopia-php/preloader": "0.2.*", "utopia-php/queue": "0.15.*", + "utopia-php/servers": "0.2.5", "utopia-php/registry": "0.5.*", "utopia-php/storage": "1.0.*", "utopia-php/system": "0.10.*", @@ -90,20 +94,32 @@ "spomky-labs/otphp": "11.*", "webonyx/graphql-php": "14.11.*", "league/csv": "9.14.*", - "enshrined/svg-sanitize": "0.22.*" + "enshrined/svg-sanitize": "0.22.*", + "utopia-php/di": "0.1.0" }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/utopia-php/database" + } + ], "require-dev": { "ext-fileinfo": "*", "appwrite/sdk-generator": "*", "brianium/paratest": "7.*", "phpunit/phpunit": "12.*", "swoole/ide-helper": "6.*", - "phpstan/phpstan": "1.12.*", + "phpstan/phpstan": "^2.0", "textalk/websocket": "1.5.*", "czproject/git-php": "4.*", - "laravel/pint": "1.*", - "phpbench/phpbench": "1.*" + "laravel/pint": "1.*" }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/utopia-php/database" + } + ], "provide": { "ext-phpiredis": "*" }, diff --git a/composer.lock b/composer.lock index 0e25d3bc5f..50b317c811 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": "b99693284208ff3d006260a089a4f7b9", + "content-hash": "1404c8821e43b3fe92e06a8ed658ed26", "packages": [ { "name": "adhocore/jwt", @@ -686,23 +686,23 @@ }, { "name": "google/protobuf", - "version": "v4.33.5", + "version": "v4.33.6", "source": { "type": "git", "url": "https://github.com/protocolbuffers/protobuf-php.git", - "reference": "ebe8010a61b2ae0cff0d246fe1c4d44e9f7dfa6d" + "reference": "84b008c23915ed94536737eae46f41ba3bccfe67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/ebe8010a61b2ae0cff0d246fe1c4d44e9f7dfa6d", - "reference": "ebe8010a61b2ae0cff0d246fe1c4d44e9f7dfa6d", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/84b008c23915ed94536737eae46f41ba3bccfe67", + "reference": "84b008c23915ed94536737eae46f41ba3bccfe67", "shasum": "" }, "require": { "php": ">=8.1.0" }, "require-dev": { - "phpunit/phpunit": ">=5.0.0 <8.5.27" + "phpunit/phpunit": ">=10.5.62 <11.0.0" }, "suggest": { "ext-bcmath": "Need to support JSON deserialization" @@ -724,9 +724,9 @@ "proto" ], "support": { - "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.5" + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.6" }, - "time": "2026-01-29T20:49:00+00:00" + "time": "2026-03-18T17:32:05+00:00" }, { "name": "halaxa/json-machine", @@ -1996,16 +1996,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.49", + "version": "3.0.50", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9" + "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/6233a1e12584754e6b5daa69fe1289b47775c1b9", - "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", + "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", "shasum": "" }, "require": { @@ -2086,7 +2086,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.49" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.50" }, "funding": [ { @@ -2102,7 +2102,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T09:17:28+00:00" + "time": "2026-03-19T02:57:58+00:00" }, { "name": "psr/clock", @@ -3606,16 +3606,16 @@ }, { "name": "utopia-php/cache", - "version": "1.0.0", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "7068870c086a6aea16173563a26b93ef3e408439" + "reference": "05ceba981436a4022553f7aaa2a05fa049d0f71c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/7068870c086a6aea16173563a26b93ef3e408439", - "reference": "7068870c086a6aea16173563a26b93ef3e408439", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/05ceba981436a4022553f7aaa2a05fa049d0f71c", + "reference": "05ceba981436a4022553f7aaa2a05fa049d0f71c", "shasum": "" }, "require": { @@ -3652,9 +3652,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/1.0.0" + "source": "https://github.com/utopia-php/cache/tree/1.0.1" }, - "time": "2026-01-28T10:55:44+00:00" + "time": "2026-03-12T03:39:09+00:00" }, { "name": "utopia-php/cli", @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.8", + "version": "5.3.17", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "4920bb60afb98d4bd81f4d331765716ae1d40255" + "reference": "cff2b6ed63d3291b74110d086e16ff089fe05993" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/4920bb60afb98d4bd81f4d331765716ae1d40255", - "reference": "4920bb60afb98d4bd81f4d331765716ae1d40255", + "url": "https://api.github.com/repos/utopia-php/database/zipball/cff2b6ed63d3291b74110d086e16ff089fe05993", + "reference": "cff2b6ed63d3291b74110d086e16ff089fe05993", "shasum": "" }, "require": { @@ -3868,9 +3868,10 @@ "ext-pdo": "*", "php": ">=8.4", "utopia-php/cache": "1.*", - "utopia-php/framework": "0.33.*", + "utopia-php/console": "0.1.*", "utopia-php/mongo": "1.*", - "utopia-php/pools": "1.*" + "utopia-php/pools": "1.*", + "utopia-php/validators": "0.2.*" }, "require-dev": { "fakerphp/faker": "1.23.*", @@ -3880,7 +3881,7 @@ "phpunit/phpunit": "9.*", "rregeer/phpunit-coverage-check": "0.3.*", "swoole/ide-helper": "5.1.3", - "utopia-php/cli": "0.14.*" + "utopia-php/cli": "0.22.*" }, "type": "library", "autoload": { @@ -3888,7 +3889,38 @@ "Utopia\\Database\\": "src/Database" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "psr-4": { + "Tests\\E2E\\": "tests/e2e", + "Tests\\Unit\\": "tests/unit" + } + }, + "scripts": { + "build": [ + "Composer\\Config::disableProcessTimeout", + "docker compose build" + ], + "start": [ + "Composer\\Config::disableProcessTimeout", + "docker compose up -d" + ], + "test": [ + "Composer\\Config::disableProcessTimeout", + "docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml" + ], + "lint": [ + "php -d memory_limit=2G ./vendor/bin/pint --test" + ], + "format": [ + "php -d memory_limit=2G ./vendor/bin/pint" + ], + "check": [ + "./vendor/bin/phpstan analyse --level 7 src tests --memory-limit 2G" + ], + "coverage": [ + "./vendor/bin/coverage-check ./tmp/clover.xml 90" + ] + }, "license": [ "MIT" ], @@ -3901,10 +3933,10 @@ "utopia" ], "support": { - "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.8" + "source": "https://github.com/utopia-php/database/tree/5.3.17", + "issues": "https://github.com/utopia-php/database/issues" }, - "time": "2026-03-11T01:03:34+00:00" + "time": "2026-03-20T01:18:52+00:00" }, { "name": "utopia-php/detector", @@ -4058,16 +4090,16 @@ }, { "name": "utopia-php/domains", - "version": "1.0.2", + "version": "1.0.5", "source": { "type": "git", "url": "https://github.com/utopia-php/domains.git", - "reference": "b4896a6746f0fbe29dfd5e32f7790bd94c1af1e6" + "reference": "0edf6bb2b07f30db849a267027077bf5abb994c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/domains/zipball/b4896a6746f0fbe29dfd5e32f7790bd94c1af1e6", - "reference": "b4896a6746f0fbe29dfd5e32f7790bd94c1af1e6", + "url": "https://api.github.com/repos/utopia-php/domains/zipball/0edf6bb2b07f30db849a267027077bf5abb994c6", + "reference": "0edf6bb2b07f30db849a267027077bf5abb994c6", "shasum": "" }, "require": { @@ -4114,9 +4146,9 @@ ], "support": { "issues": "https://github.com/utopia-php/domains/issues", - "source": "https://github.com/utopia-php/domains/tree/1.0.2" + "source": "https://github.com/utopia-php/domains/tree/1.0.5" }, - "time": "2026-02-25T08:18:25+00:00" + "time": "2026-03-03T09:20:50+00:00" }, { "name": "utopia-php/dsn", @@ -4167,16 +4199,16 @@ }, { "name": "utopia-php/emails", - "version": "0.6.8", + "version": "0.6.9", "source": { "type": "git", "url": "https://github.com/utopia-php/emails.git", - "reference": "25dfcd46ed47b862d2a7e7c98d92a3a4680b6f1b" + "reference": "3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/emails/zipball/25dfcd46ed47b862d2a7e7c98d92a3a4680b6f1b", - "reference": "25dfcd46ed47b862d2a7e7c98d92a3a4680b6f1b", + "url": "https://api.github.com/repos/utopia-php/emails/zipball/3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf", + "reference": "3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf", "shasum": "" }, "require": { @@ -4222,9 +4254,9 @@ ], "support": { "issues": "https://github.com/utopia-php/emails/issues", - "source": "https://github.com/utopia-php/emails/tree/0.6.8" + "source": "https://github.com/utopia-php/emails/tree/0.6.9" }, - "time": "2026-02-09T12:31:56+00:00" + "time": "2026-03-14T13:52:56+00:00" }, { "name": "utopia-php/fetch", @@ -4572,16 +4604,16 @@ }, { "name": "utopia-php/mongo", - "version": "1.0.0", + "version": "1.0.2", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "45bedf36c2c946ec7a0a3e59b9f12f772de0b01d" + "reference": "677a21c53f7a1316c528b4b45b3fce886cee7223" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/45bedf36c2c946ec7a0a3e59b9f12f772de0b01d", - "reference": "45bedf36c2c946ec7a0a3e59b9f12f772de0b01d", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/677a21c53f7a1316c528b4b45b3fce886cee7223", + "reference": "677a21c53f7a1316c528b4b45b3fce886cee7223", "shasum": "" }, "require": { @@ -4627,9 +4659,9 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/1.0.0" + "source": "https://github.com/utopia-php/mongo/tree/1.0.2" }, - "time": "2026-02-12T05:54:06+00:00" + "time": "2026-03-18T02:45:50+00:00" }, { "name": "utopia-php/platform", @@ -5059,16 +5091,16 @@ }, { "name": "utopia-php/system", - "version": "0.10.0", + "version": "0.10.1", "source": { "type": "git", "url": "https://github.com/utopia-php/system.git", - "reference": "6441a9c180958a373e5ddb330264dd638539dfdb" + "reference": "7c1669533bb9c285de19191270c8c1439161a78a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/system/zipball/6441a9c180958a373e5ddb330264dd638539dfdb", - "reference": "6441a9c180958a373e5ddb330264dd638539dfdb", + "url": "https://api.github.com/repos/utopia-php/system/zipball/7c1669533bb9c285de19191270c8c1439161a78a", + "reference": "7c1669533bb9c285de19191270c8c1439161a78a", "shasum": "" }, "require": { @@ -5109,9 +5141,9 @@ ], "support": { "issues": "https://github.com/utopia-php/system/issues", - "source": "https://github.com/utopia-php/system/tree/0.10.0" + "source": "https://github.com/utopia-php/system/tree/0.10.1" }, - "time": "2025-10-15T19:12:00+00:00" + "time": "2026-03-15T21:07:41+00:00" }, { "name": "utopia-php/telemetry", @@ -5215,29 +5247,28 @@ }, { "name": "utopia-php/vcs", - "version": "2.0.0", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "058049326e04a2a0c2f0ce8ad00c7e84825aba14" + "reference": "5769679308bad498f2777547d48ab332166c4c0b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/058049326e04a2a0c2f0ce8ad00c7e84825aba14", - "reference": "058049326e04a2a0c2f0ce8ad00c7e84825aba14", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/5769679308bad498f2777547d48ab332166c4c0b", + "reference": "5769679308bad498f2777547d48ab332166c4c0b", "shasum": "" }, "require": { "adhocore/jwt": "^1.1", "php": ">=8.0", - "utopia-php/cache": "1.0.*", - "utopia-php/framework": "0.*.*", - "utopia-php/system": "0.10.*" + "utopia-php/cache": "1.0.*" }, "require-dev": { "laravel/pint": "1.*.*", "phpstan/phpstan": "1.*.*", - "phpunit/phpunit": "^9.4" + "phpunit/phpunit": "^9.4", + "utopia-php/system": "0.10.*" }, "type": "library", "autoload": { @@ -5258,9 +5289,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/2.0.0" + "source": "https://github.com/utopia-php/vcs/tree/2.0.2" }, - "time": "2026-02-25T11:36:45+00:00" + "time": "2026-03-13T15:25:16+00:00" }, { "name": "utopia-php/websocket", @@ -5438,16 +5469,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.11.1", + "version": "1.11.11", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "6ff411f26f2750eea05c7598c14bb3a2ada898cb" + "reference": "cfc37c85161a5515af4cd2f9885a811f51a2483a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/6ff411f26f2750eea05c7598c14bb3a2ada898cb", - "reference": "6ff411f26f2750eea05c7598c14bb3a2ada898cb", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/cfc37c85161a5515af4cd2f9885a811f51a2483a", + "reference": "cfc37c85161a5515af4cd2f9885a811f51a2483a", "shasum": "" }, "require": { @@ -5483,22 +5514,22 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.11.1" + "source": "https://github.com/appwrite/sdk-generator/tree/1.11.11" }, - "time": "2026-02-25T07:15:19+00:00" + "time": "2026-03-19T16:21:03+00:00" }, { "name": "brianium/paratest", - "version": "v7.19.0", + "version": "v7.19.2", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6" + "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6", - "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", + "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", "shasum": "" }, "require": { @@ -5512,9 +5543,9 @@ "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", "phpunit/php-file-iterator": "^6.0.1 || ^7", "phpunit/php-timer": "^8 || ^9", - "phpunit/phpunit": "^12.5.9 || ^13", + "phpunit/phpunit": "^12.5.14 || ^13.0.5", "sebastian/environment": "^8.0.3 || ^9", - "symfony/console": "^7.4.4 || ^8.0.4", + "symfony/console": "^7.4.7 || ^8.0.7", "symfony/process": "^7.4.5 || ^8.0.5" }, "require-dev": { @@ -5522,11 +5553,11 @@ "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.38", - "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.12", - "phpstan/phpstan-strict-rules": "^2.0.8", - "symfony/filesystem": "^7.4.0 || ^8.0.1" + "phpstan/phpstan": "^2.1.40", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "symfony/filesystem": "^7.4.6 || ^8.0.6" }, "bin": [ "bin/paratest", @@ -5566,7 +5597,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.19.0" + "source": "https://github.com/paratestphp/paratest/tree/v7.19.2" }, "funding": [ { @@ -5578,7 +5609,7 @@ "type": "paypal" } ], - "time": "2026-02-06T10:53:26+00:00" + "time": "2026-03-09T14:33:17+00:00" }, { "name": "czproject/git-php", @@ -5644,160 +5675,6 @@ ], "time": "2025-11-10T07:24:07+00:00" }, - { - "name": "doctrine/annotations", - "version": "2.0.2", - "source": { - "type": "git", - "url": "https://github.com/doctrine/annotations.git", - "reference": "901c2ee5d26eb64ff43c47976e114bf00843acf7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/annotations/zipball/901c2ee5d26eb64ff43c47976e114bf00843acf7", - "reference": "901c2ee5d26eb64ff43c47976e114bf00843acf7", - "shasum": "" - }, - "require": { - "doctrine/lexer": "^2 || ^3", - "ext-tokenizer": "*", - "php": "^7.2 || ^8.0", - "psr/cache": "^1 || ^2 || ^3" - }, - "require-dev": { - "doctrine/cache": "^2.0", - "doctrine/coding-standard": "^10", - "phpstan/phpstan": "^1.10.28", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "symfony/cache": "^5.4 || ^6.4 || ^7", - "vimeo/psalm": "^4.30 || ^5.14" - }, - "suggest": { - "php": "PHP 8.0 or higher comes with attributes, a native replacement for annotations" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Common\\Annotations\\": "lib/Doctrine/Common/Annotations" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "Docblock Annotations Parser", - "homepage": "https://www.doctrine-project.org/projects/annotations.html", - "keywords": [ - "annotations", - "docblock", - "parser" - ], - "support": { - "issues": "https://github.com/doctrine/annotations/issues", - "source": "https://github.com/doctrine/annotations/tree/2.0.2" - }, - "abandoned": true, - "time": "2024-09-05T10:17:24+00:00" - }, - { - "name": "doctrine/lexer", - "version": "3.0.1", - "source": { - "type": "git", - "url": "https://github.com/doctrine/lexer.git", - "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", - "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "doctrine/coding-standard": "^12", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.5", - "psalm/plugin-phpunit": "^0.18.3", - "vimeo/psalm": "^5.21" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Common\\Lexer\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", - "homepage": "https://www.doctrine-project.org/projects/lexer.html", - "keywords": [ - "annotations", - "docblock", - "lexer", - "parser", - "php" - ], - "support": { - "issues": "https://github.com/doctrine/lexer/issues", - "source": "https://github.com/doctrine/lexer/tree/3.0.1" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", - "type": "tidelift" - } - ], - "time": "2024-02-05T11:56:58+00:00" - }, { "name": "fidry/cpu-core-counter", "version": "1.3.0", @@ -5921,16 +5798,16 @@ }, { "name": "laravel/pint", - "version": "v1.27.1", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5" + "reference": "bdec963f53172c5e36330f3a400604c69bf02d39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5", - "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5", + "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39", + "reference": "bdec963f53172c5e36330f3a400604c69bf02d39", "shasum": "" }, "require": { @@ -5941,13 +5818,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.93.1", - "illuminate/view": "^12.51.0", - "larastan/larastan": "^3.9.2", + "friendsofphp/php-cs-fixer": "^3.94.2", + "illuminate/view": "^12.54.1", + "larastan/larastan": "^3.9.3", "laravel-zero/framework": "^12.0.5", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3.3", - "pestphp/pest": "^3.8.5" + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.0" }, "bin": [ "builds/pint" @@ -5984,7 +5862,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-02-10T20:00:20+00:00" + "time": "2026-03-12T15:51:39+00:00" }, { "name": "matthiasmullie/minify", @@ -6345,166 +6223,17 @@ }, "time": "2022-02-21T01:04:05+00:00" }, - { - "name": "phpbench/container", - "version": "2.2.3", - "source": { - "type": "git", - "url": "https://github.com/phpbench/container.git", - "reference": "0c7b2d36c1ea53fe27302fb8873ded7172047196" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpbench/container/zipball/0c7b2d36c1ea53fe27302fb8873ded7172047196", - "reference": "0c7b2d36c1ea53fe27302fb8873ded7172047196", - "shasum": "" - }, - "require": { - "psr/container": "^1.0|^2.0", - "symfony/options-resolver": "^4.2 || ^5.0 || ^6.0 || ^7.0 || ^8.0" - }, - "require-dev": { - "php-cs-fixer/shim": "^3.89", - "phpstan/phpstan": "^0.12.52", - "phpunit/phpunit": "^8" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "PhpBench\\DependencyInjection\\": "lib/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Daniel Leech", - "email": "daniel@dantleech.com" - } - ], - "description": "Simple, configurable, service container.", - "support": { - "issues": "https://github.com/phpbench/container/issues", - "source": "https://github.com/phpbench/container/tree/2.2.3" - }, - "time": "2025-11-06T09:05:13+00:00" - }, - { - "name": "phpbench/phpbench", - "version": "1.4.3", - "source": { - "type": "git", - "url": "https://github.com/phpbench/phpbench.git", - "reference": "b641dde59d969ea42eed70a39f9b51950bc96878" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpbench/phpbench/zipball/b641dde59d969ea42eed70a39f9b51950bc96878", - "reference": "b641dde59d969ea42eed70a39f9b51950bc96878", - "shasum": "" - }, - "require": { - "doctrine/annotations": "^2.0", - "ext-dom": "*", - "ext-json": "*", - "ext-pcre": "*", - "ext-reflection": "*", - "ext-spl": "*", - "ext-tokenizer": "*", - "php": "^8.1", - "phpbench/container": "^2.2", - "psr/log": "^1.1 || ^2.0 || ^3.0", - "seld/jsonlint": "^1.1", - "symfony/console": "^6.1 || ^7.0 || ^8.0", - "symfony/filesystem": "^6.1 || ^7.0 || ^8.0", - "symfony/finder": "^6.1 || ^7.0 || ^8.0", - "symfony/options-resolver": "^6.1 || ^7.0 || ^8.0", - "symfony/process": "^6.1 || ^7.0 || ^8.0", - "webmozart/glob": "^4.6" - }, - "require-dev": { - "dantleech/invoke": "^2.0", - "ergebnis/composer-normalize": "^2.39", - "jangregor/phpstan-prophecy": "^1.0", - "php-cs-fixer/shim": "^3.9", - "phpspec/prophecy": "^1.22", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^10.4 || ^11.0", - "rector/rector": "^1.2", - "symfony/error-handler": "^6.1 || ^7.0 || ^8.0", - "symfony/var-dumper": "^6.1 || ^7.0 || ^8.0" - }, - "suggest": { - "ext-xdebug": "For Xdebug profiling extension." - }, - "bin": [ - "bin/phpbench" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.2-dev" - } - }, - "autoload": { - "files": [ - "lib/Report/Func/functions.php" - ], - "psr-4": { - "PhpBench\\": "lib/", - "PhpBench\\Extensions\\XDebug\\": "extensions/xdebug/lib/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Daniel Leech", - "email": "daniel@dantleech.com" - } - ], - "description": "PHP Benchmarking Framework", - "keywords": [ - "benchmarking", - "optimization", - "performance", - "profiling", - "testing" - ], - "support": { - "issues": "https://github.com/phpbench/phpbench/issues", - "source": "https://github.com/phpbench/phpbench/tree/1.4.3" - }, - "funding": [ - { - "url": "https://github.com/dantleech", - "type": "github" - } - ], - "time": "2025-11-06T19:07:31+00:00" - }, { "name": "phpstan/phpstan", - "version": "1.12.32", + "version": "2.1.42", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8", - "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1279e1ce86ba768f0780c9d889852b4e02ff40d0", + "reference": "1279e1ce86ba768f0780c9d889852b4e02ff40d0", "shasum": "" }, "require": { - "php": "^7.2|^8.0" + "php": "^7.4|^8.0" }, "conflict": { "phpstan/phpstan-shim": "*" @@ -6545,7 +6274,7 @@ "type": "github" } ], - "time": "2025-09-30T10:16:31+00:00" + "time": "2026-03-17T14:58:32+00:00" }, { "name": "phpunit/php-code-coverage", @@ -6999,55 +6728,6 @@ ], "time": "2026-02-18T12:38:40+00:00" }, - { - "name": "psr/cache", - "version": "3.0.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Cache\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for caching libraries", - "keywords": [ - "cache", - "psr", - "psr-6" - ], - "support": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" - }, - "time": "2021-02-03T23:26:27+00:00" - }, { "name": "sebastian/cli-parser", "version": "4.2.0", @@ -7336,16 +7016,16 @@ }, { "name": "sebastian/environment", - "version": "8.0.3", + "version": "8.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" + "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", + "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", "shasum": "" }, "require": { @@ -7388,7 +7068,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3" + "source": "https://github.com/sebastianbergmann/environment/tree/8.0.4" }, "funding": [ { @@ -7408,7 +7088,7 @@ "type": "tidelift" } ], - "time": "2025-08-12T14:11:56+00:00" + "time": "2026-03-15T07:05:40+00:00" }, { "name": "sebastian/exporter", @@ -7945,70 +7625,6 @@ ], "time": "2025-02-07T05:00:38+00:00" }, - { - "name": "seld/jsonlint", - "version": "1.11.0", - "source": { - "type": "git", - "url": "https://github.com/Seldaek/jsonlint.git", - "reference": "1748aaf847fc731cfad7725aec413ee46f0cc3a2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/1748aaf847fc731cfad7725aec413ee46f0cc3a2", - "reference": "1748aaf847fc731cfad7725aec413ee46f0cc3a2", - "shasum": "" - }, - "require": { - "php": "^5.3 || ^7.0 || ^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^1.11", - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^8.5.13" - }, - "bin": [ - "bin/jsonlint" - ], - "type": "library", - "autoload": { - "psr-4": { - "Seld\\JsonLint\\": "src/Seld/JsonLint/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "https://seld.be" - } - ], - "description": "JSON Linter", - "keywords": [ - "json", - "linter", - "parser", - "validator" - ], - "support": { - "issues": "https://github.com/Seldaek/jsonlint/issues", - "source": "https://github.com/Seldaek/jsonlint/tree/1.11.0" - }, - "funding": [ - { - "url": "https://github.com/Seldaek", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/seld/jsonlint", - "type": "tidelift" - } - ], - "time": "2024-07-11T14:55:45+00:00" - }, { "name": "staabm/side-effects-detector", "version": "1.0.5", @@ -8095,16 +7711,16 @@ }, { "name": "symfony/console", - "version": "v8.0.4", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b" + "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/ace03c4cf9805080ff40cbeec69fca180c339a3b", - "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b", + "url": "https://api.github.com/repos/symfony/console/zipball/15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", + "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", "shasum": "" }, "require": { @@ -8161,7 +7777,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.4" + "source": "https://github.com/symfony/console/tree/v8.0.7" }, "funding": [ { @@ -8181,216 +7797,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T13:06:50+00:00" - }, - { - "name": "symfony/filesystem", - "version": "v8.0.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d", - "shasum": "" - }, - "require": { - "php": ">=8.4", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" - }, - "require-dev": { - "symfony/process": "^7.4|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Filesystem\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides basic utilities for the filesystem", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.1" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "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-12-01T09:13:36+00:00" - }, - { - "name": "symfony/finder", - "version": "v8.0.5", - "source": { - "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8bd576e97c67d45941365bf824e18dc8538e6eb0", - "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "require-dev": { - "symfony/filesystem": "^7.4|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Finder\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Finds files and directories via an intuitive fluent interface", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/finder/tree/v8.0.5" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "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": "2026-01-26T15:08:38+00:00" - }, - { - "name": "symfony/options-resolver", - "version": "v8.0.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/options-resolver.git", - "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7", - "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7", - "shasum": "" - }, - "require": { - "php": ">=8.4", - "symfony/deprecation-contracts": "^2.5|^3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\OptionsResolver\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides an improved replacement for the array_replace PHP function", - "homepage": "https://symfony.com", - "keywords": [ - "config", - "configuration", - "options" - ], - "support": { - "source": "https://github.com/symfony/options-resolver/tree/v8.0.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "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-11-12T15:55:31+00:00" + "time": "2026-03-06T14:06:22+00:00" }, { "name": "symfony/polyfill-ctype", @@ -8789,16 +8196,16 @@ }, { "name": "symfony/string", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "758b372d6882506821ed666032e43020c4f57194" + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", - "reference": "758b372d6882506821ed666032e43020c4f57194", + "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", "shasum": "" }, "require": { @@ -8855,7 +8262,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.4" + "source": "https://github.com/symfony/string/tree/v8.0.6" }, "funding": [ { @@ -8875,7 +8282,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-02-09T10:14:57+00:00" }, { "name": "textalk/websocket", @@ -9054,55 +8461,6 @@ } ], "time": "2024-11-07T12:36:22+00:00" - }, - { - "name": "webmozart/glob", - "version": "4.7.0", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/glob.git", - "reference": "8a2842112d6916e61e0e15e316465b611f3abc17" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/glob/zipball/8a2842112d6916e61e0e15e316465b611f3abc17", - "reference": "8a2842112d6916e61e0e15e316465b611f3abc17", - "shasum": "" - }, - "require": { - "php": "^7.3 || ^8.0.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.5", - "symfony/filesystem": "^5.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.1-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Glob\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "A PHP implementation of Ant's glob.", - "support": { - "issues": "https://github.com/webmozarts/glob/issues", - "source": "https://github.com/webmozarts/glob/tree/4.7.0" - }, - "time": "2024-03-07T20:33:40+00:00" } ], "aliases": [], diff --git a/docker-compose.yml b/docker-compose.yml index c0b0560a7f..7d64dfa867 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1122,6 +1122,7 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS + - _APP_DATABASE_SHARED_TABLES appwrite-task-scheduler-messages: entrypoint: schedule-messages @@ -1300,7 +1301,7 @@ services: networks: - appwrite volumes: - - appwrite-postgresql:/var/lib/postgresql/data:rw + - appwrite-postgresql:/var/lib/postgresql:rw ports: - "5432:5432" environment: diff --git a/docs/references/users/update-user-impersonator.md b/docs/references/users/update-user-impersonator.md new file mode 100644 index 0000000000..c20e9de29f --- /dev/null +++ b/docs/references/users/update-user-impersonator.md @@ -0,0 +1 @@ +Enable or disable whether a user can impersonate other users. When impersonation headers are used, the request runs as the target user for API behavior, while internal audit logs still attribute the action to the original impersonator and store the impersonated target details only in internal audit payload data. diff --git a/docs/sdks/python/GETTING_STARTED.md b/docs/sdks/python/GETTING_STARTED.md index 2732ef8483..09569d3eb0 100644 --- a/docs/sdks/python/GETTING_STARTED.md +++ b/docs/sdks/python/GETTING_STARTED.md @@ -20,10 +20,16 @@ client = Client() ### Make Your First Request Once your SDK object is set, create any of the Appwrite service objects and choose any request to send. Full documentation for any service method you would like to use can be found in your SDK documentation or in the [API References](https://appwrite.io/docs) section. +All service methods return typed Pydantic models, so you can access response fields as attributes: + ```python users = Users(client) -result = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien") +user = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien") + +print(user.name) # "Walter O'Brien" +print(user.email) # "email@example.com" +print(user.id) # The generated user ID ``` ### Full Example @@ -43,7 +49,60 @@ client = Client() users = Users(client) -result = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien") +user = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien") + +print(user.name) # Access fields as attributes +print(user.to_dict()) # Convert to dictionary if needed +``` + +### Type Safety with Models + +The Appwrite Python SDK provides type safety when working with database rows through generic methods. Methods like `get_row`, `list_rows`, and others accept a `model_type` parameter that allows you to specify your custom Pydantic model for full type safety. + +```python +from pydantic import BaseModel +from datetime import datetime +from typing import Optional +from appwrite.client import Client +from appwrite.services.tables_db import TablesDB + +# Define your custom model matching your table schema +class Post(BaseModel): + postId: int + authorId: int + title: str + content: str + createdAt: datetime + updatedAt: datetime + isPublished: bool + excerpt: Optional[str] = None + +client = Client() +# ... configure your client ... + +tables_db = TablesDB(client) + +# Fetch a single row with type safety +row = tables_db.get_row( + database_id="your-database-id", + table_id="your-table-id", + row_id="your-row-id", + model_type=Post # Pass your custom model type +) + +print(row.data.title) # Fully typed - IDE autocomplete works +print(row.data.postId) # int type, not Any +print(row.data.createdAt) # datetime type + +# Fetch multiple rows with type safety +result = tables_db.list_rows( + database_id="your-database-id", + table_id="your-table-id", + model_type=Post +) + +for row in result.rows: + print(f"{row.data.title} by {row.data.authorId}") ``` ### Error Handling @@ -52,7 +111,8 @@ The Appwrite Python SDK raises `AppwriteException` object with `message`, `code` ```python users = Users(client) try: - result = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien") + user = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien") + print(user.name) except AppwriteException as e: print(e.message) ``` diff --git a/mongo-entrypoint.sh b/mongo-entrypoint.sh old mode 100755 new mode 100644 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..6ab33b14fe --- /dev/null +++ b/package-lock.json @@ -0,0 +1,10 @@ +{ + "name": "@appwrite.io/repo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@appwrite.io/repo" + } + } +} diff --git a/phpbench.json b/phpbench.json deleted file mode 100644 index adc40d1294..0000000000 --- a/phpbench.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema":"vendor/phpbench/phpbench/phpbench.schema.json", - "runner.bootstrap": "vendor/autoload.php", - "runner.path": "tests", - "runner.file_pattern": "*Bench.php" -} \ No newline at end of file diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000000..979deae17e --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,1849 @@ +parameters: + ignoreErrors: + - + message: '#^PHPDoc tag @var above a method has no effect\.$#' + identifier: varTag.misplaced + count: 1 + path: app/config/templates/function.php + + - + message: '#^Array has 2 duplicate keys with value ''outputDirectory'' \(''outputDirectory'', ''outputDirectory''\)\.$#' + identifier: array.duplicateKey + count: 3 + path: app/config/templates/site.php + + - + message: '#^PHPDoc tag @var above a method has no effect\.$#' + identifier: varTag.misplaced + count: 1 + path: app/config/templates/site.php + + - + message: '#^Result of method Appwrite\\Utopia\\Response\:\:dynamic\(\) \(void\) is used\.$#' + identifier: method.void + count: 2 + path: app/controllers/api/account.php + + - + message: '#^Result of method Utopia\\Http\\Response\:\:noContent\(\) \(void\) is used\.$#' + identifier: method.void + count: 1 + path: app/controllers/api/account.php + + - + message: '#^Variable \$session might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: app/controllers/api/account.php + + - + message: '#^Variable \$output might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: app/controllers/api/locale.php + + - + message: '#^Variable \$currentScheduledAt on left side of \?\? is never defined\.$#' + identifier: nullCoalesce.variable + count: 1 + path: app/controllers/api/messaging.php + + - + message: '#^Result of method Utopia\\Http\\Response\:\:noContent\(\) \(void\) is used\.$#' + identifier: method.void + count: 1 + path: app/controllers/api/projects.php + + - + message: '#^Result of method Appwrite\\Utopia\\Response\:\:dynamic\(\) \(void\) is used\.$#' + identifier: method.void + count: 2 + path: app/controllers/api/users.php + + - + message: '#^Result of method Utopia\\Http\\Response\:\:noContent\(\) \(void\) is used\.$#' + identifier: method.void + count: 1 + path: app/controllers/api/users.php + + - + message: '#^Result of method Utopia\\Http\\Response\:\:redirect\(\) \(void\) is used\.$#' + identifier: method.void + count: 2 + path: app/controllers/general.php + + - + message: '#^Result of method Utopia\\Http\\Response\:\:send\(\) \(void\) is used\.$#' + identifier: method.void + count: 1 + path: app/controllers/general.php + + - + message: '#^Variable \$body on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: app/controllers/general.php + + - + message: '#^Variable \$deployment in PHPDoc tag @var does not exist\.$#' + identifier: varTag.variableNotFound + count: 1 + path: app/controllers/general.php + + - + message: '#^Variable \$executionId on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: app/controllers/general.php + + - + message: '#^Variable \$executionResponse might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: app/controllers/general.php + + - + message: '#^Variable \$user might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: app/controllers/general.php + + - + message: '#^Variable \$installation might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: app/controllers/mock.php + + - + message: '#^Call to an undefined method Utopia\\Database\\Document\:\:getRoles\(\)\.$#' + identifier: method.notFound + count: 1 + path: app/controllers/shared/api.php + + - + message: '#^Variable \$register might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: app/http.php + + - + message: '#^Variable \$tag on left side of \?\? always exists and is always null\.$#' + identifier: nullCoalesce.variable + count: 1 + path: app/init/database/filters.php + + - + message: '#^Anonymous function has an unused use \$dsn\.$#' + identifier: closure.unusedUse + count: 1 + path: app/init/registers.php + + - + message: '#^Binary operation "/" between string and string results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: app/init/registers.php + + - + message: '#^Property PHPMailer\\PHPMailer\\PHPMailer\:\:\$Port \(int\) does not accept string\.$#' + identifier: assign.propertyType + count: 1 + path: app/init/registers.php + + - + message: '#^Variable \$providerConfig in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 2 + path: app/init/registers.php + + - + message: '#^Variable \$providerConfig on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: app/init/registers.php + + - + message: '#^Variable \$hostname on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: app/init/resources.php + + - + message: '#^Anonymous function has an unused use \$register\.$#' + identifier: closure.unusedUse + count: 4 + path: app/realtime.php + + - + message: '#^Class Utopia\\Database\\Validator\\Authorization does not have a constructor and must be instantiated without any parameters\.$#' + identifier: new.noConstructor + count: 1 + path: app/realtime.php + + - + message: '#^Function triggerStats\(\) returns void but does not have any side effects\.$#' + identifier: void.pure + count: 1 + path: app/realtime.php + + - + message: '#^Binary operation "\*" between \-1 and string results in an error\.$#' + identifier: binaryOp.invalid + count: 3 + path: app/worker.php + + - + message: '#^PHPDoc tag @return with type string is incompatible with native type int\.$#' + identifier: return.phpDocType + count: 1 + path: src/Appwrite/Auth/OAuth2.php + + - + message: '#^PHPDoc tag @param references unknown parameter\: \$token$#' + identifier: parameter.notFound + count: 1 + path: src/Appwrite/Auth/OAuth2/Disqus.php + + - + message: '#^PHPDoc tag @param references unknown parameter\: \$value$#' + identifier: parameter.notFound + count: 1 + path: src/Appwrite/Auth/Validator/PersonalData.php + + - + message: '#^PHPDoc tag @var above a method has no effect\.$#' + identifier: varTag.misplaced + count: 1 + path: src/Appwrite/Databases/TransactionState.php + + - + message: '#^PHPDoc tag @param has invalid value \(DeviceDetector\)\: Unexpected token "\\n ", expected variable at offset 32 on line 2$#' + identifier: phpDoc.parseError + count: 1 + path: src/Appwrite/Detector/Detector.php + + - + message: '#^PHPDoc tag @param has invalid value \(string\)\: Unexpected token "\\n ", expected variable at offset 24 on line 2$#' + identifier: phpDoc.parseError + count: 1 + path: src/Appwrite/Detector/Detector.php + + - + message: '#^PHPDoc tag @var above a method has no effect\.$#' + identifier: varTag.misplaced + count: 1 + path: src/Appwrite/Docker/Compose.php + + - + message: '#^PHPDoc tag @var above a method has no effect\.$#' + identifier: varTag.misplaced + count: 1 + path: src/Appwrite/Docker/Compose/Service.php + + - + message: '#^PHPDoc tag @var above a method has no effect\.$#' + identifier: varTag.misplaced + count: 1 + path: src/Appwrite/Docker/Env.php + + - + message: '#^PHPDoc tag @param has invalid value \(int port\)\: Unexpected token "port", expected variable at offset 50 on line 4$#' + identifier: phpDoc.parseError + count: 1 + path: src/Appwrite/Event/Mail.php + + - + message: '#^PHPDoc tag @param references unknown parameter\: \$password$#' + identifier: parameter.notFound + count: 1 + path: src/Appwrite/Event/Mail.php + + - + message: '#^PHPDoc tag @return with type string is incompatible with native type Appwrite\\Event\\Mail\.$#' + identifier: return.phpDocType + count: 1 + path: src/Appwrite/Event/Mail.php + + - + message: '#^Method Appwrite\\Event\\Message\\Usage\:\:fromArray\(\) should return static\(Appwrite\\Event\\Message\\Usage\) but returns Appwrite\\Event\\Message\\Usage\.$#' + identifier: return.type + count: 1 + path: src/Appwrite/Event/Message/Usage.php + + - + message: '#^PHPDoc tag @param references unknown parameter\: \$message$#' + identifier: parameter.notFound + count: 1 + path: src/Appwrite/Event/Messaging.php + + - + message: '#^PHPDoc tag @return with type string is incompatible with native type Utopia\\Database\\Document\.$#' + identifier: return.phpDocType + count: 1 + path: src/Appwrite/Event/Messaging.php + + - + message: '#^Method Appwrite\\Functions\\EventProcessor\:\:getFunctionsEvents\(\) should return array\ but returns array\\>\.$#' + identifier: return.type + count: 1 + path: src/Appwrite/Functions/EventProcessor.php + + - + message: '#^Method Appwrite\\Functions\\EventProcessor\:\:getWebhooksEvents\(\) should return array\ but returns array\\>\.$#' + identifier: return.type + count: 1 + path: src/Appwrite/Functions/EventProcessor.php + + - + message: '#^Variable \$hostname on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Functions/EventProcessor.php + + - + message: '#^Anonymous function has an unused use \$context\.$#' + identifier: closure.unusedUse + count: 1 + path: src/Appwrite/GraphQL/Resolvers.php + + - + message: '#^Anonymous function has an unused use \$info\.$#' + identifier: closure.unusedUse + count: 1 + path: src/Appwrite/GraphQL/Resolvers.php + + - + message: '#^Anonymous function has an unused use \$type\.$#' + identifier: closure.unusedUse + count: 5 + path: src/Appwrite/GraphQL/Resolvers.php + + - + message: '#^Variable \$request in PHPDoc tag @var does not exist\.$#' + identifier: varTag.variableNotFound + count: 1 + path: src/Appwrite/GraphQL/Resolvers.php + + - + message: '#^Variable \$response in PHPDoc tag @var does not exist\.$#' + identifier: varTag.variableNotFound + count: 1 + path: src/Appwrite/GraphQL/Resolvers.php + + - + message: '#^Variable \$databaseId might not be defined\.$#' + identifier: variable.undefined + count: 5 + path: src/Appwrite/GraphQL/Schema.php + + - + message: '#^Variable \$sdk in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: src/Appwrite/GraphQL/Schema.php + + - + message: '#^Method Appwrite\\GraphQL\\Types\:\:assoc\(\) should return Appwrite\\GraphQL\\Types\\Json but returns GraphQL\\Type\\Definition\\Type\.$#' + identifier: return.type + count: 1 + path: src/Appwrite/GraphQL/Types.php + + - + message: '#^Method Appwrite\\GraphQL\\Types\:\:inputFile\(\) should return Appwrite\\GraphQL\\Types\\InputFile but returns GraphQL\\Type\\Definition\\Type\.$#' + identifier: return.type + count: 1 + path: src/Appwrite/GraphQL/Types.php + + - + message: '#^Method Appwrite\\GraphQL\\Types\:\:json\(\) should return Appwrite\\GraphQL\\Types\\Json but returns GraphQL\\Type\\Definition\\Type\.$#' + identifier: return.type + count: 1 + path: src/Appwrite/GraphQL/Types.php + + - + message: '#^Class Appwrite\\Network\\Validator\\CNAME not found\.$#' + identifier: class.notFound + count: 1 + path: src/Appwrite/GraphQL/Types/Mapper.php + + - + message: '#^Class Utopia\\Validator\\Origin not found\.$#' + identifier: class.notFound + count: 1 + path: src/Appwrite/GraphQL/Types/Mapper.php + + - + message: '#^Unsafe access to private property Appwrite\\GraphQL\\Types\\Mapper\:\:\$models through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 3 + path: src/Appwrite/GraphQL/Types/Mapper.php + + - + message: '#^Unsafe call to private method Appwrite\\GraphQL\\Types\\Mapper\:\:getColumnImplementation\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 2 + path: src/Appwrite/GraphQL/Types/Mapper.php + + - + message: '#^Unsafe call to private method Appwrite\\GraphQL\\Types\\Mapper\:\:getHashOptionsImplementation\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: src/Appwrite/GraphQL/Types/Mapper.php + + - + message: '#^Unsafe call to private method Appwrite\\GraphQL\\Types\\Mapper\:\:getUnionImplementation\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: src/Appwrite/GraphQL/Types/Mapper.php + + - + message: '#^Call to an undefined method Appwrite\\Migration\\Version\\V15\:\:documentsIterator\(\)\.$#' + identifier: method.notFound + count: 7 + path: src/Appwrite/Migration/Version/V15.php + + - + message: '#^Method Appwrite\\Migration\\Version\\V15\:\:fixDocument\(\) should return Utopia\\Database\\Document but empty return statement found\.$#' + identifier: return.empty + count: 1 + path: src/Appwrite/Migration/Version/V15.php + + - + message: '#^PHPDoc tag @return with type string\|false is not subtype of native type string\.$#' + identifier: return.phpDocType + count: 1 + path: src/Appwrite/Migration/Version/V15.php + + - + message: '#^Variable \$tag on left side of \?\? always exists and is always null\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Migration/Version/V15.php + + - + message: '#^Call to an undefined method Appwrite\\Migration\\Version\\V17\:\:documentsIterator\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Appwrite/Migration/Version/V17.php + + - + message: '#^Call to an undefined method Appwrite\\Migration\\Version\\V18\:\:documentsIterator\(\)\.$#' + identifier: method.notFound + count: 2 + path: src/Appwrite/Migration/Version/V18.php + + - + message: '#^Call to an undefined method Appwrite\\Migration\\Version\\V19\:\:documentsIterator\(\)\.$#' + identifier: method.notFound + count: 4 + path: src/Appwrite/Migration/Version/V19.php + + - + message: '#^Call to an undefined method Appwrite\\Migration\\Version\\V20\:\:documentsIterator\(\)\.$#' + identifier: method.notFound + count: 6 + path: src/Appwrite/Migration/Version/V20.php + + - + message: '#^Variable \$query on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Migration/Version/V20.php + + - + message: '#^Method Appwrite\\Network\\Cors\:\:headers\(\) should return array\ but returns array\\.$#' + identifier: return.type + count: 5 + path: src/Appwrite/Network/Cors.php + + - + message: '#^Parameter &\$tag by\-ref type of method Appwrite\\OpenSSL\\OpenSSL\:\:encrypt\(\) expects null, string\|null given\.$#' + identifier: parameterByRef.type + count: 1 + path: src/Appwrite/OpenSSL/OpenSSL.php + + - + message: '#^PHPDoc tag @param references unknown parameter\: \$projectId$#' + identifier: parameter.notFound + count: 1 + path: src/Appwrite/Platform/Action.php + + - + message: '#^Variable \$output in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: src/Appwrite/Platform/Modules/Avatars/Http/Action.php + + - + message: '#^Variable \$output in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php + + - + message: '#^Variable \$output in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: src/Appwrite/Platform/Modules/Avatars/Http/Image/Get.php + + - + message: '#^Binary operation "%%" between string and 5 results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: src/Appwrite/Platform/Modules/Avatars/Http/Initials/Get.php + + - + message: '#^Method Appwrite\\Platform\\Modules\\Databases\\Http\\Databases\\Action\:\:setHttpPath\(\) should return Appwrite\\Platform\\Action but returns Utopia\\Platform\\Action\.$#' + identifier: return.type + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php + + - + message: '#^Method Appwrite\\Platform\\Modules\\Databases\\Http\\Databases\\Collections\\Documents\\Action\:\:setHttpPath\(\) should return Appwrite\\Platform\\Modules\\Databases\\Http\\Databases\\Action but returns Appwrite\\Platform\\Action\.$#' + identifier: return.type + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php + + - + message: '#^Variable \$relations in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php + + - + message: '#^Anonymous function has an unused use \$dbForProject\.$#' + identifier: closure.unusedUse + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php + + - + message: '#^Variable \$document in PHPDoc tag @var does not match assigned variable \$collectionTableId\.$#' + identifier: varTag.differentVariable + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php + + - + message: '#^Variable \$hostname on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php + + - + message: '#^Variable \$enabled on left side of \?\?\= always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php + + - + message: '#^Offset ''deviceBrand'' does not exist on int\.$#' + identifier: offsetAccess.notFound + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php + + - + message: '#^Offset ''deviceModel'' does not exist on int\.$#' + identifier: offsetAccess.notFound + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php + + - + message: '#^Offset ''deviceName'' does not exist on int\.$#' + identifier: offsetAccess.notFound + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php + + - + message: '#^Method Appwrite\\Platform\\Modules\\Databases\\Http\\Databases\\Transactions\\Action\:\:setHttpPath\(\) should return Appwrite\\Platform\\Modules\\Databases\\Http\\Databases\\Action but returns Appwrite\\Platform\\Action\.$#' + identifier: return.type + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php + + - + message: '#^Anonymous function has an unused use \$existing\.$#' + identifier: closure.unusedUse + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php + + - + message: '#^Anonymous function has an unused use \$queueForEvents\.$#' + identifier: closure.unusedUse + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php + + - + message: '#^Anonymous function has an unused use \$queueForFunctions\.$#' + identifier: closure.unusedUse + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php + + - + message: '#^Anonymous function has an unused use \$queueForRealtime\.$#' + identifier: closure.unusedUse + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php + + - + message: '#^Anonymous function has an unused use \$queueForWebhooks\.$#' + identifier: closure.unusedUse + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php + + - + message: '#^Anonymous function has an unused use \$usage\.$#' + identifier: closure.unusedUse + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php + + - + message: '#^PHPDoc tag @throws with type Appwrite\\Platform\\Modules\\Databases\\Http\\Databases\\Transactions\\Structure\|Throwable\|Utopia\\Database\\Validator\\Authorization is not subtype of Throwable$#' + identifier: throws.notThrowable + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php + + - + message: '#^Variable \$currentDocumentId on left side of \?\? always exists and is always null\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php + + - + message: '#^Offset ''deviceBrand'' does not exist on int\.$#' + identifier: offsetAccess.notFound + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php + + - + message: '#^Offset ''deviceModel'' does not exist on int\.$#' + identifier: offsetAccess.notFound + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php + + - + message: '#^Offset ''deviceName'' does not exist on int\.$#' + identifier: offsetAccess.notFound + count: 1 + path: src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php + + - + message: '#^Variable \$device might not be defined\.$#' + identifier: variable.undefined + count: 5 + path: src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php + + - + message: '#^Variable \$path might not be defined\.$#' + identifier: variable.undefined + count: 5 + path: src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php + + - + message: '#^Call to method getAttribute\(\) on an unknown class Appwrite\\Platform\\Modules\\Functions\\Http\\Executions\\Utopia\\Database\\Document\.$#' + identifier: class.notFound + count: 1 + path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php + + - + message: '#^Call to method getId\(\) on an unknown class Appwrite\\Platform\\Modules\\Functions\\Http\\Executions\\Utopia\\Database\\Document\.$#' + identifier: class.notFound + count: 1 + path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php + + - + message: '#^Call to method isEmpty\(\) on an unknown class Appwrite\\Platform\\Modules\\Functions\\Http\\Executions\\Utopia\\Database\\Document\.$#' + identifier: class.notFound + count: 1 + path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php + + - + message: '#^Method Appwrite\\Platform\\Modules\\Functions\\Http\\Executions\\Create\:\:enqueueDeletes\(\) returns void but does not have any side effects\.$#' + identifier: void.pure + count: 1 + path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php + + - + message: '#^PHPDoc tag @var for variable \$session contains unknown class Appwrite\\Platform\\Modules\\Functions\\Http\\Executions\\Utopia\\Database\\Document\.$#' + identifier: class.notFound + count: 1 + path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php + + - + message: '#^Result of method Appwrite\\Utopia\\Response\:\:dynamic\(\) \(void\) is used\.$#' + identifier: method.void + count: 1 + path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php + + - + message: '#^Variable \$body on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php + + - + message: '#^Variable \$executionId on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php + + - + message: '#^Variable \$jwt on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php + + - + message: '#^Call to an undefined method Appwrite\\Event\\Event\:\:setSubscribers\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php + + - + message: '#^Variable \$enabled on left side of \?\?\= always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php + + - + message: '#^Undefined variable\: \$cpus$#' + identifier: variable.undefined + count: 3 + path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php + + - + message: '#^Undefined variable\: \$memory$#' + identifier: variable.undefined + count: 3 + path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php + + - + message: '#^Variable \$deployment might not be defined\.$#' + identifier: variable.undefined + count: 4 + path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php + + - + message: '#^Variable \$logsAfter on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php + + - + message: '#^Variable \$logsBefore on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php + + - + message: '#^Variable \$providerCommitHash on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php + + - + message: '#^Variable \$rule in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php + + - + message: '#^Result of method Appwrite\\Utopia\\Response\:\:dynamic\(\) \(void\) is used\.$#' + identifier: method.void + count: 1 + path: src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php + + - + message: '#^Variable \$device might not be defined\.$#' + identifier: variable.undefined + count: 5 + path: src/Appwrite/Platform/Modules/Sites/Http/Deployments/Download/Get.php + + - + message: '#^Variable \$path might not be defined\.$#' + identifier: variable.undefined + count: 5 + path: src/Appwrite/Platform/Modules/Sites/Http/Deployments/Download/Get.php + + - + message: '#^Variable \$enabled on left side of \?\?\= always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php + + - + message: '#^Variable \$iv might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php + + - + message: '#^Variable \$tag might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php + + - + message: '#^Variable \$allowedFileExtensions on left side of \?\?\= always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php + + - + message: '#^Variable \$antivirus on left side of \?\?\= always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php + + - + message: '#^Variable \$enabled on left side of \?\?\= always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php + + - + message: '#^Variable \$transformations on left side of \?\?\= always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php + + - + message: '#^Caught class Appwrite\\Platform\\Modules\\Teams\\Http\\Memberships\\Throwable not found\.$#' + identifier: class.notFound + count: 1 + path: src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php + + - + message: '#^Variable \$email in empty\(\) always exists and is always falsy\.$#' + identifier: empty.variable + count: 1 + path: src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php + + - + message: '#^Variable \$hash might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php + + - + message: '#^Variable \$invitee might not be defined\.$#' + identifier: variable.undefined + count: 14 + path: src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php + + - + message: '#^Variable \$logBase might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php + + - + message: '#^Variable \$previewUrl in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php + + - + message: '#^Variable \$repositoryName in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php + + - + message: '#^Variable \$rule in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php + + - + message: '#^Result of method Utopia\\Http\\Response\:\:redirect\(\) \(void\) is used\.$#' + identifier: method.void + count: 2 + path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php + + - + message: '#^Undefined variable\: \$redirectFailure$#' + identifier: variable.undefined + count: 2 + path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php + + - + message: '#^Variable \$redirectFailure in empty\(\) is never defined\.$#' + identifier: empty.variable + count: 1 + path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php + + - + message: '#^Result of method Appwrite\\Utopia\\Response\:\:json\(\) \(void\) is used\.$#' + identifier: method.void + count: 1 + path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php + + - + message: '#^Variable \$logBase might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php + + - + message: '#^Variable \$previewUrl in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php + + - + message: '#^Variable \$repositoryName in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php + + - + message: '#^Variable \$rule in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php + + - + message: '#^Variable \$providerConfig on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Tasks/Doctor.php + + - + message: '#^Variable \$compose in isset\(\) always exists and is not nullable\.$#' + identifier: isset.variable + count: 1 + path: src/Appwrite/Platform/Tasks/Install.php + + - + message: '#^Variable \$prUrls might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: src/Appwrite/Platform/Tasks/SDKs.php + + - + message: '#^Anonymous function has an unused use \$dbForPlatform\.$#' + identifier: closure.unusedUse + count: 1 + path: src/Appwrite/Platform/Tasks/StatsResources.php + + - + message: '#^PHPDoc tag @param references unknown parameter\: \$getProjectDB$#' + identifier: parameter.notFound + count: 1 + path: src/Appwrite/Platform/Workers/Audits.php + + - + message: '#^Anonymous function has an unused use \$certificates\.$#' + identifier: closure.unusedUse + count: 2 + path: src/Appwrite/Platform/Workers/Deletes.php + + - + message: '#^Anonymous function has an unused use \$dbForPlatform\.$#' + identifier: closure.unusedUse + count: 1 + path: src/Appwrite/Platform/Workers/Deletes.php + + - + message: '#^Anonymous function has an unused use \$dbForProject\.$#' + identifier: closure.unusedUse + count: 1 + path: src/Appwrite/Platform/Workers/Deletes.php + + - + message: '#^Anonymous function has an unused use \$project\.$#' + identifier: closure.unusedUse + count: 6 + path: src/Appwrite/Platform/Workers/Deletes.php + + - + message: '#^Anonymous function has an unused use \$resourceType\.$#' + identifier: closure.unusedUse + count: 1 + path: src/Appwrite/Platform/Workers/Deletes.php + + - + message: '#^PHPDoc tag @param references unknown parameter\: \$build$#' + identifier: parameter.notFound + count: 1 + path: src/Appwrite/Platform/Workers/Deletes.php + + - + message: '#^PHPDoc tag @param references unknown parameter\: \$dbForPlatform$#' + identifier: parameter.notFound + count: 1 + path: src/Appwrite/Platform/Workers/Deletes.php + + - + message: '#^PHPDoc tag @param references unknown parameter\: \$target$#' + identifier: parameter.notFound + count: 1 + path: src/Appwrite/Platform/Workers/Deletes.php + + - + message: '#^PHPDoc tag @throws with type Appwrite\\Extend\\Exception\|Utopia\\Database\\Exception\|Utopia\\Database\\Validator\\Authorization is not subtype of Throwable$#' + identifier: throws.notThrowable + count: 2 + path: src/Appwrite/Platform/Workers/Deletes.php + + - + message: '#^PHPDoc tag @throws with type Appwrite\\Extend\\Exception\|Utopia\\Database\\Validator\\Authorization is not subtype of Throwable$#' + identifier: throws.notThrowable + count: 1 + path: src/Appwrite/Platform/Workers/Deletes.php + + - + message: '#^PHPDoc tag @throws with type Appwrite\\Platform\\Workers\\Exception is not subtype of Throwable$#' + identifier: throws.notThrowable + count: 2 + path: src/Appwrite/Platform/Workers/Functions.php + + - + message: '#^Variable \$body on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 2 + path: src/Appwrite/Platform/Workers/Functions.php + + - + message: '#^Variable \$errorCode might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: src/Appwrite/Platform/Workers/Functions.php + + - + message: '#^Variable \$executionId on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Workers/Functions.php + + - + message: '#^Binary operation "\+\=" between 0 and array\\|int\|string\>\|int\|string results in an error\.$#' + identifier: assignOp.invalid + count: 1 + path: src/Appwrite/Platform/Workers/Messaging.php + + - + message: '#^Variable \$provider might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: src/Appwrite/Platform/Workers/Messaging.php + + - + message: '#^Variable \$aggregatedResources might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: src/Appwrite/Platform/Workers/Migrations.php + + - + message: '#^Anonymous function has an unused use \$dbForLogs\.$#' + identifier: closure.unusedUse + count: 1 + path: src/Appwrite/Platform/Workers/StatsResources.php + + - + message: '#^Anonymous function has an unused use \$sequence\.$#' + identifier: closure.unusedUse + count: 1 + path: src/Appwrite/Platform/Workers/StatsUsage.php + + - + message: '#^Callable callable\(\)\: Utopia\\Database\\Database invoked with 1 parameter, 0 required\.$#' + identifier: arguments.count + count: 2 + path: src/Appwrite/Platform/Workers/StatsUsage.php + + - + message: '#^Variable \$curlError on left side of \?\? is never defined\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/Platform/Workers/Webhooks.php + + - + message: '#^Unsafe usage of new static\(\)\.$#' + identifier: new.static + count: 3 + path: src/Appwrite/Promises/Promise.php + + - + message: '#^Property Appwrite\\SDK\\Method\:\:\$hide on left side of \?\? is not nullable nor uninitialized\.$#' + identifier: nullCoalesce.initializedProperty + count: 1 + path: src/Appwrite/SDK/Method.php + + - + message: '#^PHPDoc tag @param references unknown parameter\: \$services$#' + identifier: parameter.notFound + count: 1 + path: src/Appwrite/SDK/Specification/Format.php + + - + message: '#^PHPDoc tag @return with type Appwrite\\SDK\\Specification\\Format is incompatible with native type array\.$#' + identifier: return.phpDocType + count: 1 + path: src/Appwrite/SDK/Specification/Format.php + + - + message: '#^Cannot unset offset ''schema'' on array\{description\: ''No content'', content\?\: non\-empty\-array\<''''\|''\*/\*''\|''application/json''\|''image/\*''\|''image/png''\|''multipart/form\-data''\|''text/html''\|''text/plain'', array\{schema\: array\{''\$ref''\: non\-falsy\-string\}\}\|array\{schema\: array\{oneOf\: array\\}\}\>\}\.$#' + identifier: unset.offset + count: 1 + path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php + + - + message: '#^Class Utopia\\Database\\Validator\\DatetimeValidator not found\.$#' + identifier: class.notFound + count: 1 + path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php + + - + message: '#^Class Utopia\\Validator\\Length not found\.$#' + identifier: class.notFound + count: 1 + path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php + + - + message: '#^Class Utopia\\Validator\\Mock not found\.$#' + identifier: class.notFound + count: 1 + path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php + + - + message: '#^Offset ''securityDefinitions'' on array\{openapi\: ''3\.0\.0'', info\: array\{version\: string, title\: string, description\: string, termsOfService\: string, contact\: array\{name\: string, url\: string, email\: string\}, license\: array\{name\: ''BSD\-3\-Clause'', url\: ''https\://raw…''\}\}, servers\: array\{array\{url\: string\}, array\{url\: string\}\}, paths\: array\{\}, tags\: array, components\: array\{schemas\: array\{\}, securitySchemes\: array\}, externalDocs\: array\{description\: string, url\: string\}\} in isset\(\) does not exist\.$#' + identifier: isset.offset + count: 1 + path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php + + - + message: '#^Variable \$desc on left side of \?\?\= always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php + + - + message: '#^Variable \$validator in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php + + - + message: '#^Class Utopia\\Database\\Validator\\DatetimeValidator not found\.$#' + identifier: class.notFound + count: 1 + path: src/Appwrite/SDK/Specification/Format/Swagger2.php + + - + message: '#^Class Utopia\\Validator\\Length not found\.$#' + identifier: class.notFound + count: 1 + path: src/Appwrite/SDK/Specification/Format/Swagger2.php + + - + message: '#^Class Utopia\\Validator\\Mock not found\.$#' + identifier: class.notFound + count: 1 + path: src/Appwrite/SDK/Specification/Format/Swagger2.php + + - + message: '#^PHPDoc tag @var with type Appwrite\\SDK\\Method is not subtype of native type \*NEVER\*\.$#' + identifier: varTag.nativeType + count: 1 + path: src/Appwrite/SDK/Specification/Format/Swagger2.php + + - + message: '#^Variable \$additionalMethods in empty\(\) always exists and is always falsy\.$#' + identifier: empty.variable + count: 1 + path: src/Appwrite/SDK/Specification/Format/Swagger2.php + + - + message: '#^Variable \$desc on left side of \?\?\= always exists and is not nullable\.$#' + identifier: nullCoalesce.variable + count: 1 + path: src/Appwrite/SDK/Specification/Format/Swagger2.php + + - + message: '#^Variable \$sdk in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: src/Appwrite/SDK/Specification/Format/Swagger2.php + + - + message: '#^Variable \$validator in empty\(\) always exists and is not falsy\.$#' + identifier: empty.variable + count: 1 + path: src/Appwrite/SDK/Specification/Format/Swagger2.php + + - + message: '#^PHPDoc tag @var above a method has no effect\.$#' + identifier: varTag.misplaced + count: 2 + path: src/Appwrite/Template/Template.php + + - + message: '#^PHPDoc tag @param has invalid value \(Document \$this\)\: Unexpected token "\$this", expected variable at offset 69 on line 4$#' + identifier: phpDoc.parseError + count: 1 + path: src/Appwrite/Utopia/Database/Documents/User.php + + - + message: '#^PHPDoc tag @param references unknown parameter\: \$sessions$#' + identifier: parameter.notFound + count: 1 + path: src/Appwrite/Utopia/Database/Documents/User.php + + - + message: '#^Unsafe call to private method Appwrite\\Utopia\\Request\\Filters\\V17\:\:appendSymbol\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 4 + path: src/Appwrite/Utopia/Request/Filters/V17.php + + - + message: '#^Unsafe call to private method Appwrite\\Utopia\\Request\\Filters\\V17\:\:isSpecialChar\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 1 + path: src/Appwrite/Utopia/Request/Filters/V17.php + + - + message: '#^PHPDoc tag @param has invalid value \(callable The callback to show sensitive information for\)\: Unexpected token "The", expected variable at offset 91 on line 4$#' + identifier: phpDoc.parseError + count: 1 + path: src/Appwrite/Utopia/Response.php + + - + message: '#^PHPDoc tag @return with type Appwrite\\Utopia\\Response\\Filter is incompatible with native type array\.$#' + identifier: return.phpDocType + count: 1 + path: src/Appwrite/Utopia/Response.php + + - + message: '#^PHPDoc tag @return with type string is incompatible with native type Utopia\\Database\\Document\.$#' + identifier: return.phpDocType + count: 1 + path: src/Appwrite/Utopia/Response/Model/User.php + + - + message: '#^Attribute class Tests\\E2E\\General\\Retry does not exist\.$#' + identifier: attribute.notFound + count: 1 + path: tests/e2e/General/UsageTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\Databases\\Legacy\\DatabasesStringTypesTest\:\:\$setupCache through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/Databases/Legacy/DatabasesStringTypesTest.php + + - + message: '#^Variable \$library might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: tests/e2e/Services/Databases/LegacyConsoleClientTest.php + + - + message: '#^Variable \$person might not be defined\.$#' + identifier: variable.undefined + count: 4 + path: tests/e2e/Services/Databases/LegacyConsoleClientTest.php + + - + message: '#^Variable \$library might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: tests/e2e/Services/Databases/LegacyCustomClientTest.php + + - + message: '#^Variable \$person might not be defined\.$#' + identifier: variable.undefined + count: 4 + path: tests/e2e/Services/Databases/LegacyCustomClientTest.php + + - + message: '#^Variable \$library might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: tests/e2e/Services/Databases/LegacyCustomServerTest.php + + - + message: '#^Variable \$person might not be defined\.$#' + identifier: variable.undefined + count: 4 + path: tests/e2e/Services/Databases/LegacyCustomServerTest.php + + - + message: '#^Call to an undefined method Tests\\E2E\\Services\\Databases\\Permissions\\LegacyPermissionsGuestTest\:\:getIndexUrl\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/e2e/Services/Databases/Permissions/LegacyPermissionsGuestTest.php + + - + message: '#^Call to an undefined method Tests\\E2E\\Services\\Databases\\Permissions\\LegacyPermissionsMemberTest\:\:getIndexUrl\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/e2e/Services/Databases/Permissions/LegacyPermissionsMemberTest.php + + - + message: '#^Call to an undefined method Tests\\E2E\\Services\\Databases\\Permissions\\LegacyPermissionsTeamTest\:\:getIndexUrl\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/e2e/Services/Databases/Permissions/LegacyPermissionsTeamTest.php + + - + message: '#^Method PHPUnit\\Framework\\TestCase\:\:addToAssertionCount\(\) invoked with 2 parameters, 1 required\.$#' + identifier: arguments.count + count: 1 + path: tests/e2e/Services/Functions/FunctionsCustomClientTest.php + + - + message: '#^Variable \$largeTag might not be defined\.$#' + identifier: variable.undefined + count: 8 + path: tests/e2e/Services/Functions/FunctionsCustomServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsClientTest\:\:\$cachedDeployment through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 3 + path: tests/e2e/Services/GraphQL/FunctionsClientTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsClientTest\:\:\$cachedExecution through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/GraphQL/FunctionsClientTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsClientTest\:\:\$cachedFunction through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 3 + path: tests/e2e/Services/GraphQL/FunctionsClientTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsServerTest\:\:\$cachedDeployment through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/GraphQL/FunctionsServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsServerTest\:\:\$cachedExecution through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 5 + path: tests/e2e/Services/GraphQL/FunctionsServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsServerTest\:\:\$cachedFunction through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/GraphQL/FunctionsServerTest.php + + - + message: '#^Binary operation "\+" between string and 1 results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: tests/e2e/Services/GraphQL/Legacy/AbuseTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$bulkData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$collection through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$database through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$document through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedEmail through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 2 + path: tests/e2e/Services/GraphQL/MessagingTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedProviders through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 8 + path: tests/e2e/Services/GraphQL/MessagingTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedPush through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/GraphQL/MessagingTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedSms through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/GraphQL/MessagingTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedSubscriber through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 5 + path: tests/e2e/Services/GraphQL/MessagingTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedTopic through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 9 + path: tests/e2e/Services/GraphQL/MessagingTest.php + + - + message: '#^Variable \$from in empty\(\) is never defined\.$#' + identifier: empty.variable + count: 1 + path: tests/e2e/Services/GraphQL/MessagingTest.php + + - + message: '#^Method Tests\\E2E\\Services\\GraphQL\\StorageClientTest\:\:testGetFileDownload\(\) should return array but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: tests/e2e/Services/GraphQL/StorageClientTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageClientTest\:\:\$cachedBucket through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 3 + path: tests/e2e/Services/GraphQL/StorageClientTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageClientTest\:\:\$cachedFile through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 5 + path: tests/e2e/Services/GraphQL/StorageClientTest.php + + - + message: '#^Method Tests\\E2E\\Services\\GraphQL\\StorageServerTest\:\:testGetFileDownload\(\) should return array but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: tests/e2e/Services/GraphQL/StorageServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageServerTest\:\:\$cachedBucket through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/GraphQL/StorageServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageServerTest\:\:\$cachedFile through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 6 + path: tests/e2e/Services/GraphQL/StorageServerTest.php + + - + message: '#^Binary operation "\+" between string and 1 results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: tests/e2e/Services/GraphQL/TablesDB/AbuseTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedBooleanColumnData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 5 + path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedBulkData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedDatabase through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedDatetimeColumnData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 5 + path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedEmailColumnData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 5 + path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedEnumColumnData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 5 + path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedFloatColumnData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 5 + path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedIPColumnData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 5 + path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedIndexData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 7 + path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedIntegerColumnData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 5 + path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedRelationshipColumnData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 5 + path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedRowData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 5 + path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedStringColumnData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 5 + path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedTableData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedURLColumnData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 5 + path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsClientTest\:\:\$cachedMembership through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/GraphQL/TeamsClientTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsClientTest\:\:\$cachedTeam through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 3 + path: tests/e2e/Services/GraphQL/TeamsClientTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsServerTest\:\:\$cachedMembership through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/GraphQL/TeamsServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsServerTest\:\:\$cachedTeam through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 3 + path: tests/e2e/Services/GraphQL/TeamsServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsServerTest\:\:\$cachedTeamWithPrefs through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 3 + path: tests/e2e/Services/GraphQL/TeamsServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\UsersTest\:\:\$cachedUser through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 3 + path: tests/e2e/Services/GraphQL/UsersTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\UsersTest\:\:\$cachedUserTarget through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 5 + path: tests/e2e/Services/GraphQL/UsersTest.php + + - + message: '#^Variable \$from in empty\(\) is never defined\.$#' + identifier: empty.variable + count: 1 + path: tests/e2e/Services/Messaging/MessagingConsoleClientTest.php + + - + message: '#^Variable \$from in empty\(\) is never defined\.$#' + identifier: empty.variable + count: 1 + path: tests/e2e/Services/Messaging/MessagingCustomClientTest.php + + - + message: '#^Variable \$from in empty\(\) is never defined\.$#' + identifier: empty.variable + count: 1 + path: tests/e2e/Services/Messaging/MessagingCustomServerTest.php + + - + message: '#^Anonymous function has an unused use \$databaseId\.$#' + identifier: closure.unusedUse + count: 5 + path: tests/e2e/Services/Migrations/MigrationsConsoleClientTest.php + + - + message: '#^Anonymous function has an unused use \$tableId\.$#' + identifier: closure.unusedUse + count: 5 + path: tests/e2e/Services/Migrations/MigrationsConsoleClientTest.php + + - + message: '#^Variable \$largeFile might not be defined\.$#' + identifier: variable.undefined + count: 8 + path: tests/e2e/Services/Storage/StorageConsoleClientTest.php + + - + message: '#^Variable \$largeFile might not be defined\.$#' + identifier: variable.undefined + count: 8 + path: tests/e2e/Services/Storage/StorageCustomClientTest.php + + - + message: '#^Variable \$largeFile might not be defined\.$#' + identifier: variable.undefined + count: 8 + path: tests/e2e/Services/Storage/StorageCustomServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\TablesDB\\DatabasesStringTypesTest\:\:\$setupCache through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/TablesDB/DatabasesStringTypesTest.php + + - + message: '#^Call to an undefined method Tests\\E2E\\Services\\TablesDB\\Permissions\\TablesDBPermissionsGuestTest\:\:getIndexUrl\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsGuestTest.php + + - + message: '#^Call to an undefined method Tests\\E2E\\Services\\TablesDB\\Permissions\\TablesDBPermissionsMemberTest\:\:getIndexUrl\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsMemberTest.php + + - + message: '#^Call to an undefined method Tests\\E2E\\Services\\TablesDB\\Permissions\\TablesDBPermissionsTeamTest\:\:getIndexUrl\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsTeamTest.php + + - + message: '#^Variable \$library might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: tests/e2e/Services/TablesDB/TablesDBConsoleClientTest.php + + - + message: '#^Variable \$person might not be defined\.$#' + identifier: variable.undefined + count: 4 + path: tests/e2e/Services/TablesDB/TablesDBConsoleClientTest.php + + - + message: '#^Variable \$library might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: tests/e2e/Services/TablesDB/TablesDBCustomClientTest.php + + - + message: '#^Variable \$person might not be defined\.$#' + identifier: variable.undefined + count: 4 + path: tests/e2e/Services/TablesDB/TablesDBCustomClientTest.php + + - + message: '#^Variable \$library might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: tests/e2e/Services/TablesDB/TablesDBCustomServerTest.php + + - + message: '#^Variable \$person might not be defined\.$#' + identifier: variable.undefined + count: 4 + path: tests/e2e/Services/TablesDB/TablesDBCustomServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensConsoleClientTest\:\:\$bucketAndFileData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/Tokens/TokensConsoleClientTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensConsoleClientTest\:\:\$tokenData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/Tokens/TokensConsoleClientTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensCustomClientTest\:\:\$bucketAndFileData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/Tokens/TokensCustomClientTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensCustomServerTest\:\:\$bucketAndFileData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/Tokens/TokensCustomServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensCustomServerTest\:\:\$tokenData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/Tokens/TokensCustomServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$cachedHashedPasswordUsers through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 2 + path: tests/e2e/Services/Users/UsersCustomServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$cachedUser through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 7 + path: tests/e2e/Services/Users/UsersCustomServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$cachedUserTarget through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 7 + path: tests/e2e/Services/Users/UsersCustomServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$userEmailUpdated through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 3 + path: tests/e2e/Services/Users/UsersCustomServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$userNameUpdated through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 3 + path: tests/e2e/Services/Users/UsersCustomServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$userNumberUpdated through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 3 + path: tests/e2e/Services/Users/UsersCustomServerTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\VCS\\VCSConsoleClientTest\:\:\$cachedFunctionData through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/VCS/VCSConsoleClientTest.php + + - + message: '#^Unsafe access to private property Tests\\E2E\\Services\\VCS\\VCSConsoleClientTest\:\:\$cachedInstallationId through static\:\:\.$#' + identifier: staticClassAccess.privateProperty + count: 4 + path: tests/e2e/Services/VCS/VCSConsoleClientTest.php + + - + message: '#^Unsafe call to private method Tests\\Unit\\Auth\\KeyTest\:\:generateKey\(\) through static\:\:\.$#' + identifier: staticClassAccess.privateMethod + count: 3 + path: tests/unit/Auth/KeyTest.php + + - + message: '#^Call to an undefined method Utopia\\Queue\\Publisher\:\:getEvents\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/unit/Event/EventTest.php + + - + message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#' + identifier: class.notFound + count: 6 + path: tests/unit/Utopia/Response/Filters/V16Test.php + + - + message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V16Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V16\.$#' + identifier: assign.propertyType + count: 1 + path: tests/unit/Utopia/Response/Filters/V16Test.php + + - + message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V16Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#' + identifier: class.notFound + count: 1 + path: tests/unit/Utopia/Response/Filters/V16Test.php + + - + message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#' + identifier: class.notFound + count: 5 + path: tests/unit/Utopia/Response/Filters/V17Test.php + + - + message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V17Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V17\.$#' + identifier: assign.propertyType + count: 1 + path: tests/unit/Utopia/Response/Filters/V17Test.php + + - + message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V17Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#' + identifier: class.notFound + count: 1 + path: tests/unit/Utopia/Response/Filters/V17Test.php + + - + message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#' + identifier: class.notFound + count: 4 + path: tests/unit/Utopia/Response/Filters/V18Test.php + + - + message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V18Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V18\.$#' + identifier: assign.propertyType + count: 1 + path: tests/unit/Utopia/Response/Filters/V18Test.php + + - + message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V18Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#' + identifier: class.notFound + count: 1 + path: tests/unit/Utopia/Response/Filters/V18Test.php + + - + message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#' + identifier: class.notFound + count: 11 + path: tests/unit/Utopia/Response/Filters/V19Test.php + + - + message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V19Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V19\.$#' + identifier: assign.propertyType + count: 1 + path: tests/unit/Utopia/Response/Filters/V19Test.php + + - + message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V19Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#' + identifier: class.notFound + count: 1 + path: tests/unit/Utopia/Response/Filters/V19Test.php diff --git a/phpstan.neon b/phpstan.neon index 90f28e7539..b87ad46eca 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,13 +1,17 @@ +includes: + - phpstan-baseline.neon + parameters: - level: 8 + level: 3 paths: - - src/Utopia/Bus - - src/Appwrite/Bus - - src/Appwrite/Transformation + - src + - app + - bin + - tests bootstrapFiles: - app/init/constants.php scanDirectories: - vendor/swoole/ide-helper excludePaths: - tests/resources - - app/sdks \ No newline at end of file + diff --git a/phpunit.xml b/phpunit.xml index 030d89af8d..9ccbaf47cc 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -34,6 +34,7 @@ ./tests/e2e/Services/Storage ./tests/e2e/Services/Tokens ./tests/e2e/Services/Webhooks + ./tests/e2e/Services/ProjectWebhooks ./tests/e2e/Services/Messaging ./tests/e2e/Services/Migrations ./tests/e2e/Services/Functions/FunctionsBase.php diff --git a/src/Appwrite/Bus/Listeners/Usage.php b/src/Appwrite/Bus/Listeners/Usage.php index 219287033d..48178f1dee 100644 --- a/src/Appwrite/Bus/Listeners/Usage.php +++ b/src/Appwrite/Bus/Listeners/Usage.php @@ -4,11 +4,12 @@ namespace Appwrite\Bus\Listeners; use Appwrite\Bus\Events\ExecutionCompleted; use Appwrite\Bus\Events\RequestCompleted; -use Appwrite\Event\StatsUsage; +use Appwrite\Event\Message\Usage as UsageMessage; +use Appwrite\Event\Publisher\Usage as Publisher; +use Appwrite\Usage\Context; use Utopia\Bus\Event; use Utopia\Bus\Listener; use Utopia\Database\Document; -use Utopia\Queue\Publisher; class Usage extends Listener { @@ -29,20 +30,21 @@ class Usage extends Listener { $this ->desc('Records usage metrics') - ->inject('publisherStatsUsage') + ->inject('publisherForUsage') + ->inject('usage') ->callback($this->handle(...)); } - public function handle(Event $event, Publisher $publisher): void + public function handle(Event $event, Publisher $publisherForUsage, Context $usage): void { match (true) { - $event instanceof ExecutionCompleted => $this->handleExecutionCompleted($event, $publisher), - $event instanceof RequestCompleted => $this->handleRequestCompleted($event, $publisher), + $event instanceof ExecutionCompleted => $this->handleExecutionCompleted($event, $publisherForUsage), + $event instanceof RequestCompleted => $this->handleRequestCompleted($event, $usage), default => null, }; } - private function handleExecutionCompleted(ExecutionCompleted $event, Publisher $publisher): void + private function handleExecutionCompleted(ExecutionCompleted $event, Publisher $publisherForUsage): void { $execution = new Document($event->execution); $resource = new Document($event->resource); @@ -61,9 +63,7 @@ class Usage extends Listener $compute = (int)($duration * 1000); $mbSeconds = (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $duration * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT)); - $queueForStatsUsage = new StatsUsage($publisher); - $queueForStatsUsage - ->setProject($project) + $context = (new Context()) ->addMetric(METRIC_EXECUTIONS, 1) ->addMetric(str_replace(['{resourceType}'], [$resourceType], METRIC_RESOURCE_TYPE_EXECUTIONS), 1) ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$resourceType, $resourceInternalId], METRIC_RESOURCE_TYPE_ID_EXECUTIONS), 1) @@ -72,11 +72,18 @@ class Usage extends Listener ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$resourceType, $resourceInternalId], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_COMPUTE), $compute) ->addMetric(METRIC_EXECUTIONS_MB_SECONDS, $mbSeconds) ->addMetric(str_replace(['{resourceType}'], [$resourceType], METRIC_RESOURCE_TYPE_EXECUTIONS_MB_SECONDS), $mbSeconds) - ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$resourceType, $resourceInternalId], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS), $mbSeconds) - ->trigger(); + ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$resourceType, $resourceInternalId], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS), $mbSeconds); + + $message = new UsageMessage( + project: $project, + metrics: $context->getMetrics(), + reduce: $context->getReduce() + ); + + $publisherForUsage->enqueue($message); } - private function handleRequestCompleted(RequestCompleted $event, Publisher $publisher): void + private function handleRequestCompleted(RequestCompleted $event, Context $usage): void { $fileSize = 0; $file = $event->request->getFiles('file'); @@ -84,18 +91,14 @@ class Usage extends Listener $fileSize = (\is_array($file['size']) && isset($file['size'][0])) ? $file['size'][0] : $file['size']; } - $project = new Document($event->project); $deployment = new Document($event->deployment); - $queueForStatsUsage = new StatsUsage($publisher); $inbound = $event->request->getSize() + $fileSize; $outbound = $event->response->getSize(); - $queueForStatsUsage->setProject($project); - if ($deployment->getAttribute('resourceType') === 'sites') { $siteInternalId = $deployment->getAttribute('resourceInternalId', ''); - $queueForStatsUsage + $usage ->addMetric(METRIC_SITES_REQUESTS, 1) ->addMetric(METRIC_SITES_INBOUND, $inbound) ->addMetric(METRIC_SITES_OUTBOUND, $outbound) @@ -103,12 +106,10 @@ class Usage extends Listener ->addMetric(str_replace('{siteInternalId}', $siteInternalId, METRIC_SITES_ID_INBOUND), $inbound) ->addMetric(str_replace('{siteInternalId}', $siteInternalId, METRIC_SITES_ID_OUTBOUND), $outbound); } else { - $queueForStatsUsage + $usage ->addMetric(METRIC_NETWORK_REQUESTS, 1) ->addMetric(METRIC_NETWORK_INBOUND, $inbound) ->addMetric(METRIC_NETWORK_OUTBOUND, $outbound); } - - $queueForStatsUsage->trigger(); } } diff --git a/src/Appwrite/Docker/Env.php b/src/Appwrite/Docker/Env.php index 51ec9e167c..3bf6fb2d50 100644 --- a/src/Appwrite/Docker/Env.php +++ b/src/Appwrite/Docker/Env.php @@ -19,7 +19,16 @@ class Env foreach ($data as &$row) { $row = explode('=', $row, 2); $key = (isset($row[0])) ? trim($row[0]) : null; - $value = (isset($row[1])) ? trim($row[1]) : null; + $value = (isset($row[1])) ? (function (string $v): string { + $v = trim($v); + if ( + (\str_starts_with($v, '"') && \str_ends_with($v, '"')) || + (\str_starts_with($v, "'") && \str_ends_with($v, "'")) + ) { + return \substr($v, 1, -1); + } + return $v; + })(trim($row[1])) : null; if ($key) { $this->vars[$key] = $value; diff --git a/src/Appwrite/Event/Message/Base.php b/src/Appwrite/Event/Message/Base.php new file mode 100644 index 0000000000..38b6d5edee --- /dev/null +++ b/src/Appwrite/Event/Message/Base.php @@ -0,0 +1,21 @@ + $metrics + * @param array $reduce + */ + public function __construct( + public readonly Document $project, + public readonly array $metrics, + public readonly array $reduce = [], + ) { + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'project' => [ + '$id' => $this->project->getId(), + '$sequence' => $this->project->getSequence(), + 'database' => $this->project->getAttribute('database', ''), + ], + 'metrics' => $this->metrics, + 'reduce' => array_map(fn (Document $doc) => $doc->getArrayCopy(), $this->reduce), + ]; + } + + /** + * @param array $data + * @return static + */ + public static function fromArray(array $data): static + { + return new self( + project: new Document($data['project'] ?? []), + metrics: $data['metrics'] ?? [], + reduce: array_map(fn (array $doc) => new Document($doc), $data['reduce'] ?? []), + ); + } +} diff --git a/src/Appwrite/Event/Publisher/Base.php b/src/Appwrite/Event/Publisher/Base.php new file mode 100644 index 0000000000..2063864723 --- /dev/null +++ b/src/Appwrite/Event/Publisher/Base.php @@ -0,0 +1,33 @@ +toArray(); + + return $this->publisher->enqueue($queue, $payload); + } + + /** + * Get the size of a queue + */ + public function getQueueSize(Queue $queue, bool $failed = false): int + { + return $this->publisher->getQueueSize($queue, $failed); + } +} diff --git a/src/Appwrite/Event/Publisher/Usage.php b/src/Appwrite/Event/Publisher/Usage.php new file mode 100644 index 0000000000..104690671b --- /dev/null +++ b/src/Appwrite/Event/Publisher/Usage.php @@ -0,0 +1,39 @@ +publish($this->queue, $message); + } catch (\Throwable $th) { + Console::error('[Usage] Failed to publish usage message: ' . $th->getMessage()); + return false; + } + } + + /** + * Get the size of the usage queue + */ + public function getSize(bool $failed = false): int + { + return $this->getQueueSize($this->queue, $failed); + } +} diff --git a/src/Appwrite/Event/StatsUsage.php b/src/Appwrite/Event/StatsUsage.php deleted file mode 100644 index a944d70c94..0000000000 --- a/src/Appwrite/Event/StatsUsage.php +++ /dev/null @@ -1,96 +0,0 @@ -setQueue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME)) - ->setClass(System::getEnv('_APP_STATS_USAGE_CLASS_NAME', Event::STATS_USAGE_CLASS_NAME)); - } - - /** - * Add reduce. - * - * @param Document $document - * @return self - */ - public function addReduce(Document $document): self - { - $this->reduce[] = $document; - - return $this; - } - - /** - * Add metric. - * - * @param string $key - * @param int $value - * @return self - */ - public function addMetric(string $key, int $value): self - { - $this->metrics[] = [ - 'key' => $key, - 'value' => $value, - ]; - - return $this; - } - - /** - * Set disabled metrics. - * - * @param string $key - * @return self - */ - public function disableMetric(string $key): self - { - $this->disabled[] = $key; - - return $this; - } - - /** - * Prepare the payload for the event - * - * @return array - */ - protected function preparePayload(): array - { - return [ - 'project' => $this->getProject(), - 'reduce' => $this->reduce, - 'metrics' => \array_filter($this->metrics, function ($metric) { - foreach ($this->disabled as $disabledMetric) { - if (\str_ends_with($metric['key'], $disabledMetric)) { - return false; - } - } - return true; - }), - ]; - } - - public function reset(): Event - { - $this->metrics = []; - parent::reset(); - return $this; - } -} diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 95a9c6ddac..a54edf7074 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -307,6 +307,7 @@ class Exception extends \Exception /** Webhooks */ public const string WEBHOOK_NOT_FOUND = 'webhook_not_found'; + public const string WEBHOOK_ALREADY_EXISTS = 'webhook_already_exists'; /** Router */ public const string ROUTER_HOST_NOT_FOUND = 'router_host_not_found'; diff --git a/src/Appwrite/GraphQL/Types/Mapper.php b/src/Appwrite/GraphQL/Types/Mapper.php index 9cd190613d..037f80bcf7 100644 --- a/src/Appwrite/GraphQL/Types/Mapper.php +++ b/src/Appwrite/GraphQL/Types/Mapper.php @@ -274,7 +274,7 @@ class Mapper case \Appwrite\Event\Validator\Event::class: case \Appwrite\Event\Validator\FunctionEvent::class: case \Appwrite\Network\Validator\CNAME::class: - case \Appwrite\Network\Validator\Email::class: + case \Utopia\Emails\Validator\Email::class: case \Appwrite\Network\Validator\Redirect::class: case \Appwrite\Network\Validator\DNS::class: case \Appwrite\Network\Validator\Origin::class: diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index 519f05de2c..a4f73eb5f2 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -91,6 +91,7 @@ abstract class Migration '1.7.4' => 'V22', '1.8.0' => 'V23', '1.8.1' => 'V23', + '1.9.0' => 'V24', ]; /** diff --git a/src/Appwrite/Migration/Version/V24.php b/src/Appwrite/Migration/Version/V24.php new file mode 100644 index 0000000000..dc9ce9d196 --- /dev/null +++ b/src/Appwrite/Migration/Version/V24.php @@ -0,0 +1,396 @@ + null, + fn () => [] + ); + } + + Console::info('Migrating collections'); + $this->migrateCollections(); + + if ($this->project->getSequence() != 'console') { + Console::info('Migrating Databases'); + $this->migrateDatabases(); + } + + Console::info('Migrating Buckets'); + $this->migrateBuckets(); + + Console::info('Migrating documents'); + $this->forEachDocument($this->migrateDocument(...)); + + Console::info('Cleaning up old attributes'); + $this->cleanupOldAttributes(); + } + + /** + * Migrate Collections. + * + * @return void + * @throws Exception|Throwable + */ + private function migrateCollections(): void + { + $projectInternalId = $this->project->getSequence(); + + if (empty($projectInternalId)) { + throw new Exception('Project ID is null'); + } + + $collectionType = match ($projectInternalId) { + 'console' => 'console', + default => 'projects', + }; + + $collections = $this->collections[$collectionType]; + + foreach ($collections as $collection) { + $id = $collection['$id']; + + if (empty($id)) { + continue; + } + + Console::log("Migrating collection \"{$id}\""); + + $this->dbForProject->purgeCachedCollection($id); + $this->dbForProject->purgeCachedDocument(Database::METADATA, $id); + + switch ($id) { + case 'projects': + if ($collectionType === 'console') { + $attributes = [ + 'labels', + 'status', + ]; + try { + $this->createAttributesFromCollection($this->dbForProject, $id, $attributes); + } catch (Throwable $th) { + Console::warning('Failed to create attributes "' . \implode(', ', $attributes) . "\" in collection {$id}: {$th->getMessage()}"); + } + } + $this->dbForProject->purgeCachedCollection($id); + break; + + case 'keys': + if ($collectionType === 'console') { + $attributes = [ + 'resourceType', + 'resourceId', + 'resourceInternalId', + ]; + try { + $this->createAttributesFromCollection($this->dbForProject, $id, $attributes); + } catch (Throwable $th) { + Console::warning('Failed to create attributes "' . \implode(', ', $attributes) . "\" in collection {$id}: {$th->getMessage()}"); + } + + try { + $this->dbForProject->deleteIndex($id, '_key_project'); + } catch (Throwable $th) { + Console::warning("Failed to delete index \"_key_project\" from {$id}: {$th->getMessage()}"); + } + + try { + $this->createIndexFromCollection($this->dbForProject, $id, '_key_resource'); + } catch (Throwable $th) { + Console::warning("Failed to create index \"_key_resource\" from {$id}: {$th->getMessage()}"); + } + } + $this->dbForProject->purgeCachedCollection($id); + break; + + case 'rules': + if ($collectionType === 'console') { + try { + $this->createAttributeFromCollection($this->dbForProject, $id, 'logs'); + } catch (Throwable $th) { + Console::warning("Failed to create attribute \"logs\" in collection {$id}: {$th->getMessage()}"); + } + + $indexesToDelete = [ + '_key_type', + '_key_trigger', + '_key_deploymentResourceType', + '_key_owner', + '_key_region', + '_key_piid_riid_rt', + ]; + foreach ($indexesToDelete as $index) { + try { + $this->dbForProject->deleteIndex($id, $index); + } catch (Throwable $th) { + Console::warning("Failed to delete index \"$index\" from {$id}: {$th->getMessage()}"); + } + } + + $indexesToCreate = [ + '_key_type', + '_key_trigger', + '_key_deploymentResourceType', + '_key_owner', + '_key_piid_diid_drt', + '_key_region_status_createdAt', + ]; + foreach ($indexesToCreate as $index) { + try { + $this->createIndexFromCollection($this->dbForProject, $id, $index); + } catch (Throwable $th) { + Console::warning("Failed to create index \"$index\" from {$id}: {$th->getMessage()}"); + } + } + } + $this->dbForProject->purgeCachedCollection($id); + break; + + case 'teams': + try { + $this->createAttributeFromCollection($this->dbForProject, $id, 'labels'); + } catch (Throwable $th) { + Console::warning("Failed to create attribute \"labels\" in collection {$id}: {$th->getMessage()}"); + } + $this->dbForProject->purgeCachedCollection($id); + break; + + case 'databases': + if ($collectionType === 'projects') { + try { + $this->createAttributeFromCollection($this->dbForProject, $id, 'database'); + } catch (Throwable $th) { + Console::warning("Failed to create attribute \"database\" in collection {$id}: {$th->getMessage()}"); + } + $this->dbForProject->purgeCachedCollection($id); + } + break; + + case 'functions': + $attributes = [ + 'deploymentRetention', + 'startCommand', + 'buildSpecification', + 'runtimeSpecification', + ]; + try { + $this->createAttributesFromCollection($this->dbForProject, $id, $attributes); + } catch (Throwable $th) { + Console::warning('Failed to create attributes "' . \implode(', ', $attributes) . "\" in collection {$id}: {$th->getMessage()}"); + } + $this->dbForProject->purgeCachedCollection($id); + break; + + case 'sites': + $attributes = [ + 'startCommand', + 'deploymentRetention', + 'buildSpecification', + 'runtimeSpecification', + ]; + try { + $this->createAttributesFromCollection($this->dbForProject, $id, $attributes); + } catch (Throwable $th) { + Console::warning('Failed to create attributes "' . \implode(', ', $attributes) . "\" in collection {$id}: {$th->getMessage()}"); + } + $this->dbForProject->purgeCachedCollection($id); + break; + + case 'deployments': + try { + $this->createAttributeFromCollection($this->dbForProject, $id, 'startCommand'); + } catch (Throwable $th) { + Console::warning("Failed to create attribute \"startCommand\" in collection {$id}: {$th->getMessage()}"); + } + $this->dbForProject->purgeCachedCollection($id); + break; + + case 'executions': + try { + $this->dbForProject->deleteIndex($id, '_key_function_internal_id'); + } catch (Throwable $th) { + Console::warning("Failed to delete index \"_key_function_internal_id\" from {$id}: {$th->getMessage()}"); + } + try { + $this->createIndexFromCollection($this->dbForProject, $id, '_key_resourceType'); + } catch (Throwable $th) { + Console::warning("Failed to create index \"_key_resourceType\" from {$id}: {$th->getMessage()}"); + } + $this->dbForProject->purgeCachedCollection($id); + break; + + case 'buckets': + try { + $this->dbForProject->deleteIndex($id, '_fulltext_name'); + } catch (Throwable $th) { + Console::warning("Failed to delete index \"_fulltext_name\" from {$id}: {$th->getMessage()}"); + } + $this->dbForProject->purgeCachedCollection($id); + break; + + case 'providers': + try { + $this->dbForProject->deleteIndex($id, '_key_name'); + } catch (Throwable $th) { + Console::warning("Failed to delete index \"_key_name\" from {$id}: {$th->getMessage()}"); + } + $this->dbForProject->purgeCachedCollection($id); + break; + + case 'topics': + try { + $this->dbForProject->deleteIndex($id, '_key_name'); + } catch (Throwable $th) { + Console::warning("Failed to delete index \"_key_name\" from {$id}: {$th->getMessage()}"); + } + $this->dbForProject->purgeCachedCollection($id); + break; + + default: + break; + } + } + } + + /** + * Migrate all Database tables + * + * @return void + * @throws Exception + */ + private function migrateDatabases(): void + { + $this->dbForProject->foreach('databases', function (Document $database) { + Console::log("Migrating Collections of {$database->getId()} ({$database->getAttribute('name')})"); + + $databaseTable = "database_{$database->getSequence()}"; + $this->dbForProject->purgeCachedCollection($databaseTable); + + $this->dbForProject->foreach($databaseTable, function (Document $collection) use ($databaseTable) { + Console::log("Migrating Collection of {$collection->getId()} ({$collection->getAttribute('name')})"); + + $collectionTable = "{$databaseTable}_collection_{$collection->getSequence()}"; + $this->dbForProject->purgeCachedCollection($collectionTable); + }); + }); + } + + /** + * Migrate all Bucket tables + * + * @return void + * @throws \Exception + * @throws \PDOException + */ + protected function migrateBuckets(): void + { + $this->dbForProject->foreach('buckets', function (Document $bucket) { + Console::log("Migrating Bucket {$bucket->getId()} ({$bucket->getAttribute('name')})"); + + $bucketTable = "bucket_{$bucket->getSequence()}"; + $this->dbForProject->purgeCachedCollection($bucketTable); + }); + } + + /** + * Fix run on each document + * + * @param Document $document + * @return Document + * @throws Conflict + * @throws Structure + * @throws Timeout + * @throws \Utopia\Database\Exception + * @throws \Utopia\Database\Exception\Authorization + * @throws \Utopia\Database\Exception\Query + */ + private function migrateDocument(Document $document): Document + { + switch ($document->getCollection()) { + case 'keys': + $projectInternalId = $document->getAttribute('projectInternalId'); + $projectId = $document->getAttribute('projectId'); + + if (!empty($projectInternalId) && empty($document->getAttribute('resourceInternalId'))) { + $document->setAttribute('resourceType', 'projects'); + $document->setAttribute('resourceId', $projectId); + $document->setAttribute('resourceInternalId', $projectInternalId); + } + break; + default: + break; + } + return $document; + } + + /** + * Clean up old attributes after document migration is complete. + * + * @return void + */ + private function cleanupOldAttributes(): void + { + $collectionType = match ($this->project->getSequence()) { + 'console' => 'console', + default => 'projects', + }; + + if ($collectionType === 'console') { + $attributesToDelete = [ + 'keys' => ['projectInternalId', 'projectId'], + ]; + + foreach ($attributesToDelete as $collectionId => $attributes) { + foreach ($attributes as $attribute) { + try { + $this->dbForProject->deleteAttribute($collectionId, $attribute); + } catch (Throwable $th) { + Console::warning("Failed to delete attribute \"{$attribute}\" from {$collectionId}: {$th->getMessage()}"); + } + } + $this->dbForProject->purgeCachedCollection($collectionId); + } + } + } +} diff --git a/src/Appwrite/Network/Platform.php b/src/Appwrite/Network/Platform.php index ea64ff98c1..1cf5de91d1 100644 --- a/src/Appwrite/Network/Platform.php +++ b/src/Appwrite/Network/Platform.php @@ -35,6 +35,7 @@ class Platform public const SCHEME_ANDROID = 'appwrite-android'; public const SCHEME_WINDOWS = 'appwrite-windows'; public const SCHEME_LINUX = 'appwrite-linux'; + public const SCHEME_TAURI = 'tauri'; /** * @var array Map scheme types to user-friendly platform names. @@ -53,6 +54,7 @@ class Platform self::SCHEME_FIREFOX_EXTENSION => 'Web (Firefox Extension)', self::SCHEME_SAFARI_EXTENSION => 'Web (Safari Extension)', self::SCHEME_EDGE_EXTENSION => 'Web (Edge Extension)', + self::SCHEME_TAURI => 'Web (Tauri)', ]; /** diff --git a/src/Appwrite/Network/Validator/Email.php b/src/Appwrite/Network/Validator/Email.php deleted file mode 100644 index 3209a4aada..0000000000 --- a/src/Appwrite/Network/Validator/Email.php +++ /dev/null @@ -1,79 +0,0 @@ -allowEmpty = $allowEmpty; - } - - /** - * Get Description - * - * Returns validator description - * - * @return string - */ - public function getDescription(): string - { - return 'Value must be a valid email address'; - } - - /** - * Is valid - * - * Validation will pass when $value is valid email address. - * - * @param mixed $value - * @return bool - */ - public function isValid($value): bool - { - if ($this->allowEmpty && \strlen($value) === 0) { - return true; - } - - if (!\filter_var($value, FILTER_VALIDATE_EMAIL)) { - return false; - } - - return true; - } - - /** - * Is array - * - * Function will return true if object is array. - * - * @return bool - */ - public function isArray(): bool - { - return false; - } - - /** - * Get Type - * - * Returns validator type. - * - * @return string - */ - public function getType(): string - { - return self::TYPE_STRING; - } -} diff --git a/src/Appwrite/Network/Validator/Origin.php b/src/Appwrite/Network/Validator/Origin.php index 8b9974e990..3c4e8a254a 100644 --- a/src/Appwrite/Network/Validator/Origin.php +++ b/src/Appwrite/Network/Validator/Origin.php @@ -69,6 +69,7 @@ class Origin extends Validator Platform::SCHEME_FIREFOX_EXTENSION, Platform::SCHEME_SAFARI_EXTENSION, Platform::SCHEME_EDGE_EXTENSION, + Platform::SCHEME_TAURI, ]; if (in_array($this->scheme, $webPlatforms, true)) { $validator = new Hostname($this->allowedHostnames); diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index 681e1038c3..77b9c4d1dd 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -16,6 +16,7 @@ use Appwrite\Platform\Modules\Storage; use Appwrite\Platform\Modules\Teams; use Appwrite\Platform\Modules\Tokens; use Appwrite\Platform\Modules\VCS; +use Appwrite\Platform\Modules\Webhooks; use Utopia\Platform\Platform; class Appwrite extends Platform @@ -36,5 +37,6 @@ class Appwrite extends Platform $this->addModule(new Tokens\Module()); $this->addModule(new Storage\Module()); $this->addModule(new VCS\Module()); + $this->addModule(new Webhooks\Module()); } } diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Complete.php b/src/Appwrite/Platform/Installer/Http/Installer/Complete.php new file mode 100644 index 0000000000..92a00651fe --- /dev/null +++ b/src/Appwrite/Platform/Installer/Http/Installer/Complete.php @@ -0,0 +1,82 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/install/complete') + ->desc('Complete installation') + ->param('installId', '', new Text(64, 0), 'Installation ID', true) + ->param('sessionId', '', new Text(256, 0), 'Session ID', true) + ->param('sessionSecret', '', new Text(256, 0), 'Session secret', true) + ->param('sessionExpire', '', new Text(64, 0), 'Session expiry timestamp', true) + ->inject('request') + ->inject('response') + ->inject('installerState') + ->callback($this->action(...)); + } + + public function action(string $installId, string $sessionId, string $sessionSecret, string $sessionExpire, Request $request, Response $response, State $state): void + { + if (!Validate::validateCsrf($request)) { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['success' => false, 'message' => 'Invalid CSRF token']); + return; + } + + $installId = $state->sanitizeInstallId($installId); + + if ($installId !== '') { + $state->updateGlobalLock($installId, Server::STATUS_COMPLETED); + } + + @touch(Server::INSTALLER_COMPLETE_FILE); + + if (!$sessionSecret && $installId !== '') { + $data = $state->readProgressFile($installId); + $details = $data['details'][Server::STEP_ACCOUNT_SETUP] ?? []; + if (!empty($details['sessionSecret'])) { + $sessionSecret = $details['sessionSecret']; + $sessionId = $sessionId ?: ($details['sessionId'] ?? ''); + $sessionExpire = $sessionExpire ?: ($details['sessionExpire'] ?? ''); + } + } + + if ($sessionSecret) { + $isHttps = $request->getProtocol() === 'https'; + $sameSite = $isHttps ? Response::COOKIE_SAMESITE_NONE : Response::COOKIE_SAMESITE_LAX; + $expires = 0; + if ($sessionExpire) { + $timestamp = strtotime($sessionExpire); + if ($timestamp !== false) { + $expires = $timestamp; + } + } + $response->addCookie('a_session_console', $sessionSecret, $expires, '/', '', $isHttps, true, $sameSite); + $response->addCookie('a_session_console_legacy', $sessionSecret, $expires, '/', '', $isHttps, true, $sameSite); + if ($sessionId) { + $response->addHeader('X-Appwrite-Session', $sessionId); + } + } + + @unlink(Server::INSTALLER_CONFIG_FILE); + + $response->json(['success' => true]); + } +} diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Error.php b/src/Appwrite/Platform/Installer/Http/Installer/Error.php new file mode 100644 index 0000000000..506c545125 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Http/Installer/Error.php @@ -0,0 +1,37 @@ +setType(Action::TYPE_ERROR) + ->inject('error') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(\Throwable $error, Response $response): void + { + if ($response->isSent()) { + return; + } + $code = $error->getCode(); + if ($code < 100 || $code > 599) { + $code = 500; + } + $response->setStatusCode($code); + $message = $code >= 500 ? 'Internal installer error' : $error->getMessage(); + $response->json(['success' => false, 'message' => $message]); + } +} diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Install.php b/src/Appwrite/Platform/Installer/Http/Installer/Install.php new file mode 100644 index 0000000000..0b2fa17c0d --- /dev/null +++ b/src/Appwrite/Platform/Installer/Http/Installer/Install.php @@ -0,0 +1,402 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/install') + ->desc('Run installation') + ->param('appDomain', '', new AppDomain(), 'Application domain (hostname, IP, or bracket IPv6 with optional port)') + ->param('httpPort', 80, new Range(1, 65535), 'HTTP port') + ->param('httpsPort', 443, new Range(1, 65535), 'HTTPS port') + ->param('emailCertificates', '', new Email(), 'Email for SSL certificates') + ->param('opensslKey', '', new Text(64, 0), 'Secret API key', true) + ->param('assistantOpenAIKey', '', new Text(256, 0), 'OpenAI API key for assistant', true) + ->param('accountEmail', '', new Email(allowEmpty: true), 'Account email address', true) + ->param('accountPassword', '', new Password(allowEmpty: true), 'Account password', true) + ->param('database', '', new WhiteList(['mongodb', 'mariadb', 'postgresql']), 'Database adapter', true) + ->param('installId', '', new Text(64, 0), 'Installation ID', true) + ->param('retryStep', null, new Nullable(new WhiteList([Server::STEP_DOCKER_COMPOSE, Server::STEP_ENV_VARS, Server::STEP_DOCKER_CONTAINERS], true)), 'Retry from step', true) + ->inject('request') + ->inject('response') + ->inject('swooleResponse') + ->inject('installerState') + ->inject('installerConfig') + ->inject('installerPaths') + ->callback($this->action(...)); + } + + public function action( + string $appDomain, + int $httpPort, + int $httpsPort, + string $emailCertificates, + string $opensslKey, + string $assistantOpenAIKey, + string $accountEmail, + string $accountPassword, + string $database, + string $installId, + ?string $retryStep, + Request $request, + Response $response, + SwooleResponse $swooleResponse, + State $state, + Config $config, + array $paths + ): void { + $acceptHeader = $request->getHeader('accept'); + $wantsStream = stripos($acceptHeader, 'text/event-stream') !== false; + + if ($wantsStream) { + $swooleResponse->header('Content-Type', 'text/event-stream'); + $swooleResponse->header('Cache-Control', 'no-cache'); + $swooleResponse->header('Connection', 'keep-alive'); + $swooleResponse->header('X-Accel-Buffering', 'no'); + + $swooleResponse->write("event: ping\ndata: {\"time\":" . time() . "}\n\n"); + } + + if (!Validate::validateCsrf($request)) { + $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Invalid CSRF token'); + return; + } + + $appDomain = trim($appDomain); + $emailCertificates = trim($emailCertificates); + $opensslKey = trim($opensslKey); + $assistantOpenAIKey = trim($assistantOpenAIKey); + + if ($opensslKey === '' && !$config->isUpgrade()) { + $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Secret key is required'); + return; + } + + $account = []; + if (!$config->isUpgrade()) { + $accountEmail = trim($accountEmail); + if ($accountEmail === '' || !$state->isValidEmailAddress($accountEmail)) { + $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Please enter a valid email address', Server::STEP_ACCOUNT_SETUP); + return; + } + + if (!$state->isValidPassword($accountPassword)) { + $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Password must be at least 8 characters', Server::STEP_ACCOUNT_SETUP); + return; + } + + $accountName = $this->deriveNameFromEmail($accountEmail); + + $account = [ + 'name' => $accountName, + 'email' => $accountEmail, + 'password' => $accountPassword, + ]; + } + + $lockedDatabase = $config->getLockedDatabase(); + if (!$lockedDatabase) { + $database = strtolower(trim($database)); + if (!$state->isValidDatabaseAdapter($database)) { + $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Please select a supported database'); + return; + } + if (!$config->isDatabaseEnabled($database)) { + $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'The selected database is not available'); + return; + } + } + + $installId = $state->sanitizeInstallId($installId); + if ($installId === '') { + $installId = bin2hex(random_bytes(8)); + } + + @unlink(Server::INSTALLER_COMPLETE_FILE); + + try { + $lockResult = $state->reserveGlobalLock($installId); + } catch (\Throwable $e) { + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => 'Lock failed: ' . $e->getMessage()]); + $swooleResponse->end(); + } else { + $response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR); + $response->json(['success' => false, 'message' => 'Lock failed: ' . $e->getMessage()]); + } + return; + } + + if ($lockResult !== 'ok') { + $lockMessage = $lockResult === 'locked' + ? 'Installation already in progress' + : 'Installer lock unavailable'; + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => $lockMessage]); + $swooleResponse->end(); + } else { + $statusCode = $lockResult === 'locked' + ? Response::STATUS_CODE_CONFLICT + : Response::STATUS_CODE_SERVICE_UNAVAILABLE; + $response->setStatusCode($statusCode); + $response->json(['success' => false, 'message' => $lockMessage]); + } + return; + } + + $existingPath = $state->progressFilePath($installId); + $existing = null; + if (file_exists($existingPath)) { + $existing = $state->readProgressFile($installId); + if (!empty($existing['steps']) && $retryStep === null) { + $state->updateGlobalLock($installId, Server::STATUS_ERROR); + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => 'Installation already started']); + $swooleResponse->end(); + } else { + $response->setStatusCode(Response::STATUS_CODE_CONFLICT); + $response->json(['success' => false, 'message' => 'Installation already started']); + } + return; + } + } + + try { + $state->ensureBootstrapped(); + $installer = new \Appwrite\Platform\Tasks\Install(); + + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, 'install-id', ['installId' => $installId]); + } + + $state->updateGlobalLock($installId, Server::STATUS_IN_PROGRESS); + + $payloadInput = [ + '_APP_ENV' => 'production', + '_APP_OPENSSL_KEY_V1' => $opensslKey, + '_APP_DOMAIN' => $appDomain ?: 'localhost', + '_APP_DOMAIN_TARGET' => $appDomain ?: 'localhost', + '_APP_EMAIL_CERTIFICATES' => $emailCertificates, + '_APP_DB_ADAPTER' => $lockedDatabase ?? ($database ?: 'mongodb'), + '_APP_ASSISTANT_OPENAI_API_KEY' => $assistantOpenAIKey, + ]; + + if ($this->hasPayload($existing)) { + $stored = $existing['payload']; + $inputValues = [ + 'httpPort' => (string) $httpPort, + 'httpsPort' => (string) $httpsPort, + 'database' => $database, + 'appDomain' => $appDomain, + 'emailCertificates' => $emailCertificates, + ]; + foreach ($inputValues as $field => $inputValue) { + if (isset($stored[$field]) && $inputValue !== '') { + $storedValue = (string) $stored[$field]; + if (in_array($field, ['httpPort', 'httpsPort'], true)) { + $storedValue = trim($storedValue); + $inputValue = trim($inputValue); + } + if ($storedValue !== $inputValue) { + if ($installId !== '') { + $state->updateGlobalLock($installId, Server::STATUS_ERROR); + } + $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch'); + return; + } + } + } + + $sensitiveFields = [ + 'opensslKey' => ['hash' => 'opensslKeyHash', 'value' => $opensslKey], + 'assistantOpenAIKey' => ['hash' => 'assistantOpenAIKeyHash', 'value' => $assistantOpenAIKey], + ]; + foreach ($sensitiveFields as $field => $info) { + $hashField = $info['hash']; + $incomingValue = $info['value']; + if (!isset($stored[$hashField]) && !isset($stored[$field])) { + continue; + } + $incomingHash = $state->hashSensitiveValue($incomingValue); + if (isset($stored[$hashField])) { + if (!hash_equals((string) $stored[$hashField], $incomingHash)) { + if ($installId !== '') { + $state->updateGlobalLock($installId, Server::STATUS_ERROR); + } + $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch'); + return; + } + } elseif (isset($stored[$field]) && $incomingValue !== '' && (string) $stored[$field] !== $incomingValue) { + if ($installId !== '') { + $state->updateGlobalLock($installId, Server::STATUS_ERROR); + } + $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch'); + return; + } + } + + $payloadInput['_APP_DOMAIN'] = $stored['appDomain'] ?? $payloadInput['_APP_DOMAIN']; + $payloadInput['_APP_DOMAIN_TARGET'] = $stored['appDomain'] ?? $payloadInput['_APP_DOMAIN_TARGET']; + $payloadInput['_APP_EMAIL_CERTIFICATES'] = $stored['emailCertificates'] ?? $payloadInput['_APP_EMAIL_CERTIFICATES']; + $payloadInput['_APP_DB_ADAPTER'] = $lockedDatabase ?? ($stored['database'] ?? $payloadInput['_APP_DB_ADAPTER']); + $httpPort = (int) ($stored['httpPort'] ?? $httpPort ?: $config->getDefaultHttpPort()); + $httpsPort = (int) ($stored['httpsPort'] ?? $httpsPort ?: $config->getDefaultHttpsPort()); + } + + $vars = $config->getVars(); + $shouldGenerateSecrets = !$installer->hasExistingConfig() && !$config->isUpgrade(); + $envVars = $installer->prepareEnvironmentVariables($payloadInput, $vars, $shouldGenerateSecrets); + + $state->writeProgressFile($installId, [ + 'payload' => [ + 'httpPort' => $httpPort ?: $config->getDefaultHttpPort(), + 'httpsPort' => $httpsPort ?: $config->getDefaultHttpsPort(), + 'database' => $lockedDatabase ?? ($database ?: 'mongodb'), + 'appDomain' => $appDomain ?: 'localhost', + 'emailCertificates' => $emailCertificates, + 'opensslKeyHash' => $state->hashSensitiveValue($opensslKey), + 'assistantOpenAIKeyHash' => $state->hashSensitiveValue($assistantOpenAIKey), + ], + 'step' => 'start', + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Installation started', + 'updatedAt' => time(), + ]); + + $progress = function (string $step, string $status, string $message, array $details = []) use ($installId, $wantsStream, $swooleResponse, $state) { + $payload = [ + 'installId' => $installId, + 'step' => $step, + 'status' => $status, + 'message' => $message, + 'updatedAt' => time(), + ]; + if (!empty($details)) { + $payload['details'] = $details; + } + $state->writeProgressFile($installId, $payload); + $state->updateGlobalLock($installId, Server::STATUS_IN_PROGRESS); + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, 'progress', $payload); + } + }; + + $installer->performInstallation( + $httpPort ?: $config->getDefaultHttpPort(), + $httpsPort ?: $config->getDefaultHttpsPort(), + $config->getOrganization(), + $config->getImage(), + $envVars, + $config->getNoStart(), + $progress, + $retryStep, + $config->isUpgrade(), + $account + ); + + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, 'done', ['installId' => $installId, 'success' => true]); + usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS); + $swooleResponse->write(": keepalive\n\n"); + usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS); + $swooleResponse->end(); + } else { + $response->json([ + 'success' => true, + 'installId' => $installId, + 'message' => 'Installation completed successfully', + ]); + } + $state->updateGlobalLock($installId, Server::STATUS_COMPLETED); + } catch (\Throwable $e) { + $this->handleInstallationError($e, $installId, $wantsStream, $response, $swooleResponse, $state); + } + } + + private function writeSseEvent(SwooleResponse $swooleResponse, string $event, array $payload): void + { + $swooleResponse->write("event: $event\ndata: " . json_encode($payload) . "\n\n"); + } + + private function sendBadRequest(Response $response, SwooleResponse $swooleResponse, bool $wantsStream, string $message, string $step = Server::STEP_CONFIG_FILES): void + { + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => $message, 'step' => $step]); + $swooleResponse->end(); + } else { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['success' => false, 'message' => $message]); + } + } + + private function handleInstallationError(\Throwable $e, string $installId, bool $wantsStream, Response $response, SwooleResponse $swooleResponse, State $state): void + { + if ($installId !== '') { + $state->writeProgressFile($installId, [ + 'step' => Server::STATUS_ERROR, + 'status' => Server::STATUS_ERROR, + 'message' => $e->getMessage(), + 'details' => $this->buildErrorDetails($e), + 'updatedAt' => time(), + ]); + $state->updateGlobalLock($installId, Server::STATUS_ERROR); + } + + @unlink(Server::INSTALLER_CONFIG_FILE); + + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, [ + 'message' => $e->getMessage(), + 'details' => $this->buildErrorDetails($e) + ]); + $swooleResponse->end(); + } else { + $response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR); + $response->json(['success' => false, 'message' => $e->getMessage()]); + } + } + + private function buildErrorDetails(\Throwable $e): array + { + return []; + } + + private function hasPayload(mixed $data): bool + { + return is_array($data) && isset($data['payload']) && is_array($data['payload']); + } + + private function deriveNameFromEmail(string $email): string + { + $parts = explode('@', $email); + $username = $parts[0] ?? ''; + $cleaned = preg_replace('/[^a-zA-Z0-9]/', '', $username); + return ucfirst($cleaned); + } +} diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Shutdown.php b/src/Appwrite/Platform/Installer/Http/Installer/Shutdown.php new file mode 100644 index 0000000000..7828038ffe --- /dev/null +++ b/src/Appwrite/Platform/Installer/Http/Installer/Shutdown.php @@ -0,0 +1,48 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/install/shutdown') + ->desc('Shutdown installer server') + ->inject('request') + ->inject('response') + ->inject('swooleServer') + ->callback($this->action(...)); + } + + public function action(Request $request, Response $response, ?SwooleServer $swooleServer): void + { + if (!Validate::validateCsrf($request)) { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['success' => false, 'message' => 'Invalid CSRF token']); + return; + } + + $response->json(['success' => true]); + + if ($swooleServer) { + Timer::after(self::SHUTDOWN_DELAY_SECONDS * 1000, function () use ($swooleServer) { + $swooleServer->shutdown(); + }); + } + } +} diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Status.php b/src/Appwrite/Platform/Installer/Http/Installer/Status.php new file mode 100644 index 0000000000..e53a501f4c --- /dev/null +++ b/src/Appwrite/Platform/Installer/Http/Installer/Status.php @@ -0,0 +1,65 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/install/status') + ->desc('Poll installation progress') + ->param('installId', '', new Text(64, 0), 'Installation ID', true) + ->inject('response') + ->inject('installerState') + ->callback($this->action(...)); + } + + public function action(string $installId, Response $response, State $state): void + { + $installId = $state->sanitizeInstallId($installId); + if ($installId === '') { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['success' => false, 'message' => 'Missing installId']); + return; + } + + $path = $state->progressFilePath($installId); + if (!file_exists($path)) { + $response->setStatusCode(Response::STATUS_CODE_NOT_FOUND); + $response->json(['success' => false, 'message' => 'Install not found']); + return; + } + + $data = $state->readProgressFile($installId); + if (is_array($data) && isset($data['payload']) && is_array($data['payload'])) { + unset( + $data['payload']['opensslKey'], + $data['payload']['assistantOpenAIKey'], + $data['payload']['opensslKeyHash'], + $data['payload']['assistantOpenAIKeyHash'], + ); + } + // Strip sensitive data from step details + if (is_array($data) && isset($data['details']) && is_array($data['details'])) { + foreach ($data['details'] as $stepKey => &$stepDetails) { + if (is_array($stepDetails)) { + unset($stepDetails['sessionSecret'], $stepDetails['trace']); + } + } + unset($stepDetails); + } + $response->json(['success' => true, 'progress' => $data]); + } +} diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Validate.php b/src/Appwrite/Platform/Installer/Http/Installer/Validate.php new file mode 100644 index 0000000000..9a2e0b4528 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Http/Installer/Validate.php @@ -0,0 +1,45 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/install/validate') + ->desc('Validate CSRF token') + ->inject('request') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(Request $request, Response $response): void + { + if (!self::validateCsrf($request)) { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['success' => false, 'message' => 'Invalid CSRF token']); + return; + } + $response->json(['success' => true]); + } + + public static function validateCsrf(Request $request): bool + { + $cookie = $request->getCookie(Server::CSRF_COOKIE); + $header = $request->getHeader('x-appwrite-installer-csrf'); + + return $cookie !== '' && $header !== '' && hash_equals($cookie, $header); + } +} diff --git a/src/Appwrite/Platform/Installer/Http/Installer/View.php b/src/Appwrite/Platform/Installer/Http/Installer/View.php new file mode 100644 index 0000000000..ce308aa906 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Http/Installer/View.php @@ -0,0 +1,91 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/') + ->desc('Serve installer UI') + ->param('step', 1, new Integer(true), 'Step number (1-5)', true) + ->param('partial', null, new Nullable(new Text(1, 0)), 'Render partial step only', true) + ->inject('request') + ->inject('response') + ->inject('installerConfig') + ->inject('installerPaths') + ->callback($this->action(...)); + } + + public function action(int $step, ?string $partial, Request $request, Response $response, Config $config, array $paths): void + { + $csrfToken = $this->makeCsrf($request, $response); + + $response->addHeader('Content-Security-Policy', implode('; ', Server::INSTALLER_CSP)); + + $vars = $config->getVars(); + $defaultHttpPort = $config->getDefaultHttpPort(); + $defaultHttpsPort = $config->getDefaultHttpsPort(); + $isUpgrade = $config->isUpgrade(); + $lockedDatabase = $config->getLockedDatabase(); + $enabledDatabases = $config->getEnabledDatabases(); + $isLocalInstall = $config->isLocal(); + + $defaultEmailCertificates = $vars['_APP_EMAIL_CERTIFICATES']['default'] ?? ''; + if ($isLocalInstall && empty($defaultEmailCertificates)) { + $defaultEmailCertificates = 'walterobrien@example.com'; + } + + $step = max(1, min(5, $step)); + if ($isUpgrade && ($step === 2 || $step === 3)) { + $step = 4; + } + + $partialFile = $paths['views'] . "/installer/templates/steps/step-{$step}.phtml"; + if (!is_file($partialFile)) { + $partialFile = $paths['views'] . '/installer/templates/steps/step-1.phtml'; + } + + if ($partial !== null) { + ob_start(); + include $partialFile; + $html = ob_get_clean(); + $response->html($html); + return; + } + + ob_start(); + include $paths['views'] . '/installer.phtml'; + $html = ob_get_clean(); + + $response->html($html); + } + + private function makeCsrf(Request $request, Response $response): string + { + $existing = $request->getCookie(Server::CSRF_COOKIE); + if ($existing !== '') { + return $existing; + } + + $token = bin2hex(random_bytes(16)); + $response->addCookie(Server::CSRF_COOKIE, $token, null, '/', null, null, true, Response::COOKIE_SAMESITE_STRICT); + return $token; + } +} diff --git a/src/Appwrite/Platform/Installer/Installer.php b/src/Appwrite/Platform/Installer/Installer.php new file mode 100644 index 0000000000..c5b7c31674 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Installer.php @@ -0,0 +1,13 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Installer/Runtime/Config.php b/src/Appwrite/Platform/Installer/Runtime/Config.php new file mode 100644 index 0000000000..99db12dfed --- /dev/null +++ b/src/Appwrite/Platform/Installer/Runtime/Config.php @@ -0,0 +1,235 @@ +containsKnownKeys($values)) { + $this->setVars($values); + return; + } + $this->apply($values); + } + + public function apply(array $values): void + { + if ($this->hasValidStringValue($values, 'defaultHttpPort')) { + $this->setDefaultHttpPort((string) $values['defaultHttpPort']); + } + if ($this->hasValidStringValue($values, 'defaultHttpsPort')) { + $this->setDefaultHttpsPort((string) $values['defaultHttpsPort']); + } + if ($this->hasValidStringValue($values, 'organization')) { + $this->setOrganization((string) $values['organization']); + } + if ($this->hasValidStringValue($values, 'image')) { + $this->setImage((string) $values['image']); + } + if (array_key_exists('noStart', $values) && $values['noStart'] !== null) { + $this->setNoStart((bool) $values['noStart']); + } + if (array_key_exists('isUpgrade', $values) && $values['isUpgrade'] !== null) { + $this->setIsUpgrade((bool) $values['isUpgrade']); + } + if (array_key_exists('isLocal', $values) && $values['isLocal'] !== null) { + $this->setIsLocal((bool) $values['isLocal']); + } + if (array_key_exists('hostPath', $values)) { + $hostPath = $values['hostPath']; + $this->setHostPath($hostPath !== null && $hostPath !== '' ? (string) $hostPath : null); + } + if ($this->hasValidStringValue($values, 'lockedDatabase')) { + $this->setLockedDatabase((string) $values['lockedDatabase']); + } + if (array_key_exists('enabledDatabases', $values) && is_array($values['enabledDatabases'])) { + $this->setEnabledDatabases($values['enabledDatabases']); + } + if (array_key_exists('vars', $values) && is_array($values['vars'])) { + $this->setVars($values['vars']); + } + } + + private function hasValidStringValue(array $values, string $key): bool + { + return array_key_exists($key, $values) && $values[$key] !== null && $values[$key] !== ''; + } + + private function containsKnownKeys(array $values): bool + { + foreach (self::KNOWN_KEYS as $key) { + if (array_key_exists($key, $values)) { + return true; + } + } + return false; + } + + public function toArray(): array + { + return [ + 'defaultHttpPort' => $this->defaultHttpPort, + 'defaultHttpsPort' => $this->defaultHttpsPort, + 'organization' => $this->organization, + 'image' => $this->image, + 'noStart' => $this->noStart, + 'vars' => $this->vars, + 'isUpgrade' => $this->isUpgrade, + 'isLocal' => $this->isLocal, + 'hostPath' => $this->hostPath, + 'lockedDatabase' => $this->lockedDatabase, + 'enabledDatabases' => $this->enabledDatabases, + ]; + } + + public function getDefaultHttpPort(): string + { + return $this->defaultHttpPort; + } + + public function setDefaultHttpPort(string $value): void + { + $this->defaultHttpPort = $value; + } + + public function getDefaultHttpsPort(): string + { + return $this->defaultHttpsPort; + } + + public function setDefaultHttpsPort(string $value): void + { + $this->defaultHttpsPort = $value; + } + + public function getOrganization(): string + { + return $this->organization; + } + + public function setOrganization(string $value): void + { + $this->organization = $value; + } + + public function getImage(): string + { + return $this->image; + } + + public function setImage(string $value): void + { + $this->image = $value; + } + + public function getNoStart(): bool + { + return $this->noStart; + } + + public function setNoStart(bool $value): void + { + $this->noStart = $value; + } + + public function getVars(): array + { + return $this->vars; + } + + public function setVars(array $vars): void + { + $this->vars = $vars; + } + + public function isUpgrade(): bool + { + return $this->isUpgrade; + } + + public function setIsUpgrade(bool $value): void + { + $this->isUpgrade = $value; + } + + public function isLocal(): bool + { + return $this->isLocal; + } + + public function setIsLocal(bool $value): void + { + $this->isLocal = $value; + } + + public function getHostPath(): ?string + { + return $this->hostPath; + } + + public function setHostPath(?string $value): void + { + $this->hostPath = $value; + } + + public function getLockedDatabase(): ?string + { + return $this->lockedDatabase; + } + + public function setLockedDatabase(?string $value): void + { + $this->lockedDatabase = $value; + } + + /** + * @return string[] + */ + public function getEnabledDatabases(): array + { + return $this->enabledDatabases; + } + + /** + * @param string[] $value + */ + public function setEnabledDatabases(array $value): void + { + $filtered = array_values(array_filter($value, fn ($v) => is_string($v) && $v !== '')); + if (!empty($filtered)) { + $this->enabledDatabases = $filtered; + } + } + + public function isDatabaseEnabled(string $adapter): bool + { + return in_array($adapter, $this->enabledDatabases, true); + } +} diff --git a/src/Appwrite/Platform/Installer/Runtime/State.php b/src/Appwrite/Platform/Installer/Runtime/State.php new file mode 100644 index 0000000000..5552eb5632 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Runtime/State.php @@ -0,0 +1,467 @@ +paths = $paths; + } + + public function buildConfig(array $overrides = [], bool $useEnv = true): Config + { + $cfg = new Config(); + $configJson = null; + $decodedOk = false; + if ($useEnv) { + $configJson = getenv('APPWRITE_INSTALLER_CONFIG'); + if ($configJson !== false && $configJson !== '') { + $decoded = json_decode($configJson, true); + if (is_array($decoded)) { + $cfg->apply($decoded); + $decodedOk = true; + } + } + } + if ($useEnv && (!$decodedOk)) { + $fileConfig = $this->readConfigFile(); + if (is_array($fileConfig)) { + $cfg->apply($fileConfig); + } + } + + if ($cfg->isLocal() && empty($cfg->getVars())) { + $envPath = dirname(__DIR__, 5) . '/.env'; + if (file_exists($envPath)) { + $envContent = file_get_contents($envPath); + if ($envContent !== false) { + $vars = $this->parseEnvFile($envContent); + if (!empty($vars)) { + $cfg->setVars($vars); + } + } + } + } + + $cfg->apply($overrides); + + return $cfg; + } + + public function applyEnvConfig(Config|array $cfg): void + { + $values = $cfg instanceof Config ? $cfg->toArray() : $cfg; + $json = json_encode($values, JSON_UNESCAPED_SLASHES); + if (!is_string($json)) { + return; + } + putenv('APPWRITE_INSTALLER_CONFIG=' . $json); + $this->writeConfigFile($json); + } + + private function readConfigFile(): ?array + { + $path = Server::INSTALLER_CONFIG_FILE; + if (!file_exists($path)) { + return null; + } + $contents = file_get_contents($path); + if ($contents === false || $contents === '') { + return null; + } + $decoded = json_decode($contents, true); + return is_array($decoded) ? $decoded : null; + } + + private function writeConfigFile(string $json): void + { + $path = Server::INSTALLER_CONFIG_FILE; + if (@file_put_contents($path, $json) === false) { + return; + } + @chmod($path, self::CONFIG_FILE_PERMISSION); + } + + + public function ensureBootstrapped(): void + { + if ($this->bootstrapped) { + return; + } + + require_once __DIR__ . '/../../../../../app/init.php'; + $this->bootstrapped = true; + } + + public function sanitizeInstallId($value): string + { + if (!is_string($value)) { + return ''; + } + + $clean = preg_replace(self::PATTERN_INSTALL_ID_SANITIZE, '', $value); + if (!is_string($clean)) { + return ''; + } + + return substr($clean, 0, 64); + } + + public function hashSensitiveValue(string $value): string + { + $trimmed = trim($value); + if ($trimmed === '') { + return ''; + } + return hash('sha256', $trimmed); + } + + public function isValidPort($value): bool + { + $string = (string) $value; + if ($string === '' || !preg_match(self::PATTERN_DIGITS_ONLY, $string)) { + return false; + } + $port = (int) $string; + return $port >= self::PORT_MIN && $port <= self::PORT_MAX; + } + + public function isValidEmailAddress(string $value): bool + { + return filter_var($value, FILTER_VALIDATE_EMAIL) !== false; + } + + public function isValidPassword(string $value): bool + { + return strlen($value) >= 8 && preg_match(self::PATTERN_HAS_NON_WHITESPACE, $value) === 1; + } + + public function isValidSecretKey(string $value): bool + { + return $value !== '' && strlen($value) <= 64; + } + + public function isValidAccountName(string $value): bool + { + return trim($value) !== ''; + } + + public function isValidAppDomainInput(string $value): bool + { + $value = trim($value); + if ($value === '') { + return false; + } + + $host = $value; + $port = null; + + if (str_starts_with($value, '[')) { + if (!preg_match(self::PATTERN_IPV6_WITH_PORT, $value, $matches)) { + return false; + } + $host = $matches[1] ?? ''; + $port = $matches[2] ?? null; + } else { + $parts = explode(':', $value); + if (count($parts) > 2) { + return false; + } + if (count($parts) === 2) { + [$host, $port] = $parts; + } + } + + if ($port !== null && $port !== '' && !$this->isValidPort($port)) { + return false; + } + + return $this->isValidAppDomain($host); + } + + public function isValidDatabaseAdapter(string $value): bool + { + return in_array($value, ['mongodb', 'mariadb', 'postgresql'], true); + } + + public function progressFilePath(string $installId): string + { + return sys_get_temp_dir() . '/appwrite-install-' . $installId . '.json'; + } + + public function clearStaleLock(): void + { + $this->withGlobalLock(function ($handle, $lock) { + if (!$handle) { + return; + } + if ($lock === null) { + return; + } + if ($this->isGlobalLockActive($lock)) { + return; + } + ftruncate($handle, 0); + rewind($handle); + }); + + $tempDir = sys_get_temp_dir(); + foreach ((array) glob($tempDir . '/appwrite-install-*.json') as $file) { + $contents = @file_get_contents($file); + if ($contents === false) { + continue; + } + $data = json_decode($contents, true); + if (!is_array($data)) { + @unlink($file); + continue; + } + $updatedAt = $data['updatedAt'] ?? 0; + $age = time() - (int) $updatedAt; + $isTerminal = isset($data['error']); + if (!$isTerminal && !empty($data['steps'])) { + $isTerminal = true; + foreach ($data['steps'] as $step) { + if (($step['status'] ?? '') !== Server::STATUS_COMPLETED) { + $isTerminal = false; + break; + } + } + } + if ($age > self::GLOBAL_LOCK_TIMEOUT_SECONDS) { + @unlink($file); + } elseif ($isTerminal && $age > 60) { + @unlink($file); + } + } + } + + public function reserveGlobalLock(string $installId): string + { + return (string) $this->withGlobalLock(function ($handle, $lock) use ($installId) { + if (!$handle) { + return 'unavailable'; + } + if ($this->isGlobalLockActive($lock) && ($lock['installId'] ?? '') !== $installId) { + return 'locked'; + } + $payload = [ + 'installId' => $installId, + 'status' => Server::STATUS_IN_PROGRESS, + 'updatedAt' => time(), + ]; + ftruncate($handle, 0); + rewind($handle); + fwrite($handle, json_encode($payload)); + return 'ok'; + }); + } + + public function updateGlobalLock(string $installId, string $status): void + { + $this->withGlobalLock(function ($handle, $lock) use ($installId, $status) { + if (!$handle) { + return; + } + if ($this->isGlobalLockActive($lock) && ($lock['installId'] ?? '') !== $installId) { + return; + } + $payload = [ + 'installId' => $installId, + 'status' => $status, + 'updatedAt' => time(), + ]; + ftruncate($handle, 0); + rewind($handle); + fwrite($handle, json_encode($payload)); + }); + } + + public function readProgressFile(string $installId): array + { + $path = $this->progressFilePath($installId); + if (!file_exists($path)) { + return [ + 'installId' => $installId, + 'steps' => [], + ]; + } + + $contents = file_get_contents($path); + if ($contents === false) { + return [ + 'installId' => $installId, + 'steps' => [], + ]; + } + + $data = json_decode($contents, true); + if (!is_array($data)) { + return [ + 'installId' => $installId, + 'steps' => [], + ]; + } + + return $data; + } + + public function writeProgressFile(string $installId, array $payload): void + { + $data = $this->readProgressFile($installId); + if (!isset($data['steps']) || !is_array($data['steps'])) { + $data['steps'] = []; + } + + if (!empty($payload['step'])) { + $data['steps'][$payload['step']] = [ + 'status' => $payload['status'] ?? Server::STATUS_IN_PROGRESS, + 'message' => $payload['message'] ?? '', + 'updatedAt' => $payload['updatedAt'] ?? time(), + ]; + } + + if (!empty($payload['status']) && $payload['status'] === Server::STATUS_ERROR) { + $data['error'] = $payload['message'] ?? 'Installation failed'; + } + + if (isset($payload['details']) && is_array($payload['details'])) { + $data['details'][$payload['step']] = $payload['details']; + } + + if (isset($payload['payload']) && is_array($payload['payload'])) { + $data['payload'] = $payload['payload']; + if (!isset($data['startedAt'])) { + $data['startedAt'] = $payload['updatedAt'] ?? time(); + } + } + + $data['updatedAt'] = $payload['updatedAt'] ?? time(); + + file_put_contents($this->progressFilePath($installId), json_encode($data), LOCK_EX); + } + + private function parseEnvFile(string $contents): array + { + $vars = []; + foreach ((array) preg_split(self::PATTERN_LINE_BREAKS, $contents) as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#') { + continue; + } + $pos = strpos($line, '='); + if ($pos === false) { + continue; + } + $key = trim(substr($line, 0, $pos)); + $value = trim(substr($line, $pos + 1)); + if ($key === '') { + continue; + } + $value = $this->stripEnvQuotes($value); + + $vars[] = [ + 'name' => $key, + 'default' => $value, + ]; + } + + return $vars; + } + + private function stripEnvQuotes(string $value): string + { + if ($value === '') { + return $value; + } + $first = $value[0]; + $last = substr($value, -1); + if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) { + $value = substr($value, 1, -1); + } + return $value; + } + + private function globalLockPath(): string + { + return Server::INSTALLER_LOCK_FILE; + } + + private function isGlobalLockActive(?array $lock): bool + { + if (!$lock || !isset($lock['updatedAt'])) { + return false; + } + + if (isset($lock['status']) && in_array($lock['status'], [Server::STATUS_COMPLETED, Server::STATUS_ERROR], true)) { + return false; + } + + if (time() - (int) $lock['updatedAt'] > self::GLOBAL_LOCK_TIMEOUT_SECONDS) { + return false; + } + + return true; + } + + private function withGlobalLock(callable $callback) + { + $path = $this->globalLockPath(); + $handle = fopen($path, 'c+'); + if ($handle === false) { + return $callback(null, null); + } + if (!flock($handle, LOCK_EX)) { + fclose($handle); + return $callback(null, null); + } + + $contents = stream_get_contents($handle); + $lock = null; + if ($contents !== false && $contents !== '') { + $decoded = json_decode($contents, true); + if (is_array($decoded)) { + $lock = $decoded; + } + } + + try { + $result = $callback($handle, $lock); + } finally { + fflush($handle); + flock($handle, LOCK_UN); + fclose($handle); + } + + return $result; + } + + private function isValidAppDomain(string $value): bool + { + if ($value === 'localhost') { + return true; + } + if (filter_var($value, FILTER_VALIDATE_IP) !== false) { + return true; + } + return filter_var($value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false; + } +} diff --git a/src/Appwrite/Platform/Installer/Server.php b/src/Appwrite/Platform/Installer/Server.php new file mode 100644 index 0000000000..f36c270553 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Server.php @@ -0,0 +1,317 @@ +initPaths(); + + $this->state = new State($this->paths); + + if (PHP_SAPI === 'cli') { + $this->runCli(); + return; + } + } + + private function initPaths(): void + { + if (!empty($this->paths)) { + return; + } + + $root = dirname(__DIR__, 4); + $this->paths = [ + 'public' => $root . '/public', + 'views' => $root . '/app/views/install', + ]; + } + + private function runCli(): void + { + $opts = getopt('', ['upgrade', 'locked-database::', 'docker', 'clean', 'port::', 'ready-file::']); + $cfg = $this->state->buildConfig([], true); + $isDocker = isset($opts['docker']); + if ($isDocker) { + $cfg->setIsLocal(true); + if ($cfg->getHostPath() === null) { + $cwd = getcwd(); + if ($cwd !== false) { + $cfg->setHostPath($cwd); + } + } + } + if (isset($opts['upgrade'])) { + $cfg->setIsUpgrade(true); + } + if (!empty($opts['locked-database'])) { + $cfg->setLockedDatabase($opts['locked-database']); + } + $this->state->applyEnvConfig($cfg); + + $host = self::INSTALLER_WEB_HOST; + $port = !empty($opts['port']) ? (string) $opts['port'] : (string) self::INSTALLER_WEB_PORT; + $readyFile = !empty($opts['ready-file']) ? (string) $opts['ready-file'] : null; + + if (isset($opts['clean'])) { + $this->removeDockerInstallerContainer(self::DEFAULT_CONTAINER); + $this->cleanupWebInstallerFiles(); + exit(0); + } + + if (isset($opts['docker'])) { + $this->printInstallerUrl($host, $port); + $this->startDockerInstaller($opts); + } + + $this->printInstallerUrl($host, $port); + $this->startSwooleServer($host, (int) $port, $readyFile); + } + + private function printInstallerUrl(string $host, string $port): void + { + $displayHost = $host === self::INSTALLER_WEB_HOST ? 'localhost' : $host; + $url = "http://$displayHost:$port"; + fwrite(STDOUT, "Open $url" . PHP_EOL); + } + + private function startSwooleServer(string $host, int $port, ?string $readyFile = null): void + { + $this->state->clearStaleLock(); + + // Preload static files into memory + $files = new Files(); + $files->load($this->paths['views']); + + // Register resources for dependency injection into actions + $config = $this->state->buildConfig(); + $paths = $this->paths; + $state = $this->state; + + Http::setResource('installerState', fn () => $state); + Http::setResource('installerConfig', fn () => $config); + Http::setResource('installerPaths', fn () => $paths); + + // Register routes via Utopia Platform + $platform = new Installer(); + $platform->init(Service::TYPE_HTTP); + + // Register error handler directly so Http::error() preserves the '*' group + $errorHandler = new Error(); + Http::error() + ->inject('error') + ->inject('response') + ->action($errorHandler->action(...)); + + $adapter = new class ($host, $port, ['worker_num' => 1]) extends SwooleAdapter { + public function getNativeServer(): SwooleServer + { + return $this->server; + } + }; + + $nativeServer = $adapter->getNativeServer(); + + Http::setResource('swooleServer', fn () => $nativeServer); + + $nativeServer->on('start', function () use ($nativeServer, $port, $readyFile) { + \Swoole\Process::signal(SIGTERM, fn () => $nativeServer->shutdown()); + \Swoole\Process::signal(SIGINT, fn () => $nativeServer->shutdown()); + + if ($readyFile !== null) { + file_put_contents($readyFile, json_encode(['port' => $port, 'pid' => getmypid()])); + } + }); + + $adapter->onRequest(function (Request $request, Response $response) use ($files) { + // Serve static files from memory + $uri = $request->getURI(); + if ($files->isFileLoaded($uri)) { + $response + ->setContentType($files->getFileMimeType($uri)) + ->send($files->getFileContents($uri)); + return; + } + + $app = new Http('UTC'); + $app->run($request, $response); + }); + + $adapter->start(); + } + + private function removeDockerInstallerContainer(string $container): void + { + $name = escapeshellarg($container); + exec("docker rm -f $name >/dev/null 2>&1"); + } + + private function cleanupWebInstallerFiles(): void + { + $cwd = getcwd(); + if ($cwd === false) { + return; + } + + $filesToRemove = [ + $cwd . '/.env.web-installer', + $cwd . '/docker-compose.web-installer.yml', + ]; + + foreach ($filesToRemove as $file) { + if (file_exists($file)) { + @unlink($file); + } + } + + $tempDir = sys_get_temp_dir(); + @unlink(self::INSTALLER_LOCK_FILE); + @unlink(self::INSTALLER_CONFIG_FILE); + foreach ((array) glob($tempDir . '/appwrite-install-*.json') as $file) { + @unlink($file); + } + } + + private function dockerImageExists(string $image): bool + { + $result = 1; + exec("docker image inspect " . escapeshellarg($image) . " >/dev/null 2>&1", $output, $result); + return $result === 0; + } + + private function buildDockerInstallerImage(string $image): void + { + fwrite(STDOUT, "Building Docker image: {$image}\n"); + $buildCommand = 'docker compose build appwrite'; + passthru($buildCommand, $status); + if ($status !== 0 || !$this->dockerImageExists($image)) { + fwrite(STDERR, "Failed to build Docker image: $image\n"); + fwrite(STDERR, "Try: docker compose build appwrite\n"); + exit(1); + } + } + + private function ensureLocalInstallerTag(string $source, string $target): void + { + $sourceArg = escapeshellarg($source); + $targetArg = escapeshellarg($target); + exec("docker tag {$sourceArg} {$targetArg}", $tagOutput, $tagStatus); + if ($tagStatus !== 0) { + fwrite(STDERR, "Failed to tag Docker image {$source} as {$target}\n"); + exit(1); + } + } + + private function startDockerInstaller(array $opts): void + { + $image = self::DEFAULT_IMAGE; + $container = self::DEFAULT_CONTAINER; + if (!$this->dockerImageExists($image)) { + $this->buildDockerInstallerImage($image); + } + $this->ensureLocalInstallerTag($image, 'appwrite/appwrite:local'); + $port = (string)self::INSTALLER_WEB_PORT; + $entrypoint = isset($opts['upgrade']) ? 'upgrade' : 'install'; + + $this->removeDockerInstallerContainer($container); + + $root = realpath(dirname(__DIR__, 4)); + $volumePath = $root !== false ? $root : (getcwd() ?: '.'); + $dockerConfig = $this->state->buildConfig([], false); + $dockerConfig->setIsLocal(true); + $dockerConfig->setHostPath($volumePath); + if (isset($opts['upgrade'])) { + $dockerConfig->setIsUpgrade(true); + } + if (!empty($opts['locked-database'])) { + $dockerConfig->setLockedDatabase($opts['locked-database']); + } + $configJson = json_encode($dockerConfig->toArray(), JSON_UNESCAPED_SLASHES); + if (!is_string($configJson)) { + $configJson = '{}'; + } + + $args = [ + 'docker', + 'run', + '-i', + '--rm', + '--name', $container, + '-p', "127.0.0.1:$port:" . self::INSTALLER_WEB_PORT, + '--volume', '/var/run/docker.sock:/var/run/docker.sock', + '--volume', "$volumePath:/usr/src/code:rw", + ]; + $args[] = '-e'; + $args[] = 'APPWRITE_INSTALLER_CONFIG=' . $configJson; + $args[] = '--entrypoint=' . $entrypoint; + $args[] = $image; + + $command = implode(' ', array_map(escapeshellarg(...), $args)); + passthru($command, $status); + exit($status); + } +} + +/** + * Run server only on direct CLI execution. + */ +function shouldRunInstallerServer(): bool +{ + return PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === realpath(__FILE__); +} + +if (shouldRunInstallerServer()) { + require_once __DIR__ . '/../../../../vendor/autoload.php'; + $server = new Server(); + $server->run(); +} diff --git a/src/Appwrite/Platform/Installer/Services/Http.php b/src/Appwrite/Platform/Installer/Services/Http.php new file mode 100644 index 0000000000..bd0fc62cdc --- /dev/null +++ b/src/Appwrite/Platform/Installer/Services/Http.php @@ -0,0 +1,26 @@ +type = Service::TYPE_HTTP; + + $this->addAction(View::getName(), new View()); + $this->addAction(Status::getName(), new Status()); + $this->addAction(Validate::getName(), new Validate()); + $this->addAction(Complete::getName(), new Complete()); + $this->addAction(Shutdown::getName(), new Shutdown()); + $this->addAction(Install::getName(), new Install()); + } +} diff --git a/src/Appwrite/Platform/Installer/Validator/AppDomain.php b/src/Appwrite/Platform/Installer/Validator/AppDomain.php new file mode 100644 index 0000000000..f631015654 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Validator/AppDomain.php @@ -0,0 +1,82 @@ + 2) { + return false; + } + if (count($parts) === 2) { + [$host, $port] = $parts; + } + } + + if ($port !== null && $port !== '') { + $portInt = (int) $port; + if ((string) $portInt !== $port || $portInt < 1 || $portInt > 65535) { + return false; + } + } + + return $this->isValidDomain($host); + } + + private function isValidDomain(string $value): bool + { + if ($value === 'localhost') { + return true; + } + if (filter_var($value, FILTER_VALIDATE_IP) !== false) { + return true; + } + return filter_var($value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false; + } +} diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php index cde6b90fd6..20a6afed2e 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php @@ -7,7 +7,6 @@ use Appwrite\Detector\Detector; use Appwrite\Event\Event; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -15,6 +14,7 @@ use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Template\Template; +use Appwrite\Usage\Context; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use libphonenumber\NumberParseException; @@ -104,7 +104,7 @@ class Create extends Action ->inject('queueForMessaging') ->inject('queueForMails') ->inject('timelimit') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('plan') ->inject('proofForToken') ->inject('proofForCode') @@ -124,7 +124,7 @@ class Create extends Action Messaging $queueForMessaging, Mail $queueForMails, callable $timelimit, - StatsUsage $queueForStatsUsage, + Context $usage, array $plan, ProofsToken $proofForToken, ProofsCode $proofForCode @@ -201,16 +201,12 @@ class Create extends Action $countryCode = $helper->parse($phone)->getCountryCode(); if (!empty($countryCode)) { - $queueForStatsUsage - ->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1); + $usage->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1); } } catch (NumberParseException $e) { // Ignore invalid phone number for country code stats } - $queueForStatsUsage - ->addMetric(METRIC_AUTH_METHOD_PHONE, 1) - ->setProject($project) - ->trigger(); + $usage->addMetric(METRIC_AUTH_METHOD_PHONE, 1); break; case Type::EMAIL: if (empty(System::getEnv('_APP_SMTP_HOST'))) { diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php index b6fd354ee3..2df12b17d1 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Avatars\Http\Screenshots; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Avatars\Http\Action; use Appwrite\SDK\AuthType; @@ -10,6 +9,7 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\MethodType; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Usage\Context; use Appwrite\Utopia\Response; use Utopia\Config\Config; use Utopia\Domains\Domain; @@ -84,11 +84,11 @@ class Get extends Action ->param('quality', -1, new Range(-1, 100), 'Screenshot quality. Pass an integer between 0 to 100. Defaults to keep existing image quality.', true, example: '85') ->param('output', '', new WhiteList(\array_keys(Config::getParam('storage-outputs')), true), 'Output format type (jpeg, jpg, png, gif and webp).', true, example: 'jpeg') ->inject('response') - ->inject('queueForStatsUsage') + ->inject('usage') ->callback($this->action(...)); } - public function action(string $url, array $headers, int $viewportWidth, int $viewportHeight, float $scale, string $theme, string $userAgent, bool $fullpage, string $locale, string $timezone, float $latitude, float $longitude, float $accuracy, bool $touch, array $permissions, int $sleep, int $width, int $height, int $quality, string $output, Response $response, StatsUsage $queueForStatsUsage) + public function action(string $url, array $headers, int $viewportWidth, int $viewportHeight, float $scale, string $theme, string $userAgent, bool $fullpage, string $locale, string $timezone, float $latitude, float $longitude, float $accuracy, bool $touch, array $permissions, int $sleep, int $width, int $height, int $quality, string $output, Response $response, Context $usage) { if (!\extension_loaded('imagick')) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing'); @@ -210,7 +210,7 @@ class Get extends Action $outputs = Config::getParam('storage-outputs'); $contentType = $outputs[$output] ?? $outputs['png']; - $queueForStatsUsage->addMetric(METRIC_AVATARS_SCREENSHOTS_GENERATED, 1); + $usage->addMetric(METRIC_AVATARS_SCREENSHOTS_GENERATED, 1); $response ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Email/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Email/Create.php index 5522853c11..6530cdb1dd 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Email/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Email/Create.php @@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attribu use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; -use Appwrite\Network\Validator\Email; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Deprecated; @@ -16,6 +15,7 @@ use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Key; use Utopia\Database\Validator\UID; +use Utopia\Emails\Validator\Email; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; use Utopia\Validator\Boolean; use Utopia\Validator\Nullable; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Email/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Email/Update.php index 71fa72378c..0322a69250 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Email/Update.php @@ -3,7 +3,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Email; use Appwrite\Event\Event; -use Appwrite\Network\Validator\Email; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -15,6 +14,7 @@ use Utopia\Database\Database; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Key; use Utopia\Database\Validator\UID; +use Utopia\Emails\Validator\Email; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; use Utopia\Validator\Boolean; use Utopia\Validator\Nullable; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php index 85813b2354..54557eaac0 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php @@ -3,7 +3,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Attribute; use Appwrite\Event\Event; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Action; use Appwrite\SDK\AuthType; @@ -11,6 +10,7 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Usage\Context; use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; use InvalidArgumentException; @@ -83,13 +83,13 @@ class Decrement extends Action ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('plan') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $min, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage, array $plan, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $min, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization): void { $isAPIKey = User::isApp($authorization->getRoles()); $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); @@ -200,7 +200,7 @@ class Decrement extends Action ) ); - $queueForStatsUsage + $usage ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, 1) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), 1); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php index 031b5abcc6..b9c19b2d06 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php @@ -3,7 +3,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Attribute; use Appwrite\Event\Event; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Action; use Appwrite\SDK\AuthType; @@ -11,6 +10,7 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Usage\Context; use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; use InvalidArgumentException; @@ -83,13 +83,13 @@ class Increment extends Action ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('plan') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $max, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage, array $plan, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $max, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization): void { $isAPIKey = User::isApp($authorization->getRoles()); $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); @@ -200,7 +200,7 @@ class Increment extends Action ) ); - $queueForStatsUsage + $usage ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, 1) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), 1); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php index 6ab67318c7..f45b126f16 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php @@ -3,7 +3,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Bulk; use Appwrite\Event\Event; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\Functions\EventProcessor; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Action; @@ -12,6 +11,7 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Usage\Context; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; use Utopia\Database\Document; @@ -76,7 +76,7 @@ class Delete extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('queueForEvents') ->inject('queueForRealtime') ->inject('queueForFunctions') @@ -86,7 +86,7 @@ class Delete extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void + public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Context $usage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void { $database = $dbForProject->getDocument('databases', $databaseId); if ($database->isEmpty()) { @@ -185,10 +185,10 @@ class Delete extends Action foreach ($documents as $document) { $document->setAttribute('$databaseId', $database->getId()); - $document->setAttribute('$'.$this->getCollectionsEventsContext().'Id', $collection->getId()); + $document->setAttribute('$' . $this->getCollectionsEventsContext() . 'Id', $collection->getId()); } - $queueForStatsUsage + $usage ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $modified)) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $modified)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php index d306414a89..000b59ff07 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php @@ -3,7 +3,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Bulk; use Appwrite\Event\Event; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\Functions\EventProcessor; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Action; @@ -12,6 +11,7 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Usage\Context; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; use Utopia\Database\Document; @@ -80,7 +80,7 @@ class Update extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('queueForEvents') ->inject('queueForRealtime') ->inject('queueForFunctions') @@ -90,7 +90,7 @@ class Update extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string|array $data, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void + public function action(string $databaseId, string $collectionId, string|array $data, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Context $usage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void { $data = \is_string($data) ? \json_decode($data, true) @@ -216,10 +216,10 @@ class Update extends Action foreach ($documents as $document) { $document->setAttribute('$databaseId', $database->getId()); - $document->setAttribute('$'.$this->getCollectionsEventsContext().'Id', $collection->getId()); + $document->setAttribute('$' . $this->getCollectionsEventsContext() . 'Id', $collection->getId()); } - $queueForStatsUsage + $usage ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $modified)) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $modified)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php index cf30fee173..564b5ee7b6 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php @@ -3,7 +3,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Bulk; use Appwrite\Event\Event; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\Functions\EventProcessor; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Action; @@ -12,6 +11,7 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Usage\Context; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; use Utopia\Database\Document; @@ -78,7 +78,7 @@ class Upsert extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('queueForEvents') ->inject('queueForRealtime') ->inject('queueForFunctions') @@ -88,7 +88,7 @@ class Upsert extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void + public function action(string $databaseId, string $collectionId, array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Context $usage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void { $database = $dbForProject->getDocument('databases', $databaseId); if ($database->isEmpty()) { @@ -106,7 +106,7 @@ class Upsert extends Action ); if ($hasRelationships) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Bulk upsert is not supported for ' . $this->getSDKNamespace() . ' with relationship attributes'); + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Bulk upsert is not supported for ' . $this->getSDKNamespace() . ' with relationship attributes'); } foreach ($documents as $key => $document) { @@ -191,10 +191,10 @@ class Upsert extends Action foreach ($upserted as $document) { $document->setAttribute('$databaseId', $database->getId()); - $document->setAttribute('$'.$this->getCollectionsEventsContext().'Id', $collection->getId()); + $document->setAttribute('$' . $this->getCollectionsEventsContext() . 'Id', $collection->getId()); } - $queueForStatsUsage + $usage ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $modified)) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $modified)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index 9b14122abf..0bbe7c75cf 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -3,7 +3,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents; use Appwrite\Event\Event; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\Functions\EventProcessor; use Appwrite\SDK\AuthType; @@ -12,6 +11,7 @@ use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Parameter; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Usage\Context; use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Response as UtopiaResponse; @@ -129,7 +129,7 @@ class Create extends Action ->inject('dbForProject') ->inject('user') ->inject('queueForEvents') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('queueForRealtime') ->inject('queueForFunctions') ->inject('queueForWebhooks') @@ -138,7 +138,7 @@ class Create extends Action ->inject('eventProcessor') ->callback($this->action(...)); } - public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Document $user, Event $queueForEvents, StatsUsage $queueForStatsUsage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, Authorization $authorization, EventProcessor $eventProcessor): void + public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Document $user, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, Authorization $authorization, EventProcessor $eventProcessor): void { $data = \is_string($data) ? \json_decode($data, true) @@ -205,7 +205,7 @@ class Create extends Action ); if ($isBulk && $hasRelationships) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Bulk create is not supported for ' . $this->getSDKNamespace() .' with relationship ' . $this->getStructureContext()); + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Bulk create is not supported for ' . $this->getSDKNamespace() . ' with relationship ' . $this->getStructureContext()); } $setPermissions = function (Document $document, ?array $permissions) use ($user, $isAPIKey, $isPrivilegedUser, $isBulk, $dbForProject, $authorization) { @@ -489,7 +489,7 @@ class Create extends Action ); } - $queueForStatsUsage + $usage ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $operations)) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $operations)); // per collection diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php index 3171fe7aaf..0996fa24ab 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php @@ -4,13 +4,13 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documen use Appwrite\Databases\TransactionState; use Appwrite\Event\Event; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Usage\Context; use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; @@ -80,7 +80,7 @@ class Delete extends Action ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('transactionState') ->inject('plan') ->inject('authorization') @@ -96,7 +96,7 @@ class Delete extends Action UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, - StatsUsage $queueForStatsUsage, + Context $usage, TransactionState $transactionState, array $plan, Authorization $authorization @@ -210,7 +210,7 @@ class Delete extends Action authorization: $authorization ); - $queueForStatsUsage + $usage ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, 1) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), 1); // per collection diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php index 515b7029e6..10de481072 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php @@ -3,13 +3,13 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents; use Appwrite\Databases\TransactionState; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Usage\Context; use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; @@ -68,13 +68,13 @@ class Get extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('transactionState') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, TransactionState $transactionState, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $documentId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Context $usage, TransactionState $transactionState, Authorization $authorization): void { $isAPIKey = User::isApp($authorization->getRoles()); $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); @@ -103,11 +103,9 @@ class Get extends Action // Use transaction-aware document retrieval if transactionId is provided if ($transactionId !== null) { $document = $transactionState->getDocument($collectionTableId, $documentId, $transactionId, $queries); - } elseif (! empty($selects)) { - // has selects, allow relationship on documents! + } elseif (!empty($selects)) { $document = $dbForProject->getDocument($collectionTableId, $documentId, $queries); } else { - // has no selects, disable relationship looping on documents! $document = $dbForProject->skipRelationships(fn () => $dbForProject->getDocument($collectionTableId, $documentId, $queries)); } } catch (QueryException $e) { @@ -130,7 +128,7 @@ class Get extends Action operations: $operations ); - $queueForStatsUsage + $usage ->addMetric(METRIC_DATABASES_OPERATIONS_READS, max($operations, 1)) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_READS), $operations); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php index 06eca79dad..ca7935dfbd 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php @@ -4,13 +4,13 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documen use Appwrite\Databases\TransactionState; use Appwrite\Event\Event; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Usage\Context; use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; @@ -84,14 +84,14 @@ class Update extends Action ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('transactionState') ->inject('plan') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage, TransactionState $transactionState, array $plan, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, Context $usage, TransactionState $transactionState, array $plan, Authorization $authorization): void { $data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array @@ -246,7 +246,7 @@ class Update extends Action $setCollection($collection, $newDocument); - $queueForStatsUsage + $usage ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, max($operations, 1)) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), $operations); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php index ef76ebe7cd..dc6655dfd3 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php @@ -4,13 +4,13 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documen use Appwrite\Databases\TransactionState; use Appwrite\Event\Event; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Usage\Context; use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Response as UtopiaResponse; @@ -88,14 +88,14 @@ class Upsert extends Action ->inject('user') ->inject('dbForProject') ->inject('queueForEvents') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('transactionState') ->inject('plan') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Document $user, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage, TransactionState $transactionState, array $plan, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Document $user, Database $dbForProject, Event $queueForEvents, Context $usage, TransactionState $transactionState, array $plan, Authorization $authorization): void { $data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array @@ -256,7 +256,7 @@ class Upsert extends Action $setCollection($collection, $newDocument); - $queueForStatsUsage + $usage ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $operations)) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $operations)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index 0c93eaf105..a7d77d8a93 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -3,19 +3,20 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents; use Appwrite\Databases\TransactionState; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Usage\Context; use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; 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\Exception\Timeout; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Query\Cursor; @@ -75,13 +76,13 @@ class XList extends Action ->inject('response') ->inject('dbForProject') ->inject('user') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('transactionState') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, Document $user, StatsUsage $queueForStatsUsage, TransactionState $transactionState, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, Document $user, Context $usage, TransactionState $transactionState, Authorization $authorization): void { $isAPIKey = User::isApp($authorization->getRoles()); $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); @@ -212,6 +213,8 @@ class XList extends Action throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, $message); } catch (QueryException $e) { throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } catch (Timeout) { + throw new Exception(Exception::DATABASE_TIMEOUT); } $operations = 0; @@ -228,7 +231,7 @@ class XList extends Action ); } - $queueForStatsUsage + $usage ->addMetric(METRIC_DATABASES_OPERATIONS_READS, max($operations, 1)) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_READS), $operations); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Delete.php index f849de94c1..1046d7e566 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Delete.php @@ -60,7 +60,6 @@ class Delete extends Action ->inject('dbForProject') ->inject('queueForDatabase') ->inject('queueForEvents') - ->inject('queueForStatsUsage') ->callback($this->action(...)); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php index a73ad70786..9a5a63ea91 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php @@ -5,13 +5,13 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Transactions; use Appwrite\Databases\TransactionState; use Appwrite\Event\Delete; use Appwrite\Event\Event; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\Functions\EventProcessor; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Usage\Context; use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; @@ -73,7 +73,7 @@ class Update extends Action ->inject('transactionState') ->inject('queueForDeletes') ->inject('queueForEvents') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('queueForRealtime') ->inject('queueForFunctions') ->inject('queueForWebhooks') @@ -92,7 +92,7 @@ class Update extends Action * @param TransactionState $transactionState * @param Delete $queueForDeletes * @param Event $queueForEvents - * @param StatsUsage $queueForStatsUsage + * @param Context $usage * @param Event $queueForRealtime * @param Event $queueForFunctions * @param Event $queueForWebhooks @@ -106,7 +106,7 @@ class Update extends Action * @throws Structure * @throws \Utopia\Http\Exception */ - public function action(string $transactionId, bool $commit, bool $rollback, UtopiaResponse $response, Database $dbForProject, Document $user, TransactionState $transactionState, Delete $queueForDeletes, Event $queueForEvents, StatsUsage $queueForStatsUsage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, Authorization $authorization, EventProcessor $eventProcessor): void + public function action(string $transactionId, bool $commit, bool $rollback, UtopiaResponse $response, Database $dbForProject, Document $user, TransactionState $transactionState, Delete $queueForDeletes, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, Authorization $authorization, EventProcessor $eventProcessor): void { if (!$commit && !$rollback) { throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Either commit or rollback must be true'); @@ -142,7 +142,7 @@ class Update extends Action $currentDocumentId = null; try { - $dbForProject->withTransaction(function () use ($dbForProject, $transactionState, $queueForDeletes, $transactionId, &$transaction, &$operations, &$totalOperations, &$databaseOperations, &$currentDocumentId, $queueForEvents, $queueForStatsUsage, $queueForRealtime, $queueForFunctions, $queueForWebhooks, $authorization) { + $dbForProject->withTransaction(function () use ($dbForProject, $transactionState, $queueForDeletes, $transactionId, &$transaction, &$operations, &$totalOperations, &$databaseOperations, &$currentDocumentId, $queueForEvents, $usage, $queueForRealtime, $queueForFunctions, $queueForWebhooks, $authorization) { $authorization->skip(fn () => $dbForProject->updateDocument('transactions', $transactionId, new Document([ 'status' => 'committing', ]))); @@ -279,11 +279,10 @@ class Update extends Action throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } - $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, $totalOperations); + $usage->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, $totalOperations); foreach ($databaseOperations as $sequence => $count) { - $queueForStatsUsage->addMetric( + $usage->addMetric( str_replace('{databaseInternalId}', $sequence, METRIC_DATABASE_ID_OPERATIONS_WRITES), $count ); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Delete.php index c6cd0c6999..7873d369e6 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Delete.php @@ -50,7 +50,6 @@ class Delete extends DatabaseDelete ->inject('dbForProject') ->inject('queueForDatabase') ->inject('queueForEvents') - ->inject('queueForStatsUsage') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php index 3da6a67be7..b0e81ed6b7 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Columns\Email; -use Appwrite\Network\Validator\Email; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Email\Create as EmailCreate; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -11,6 +10,7 @@ use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; use Utopia\Database\Validator\Key; use Utopia\Database\Validator\UID; +use Utopia\Emails\Validator\Email; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; use Utopia\Validator\Boolean; use Utopia\Validator\Nullable; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php index 47921f9579..d1278376c1 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Columns\Email; -use Appwrite\Network\Validator\Email; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Email\Update as EmailUpdate; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -12,6 +11,7 @@ use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; use Utopia\Database\Validator\Key; use Utopia\Database\Validator\UID; +use Utopia\Emails\Validator\Email; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; use Utopia\Validator\Boolean; use Utopia\Validator\Nullable; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php index a1695bdbc6..adaf83ccf1 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php @@ -61,7 +61,7 @@ class Delete extends DocumentsDelete ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('queueForEvents') ->inject('queueForRealtime') ->inject('queueForFunctions') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Update.php index a6bc78b3e9..d706d1f28b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Update.php @@ -63,7 +63,7 @@ class Update extends DocumentsUpdate ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('queueForEvents') ->inject('queueForRealtime') ->inject('queueForFunctions') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Upsert.php index 6c0815312d..58da5064f9 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Upsert.php @@ -63,7 +63,7 @@ class Upsert extends DocumentsUpsert ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('queueForEvents') ->inject('queueForRealtime') ->inject('queueForFunctions') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Decrement.php index 63d70b40e2..e1e717e9b1 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Decrement.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Decrement.php @@ -66,7 +66,7 @@ class Decrement extends DecrementDocumentAttribute ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('plan') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Increment.php index 5beb8468d9..0b20450254 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Increment.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Increment.php @@ -66,7 +66,7 @@ class Increment extends IncrementDocumentAttribute ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('plan') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Create.php index 4385303ffa..fde8005d2b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Create.php @@ -106,7 +106,7 @@ class Create extends DocumentCreate ->inject('dbForProject') ->inject('user') ->inject('queueForEvents') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('queueForRealtime') ->inject('queueForFunctions') ->inject('queueForWebhooks') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Delete.php index bee4dc1093..1845edc307 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Delete.php @@ -68,7 +68,7 @@ class Delete extends DocumentDelete ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('transactionState') ->inject('plan') ->inject('authorization') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Get.php index f0a7fcbbc2..43b799e5b1 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Get.php @@ -57,7 +57,7 @@ class Get extends DocumentGet ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('transactionState') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Update.php index 71abb5d167..c0d90f9531 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Update.php @@ -66,7 +66,7 @@ class Update extends DocumentUpdate ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('transactionState') ->inject('plan') ->inject('authorization') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Upsert.php index 0bcf9f9a63..7f0aa0ad7d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Upsert.php @@ -69,7 +69,7 @@ class Upsert extends DocumentUpsert ->inject('user') ->inject('dbForProject') ->inject('queueForEvents') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('transactionState') ->inject('plan') ->inject('authorization') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php index 3b8ac0a70e..6e5dcd9370 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php @@ -61,7 +61,7 @@ class XList extends DocumentXList ->inject('response') ->inject('dbForProject') ->inject('user') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('transactionState') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php index 7da389b265..68ea2b8901 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php @@ -57,7 +57,7 @@ class Update extends TransactionsUpdate ->inject('transactionState') ->inject('queueForDeletes') ->inject('queueForEvents') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('queueForRealtime') ->inject('queueForFunctions') ->inject('queueForWebhooks') diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index bc506c654a..ee33abe9e1 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -6,7 +6,6 @@ use Ahc\Jwt\JWT; use Appwrite\Event\Delete as DeleteEvent; use Appwrite\Event\Event; use Appwrite\Event\Func; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\Extend\Exception as AppwriteException; use Appwrite\Functions\Validator\Headers; @@ -15,6 +14,7 @@ use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Usage\Context; use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response; use Executor\Executor; @@ -93,7 +93,7 @@ class Create extends Base ->inject('dbForPlatform') ->inject('user') ->inject('queueForEvents') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('queueForFunctions') ->inject('geodb') ->inject('store') @@ -121,7 +121,7 @@ class Create extends Base Database $dbForPlatform, Document $user, Event $queueForEvents, - StatsUsage $queueForStatsUsage, + Context $usage, Func $queueForFunctions, Reader $geodb, Store $store, @@ -499,7 +499,7 @@ class Create extends Base throw $th; } } finally { - $queueForStatsUsage + $usage ->addMetric(METRIC_EXECUTIONS, 1) ->addMetric(str_replace(['{resourceType}'], [RESOURCE_TYPE_FUNCTIONS], METRIC_RESOURCE_TYPE_EXECUTIONS), 1) ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_FUNCTIONS, $function->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS), 1) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index c080f5d3dd..bcedeee764 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -5,11 +5,13 @@ namespace Appwrite\Platform\Modules\Functions\Workers; use Ahc\Jwt\JWT; use Appwrite\Event\Event; use Appwrite\Event\Func; +use Appwrite\Event\Message\Usage as UsageMessage; +use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; use Appwrite\Event\Screenshot; -use Appwrite\Event\StatsUsage; use Appwrite\Event\Webhook; use Appwrite\Filter\BranchDomain as BranchDomainFilter; +use Appwrite\Usage\Context; use Appwrite\Utopia\Response\Model\Deployment; use Appwrite\Vcs\Comment; use Exception; @@ -60,7 +62,8 @@ class Builds extends Action ->inject('queueForWebhooks') ->inject('queueForFunctions') ->inject('queueForRealtime') - ->inject('queueForStatsUsage') + ->inject('usage') + ->inject('publisherForUsage') ->inject('cache') ->inject('dbForProject') ->inject('deviceForFunctions') @@ -74,24 +77,6 @@ class Builds extends Action } /** - * @param Message $message - * @param Document $project - * @param Database $dbForPlatform - * @param Event $queueForEvents - * @param Screenshot $queueForScreenshots - * @param Webhook $queueForWebhooks - * @param Func $queueForFunctions - * @param Realtime $queueForRealtime - * @param StatsUsage $queueForStatsUsage - * @param Cache $cache - * @param Database $dbForProject - * @param Device $deviceForFunctions - * @param Device $deviceForSites - * @param Device $deviceForFiles - * @param Log $log - * @param Executor $executor - * @param array $plan - * @return void * @throws \Utopia\Database\Exception */ public function action( @@ -103,7 +88,8 @@ class Builds extends Action Webhook $queueForWebhooks, Func $queueForFunctions, Realtime $queueForRealtime, - StatsUsage $queueForStatsUsage, + Context $usage, + UsagePublisher $publisherForUsage, Cache $cache, Database $dbForProject, Device $deviceForFunctions, @@ -145,7 +131,8 @@ class Builds extends Action $queueForFunctions, $queueForRealtime, $queueForEvents, - $queueForStatsUsage, + $usage, + $publisherForUsage, $dbForPlatform, $dbForProject, $github, @@ -167,28 +154,7 @@ class Builds extends Action } /** - * @param Device $deviceForFunctions - * @param Device $deviceForSites - * @param Device $deviceForFiles - * @param Screenshot $queueForScreenshots - * @param Webhook $queueForWebhooks - * @param Func $queueForFunctions - * @param Realtime $queueForRealtime - * @param Event $queueForEvents - * @param StatsUsage $queueForStatsUsage - * @param Database $dbForPlatform - * @param Database $dbForProject - * @param GitHub $github - * @param Document $project - * @param Document $resource - * @param Document $deployment - * @param Document $template - * @param Log $log - * @param Executor $executor - * @param array $plan - * @return void * @throws \Utopia\Database\Exception - * * @throws Exception */ protected function buildDeployment( @@ -200,7 +166,8 @@ class Builds extends Action Func $queueForFunctions, Realtime $queueForRealtime, Event $queueForEvents, - StatsUsage $queueForStatsUsage, + Context $usage, + UsagePublisher $publisherForUsage, Database $dbForPlatform, Database $dbForProject, GitHub $github, @@ -272,6 +239,7 @@ class Builds extends Action if ($deployment->getAttribute('status') === 'canceled') { $this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime); + return; } @@ -298,7 +266,7 @@ class Builds extends Action $installationId = $deployment->getAttribute('installationId', ''); $providerRepositoryId = $deployment->getAttribute('providerRepositoryId', ''); $providerCommitHash = $deployment->getAttribute('providerCommitHash', ''); - $isVcsEnabled = !empty($providerRepositoryId); + $isVcsEnabled = ! empty($providerRepositoryId); $owner = ''; $repositoryName = ''; @@ -312,7 +280,7 @@ class Builds extends Action } try { - if (!$isVcsEnabled) { + if (! $isVcsEnabled) { // Non-VCS + Template $templateRepositoryName = $template->getAttribute('repositoryName', ''); $templateOwnerName = $template->getAttribute('ownerName', ''); @@ -324,7 +292,7 @@ class Builds extends Action $templateRootDirectory = \ltrim($templateRootDirectory, '.'); $templateRootDirectory = \ltrim($templateRootDirectory, '/'); - if (!empty($templateRepositoryName) && !empty($templateOwnerName) && !empty($templateReferenceType) && !empty($templateReferenceValue)) { + if (! empty($templateRepositoryName) && ! empty($templateOwnerName) && ! empty($templateReferenceType) && ! empty($templateReferenceValue)) { $stdout = ''; $stderr = ''; @@ -358,8 +326,8 @@ class Builds extends Action $source = $device->getPath($deployment->getId() . '.' . \pathinfo('code.tar.gz', PATHINFO_EXTENSION)); $result = $localDevice->transfer($tmpPathFile, $source, $device); - if (!$result) { - throw new \Exception("Unable to move file"); + if (! $result) { + throw new \Exception('Unable to move file'); } Console::execute('rm -rf ' . \escapeshellarg($tmpTemplateDirectory), '', $stdout, $stderr); @@ -400,7 +368,7 @@ class Builds extends Action $cloneVersion = $branchName; $cloneType = GitHub::CLONE_TYPE_BRANCH; - if (!empty($commitHash)) { + if (! empty($commitHash)) { $cloneVersion = $commitHash; $cloneType = GitHub::CLONE_TYPE_COMMIT; } @@ -413,6 +381,7 @@ class Builds extends Action if ($dbForProject->getDocument('deployments', $deploymentId)->getAttribute('status') === 'canceled') { $this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime); + return; } @@ -429,7 +398,7 @@ class Builds extends Action $rootDirectoryWithoutSpaces = str_replace(' ', '', $rootDirectory); $from = $tmpDirectory . '/' . $rootDirectory; $to = $tmpDirectory . '/' . $rootDirectoryWithoutSpaces; - $exit = Console::execute('mv "' . \escapeshellarg($from) . '" "' . \escapeshellarg($to) . '"', '', $stdout, $stderr); + $exit = Console::execute('mv ' . \escapeshellarg($from) . ' ' . \escapeshellarg($to), '', $stdout, $stderr); if ($exit !== 0) { throw new \Exception('Unable to move function with spaces' . $stderr); @@ -437,7 +406,6 @@ class Builds extends Action $rootDirectory = $rootDirectoryWithoutSpaces; } - // Build from template $templateRepositoryName = $template->getAttribute('repositoryName', ''); $templateOwnerName = $template->getAttribute('ownerName', ''); @@ -449,7 +417,7 @@ class Builds extends Action $templateRootDirectory = \ltrim($templateRootDirectory, '.'); $templateRootDirectory = \ltrim($templateRootDirectory, '/'); - if (!empty($templateRepositoryName) && !empty($templateOwnerName) && !empty($templateReferenceType) && !empty($templateReferenceValue)) { + if (! empty($templateRepositoryName) && ! empty($templateOwnerName) && ! empty($templateReferenceType) && ! empty($templateReferenceValue)) { // Clone template repo $tmpTemplateDirectory = '/tmp/builds/' . $deploymentId . '/template'; @@ -468,7 +436,7 @@ class Builds extends Action Console::execute('rsync -av --exclude \'.git\' ' . \escapeshellarg($tmpTemplateDirectory . '/' . $templateRootDirectory . '/') . ' ' . \escapeshellarg($tmpDirectory . '/' . $rootDirectory), '', $stdout, $stderr); // Commit and push - $exit = Console::execute('git config --global user.email '. \escapeshellarg(APP_VCS_GITHUB_EMAIL) .' && git config --global user.name '. \escapeshellarg(APP_VCS_GITHUB_USERNAME) .' && cd ' . \escapeshellarg($tmpDirectory) . ' && git checkout -b ' . \escapeshellarg($branchName) . ' && git add . && git commit -m "Create ' . \escapeshellarg($resource->getAttribute('name', '')) . ' function" && git push origin ' . \escapeshellarg($branchName), '', $stdout, $stderr); + $exit = Console::execute('git config --global user.email ' . \escapeshellarg(APP_VCS_GITHUB_EMAIL) . ' && git config --global user.name ' . \escapeshellarg(APP_VCS_GITHUB_USERNAME) . ' && cd ' . \escapeshellarg($tmpDirectory) . ' && git checkout -b ' . \escapeshellarg($branchName) . ' && git add . && git commit -m "Create ' . \escapeshellarg($resource->getAttribute('name', '')) . ' function" && git push origin ' . \escapeshellarg($branchName), '', $stdout, $stderr); if ($exit !== 0) { throw new \Exception('Unable to push code repository: ' . $stderr); @@ -511,7 +479,7 @@ class Builds extends Action } $directorySize = $localDevice->getDirectorySize($tmpDirectory); - $sizeLimit = (int)System::getEnv('_APP_COMPUTE_SIZE_LIMIT', '30000000'); + $sizeLimit = (int) System::getEnv('_APP_COMPUTE_SIZE_LIMIT', '30000000'); if (isset($plan['deploymentSize'])) { $sizeLimit = (int) $plan['deploymentSize'] * 1000 * 1000; @@ -529,8 +497,8 @@ class Builds extends Action $source = $device->getPath($deployment->getId() . '.' . \pathinfo('code.tar.gz', PATHINFO_EXTENSION)); $result = $localDevice->transfer($tmpPathFile, $source, $device); - if (!$result) { - throw new \Exception("Unable to move file"); + if (! $result) { + throw new \Exception('Unable to move file'); } Console::execute('rm -rf ' . \escapeshellarg($tmpPath), '', $stdout, $stderr); @@ -623,16 +591,15 @@ class Builds extends Action } $cpus = $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT; - $memory = max($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, $minMemory); + $memory = max($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, $minMemory); $timeout = (int) System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900); - - $jwtExpiry = (int)System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900); + $jwtExpiry = (int) System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900); $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); $apiKey = $jwtObj->encode([ 'projectId' => $project->getId(), - 'scopes' => $resource->getAttribute('scopes', []) + 'scopes' => $resource->getAttribute('scopes', []), ]); // Appwrite vars @@ -700,6 +667,7 @@ class Builds extends Action if ($dbForProject->getDocument('deployments', $deploymentId)->getAttribute('status') === 'canceled') { $this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime); + return; } @@ -721,7 +689,7 @@ class Builds extends Action $listFilesCommand .= 'echo "{APPWRITE_DETECTION_SEPARATOR_START}" && cd /usr/local/build'; // Enter output directory, if set - if (!empty($outputDirectory)) { + if (! empty($outputDirectory)) { $listFilesCommand .= ' && cd ' . \escapeshellarg($outputDirectory); } @@ -748,7 +716,7 @@ class Builds extends Action cpus: $cpus, memory: $memory, timeout: $timeout, - remove: true, + remove: true, entrypoint: $deployment->getAttribute('entrypoint', ''), destination: APP_STORAGE_BUILDS . "/app-{$project->getId()}", variables: $vars, @@ -782,6 +750,7 @@ class Builds extends Action if ($deployment->getAttribute('status') === 'canceled') { $isCanceled = true; Console::info('Ignoring realtime logs because build has been canceled'); + return; } @@ -789,7 +758,7 @@ class Builds extends Action $logs = \mb_substr($logs, 0, null, 'UTF-8'); // Do not stream logs added for SSR detection - if (!$insideSeparation) { + if (! $insideSeparation) { $separator = \strpos($logs, '{APPWRITE_DETECTION_SEPARATOR_START}'); if ($separator !== false) { $logs = \substr($logs, 0, $separator); @@ -819,19 +788,19 @@ class Builds extends Action $currentLogs = $deployment->getAttribute('buildLogs', ''); $affected = false; - $streamLogs = \str_replace("\\n", "{APPWRITE_LINEBREAK_PLACEHOLDER}", $logs); + $streamLogs = \str_replace('\\n', '{APPWRITE_LINEBREAK_PLACEHOLDER}', $logs); foreach (\explode("\n", $streamLogs) as $streamLog) { if (empty($streamLog)) { continue; } - $streamLog = \str_replace("{APPWRITE_LINEBREAK_PLACEHOLDER}", "\n", $streamLog); - $streamParts = \explode(" ", $streamLog, 2); + $streamLog = \str_replace('{APPWRITE_LINEBREAK_PLACEHOLDER}', "\n", $streamLog); + $streamParts = \explode(' ', $streamLog, 2); // TODO: use part[0] as timestamp when switching to dbForLogs for build logs $currentLogs .= $streamParts[1]; - if (!empty($streamParts[1])) { + if (! empty($streamParts[1])) { $affected = true; } } @@ -863,6 +832,7 @@ class Builds extends Action if ($dbForProject->getDocument('deployments', $deploymentId)->getAttribute('status') === 'canceled') { $this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime); + return; } @@ -870,7 +840,7 @@ class Builds extends Action throw $err; } - $buildSizeLimit = (int)System::getEnv('_APP_COMPUTE_BUILD_SIZE_LIMIT', '2000000000'); + $buildSizeLimit = (int) System::getEnv('_APP_COMPUTE_BUILD_SIZE_LIMIT', '2000000000'); if (isset($plan['buildSize'])) { $buildSizeLimit = $plan['buildSize'] * 1000 * 1000; } @@ -898,7 +868,7 @@ class Builds extends Action $deployment->setAttribute('buildLogs', $logs); $adapter = null; - if ($resource->getCollection() === 'sites' && !empty($detectionLogs)) { + if ($resource->getCollection() === 'sites' && ! empty($detectionLogs)) { $files = \explode("\n", $detectionLogs); // Parse output $files = \array_filter($files); // Remove empty $files = \array_map(fn ($file) => \trim($file), $files); // Remove whitepsaces @@ -970,9 +940,9 @@ class Builds extends Action // Check if current active deployment started later than this deployment $resource = $dbForProject->getDocument($resource->getCollection(), $resource->getId()); $currentActiveDeploymentId = $resource->getAttribute('deploymentId', ''); - if (!empty($currentActiveDeploymentId)) { + if (! empty($currentActiveDeploymentId)) { $currentActiveDeployment = $dbForProject->getDocument('deployments', $currentActiveDeploymentId); - if (!$currentActiveDeployment->isEmpty()) { + if (! $currentActiveDeployment->isEmpty()) { $currentActiveStartTime = $currentActiveDeployment->getCreatedAt(); $deploymentStartTime = $deployment->getCreatedAt(); @@ -1058,7 +1028,7 @@ class Builds extends Action if ($resource->getCollection() === 'sites') { // VCS branch $branchName = $deployment->getAttribute('providerBranch'); - if (!empty($branchName)) { + if (! empty($branchName)) { $domain = (new BranchDomainFilter())->apply([ 'branch' => $branchName, 'resourceId' => $resource->getId(), @@ -1078,14 +1048,14 @@ class Builds extends Action 'deploymentId' => $deployment->getId(), 'deploymentInternalId' => $deployment->getSequence(), 'deploymentResourceType' => 'site', - 'deploymentResourceId' => $deployment->getId(), - 'deploymentResourceInternalId' => $deployment->getSequence(), + 'deploymentResourceId' => $resource->getId(), + 'deploymentResourceInternalId' => $resource->getSequence(), 'deploymentVcsProviderBranch' => $branchName, 'status' => 'verified', 'certificateId' => '', 'search' => implode(' ', [$ruleId, $domain]), 'owner' => 'Appwrite', - 'region' => $project->getAttribute('region') + 'region' => $project->getAttribute('region'), ])); } catch (Duplicate $err) { $rule = $dbForPlatform->updateDocument('rules', $ruleId, new Document([ @@ -1126,6 +1096,7 @@ class Builds extends Action if ($dbForProject->getDocument('deployments', $deploymentId)->getAttribute('status') === 'canceled') { $this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime); + return; } @@ -1139,7 +1110,7 @@ class Builds extends Action $schedule ->setAttribute('resourceUpdatedAt', DateTime::now()) ->setAttribute('schedule', $resource->getAttribute('schedule')) - ->setAttribute('active', !empty($resource->getAttribute('schedule')) && !empty($resource->getAttribute('deploymentId'))); + ->setAttribute('active', ! empty($resource->getAttribute('schedule')) && ! empty($resource->getAttribute('deploymentId'))); $dbForPlatform->updateDocument('schedules', $schedule->getId(), new Document([ 'resourceUpdatedAt' => $schedule->getAttribute('resourceUpdatedAt'), 'schedule' => $schedule->getAttribute('schedule'), @@ -1167,13 +1138,14 @@ class Builds extends Action if ($dbForProject->getDocument('deployments', $deploymentId)->getAttribute('status') === 'canceled') { $this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime); + return; } // Color message red $message = $th->getMessage(); - if (!\str_contains($message, '')) { - $message = "" . $message; + if (! \str_contains($message, '')) { + $message = '' . $message; } $message = \str_replace('{APPWRITE_DETECTION_SEPARATOR_START}', '', $message); @@ -1181,9 +1153,9 @@ class Builds extends Action // Combine with previous logs if deployment got past build process $previousLogs = ''; - if (!is_null($deployment->getAttribute('buildSize', null))) { + if (! is_null($deployment->getAttribute('buildSize', null))) { $previousLogs = $deployment->getAttribute('buildLogs', ''); - if (!empty($previousLogs)) { + if (! empty($previousLogs)) { $message = $previousLogs . "\n" . $message; } } @@ -1219,102 +1191,102 @@ class Builds extends Action ->trigger(); $this->sendUsage( - resource:$resource, + resource: $resource, deployment: $deployment, project: $project, - queue: $queueForStatsUsage + usage: $usage, + publisherForUsage: $publisherForUsage ); } } - protected function sendUsage(Document $resource, Document $deployment, Document $project, StatsUsage $queue): void + protected function sendUsage(Document $resource, Document $deployment, Document $project, Context $usage, UsagePublisher $publisherForUsage): void { $spec = Config::getParam('specifications')[$resource->getAttribute('buildSpecification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; switch ($deployment->getAttribute('status')) { case 'ready': - $queue + $usage ->addMetric(METRIC_BUILDS_SUCCESS, 1) // per project - ->addMetric(METRIC_BUILDS_COMPUTE_SUCCESS, (int)$deployment->getAttribute('buildDuration', 0) * 1000) + ->addMetric(METRIC_BUILDS_COMPUTE_SUCCESS, (int) $deployment->getAttribute('buildDuration', 0) * 1000) ->addMetric(str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_BUILDS_SUCCESS), 1) // per function - ->addMetric(str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_BUILDS_COMPUTE_SUCCESS), (int)$deployment->getAttribute('buildDuration', 0) * 1000) + ->addMetric(str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_BUILDS_COMPUTE_SUCCESS), (int) $deployment->getAttribute('buildDuration', 0) * 1000) ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_SUCCESS), 1) // per function - ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_COMPUTE_SUCCESS), (int)$deployment->getAttribute('buildDuration', 0) * 1000); + ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_COMPUTE_SUCCESS), (int) $deployment->getAttribute('buildDuration', 0) * 1000); break; case 'failed': - $queue + $usage ->addMetric(METRIC_BUILDS_FAILED, 1) // per project - ->addMetric(METRIC_BUILDS_COMPUTE_FAILED, (int)$deployment->getAttribute('buildDuration', 0) * 1000) + ->addMetric(METRIC_BUILDS_COMPUTE_FAILED, (int) $deployment->getAttribute('buildDuration', 0) * 1000) ->addMetric(str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_BUILDS_FAILED), 1) // per function - ->addMetric(str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_BUILDS_COMPUTE_FAILED), (int)$deployment->getAttribute('buildDuration', 0) * 1000) + ->addMetric(str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_BUILDS_COMPUTE_FAILED), (int) $deployment->getAttribute('buildDuration', 0) * 1000) ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_FAILED), 1) // per function - ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_COMPUTE_FAILED), (int)$deployment->getAttribute('buildDuration', 0) * 1000); + ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_COMPUTE_FAILED), (int) $deployment->getAttribute('buildDuration', 0) * 1000); break; } - $queue + $usage ->addMetric(METRIC_BUILDS, 1) // per project ->addMetric(METRIC_BUILDS_STORAGE, $deployment->getAttribute('buildSize', 0)) - ->addMetric(METRIC_BUILDS_COMPUTE, (int)$deployment->getAttribute('buildDuration', 0) * 1000) - ->addMetric(METRIC_BUILDS_MB_SECONDS, (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $deployment->getAttribute('buildDuration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT))) + ->addMetric(METRIC_BUILDS_COMPUTE, (int) $deployment->getAttribute('buildDuration', 0) * 1000) + ->addMetric(METRIC_BUILDS_MB_SECONDS, (int) ($memory * $deployment->getAttribute('buildDuration', 0) * $cpus)) ->addMetric(str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_BUILDS), 1) // per function ->addMetric(str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_BUILDS_STORAGE), $deployment->getAttribute('buildSize', 0)) - ->addMetric(str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_BUILDS_COMPUTE), (int)$deployment->getAttribute('buildDuration', 0) * 1000) - ->addMetric(str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_BUILDS_MB_SECONDS), (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $deployment->getAttribute('buildDuration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT))) + ->addMetric(str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_BUILDS_COMPUTE), (int) $deployment->getAttribute('buildDuration', 0) * 1000) + ->addMetric(str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_BUILDS_MB_SECONDS), (int) ($memory * $deployment->getAttribute('buildDuration', 0) * $cpus)) ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS), 1) // per function ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_STORAGE), $deployment->getAttribute('buildSize', 0)) - ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_COMPUTE), (int)$deployment->getAttribute('buildDuration', 0) * 1000) - ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_MB_SECONDS), (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $deployment->getAttribute('buildDuration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT))) - ->setProject($project) - ->trigger(); + ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_COMPUTE), (int) $deployment->getAttribute('buildDuration', 0) * 1000) + ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_MB_SECONDS), (int) ($memory * $deployment->getAttribute('buildDuration', 0) * $cpus)); + + // Publish usage metrics + if (! $usage->isEmpty()) { + $message = new UsageMessage( + project: $project, + metrics: $usage->getMetrics(), + reduce: $usage->getReduce() + ); + $publisherForUsage->enqueue($message); + $usage->reset(); + } } /** * Hook to run after build success * - * @param Realtime $queueForRealtime - * @param Database $dbForProject - * @param Document $deployment - * @param array $runtime - * @param string|null $adapter - * @return void * @throws Exception */ protected function afterBuildSuccess(Realtime $queueForRealtime, Database $dbForProject, Document &$deployment, array $runtime, ?string $adapter): void { - if (!($queueForRealtime instanceof Realtime)) { + if (! ($queueForRealtime instanceof Realtime)) { throw new Exception('queueForRealtime must be an instance of Realtime'); } - if (!($dbForProject instanceof Database)) { + if (! ($dbForProject instanceof Database)) { throw new Exception('dbForProject must be an instance of Database'); } - if (!($deployment instanceof Document)) { + if (! ($deployment instanceof Document)) { throw new Exception('deployment must be an instance of Document'); } - if (!is_array($runtime)) { + if (! is_array($runtime)) { throw new Exception('runtime must be an array'); } - if (!is_string($adapter) && !is_null($adapter)) { + if (! is_string($adapter) && ! is_null($adapter)) { throw new Exception('adapter must be a string or null'); } } /** * Hook to run after deployment is activated - * - * @param Document $project - * @param Document $deployment - * @return void */ protected function afterDeploymentSuccess( Document $project, Document $deployment, ): void { - if (!($project instanceof Document)) { + if (! ($project instanceof Document)) { throw new Exception('project must be an instance of Document'); } - if (!($deployment instanceof Document)) { + if (! ($deployment instanceof Document)) { throw new Exception('deployment must be an instance of Document'); } } @@ -1322,7 +1294,7 @@ class Builds extends Action protected function getRuntime(Document $resource, string $version): array { $runtimes = Config::getParam($version === 'v2' ? 'runtimes-v2' : 'runtimes', []); - $key = $resource->getAttribute('runtime'); + $key = $resource->getAttribute('runtime'); $runtime = match ($resource->getCollection()) { 'functions' => $runtimes[$resource->getAttribute('runtime')] ?? null, 'sites' => $runtimes[$resource->getAttribute('buildRuntime')] ?? null, @@ -1355,7 +1327,7 @@ class Builds extends Action $envCommand = ''; $bundleCommand = ''; - if (!is_null($framework)) { + if (! is_null($framework)) { $envCommand = $framework['envCommand'] ?? ''; $bundleCommand = $framework['bundleCommand'] ?? ''; } @@ -1364,7 +1336,7 @@ class Builds extends Action $commands[] = $deployment->getAttribute('buildCommands', ''); $commands[] = $bundleCommand; - $commands = array_filter($commands, fn ($command) => !empty($command)); + $commands = array_filter($commands, fn ($command) => ! empty($command)); return implode(' && ', $commands); } @@ -1373,19 +1345,6 @@ class Builds extends Action } /** - * @param string $status - * @param GitHub $github - * @param string $providerCommitHash - * @param string $owner - * @param string $repositoryName - * @param Document $project - * @param Document $resource - * @param string $deploymentId - * @param Database $dbForProject - * @param Database $dbForPlatform - * @param Realtime $queueForRealtime - * @param array $platform - * @return void * @throws Structure * @throws \Utopia\Database\Exception * @throws Conflict @@ -1413,7 +1372,7 @@ class Builds extends Action $deployment = $dbForProject->getDocument('deployments', $deploymentId); $commentId = $deployment->getAttribute('providerCommentId', ''); - if (!empty($providerCommitHash)) { + if (! empty($providerCommitHash)) { $message = match ($status) { 'ready' => 'Build succeeded.', 'failed' => 'Build failed.', @@ -1448,7 +1407,7 @@ class Builds extends Action $github->updateCommitStatus($repositoryName, $providerCommitHash, $owner, $state, $message, $providerTargetUrl, $name); } - if (!empty($commentId)) { + if (! empty($commentId)) { $retries = 0; while (true) { @@ -1456,7 +1415,7 @@ class Builds extends Action try { $dbForPlatform->createDocument('vcsCommentLocks', new Document([ - '$id' => $commentId + '$id' => $commentId, ])); break; } catch (\Throwable $err) { @@ -1470,22 +1429,22 @@ class Builds extends Action // Wrap in try/finally to ensure lock file gets deleted try { - $resourceType = match($resource->getCollection()) { + $resourceType = match ($resource->getCollection()) { 'functions' => 'function', 'sites' => 'site', default => throw new \Exception('Invalid resource type') }; $rule = $dbForPlatform->findOne('rules', [ - Query::equal("projectInternalId", [$project->getSequence()]), - Query::equal("type", ["deployment"]), - Query::equal("deploymentInternalId", [$deployment->getSequence()]), + Query::equal('projectInternalId', [$project->getSequence()]), + Query::equal('type', ['deployment']), + Query::equal('deploymentInternalId', [$deployment->getSequence()]), ]); $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; - $previewUrl = match($resource->getCollection()) { + $previewUrl = match ($resource->getCollection()) { 'functions' => '', - 'sites' => !empty($rule) ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '', + 'sites' => ! empty($rule) ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '', default => throw new \Exception('Invalid resource type') }; @@ -1498,7 +1457,7 @@ class Builds extends Action } } } catch (\Throwable $th) { - Console::warning("Git action failed:"); + Console::warning('Git action failed:'); Console::warning($th->getMessage()); Console::warning($th->getTraceAsString()); 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 dc84d0ee37..cb3640746f 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 @@ -12,9 +12,9 @@ use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; use Appwrite\Event\Migration; +use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Screenshot; use Appwrite\Event\StatsResources; -use Appwrite\Event\StatsUsage; use Appwrite\Event\Webhook; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; @@ -79,7 +79,7 @@ class Get extends Base ->inject('queueForMails') ->inject('queueForFunctions') ->inject('queueForStatsResources') - ->inject('queueForStatsUsage') + ->inject('publisherForUsage') ->inject('queueForWebhooks') ->inject('queueForCertificates') ->inject('queueForBuilds') @@ -99,7 +99,7 @@ class Get extends Base Mail $queueForMails, Func $queueForFunctions, StatsResources $queueForStatsResources, - StatsUsage $queueForStatsUsage, + UsagePublisher $publisherForUsage, Webhook $queueForWebhooks, Certificate $queueForCertificates, Build $queueForBuilds, @@ -116,7 +116,7 @@ class Get extends Base System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME) => $queueForMails, System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME) => $queueForFunctions, System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME) => $queueForStatsResources, - System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME) => $queueForStatsUsage, + 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) => $queueForCertificates, System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $queueForBuilds, diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsUsage/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsUsage/Get.php index 10678efbc3..65b3d228a6 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsUsage/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsUsage/Get.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\StatsUsage; -use Appwrite\Event\StatsUsage; +use Appwrite\Event\Publisher\Usage as UsagePublisher; 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('queueForStatsUsage') + ->inject('publisherForUsage') ->inject('response') ->callback($this->action(...)); } - public function action(int|string $threshold, StatsUsage $queueForStatsUsage, Response $response): void + public function action(int|string $threshold, UsagePublisher $publisherForUsage, Response $response): void { $threshold = (int) $threshold; - $size = $queueForStatsUsage->getSize(); + $size = $publisherForUsage->getSize(); $this->assertQueueThreshold($size, $threshold); diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index 1dd9487ec7..eb71e5a02f 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -218,9 +218,13 @@ class Create extends Action $dbForProject->setDatabase(APP_DATABASE); if ($sharedTables) { + $tenant = null; + if ($sharedTablesV1) { + $tenant = $project->getSequence(); + } $dbForProject ->setSharedTables(true) - ->setTenant($sharedTablesV1 ? (int)$project->getSequence() : null) + ->setTenant($tenant) ->setNamespace($dsn->getParam('namespace')); } else { $dbForProject @@ -272,14 +276,37 @@ class Create extends Action try { $dbForProject->createCollection($key, $attributes, $indexes); } catch (Duplicate) { - $dbForProject->createDocument(Database::METADATA, new Document([ - '$id' => ID::custom($key), - '$permissions' => [Permission::create(Role::any())], - 'name' => $key, - 'attributes' => $attributes, - 'indexes' => $indexes, - 'documentSecurity' => true - ])); + try { + $dbForProject->createDocument(Database::METADATA, new Document([ + '$id' => ID::custom($key), + '$permissions' => [Permission::create(Role::any())], + 'name' => $key, + 'attributes' => $attributes, + 'indexes' => $indexes, + 'documentSecurity' => true + ])); + } catch (Duplicate) { + // Metadata already exists from concurrent creation + } + } catch (\Throwable $e) { + // PostgreSQL adapter may throw a non-Duplicate exception when + // a table or index already exists during concurrent project + // creation in shared mode. Treat as duplicate if metadata + // can be created successfully. + try { + $dbForProject->createDocument(Database::METADATA, new Document([ + '$id' => ID::custom($key), + '$permissions' => [Permission::create(Role::any())], + 'name' => $key, + 'attributes' => $attributes, + 'indexes' => $indexes, + 'documentSecurity' => true + ])); + } catch (Duplicate) { + // Metadata already exists from concurrent creation + } catch (\Throwable) { + throw $e; // Rethrow original if metadata creation also fails + } } } } diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php index 47195a3eb5..ba99cefb42 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php @@ -75,7 +75,7 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $domain, string $siteId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) + public function action(string $domain, string $siteId, ?string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) { $this->validateDomainRestrictions($domain, $platform); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 3bf597eaca..0632aea3dd 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -6,14 +6,13 @@ use Appwrite\Auth\Validator\Phone; use Appwrite\Event\Event; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; -use Appwrite\Network\Validator\Email as EmailValidator; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Template\Template; +use Appwrite\Usage\Context; use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response; use libphonenumber\NumberParseException; @@ -32,6 +31,7 @@ use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Key; use Utopia\Database\Validator\UID; use Utopia\Emails\Email; +use Utopia\Emails\Validator\Email as EmailValidator; use Utopia\Locale\Locale; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; @@ -70,7 +70,7 @@ class Create extends Action new SDKResponse( code: Response::STATUS_CODE_CREATED, model: Response::MODEL_MEMBERSHIP, - ) + ), ] )) ->label('abuse-limit', 10) @@ -91,20 +91,20 @@ class Create extends Action ->inject('queueForMessaging') ->inject('queueForEvents') ->inject('timelimit') - ->inject('queueForStatsUsage') + ->inject('usage') ->inject('plan') ->inject('proofForPassword') ->inject('proofForToken') ->callback($this->action(...)); } - public function action(string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, Document $user, Database $dbForProject, Authorization $authorization, Locale $locale, Mail $queueForMails, Messaging $queueForMessaging, Event $queueForEvents, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, Password $proofForPassword, Token $proofForToken) + public function action(string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, Document $user, Database $dbForProject, Authorization $authorization, Locale $locale, Mail $queueForMails, Messaging $queueForMessaging, Event $queueForEvents, callable $timelimit, Context $usage, array $plan, Password $proofForPassword, Token $proofForToken) { $isAppUser = User::isApp($authorization->getRoles()); $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); if (empty($url)) { - if (!$isAppUser && !$isPrivilegedUser) { + if (! $isAppUser && ! $isPrivilegedUser) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'URL is required'); } } @@ -113,7 +113,7 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'At least one of userId, email, or phone is required'); } - if (!$isPrivilegedUser && !$isAppUser && empty(System::getEnv('_APP_SMTP_HOST'))) { + if (! $isPrivilegedUser && ! $isAppUser && empty(System::getEnv('_APP_SMTP_HOST'))) { throw new Exception(Exception::GENERAL_SMTP_DISABLED); } @@ -124,28 +124,28 @@ class Create extends Action if ($team->isEmpty()) { throw new Exception(Exception::TEAM_NOT_FOUND); } - if (!empty($userId)) { + if (! empty($userId)) { $invitee = $dbForProject->getDocument('users', $userId); if ($invitee->isEmpty()) { throw new Exception(Exception::USER_NOT_FOUND, 'User with given userId doesn\'t exist.', 404); } - if (!empty($email) && $invitee->getAttribute('email', '') !== $email) { + if (! empty($email) && $invitee->getAttribute('email', '') !== $email) { throw new Exception(Exception::USER_ALREADY_EXISTS, 'Given userId and email doesn\'t match', 409); } - if (!empty($phone) && $invitee->getAttribute('phone', '') !== $phone) { + if (! empty($phone) && $invitee->getAttribute('phone', '') !== $phone) { throw new Exception(Exception::USER_ALREADY_EXISTS, 'Given userId and phone doesn\'t match', 409); } $email = $invitee->getAttribute('email', ''); $phone = $invitee->getAttribute('phone', ''); $name = $invitee->getAttribute('name', '') ?: $name; - } elseif (!empty($email)) { + } elseif (! empty($email)) { $invitee = $dbForProject->findOne('users', [Query::equal('email', [$email])]); // Get user by email address - if (!$invitee->isEmpty() && !empty($phone) && $invitee->getAttribute('phone', '') !== $phone) { + if (! $invitee->isEmpty() && ! empty($phone) && $invitee->getAttribute('phone', '') !== $phone) { throw new Exception(Exception::USER_ALREADY_EXISTS, 'Given email and phone doesn\'t match', 409); } - } elseif (!empty($phone)) { + } elseif (! empty($phone)) { $invitee = $dbForProject->findOne('users', [Query::equal('phone', [$phone])]); - if (!$invitee->isEmpty() && !empty($email) && $invitee->getAttribute('email', '') !== $email) { + if (! $invitee->isEmpty() && ! empty($email) && $invitee->getAttribute('email', '') !== $email) { throw new Exception(Exception::USER_ALREADY_EXISTS, 'Given phone and email doesn\'t match', 409); } } @@ -153,7 +153,7 @@ class Create extends Action if ($invitee->isEmpty()) { // Create new user if no user with same email found $limit = $project->getAttribute('auths', [])['limit'] ?? 0; - if (!$isPrivilegedUser && !$isAppUser && $limit !== 0 && $project->getId() !== 'console') { // check users limit, console invites are allways allowed. + if (! $isPrivilegedUser && ! $isAppUser && $limit !== 0 && $project->getId() !== 'console') { // check users limit, console invites are allways allowed. $total = $dbForProject->count('users', [], APP_LIMIT_USERS); if ($total >= $limit) { @@ -165,7 +165,7 @@ class Create extends Action $identityWithMatchingEmail = $dbForProject->findOne('identities', [ Query::equal('providerEmail', [$email]), ]); - if (!$identityWithMatchingEmail->isEmpty()) { + if (! $identityWithMatchingEmail->isEmpty()) { throw new Exception(Exception::USER_EMAIL_ALREADY_EXISTS); } @@ -225,7 +225,7 @@ class Create extends Action $isOwner = $authorization->hasRole('team:' . $team->getId() . '/owner'); - if (!$isOwner && !$isPrivilegedUser && !$isAppUser) { // Not owner, not admin, not app (server) + if (! $isOwner && ! $isPrivilegedUser && ! $isAppUser) { // Not owner, not admin, not app (server) throw new Exception(Exception::USER_UNAUTHORIZED, 'User is not allowed to send invitations for this team'); } @@ -255,7 +255,7 @@ class Create extends Action 'joined' => ($isPrivilegedUser || $isAppUser) ? DateTime::now() : null, 'confirm' => ($isPrivilegedUser || $isAppUser), 'secret' => $proofForToken->hash($secret), - 'search' => implode(' ', [$membershipId, $invitee->getId()]) + 'search' => implode(' ', [$membershipId, $invitee->getId()]), ]); $membership = ($isPrivilegedUser || $isAppUser) ? @@ -292,22 +292,22 @@ class Create extends Action $url = Template::parseURL($url); $url['query'] = Template::mergeQuery(((isset($url['query'])) ? $url['query'] : ''), ['membershipId' => $membership->getId(), 'userId' => $invitee->getId(), 'secret' => $secret, 'teamId' => $teamId, 'teamName' => $team->getAttribute('name')]); $url = Template::unParseURL($url); - if (!empty($email)) { + if (! empty($email)) { $projectName = $project->isEmpty() ? 'Console' : $project->getAttribute('name', '[APP-NAME]'); - $body = $locale->getText("emails.invitation.body"); - $preview = $locale->getText("emails.invitation.preview"); - $subject = $locale->getText("emails.invitation.subject"); + $body = $locale->getText('emails.invitation.body'); + $preview = $locale->getText('emails.invitation.preview'); + $subject = $locale->getText('emails.invitation.subject'); $customTemplate = $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ?? []; $message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-inner-base.tpl'); $message ->setParam('{{body}}', $body, escapeHtml: false) - ->setParam('{{hello}}', $locale->getText("emails.invitation.hello")) - ->setParam('{{footer}}', $locale->getText("emails.invitation.footer")) - ->setParam('{{thanks}}', $locale->getText("emails.invitation.thanks")) - ->setParam('{{buttonText}}', $locale->getText("emails.invitation.buttonText")) - ->setParam('{{signature}}', $locale->getText("emails.invitation.signature")); + ->setParam('{{hello}}', $locale->getText('emails.invitation.hello')) + ->setParam('{{footer}}', $locale->getText('emails.invitation.footer')) + ->setParam('{{thanks}}', $locale->getText('emails.invitation.thanks')) + ->setParam('{{buttonText}}', $locale->getText('emails.invitation.buttonText')) + ->setParam('{{signature}}', $locale->getText('emails.invitation.signature')); $body = $message->render(); $smtp = $project->getAttribute('smtp', []); @@ -315,16 +315,16 @@ class Create extends Action $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; + $replyTo = ''; if ($smtpEnabled) { - if (!empty($smtp['senderEmail'])) { + if (! empty($smtp['senderEmail'])) { $senderEmail = $smtp['senderEmail']; } - if (!empty($smtp['senderName'])) { + if (! empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { + if (! empty($smtp['replyTo'])) { $replyTo = $smtp['replyTo']; } @@ -335,14 +335,14 @@ class Create extends Action ->setSmtpPassword($smtp['password'] ?? '') ->setSmtpSecure($smtp['secure'] ?? ''); - if (!empty($customTemplate)) { - if (!empty($customTemplate['senderEmail'])) { + if (! empty($customTemplate)) { + if (! empty($customTemplate['senderEmail'])) { $senderEmail = $customTemplate['senderEmail']; } - if (!empty($customTemplate['senderName'])) { + if (! empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { + if (! empty($customTemplate['replyTo'])) { $replyTo = $customTemplate['replyTo']; } @@ -363,7 +363,7 @@ class Create extends Action 'user' => $name, 'team' => $team->getAttribute('name'), 'redirect' => $url, - 'project' => $projectName + 'project' => $projectName, ]; $queueForMails @@ -374,7 +374,7 @@ class Create extends Action ->setName($invitee->getAttribute('name', '')) ->appendVariables($emailVariables) ->trigger(); - } elseif (!empty($phone)) { + } elseif (! empty($phone)) { if (empty(System::getEnv('_APP_SMS_PROVIDER'))) { throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured'); } @@ -382,7 +382,7 @@ class Create extends Action $message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/sms-base.tpl'); $customTemplate = $project->getAttribute('templates', [])['sms.invitation-' . $locale->default] ?? []; - if (!empty($customTemplate)) { + if (! empty($customTemplate)) { $message = $customTemplate['message']; } @@ -406,25 +406,20 @@ class Create extends Action try { $countryCode = $helper->parse($phone)->getCountryCode(); - if (!empty($countryCode)) { - $queueForStatsUsage - ->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1); + if (! empty($countryCode)) { + $usage->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1); } } catch (NumberParseException $e) { // Ignore invalid phone number for country code stats } - $queueForStatsUsage - ->addMetric(METRIC_AUTH_METHOD_PHONE, 1) - ->setProject($project) - ->trigger(); + $usage->addMetric(METRIC_AUTH_METHOD_PHONE, 1); } } $queueForEvents ->setParam('userId', $invitee->getId()) ->setParam('teamId', $team->getId()) - ->setParam('membershipId', $membership->getId()) - ; + ->setParam('membershipId', $membership->getId()); $response ->setStatusCode(Response::STATUS_CODE_CREATED) diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index c9904bb32b..638ceab59e 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -431,7 +431,7 @@ trait Deployment } } - if ($resource->getCollection() === 'sites' && !empty($latestCommentId) && !empty($previewRuleId)) { + if ($resource->getCollection() === 'sites' && !empty($latestCommentId)) { $retries = 0; $lockAcquired = false; diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Init.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Init.php new file mode 100644 index 0000000000..3a14a12ffb --- /dev/null +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Init.php @@ -0,0 +1,32 @@ +setType(Action::TYPE_INIT) + ->groups(['webhooks']) + ->inject('project') + ->callback(function (Document $project) { + if ($project->getId() === 'console') { + throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN); + } + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + }); + } +} diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php new file mode 100644 index 0000000000..91daf33b2b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php @@ -0,0 +1,129 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/webhooks') + ->httpAlias('/v1/projects/:projectId/webhooks') + ->desc('Create webhook') + ->groups(['api', 'webhooks']) + ->label('scope', 'webhooks.write') + ->label('event', 'webhooks.[webhookId].create') + ->label('audits.event', 'webhook.create') + ->label('audits.resource', 'webhook/{response.$id}') + ->label('sdk', new Method( + namespace: 'webhooks', + group: null, + name: 'create', + description: <<param('webhookId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook 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, ['dbForPlatform']) + ->param('url', '', fn () => new Multiple([new URL(['http', 'https']), new PublicDomain()], Multiple::TYPE_STRING), 'Webhook URL.') + ->param('name', null, new Text(128), 'Webhook name. Max length: 128 chars.') + ->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') + ->param('enabled', true, new Boolean(), 'Enable or disable a webhook.', true) + ->param('security', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) + ->param('httpUser', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) + ->param('httpPass', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) + ->inject('response') + ->inject('project') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + /** + * @param array $events + */ + public function action( + string $webhookId, + string $url, + string $name, + array $events, + bool $enabled, + bool $security, + string $httpUser, + string $httpPass, + Response $response, + Document $project, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization + ) { + $webhookId = ($webhookId == 'unique()') ? ID::unique() : $webhookId; + + $webhook = new Document([ + '$id' => $webhookId, + '$permissions' => [], + 'projectInternalId' => $project->getSequence(), + 'projectId' => $project->getId(), + 'name' => $name, + 'events' => $events, + 'url' => $url, + 'security' => $security, + 'httpUser' => $httpUser, + 'httpPass' => $httpPass, + 'signatureKey' => \bin2hex(\random_bytes(64)), + 'enabled' => $enabled, + ]); + + try { + $webhook = $authorization->skip(fn () => $dbForPlatform->createDocument('webhooks', $webhook)); + } catch (DuplicateException) { + throw new Exception(Exception::WEBHOOK_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('webhookId', $webhook->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($webhook, Response::MODEL_WEBHOOK); + } +} diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php new file mode 100644 index 0000000000..7730e9fc2c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php @@ -0,0 +1,94 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/webhooks/:webhookId') + ->httpAlias('/v1/projects/:projectId/webhooks/:webhookId') + ->desc('Delete webhook') + ->groups(['api', 'webhooks']) + ->label('scope', 'webhooks.write') + ->label('event', 'webhooks.[webhookId].delete') + ->label('audits.event', 'webhook.delete') + ->label('audits.resource', 'webhook/{request.webhookId}') + ->label('sdk', new Method( + namespace: 'webhooks', + group: null, + name: 'delete', + description: <<param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook ID.', false, ['dbForPlatform']) + ->inject('project') + ->inject('response') + ->inject('dbForPlatform') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $webhookId, + Document $project, + Response $response, + Database $dbForPlatform, + Event $queueForEvents, + Authorization $authorization + ) { + $webhook = $authorization->skip(fn () => $dbForPlatform->findOne('webhooks', [ + Query::equal('$id', [$webhookId]), + Query::equal('projectInternalId', [$project->getSequence()]), + ])); + + if ($webhook->isEmpty()) { + throw new Exception(Exception::WEBHOOK_NOT_FOUND); + } + + if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('webhooks', $webhook->getId()))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove document from DB'); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('webhookId', $webhook->getId()); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php new file mode 100644 index 0000000000..52ac455fc9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php @@ -0,0 +1,78 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/webhooks/:webhookId') + ->httpAlias('/v1/projects/:projectId/webhooks/:webhookId') + ->desc('Get webhook') + ->groups(['api', 'webhooks']) + ->label('scope', 'webhooks.read') + ->label('sdk', new Method( + namespace: 'webhooks', + group: null, + name: 'get', + description: <<param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook ID.', false, ['dbForPlatform']) + ->inject('project') + ->inject('response') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $webhookId, + Document $project, + Response $response, + Database $dbForPlatform, + Authorization $authorization + ) { + $webhook = $authorization->skip(fn () => $dbForPlatform->findOne('webhooks', [ + Query::equal('$id', [$webhookId]), + Query::equal('projectInternalId', [$project->getSequence()]), + ])); + + if ($webhook->isEmpty()) { + throw new Exception(Exception::WEBHOOK_NOT_FOUND); + } + + $response->dynamic($webhook, Response::MODEL_WEBHOOK); + } +} diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php new file mode 100644 index 0000000000..9b2612863f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php @@ -0,0 +1,93 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/webhooks/:webhookId/signature') + ->httpAlias('/v1/projects/:projectId/webhooks/:webhookId/signature') + ->desc('Update webhook signature key') + ->groups(['api', 'webhooks']) + ->label('scope', 'webhooks.write') + ->label('event', 'webhooks.[webhookId].update') + ->label('audits.event', 'webhooks.update') + ->label('audits.resource', 'webhook/{response.$id}') + ->label('sdk', new Method( + namespace: 'webhooks', + group: null, + name: 'updateSignature', + description: <<param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook ID.', false, ['dbForPlatform']) + ->inject('response') + ->inject('project') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $webhookId, + Response $response, + Document $project, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization + ) { + $webhook = $authorization->skip(fn () => $dbForPlatform->findOne('webhooks', [ + Query::equal('$id', [$webhookId]), + Query::equal('projectInternalId', [$project->getSequence()]), + ])); + + if ($webhook->isEmpty()) { + throw new Exception(Exception::WEBHOOK_NOT_FOUND); + } + + $updates = new Document([ + 'signatureKey' => \bin2hex(\random_bytes(64)), + ]); + + $webhook = $authorization->skip(fn () => $dbForPlatform->updateDocument('webhooks', $webhook->getId(), $updates)); + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('webhookId', $webhook->getId()); + + $response->dynamic($webhook, Response::MODEL_WEBHOOK); + } +} diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php new file mode 100644 index 0000000000..a1387c356c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php @@ -0,0 +1,124 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/webhooks/:webhookId') + ->httpAlias('/v1/projects/:projectId/webhooks/:webhookId') + ->desc('Update webhook') + ->groups(['api', 'webhooks']) + ->label('scope', 'webhooks.write') + ->label('event', 'webhooks.[webhookId].update') + ->label('audits.event', 'webhooks.update') + ->label('audits.resource', 'webhook/{response.$id}') + ->label('sdk', new Method( + namespace: 'webhooks', + group: null, + name: 'update', + description: <<param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook ID.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Webhook name. Max length: 128 chars.') + ->param('url', '', fn () => new Multiple([new URL(['http', 'https']), new PublicDomain()], Multiple::TYPE_STRING), 'Webhook URL.') + ->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') + ->param('enabled', true, new Boolean(), 'Enable or disable a webhook.', true) + ->param('security', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) + ->param('httpUser', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) + ->param('httpPass', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) + ->inject('response') + ->inject('project') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $webhookId, + string $name, + string $url, + array $events, + bool $enabled, + bool $security, + string $httpUser, + string $httpPass, + Response $response, + Document $project, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization + ) { + $webhook = $authorization->skip(fn () => $dbForPlatform->findOne('webhooks', [ + Query::equal('$id', [$webhookId]), + Query::equal('projectInternalId', [$project->getSequence()]), + ])); + + if ($webhook->isEmpty()) { + throw new Exception(Exception::WEBHOOK_NOT_FOUND); + } + + $updates = new Document([ + 'name' => $name, + 'events' => $events, + 'url' => $url, + 'security' => $security, + 'httpUser' => $httpUser, + 'httpPass' => $httpPass, + 'enabled' => $enabled, + ]); + + if ($enabled) { + $updates->setAttribute('attempts', 0); + } + + $webhook = $authorization->skip(fn () => $dbForPlatform->updateDocument('webhooks', $webhook->getId(), $updates)); + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('webhookId', $webhook->getId()); + + $response->dynamic($webhook, Response::MODEL_WEBHOOK); + } +} diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php new file mode 100644 index 0000000000..fae95d7c5d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php @@ -0,0 +1,120 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/webhooks') + ->httpAlias('/v1/projects/:projectId/webhooks') + ->desc('List webhooks') + ->groups(['api', 'webhooks']) + ->label('scope', 'webhooks.read') + ->label('sdk', new Method( + namespace: 'webhooks', + group: null, + name: 'list', + description: <<param('queries', [], new Webhooks(), '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(', ', Webhooks::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('project') + ->inject('response') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + /** + * @param array $queries + */ + public function action( + array $queries, + bool $includeTotal, + Document $project, + Response $response, + Database $dbForPlatform, + Authorization $authorization + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $queries[] = Query::equal('projectInternalId', [$project->getSequence()]); + + $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()); + } + + $webhookId = $cursor->getValue(); + $cursorDocument = $authorization->skip(fn () => $dbForPlatform->findOne('webhooks', [ + Query::equal('$id', [$webhookId]), + Query::equal('projectInternalId', [$project->getSequence()]), + ])); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Webhook '{$webhookId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $webhooks = $authorization->skip(fn () => $dbForPlatform->find('webhooks', $queries)); + $total = $includeTotal ? $authorization->skip(fn () => $dbForPlatform->count('webhooks', $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([ + 'webhooks' => $webhooks, + 'total' => $total, + ]), Response::MODEL_WEBHOOK_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Webhooks/Module.php b/src/Appwrite/Platform/Modules/Webhooks/Module.php new file mode 100644 index 0000000000..66400ccd9a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Webhooks/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php b/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php new file mode 100644 index 0000000000..4805de6ebc --- /dev/null +++ b/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php @@ -0,0 +1,31 @@ +type = Service::TYPE_HTTP; + + // Hooks + $this->addAction(Init::getName(), new Init()); + + // Webhooks + $this->addAction(CreateWebhook::getName(), new CreateWebhook()); + $this->addAction(ListWebhooks::getName(), new ListWebhooks()); + $this->addAction(GetWebhook::getName(), new GetWebhook()); + $this->addAction(DeleteWebhook::getName(), new DeleteWebhook()); + $this->addAction(UpdateWebhook::getName(), new UpdateWebhook()); + $this->addAction(UpdateWebhookSignature::getName(), new UpdateWebhookSignature()); + } +} diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index ce9fca67ba..af768444f2 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -4,17 +4,39 @@ namespace Appwrite\Platform\Tasks; use Appwrite\Docker\Compose; use Appwrite\Docker\Env; +use Appwrite\Platform\Installer\Runtime\State; +use Appwrite\Platform\Installer\Server as InstallerServer; use Appwrite\Utopia\View; use Utopia\Auth\Proofs\Password; use Utopia\Auth\Proofs\Token; use Utopia\Config\Config; use Utopia\Console; +use Utopia\Fetch\Client; use Utopia\Platform\Action; use Utopia\Validator\Boolean; use Utopia\Validator\Text; +use Utopia\Validator\WhiteList; class Install extends Action { + private const int INSTALL_STEP_DELAY_SECONDS = 2; + private const int WEB_SERVER_CHECK_ATTEMPTS = 10; + private const int WEB_SERVER_CHECK_DELAY_SECONDS = 1; + + private const int HEALTH_CHECK_ATTEMPTS = 30; + private const int HEALTH_CHECK_DELAY_SECONDS = 1; + + private const string PATTERN_ENV_VAR_NAME = '/^[A-Z0-9_]+$/'; + private const string PATTERN_DB_PASSWORD_VAR = '/^_APP_DB_.*_PASS$/'; + private const string PATTERN_SESSION_COOKIE = '/a_session_console=([^;]+)/'; + + private const string APPWRITE_API_URL = 'http://appwrite'; + private const string GROWTH_API_URL = 'https://growth.appwrite.io/v1'; + + protected bool $isUpgrade = false; + protected string $hostPath = ''; + protected ?bool $isLocalInstall = null; + protected ?array $installerConfig = null; protected string $path = '/usr/src/code/appwrite'; public static function getName(): string @@ -32,16 +54,25 @@ class Install extends Action ->param('image', 'appwrite', new Text(0), 'Main appwrite docker image', true) ->param('interactive', 'Y', new Text(1), 'Run an interactive session', true) ->param('no-start', false, new Boolean(true), 'Run an interactive session', true) - ->param('database', 'mongodb', new Text(0), 'Database to use (mongodb|mariadb|postgres)', true) + ->param('database', 'mongodb', new WhiteList(['mongodb', 'mariadb', 'postgresql']), 'Database to use (mongodb|mariadb|postgresql)', true) ->callback($this->action(...)); } - public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart, string $database): void - { + public function action( + string $httpPort, + string $httpsPort, + string $organization, + string $image, + string $interactive, + bool $noStart, + string $database + ): void { + $isUpgrade = $this->isUpgrade; + $defaultHttpPort = '80'; + $defaultHttpsPort = '443'; $config = Config::getParam('variables'); - $defaultHTTPPort = '80'; - $defaultHTTPSPort = '443'; - /** @var array> $vars array whre key is variable name and value is variable */ + + /** @var array> $vars array where key is variable name and value is variable */ $vars = []; foreach ($config as $category) { @@ -52,6 +83,9 @@ class Install extends Action Console::success('Starting Appwrite installation...'); + $isLocalInstall = $this->isLocalInstall(); + $this->applyLocalPaths($isLocalInstall, true); + // Create directory with write permissions if (!\file_exists(\dirname($this->path))) { if (!@\mkdir(\dirname($this->path), 0755, true)) { @@ -60,21 +94,16 @@ class Install extends Action } } - $data = @file_get_contents($this->path . '/docker-compose.yml'); - - if ($data !== false) { - if ($interactive == 'Y' && Console::isInteractive()) { - $answer = Console::confirm('Previous installation found, do you want to overwrite it (a backup will be created before overwriting)? (Y/n)'); - - if (\strtolower($answer) !== 'y') { - Console::info('No action taken.'); - return; - } - } + // Check for existing installation + $data = $this->readExistingCompose(); + $envFileExists = file_exists($this->path . '/' . $this->getEnvFileName()); + $existingInstallation = $data !== '' || $envFileExists; + if ($existingInstallation) { $time = \time(); - Console::info('Compose file found, creating backup: docker-compose.yml.' . $time . '.backup'); - file_put_contents($this->path . '/docker-compose.yml.' . $time . '.backup', $data); + $composeFileName = $this->getComposeFileName(); + Console::info('Compose file found, creating backup: ' . $composeFileName . '.' . $time . '.backup'); + file_put_contents($this->path . '/' . $composeFileName . '.' . $time . '.backup', $data); $compose = new Compose($data); $appwrite = $compose->getService('appwrite'); $oldVersion = $appwrite?->getImageVersion(); @@ -82,14 +111,14 @@ class Install extends Action $ports = $compose->getService('traefik')->getPorts(); } catch (\Throwable $th) { $ports = [ - $defaultHTTPPort => $defaultHTTPPort, - $defaultHTTPSPort => $defaultHTTPSPort + $defaultHttpPort => $defaultHttpPort, + $defaultHttpsPort => $defaultHttpsPort ]; Console::warning('Traefik not found. Falling back to default ports.'); } if ($oldVersion) { - foreach ($compose->getServices() as $service) { // Fetch all env vars from previous compose file + foreach ($compose->getServices() as $service) { if (!$service) { continue; } @@ -108,12 +137,14 @@ class Install extends Action } } - $data = @file_get_contents($this->path . '/.env'); + $envData = @file_get_contents($this->path . '/' . $this->getEnvFileName()); - if ($data !== false) { // Fetch all env vars from previous .env file - Console::info('Env file found, creating backup: .env.' . $time . '.backup'); - file_put_contents($this->path . '/.env.' . $time . '.backup', $data); - $env = new Env($data); + if ($envData !== false) { + if (!$isLocalInstall) { + Console::info('Env file found, creating backup: .env.' . $time . '.backup'); + file_put_contents($this->path . '/.env.' . $time . '.backup', $envData); + } + $env = new Env($envData); foreach ($env->list() as $key => $value) { if (is_null($value)) { @@ -128,18 +159,37 @@ class Install extends Action } foreach ($ports as $key => $value) { - if ($value === $defaultHTTPPort) { - $defaultHTTPPort = $key; + if ($value === $defaultHttpPort) { + $defaultHttpPort = $key; } - if ($value === $defaultHTTPSPort) { - $defaultHTTPSPort = $key; + if ($value === $defaultHttpsPort) { + $defaultHttpsPort = $key; } } } - // Block database type changes on existing installations - $existingDatabase = $vars['_APP_DB_ADAPTER']['default'] ?? null; + // Block database type changes on existing installations. + // Only enforce if the existing config explicitly set _APP_DB_ADAPTER + // (pre-1.9.0 installs never had this variable). + $existingDatabase = null; + foreach ($compose->getServices() as $service) { + if (!$service) { + continue; + } + $svcEnv = $service->getEnvironment()->list(); + if (isset($svcEnv['_APP_DB_ADAPTER'])) { + $existingDatabase = $svcEnv['_APP_DB_ADAPTER']; + break; + } + } + if ($existingDatabase === null) { + $envFilePath = $this->path . '/' . $this->getEnvFileName(); + $rawEnv = @file_get_contents($envFilePath); + if ($rawEnv !== false) { + $existingDatabase = (new Env($rawEnv))->list()['_APP_DB_ADAPTER'] ?? null; + } + } if ($existingDatabase !== null && $existingDatabase !== $database) { Console::error("Cannot change database type from '{$existingDatabase}' to '{$database}'."); Console::error('Changing database types on an existing installation is not supported.'); @@ -147,30 +197,36 @@ class Install extends Action } } - - if (empty($httpPort)) { - $httpPort = Console::confirm('Choose your server HTTP port: (default: ' . $defaultHTTPPort . ')'); - $httpPort = ($httpPort) ? $httpPort : $defaultHTTPPort; + $installerConfig = $this->readInstallerConfig(); + $enabledDatabases = $installerConfig['enabledDatabases'] ?? ['mongodb', 'mariadb']; + if (!in_array($database, $enabledDatabases, true)) { + Console::error("Database '{$database}' is not available. Available options: " . implode(', ', $enabledDatabases)); + Console::exit(1); } - if (empty($httpsPort)) { - $httpsPort = Console::confirm('Choose your server HTTPS port: (default: ' . $defaultHTTPSPort . ')'); - $httpsPort = ($httpsPort) ? $httpsPort : $defaultHTTPSPort; + // If interactive and web mode enabled, start web server + if ($interactive === 'Y' && Console::isInteractive()) { + Console::success('Starting web installer...'); + Console::info('Open your browser at: http://localhost:' . InstallerServer::INSTALLER_WEB_PORT); + Console::info('Press Ctrl+C to cancel installation'); + + $this->startWebServer($defaultHttpPort, $defaultHttpsPort, $organization, $image, $noStart, $vars); + return; } + // Fall back to CLI mode $enableAssistant = false; $assistantExistsInOldCompose = false; - - if ($data !== false && isset($compose)) { + if ($existingInstallation && isset($compose)) { try { $assistantService = $compose->getService('appwrite-assistant'); $assistantExistsInOldCompose = $assistantService !== null; } catch (\Throwable) { - // assistant service doesn't exist, keep default false + /* ignore */ } } - if ($interactive == 'Y' && Console::isInteractive()) { + if ($interactive === 'Y' && Console::isInteractive()) { $prompt = 'Add Appwrite Assistant? (Y/n)' . ($assistantExistsInOldCompose ? ' [Currently enabled]' : ''); $answer = Console::confirm($prompt); @@ -183,148 +239,912 @@ class Install extends Action $enableAssistant = true; } - $input = []; + if (empty($httpPort)) { + $httpPort = Console::confirm('Choose your server HTTP port: (default: ' . $defaultHttpPort . ')'); + $httpPort = ($httpPort) ?: $defaultHttpPort; + } - $password = new Password(); - $password->setCharset('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'); - $token = new Token(); + if (empty($httpsPort)) { + $httpsPort = Console::confirm('Choose your server HTTPS port: (default: ' . $defaultHttpsPort . ')'); + $httpsPort = ($httpsPort) ?: $defaultHttpsPort; + } + + $userInput = []; foreach ($vars as $var) { if ($var['name'] === '_APP_ASSISTANT_OPENAI_API_KEY') { if (!$enableAssistant) { - $input[$var['name']] = ''; + $userInput[$var['name']] = ''; continue; } - // key already exists if (!empty($var['default'])) { - $input[$var['name']] = $var['default']; + $userInput[$var['name']] = $var['default']; continue; } - // if assistant enabled and no key, ask for it if (Console::isInteractive() && $interactive === 'Y') { - $input[$var['name']] = Console::confirm('Enter your OpenAI API key for Appwrite Assistant:'); - if (empty($input[$var['name']])) { + $userInput[$var['name']] = Console::confirm('Enter your OpenAI API key for Appwrite Assistant:'); + if (empty($userInput[$var['name']])) { Console::warning('No API key provided. Assistant will be disabled.'); $enableAssistant = false; - $input[$var['name']] = ''; + $userInput[$var['name']] = ''; } - continue; + } else { + $userInput[$var['name']] = ''; } - $input[$var['name']] = ''; continue; } - if (!empty($var['filter']) && ($interactive !== 'Y' || !Console::isInteractive())) { - if ($data && $var['default'] !== null) { - $input[$var['name']] = $var['default']; - continue; - } - - if ($var['filter'] === 'token') { - $input[$var['name']] = $token->generate(); - continue; - } - - if ($var['filter'] === 'password') { - $input[$var['name']] = $password->generate(); - continue; - } - } if (!$var['required'] || !Console::isInteractive() || $interactive !== 'Y') { - $input[$var['name']] = $var['default']; continue; } if ($var['name'] === '_APP_DB_ADAPTER' && $data !== false) { - $input[$var['name']] = $database; + $userInput[$var['name']] = $database; continue; } - $input[$var['name']] = Console::confirm($var['question'] . ' (default: \'' . $var['default'] . '\')'); + $value = Console::confirm($var['question'] . ' (default: \'' . $var['default'] . '\')'); - if (empty($input[$var['name']])) { - $input[$var['name']] = $var['default']; + if (!empty($value)) { + $userInput[$var['name']] = $value; } - if ($var['filter'] === 'domainTarget') { - if ($input[$var['name']] !== 'localhost') { - Console::warning("\nIf you haven't already done so, set the following record for {$input[$var['name']]} on your DNS provider:\n"); - $mask = "%-15.15s %-10.10s %-30.30s\n"; - printf($mask, "Type", "Name", "Value"); - printf($mask, "A or AAAA", "@", ""); - Console::warning("\nUse 'AAAA' if you're using an IPv6 address and 'A' if you're using an IPv4 address.\n"); - } + if ($var['filter'] === 'domainTarget' && !empty($value) && $value !== 'localhost') { + Console::warning("\nIf you haven't already done so, set the following record for {$value} on your DNS provider:\n"); + $mask = "%-15.15s %-10.10s %-30.30s\n"; + printf($mask, "Type", "Name", "Value"); + printf($mask, "A or AAAA", "@", ""); + Console::warning("\nUse 'AAAA' if you're using an IPv6 address and 'A' if you're using an IPv4 address.\n"); } } - $database = $input['_APP_DB_ADAPTER']; + $userInput['_APP_DB_ADAPTER'] = $userInput['_APP_DB_ADAPTER'] ?? $database; + $database = $userInput['_APP_DB_ADAPTER']; if ($database === 'postgresql') { - $input['_APP_DB_HOST'] = 'postgresql'; - $input['_APP_DB_PORT'] = 5432; + $userInput['_APP_DB_HOST'] = 'postgresql'; + $userInput['_APP_DB_PORT'] = 5432; + } elseif ($database === 'mongodb') { + $userInput['_APP_DB_HOST'] = 'mongodb'; + $userInput['_APP_DB_PORT'] = 27017; } elseif ($database === 'mariadb') { - $input['_APP_DB_HOST'] = 'mariadb'; - $input['_APP_DB_PORT'] = 3306; + $userInput['_APP_DB_HOST'] = 'mariadb'; + $userInput['_APP_DB_PORT'] = 3306; } - $database = $input['_APP_DB_ADAPTER']; + $shouldGenerateSecrets = !$existingInstallation && !$isUpgrade; + $input = $this->prepareEnvironmentVariables($userInput, $vars, $shouldGenerateSecrets); + $this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart, null, null, $isUpgrade); + } + + + protected function startWebServer(string $defaultHttpPort, string $defaultHttpsPort, string $organization, string $image, bool $noStart, array $vars, bool $isUpgrade = false, ?string $lockedDatabase = null): void + { + $port = InstallerServer::INSTALLER_WEB_PORT; + + @unlink(InstallerServer::INSTALLER_COMPLETE_FILE); + + $state = new State([]); + $state->clearStaleLock(); + + $installerConfig = $this->readInstallerConfig(); + $enabledDatabases = $installerConfig['enabledDatabases'] ?? ['mongodb', 'mariadb']; + + $this->setInstallerConfig([ + 'defaultHttpPort' => $defaultHttpPort, + 'defaultHttpsPort' => $defaultHttpsPort, + 'organization' => $organization, + 'image' => $image, + 'noStart' => $noStart, + 'vars' => $vars, + 'isUpgrade' => $isUpgrade, + 'lockedDatabase' => $lockedDatabase, + 'enabledDatabases' => $enabledDatabases, + 'isLocal' => $this->isLocalInstall(), + 'hostPath' => $this->hostPath ?: null, + ]); + + // Start Swoole-based installer server in background + // Redirect stdout/stderr to a log file so exec() returns immediately + // (otherwise the backgrounded process holds the pipe open and exec() hangs) + $serverScript = \escapeshellarg(dirname(__DIR__) . '/Installer/Server.php'); + $logFile = \sys_get_temp_dir() . '/appwrite-installer-server.log'; + $output = []; + \exec("php {$serverScript} > " . \escapeshellarg($logFile) . " 2>&1 & echo \$!", $output); + $pid = isset($output[0]) ? (int) $output[0] : 0; + + \register_shutdown_function(function () use ($pid) { + if ($pid > 0 && \function_exists('posix_kill')) { + @\posix_kill($pid, SIGTERM); + } + }); + \sleep(1); + + if (!$this->waitForWebServer($port)) { + $log = @\file_get_contents($logFile); + if ($log !== false && $log !== '') { + Console::error('Installer server log:'); + Console::error($log); + } + Console::warning('Web installer did not respond in time. Please refresh the browser.'); + return; + } + + if ($this->isInstallationComplete($port)) { + Console::success('Installation completed.'); + } + } + + public function prepareEnvironmentVariables(array $userInput, array $vars, bool $shouldGenerateSecrets = true): array + { + $input = []; + $password = new Password(); + $token = new Token(); + + // Start with all defaults + foreach ($vars as $var) { + $filter = $var['filter'] ?? null; + $default = $var['default'] ?? null; + $hasDefault = $default !== null && $default !== ''; + + if ($filter === 'token') { + if ($hasDefault) { + $input[$var['name']] = $default; + } elseif ($shouldGenerateSecrets) { + $input[$var['name']] = $token->generate(); + } else { + $input[$var['name']] = ''; + } + } elseif ($filter === 'password') { + if ($hasDefault) { + $input[$var['name']] = $default; + } elseif ($shouldGenerateSecrets) { + /*;#+@:/?& broke DSNs locally */ + $input[$var['name']] = $this->generatePasswordValue($var['name'], $password); + } else { + $input[$var['name']] = ''; + } + } else { + $input[$var['name']] = $default; + } + } + + // Override with user inputs + foreach ($userInput as $key => $value) { + if ($value !== null && ($value !== '' || $key === '_APP_ASSISTANT_OPENAI_API_KEY')) { + $input[$key] = $value; + } + } + + foreach ($input as $key => $value) { + if (!is_string($value)) { + continue; + } + if (str_contains($value, "\n") || str_contains($value, "\r")) { + throw new \InvalidArgumentException('Invalid value for ' . $key); + } + } + + // Set database-specific connection details + $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb'; if ($database === 'mongodb') { $input['_APP_DB_HOST'] = 'mongodb'; $input['_APP_DB_PORT'] = 27017; } elseif ($database === 'mariadb') { $input['_APP_DB_HOST'] = 'mariadb'; $input['_APP_DB_PORT'] = 3306; + } elseif ($database === 'postgresql') { + $input['_APP_DB_HOST'] = 'postgresql'; + $input['_APP_DB_PORT'] = 5432; } - $templateForCompose = new View(__DIR__ . '/../../../../app/views/install/compose.phtml'); - $templateForEnv = new View(__DIR__ . '/../../../../app/views/install/env.phtml'); - $templateForCompose - ->setParam('httpPort', $httpPort) - ->setParam('httpsPort', $httpsPort) - ->setParam('version', APP_VERSION_STABLE) - ->setParam('organization', $organization) - ->setParam('image', $image) - ->setParam('enableAssistant', $enableAssistant) - ->setParam('database', $database); + return $input; + } - $templateForEnv->setParam('vars', $input); + public function hasExistingConfig(): bool + { + $isLocalInstall = $this->isLocalInstall(); + $this->applyLocalPaths($isLocalInstall, true); - if (!file_put_contents($this->path . '/docker-compose.yml', $templateForCompose->render(false))) { - $message = 'Failed to save Docker Compose file'; - Console::error($message); - Console::exit(1); + if ($this->readExistingCompose() !== '') { + return true; } - if (!file_put_contents($this->path . '/.env', $templateForEnv->render(false))) { - $message = 'Failed to save environment variables file'; - Console::error($message); - Console::exit(1); + return file_exists($this->path . '/' . $this->getEnvFileName()); + } + + private function updateProgress(?callable $progress, string $step, string $status, array $messages = [], array $details = [], ?string $messageOverride = null): void + { + if (!$progress) { + return; } - $env = ''; - $stdout = ''; - $stderr = ''; - - foreach ($input as $key => $value) { - if ($value) { - $env .= $key . '=' . \escapeshellarg($value) . ' '; + if ($messageOverride !== null) { + $message = $messageOverride; + } else { + $key = $status === InstallerServer::STATUS_COMPLETED ? 'done' : 'start'; + $message = $messages[$step][$key] ?? null; + if ($message === null) { + return; } } - $exit = 0; - if (!$noStart) { - Console::log("Running \"docker compose up -d --remove-orphans --renew-anon-volumes\""); - $exit = Console::execute("$env docker compose --project-directory $this->path up -d --remove-orphans --renew-anon-volumes", '', $stdout, $stderr); + try { + $progress($step, $status, $message, $details); + } catch (\Throwable $e) { } - - if ($exit !== 0) { - $message = 'Failed to install Appwrite dockers'; - Console::error($message); - Console::error($stderr); - Console::exit($exit); - } else { - $message = 'Appwrite installed successfully'; - Console::success($message); + if ($status === InstallerServer::STATUS_IN_PROGRESS) { + sleep(self::INSTALL_STEP_DELAY_SECONDS); } } + + private function setInstallerConfig(array $config): void + { + $json = json_encode($config, JSON_UNESCAPED_SLASHES); + if (!is_string($json)) { + return; + } + + putenv('APPWRITE_INSTALLER_CONFIG=' . $json); + $path = InstallerServer::INSTALLER_CONFIG_FILE; + if (@file_put_contents($path, $json) === false) { + return; + } + @chmod($path, 0600); + } + + public function performInstallation( + string $httpPort, + string $httpsPort, + string $organization, + string $image, + array $input, + bool $noStart, + ?callable $progress = null, + ?string $resumeFromStep = null, + bool $isUpgrade = false, + array $account = [] + ): void { + $isLocalInstall = $this->isLocalInstall(); + $this->applyLocalPaths($isLocalInstall, false); + + $isCLI = php_sapi_name() === 'cli'; + if ($isLocalInstall || $isUpgrade) { + $useExistingConfig = false; + } else { + $useExistingConfig = file_exists($this->path . '/' . $this->getComposeFileName()) + && file_exists($this->path . '/' . $this->getEnvFileName()); + } + + if ($isLocalInstall) { + $image = 'appwrite'; + $organization = 'appwrite'; + } + + $templateForEnv = new View($this->buildFromProjectPath('/app/views/install/env.phtml')); + $templateForCompose = new View($this->buildFromProjectPath('/app/views/install/compose.phtml')); + + $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb'; + + $version = \getenv('_APP_VERSION') ?: (\defined('APP_VERSION_STABLE') ? APP_VERSION_STABLE : 'latest'); + if ($isLocalInstall) { + $version = 'local'; + } + + $assistantKey = (string) ($input['_APP_ASSISTANT_OPENAI_API_KEY'] ?? ''); + $enableAssistant = trim($assistantKey) !== ''; + + $templateForCompose + ->setParam('httpPort', $httpPort) + ->setParam('httpsPort', $httpsPort) + ->setParam('version', $version) + ->setParam('organization', $organization) + ->setParam('image', $image) + ->setParam('database', $database) + ->setParam('hostPath', $this->hostPath) + ->setParam('enableAssistant', $enableAssistant); + + $templateForEnv->setParam('vars', $input); + + $steps = [ + InstallerServer::STEP_DOCKER_COMPOSE, + InstallerServer::STEP_ENV_VARS, + InstallerServer::STEP_DOCKER_CONTAINERS + ]; + + $startIndex = 0; + if ($resumeFromStep !== null) { + $resumeIndex = array_search($resumeFromStep, $steps, true); + if ($resumeIndex !== false) { + $startIndex = $resumeIndex; + } + } + + $currentStep = null; + + $messages = $this->buildStepMessages($isUpgrade); + + try { + if ($startIndex <= 1) { + $this->updateProgress($progress, InstallerServer::STEP_CONFIG_FILES, InstallerServer::STATUS_IN_PROGRESS, $messages); + } + + if ($startIndex <= 0) { + $currentStep = InstallerServer::STEP_DOCKER_COMPOSE; + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_COMPOSE, InstallerServer::STATUS_IN_PROGRESS, $messages); + + if (!$useExistingConfig) { + $this->writeComposeFile($templateForCompose); + } + + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_COMPOSE, InstallerServer::STATUS_COMPLETED, $messages); + } + + if ($startIndex <= 1) { + $currentStep = InstallerServer::STEP_ENV_VARS; + $this->updateProgress($progress, InstallerServer::STEP_ENV_VARS, InstallerServer::STATUS_IN_PROGRESS, $messages); + + if (!$useExistingConfig) { + $this->writeEnvFile($templateForEnv); + } + + $this->updateProgress($progress, InstallerServer::STEP_ENV_VARS, InstallerServer::STATUS_COMPLETED, $messages); + $this->updateProgress($progress, InstallerServer::STEP_CONFIG_FILES, InstallerServer::STATUS_COMPLETED, $messages); + } + + if ($database === 'mongodb' && !$useExistingConfig) { + $this->copyMongoEntrypointIfNeeded(); + } + + if (!$noStart && $startIndex <= 2) { + $currentStep = InstallerServer::STEP_DOCKER_CONTAINERS; + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_IN_PROGRESS, $messages); + $this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI); + + if (!$isLocalInstall) { + $this->connectInstallerToAppwriteNetwork(); + } + + $domain = $input['_APP_DOMAIN'] ?? 'localhost'; + + // Wait for Appwrite API to be healthy before marking containers as ready + $apiUrl = $this->waitForApiReady($domain, $httpPort, $isLocalInstall, $progress, InstallerServer::STEP_DOCKER_CONTAINERS); + + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages); + + if (!$isUpgrade) { + $this->createInitialAdminAccount($account, $progress, $apiUrl, $domain); + } + + // Track installs + $this->trackSelfHostedInstall($input, $isUpgrade, $version, $account); + + if ($isCLI) { + Console::success('Appwrite installed successfully'); + } + } else { + if ($isCLI) { + Console::success('Installation files created. Run "docker compose up -d" to start Appwrite'); + } + } + } catch (\Throwable $e) { + if ($currentStep) { + $details = []; + $previous = $e->getPrevious(); + if ($previous instanceof \Throwable && $previous->getMessage() !== '') { + $details['output'] = $previous->getMessage(); + } + $this->updateProgress($progress, $currentStep, InstallerServer::STATUS_ERROR, $messages, $details, $e->getMessage()); + } + throw $e; + } + } + + private function createInitialAdminAccount(array $account, ?callable $progress, string $apiUrl, string $domain): void + { + $name = $account['name'] ?? 'Admin'; + $email = $account['email'] ?? null; + $password = $account['password'] ?? null; + + if (!$email || !$password) { + return; + } + + try { + $this->updateProgress( + $progress, + InstallerServer::STEP_ACCOUNT_SETUP, + InstallerServer::STATUS_IN_PROGRESS, + messageOverride: 'Creating Appwrite account' + ); + + // Create the account — tolerate "already exists" so we can still + // create a session (common when re-running the installer). + $userId = null; + try { + $userId = $this->makeApiCall('/v1/account', [ + 'userId' => 'unique()', + 'email' => $email, + 'password' => $password, + 'name' => $name + ], false, $apiUrl, $domain); + } catch (\Throwable $e) { + if (\stripos($e->getMessage(), 'already exists') === false) { + throw $e; + } + } + + $session = $this->makeApiCall('/v1/account/sessions/email', [ + 'email' => $email, + 'password' => $password + ], true, $apiUrl, $domain); + + $this->updateProgress( + $progress, + InstallerServer::STEP_ACCOUNT_SETUP, + InstallerServer::STATUS_COMPLETED, + details: [ + 'userId' => $userId ?? $session['id'], + 'sessionId' => $session['id'], + 'sessionSecret' => $session['secret'], + 'sessionExpire' => $session['expire'] ?? null + ], + messageOverride: 'Account created successfully' + ); + } catch (\Throwable $e) { + $this->updateProgress( + $progress, + InstallerServer::STEP_ACCOUNT_SETUP, + InstallerServer::STATUS_ERROR, + details: [ + 'output' => "apiUrl={$apiUrl}, domain={$domain}", + 'trace' => $e->getTraceAsString(), + ], + messageOverride: 'Account creation failed: ' . $e->getMessage() + ); + } + } + + private function trackSelfHostedInstall(array $input, bool $isUpgrade, string $version, array $account): void + { + if ($this->isLocalInstall()) { + return; + } + + $appEnv = $input['_APP_ENV'] ?? 'development'; + $domain = $input['_APP_DOMAIN'] ?? 'localhost'; + + /* local or test instance */ + if ($appEnv !== 'production') { + return; + } + + /* prod but local or test instance */ + if ($domain === 'localhost' + || str_starts_with($domain, '127.') + || str_starts_with($domain, '0.0.0.0') + ) { + return; + } + + $type = $isUpgrade ? 'upgrade' : 'install'; + $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb'; + $name = $account['name'] ?? 'Admin'; + $email = $account['email'] ?? 'admin@selfhosted.local'; + + $payload = [ + 'action' => $type, + 'account' => 'self-hosted', + 'url' => 'https://' . $domain, + 'category' => 'self_hosted', + 'label' => 'self_hosted_' . $type, + 'version' => $version, + 'data' => json_encode([ + 'name' => $name, + 'email' => $email, + 'domain' => $domain, + 'database' => $database, + ]), + ]; + + try { + $client = new Client(); + $client + ->addHeader('Content-Type', 'application/json') + ->fetch(self::GROWTH_API_URL . '/analytics', Client::METHOD_POST, $payload); + } catch (\Throwable) { + // tracking shouldn't block installation + } + } + + /** + * Wait for the Appwrite API to respond. Builds candidate URLs based on + * the runtime context and returns whichever responds first. + * + * Candidates (in order of preference): + * - Docker internal DNS (http://appwrite) — only if on the appwrite network + * - host.docker.internal:{port} — reaches host-published ports from inside a container + * - localhost:{port} — works when running directly on the host (local dev) + */ + private function waitForApiReady(string $domain, string $httpPort, bool $isLocalInstall, ?callable $progress, string $step = InstallerServer::STEP_DOCKER_CONTAINERS): string + { + $client = new Client(); + $client + ->setTimeout(2000) + ->setConnectTimeout(2000) + ->addHeader('Host', $domain); + + $healthPath = '/v1/health/version'; + + // Local dev: reach Traefik via localhost on the host. + // Docker: reach Appwrite directly via Docker internal DNS (network connect is guaranteed). + $candidate = $isLocalInstall + ? 'http://localhost:' . $httpPort . $healthPath + : self::APPWRITE_API_URL . $healthPath; + $candidates = [$candidate]; + + $lastErrors = []; + + for ($i = 0; $i < self::HEALTH_CHECK_ATTEMPTS; $i++) { + foreach ($candidates as $url) { + try { + $response = $client->fetch($url); + if ($response->getStatusCode() === 200) { + return \rtrim(\substr($url, 0, -\strlen($healthPath)), '/'); + } + $lastErrors[$url] = "HTTP {$response->getStatusCode()}"; + } catch (\Throwable $e) { + $lastErrors[$url] = $e->getMessage(); + } + } + + if ($progress) { + try { + $progress( + $step, + InstallerServer::STATUS_IN_PROGRESS, + 'Waiting for Appwrite to be ready (' . ($i + 1) . '/' . self::HEALTH_CHECK_ATTEMPTS . ')', + [] + ); + } catch (\Throwable) { + } + } + + if ($i < self::HEALTH_CHECK_ATTEMPTS - 1) { + sleep(self::HEALTH_CHECK_DELAY_SECONDS); + } + } + + $errorDetail = implode('; ', array_map( + fn ($url, $err) => "{$url} => {$err}", + array_keys($lastErrors), + array_values($lastErrors) + )); + + throw new \Exception("Failed to connect with Appwrite in time. Tried: {$errorDetail}"); + } + + /** + * Connect the installer container to the appwrite network, retrying + * until it succeeds or we run out of attempts. + */ + private function connectInstallerToAppwriteNetwork(): void + { + $network = escapeshellarg('appwrite'); + + // Resolve our own container ID — works regardless of how the + // container was started (--name, random name, etc.). + $containerId = trim((string) @file_get_contents('/etc/hostname')); + if ($containerId === '') { + $containerId = InstallerServer::DEFAULT_CONTAINER; + } + $container = escapeshellarg($containerId); + + $outputStr = ''; + for ($i = 0; $i < 10; $i++) { + $output = []; + @exec("docker network connect {$network} {$container} 2>&1", $output, $exitCode); + + if ($exitCode === 0) { + return; + } + + $outputStr = implode(' ', $output); + if (str_contains($outputStr, 'already exists')) { + return; + } + + sleep(1); + } + + throw new \Exception('Failed to connect installer to appwrite network: ' . $outputStr); + } + + private function makeApiCall(string $endpoint, array $body, bool $extractSession = false, string $apiUrl = self::APPWRITE_API_URL, string $domain = 'localhost') + { + $client = new Client(); + $client + ->setTimeout(30000) + ->setConnectTimeout(10000) + ->addHeader('Content-Type', 'application/json') + ->addHeader('X-Appwrite-Project', 'console') + ->addHeader('Host', $domain); + + $url = $apiUrl . $endpoint; + $response = $client->fetch($url, Client::METHOD_POST, $body); + + if ($response->getStatusCode() !== 201) { + $error = $response->json(); + $message = $error['message'] ?? ('HTTP ' . $response->getStatusCode() . ': ' . $response->getBody()); + throw new \Exception("API call failed ({$endpoint}): {$message}"); + } + + $data = $response->json(); + if (!isset($data['$id'])) { + throw new \Exception('API response missing ID field'); + } + + if ($extractSession) { + $headers = $response->getHeaders(); + $setCookie = $headers['set-cookie'] ?? $headers['Set-Cookie'] ?? null; + + if (!$setCookie || !preg_match(self::PATTERN_SESSION_COOKIE, $setCookie, $matches)) { + throw new \Exception('Session created but no cookie found'); + } + + return [ + 'id' => $data['$id'], + 'secret' => urldecode($matches[1]), + 'expire' => $data['expire'] ?? null + ]; + } + + return $data['$id']; + } + + private function buildStepMessages(bool $isUpgrade): array + { + $isUpgradeLabel = $isUpgrade ? 'updated' : 'created'; + $verbs = [ + InstallerServer::STEP_CONFIG_FILES => $isUpgrade ? 'Updating' : 'Creating', + InstallerServer::STEP_DOCKER_COMPOSE => $isUpgrade ? 'Updating' : 'Generating', + InstallerServer::STEP_ENV_VARS => $isUpgrade ? 'Updating' : 'Configuring', + InstallerServer::STEP_DOCKER_CONTAINERS => $isUpgrade ? 'Restarting' : 'Starting', + ]; + + return [ + InstallerServer::STEP_CONFIG_FILES => [ + 'start' => $verbs[InstallerServer::STEP_CONFIG_FILES] . ' configuration files...', + 'done' => 'Configuration files ' . $isUpgradeLabel, + ], + InstallerServer::STEP_DOCKER_COMPOSE => [ + 'start' => $verbs[InstallerServer::STEP_DOCKER_COMPOSE] . ' Docker Compose file...', + 'done' => 'Docker Compose file ' . $isUpgradeLabel, + ], + InstallerServer::STEP_ENV_VARS => [ + 'start' => $verbs[InstallerServer::STEP_ENV_VARS] . ' environment variables...', + 'done' => 'Environment variables ' . $isUpgradeLabel, + ], + InstallerServer::STEP_DOCKER_CONTAINERS => [ + 'start' => $verbs[InstallerServer::STEP_DOCKER_CONTAINERS] . ' Docker containers...', + 'done' => $isUpgrade ? 'Docker containers restarted' : 'Docker containers started', + ], + ]; + } + + private function writeComposeFile(View $template): void + { + $composeFileName = $this->getComposeFileName(); + $targetPath = $this->path . '/' . $composeFileName; + $renderedContent = $template->render(false); + + $result = @file_put_contents($targetPath, $renderedContent); + if ($result === false) { + $lastError = error_get_last(); + $errorMsg = $lastError ? $lastError['message'] : 'Unknown error'; + throw new \Exception('Failed to save Docker Compose file: ' . $errorMsg . ' (path: ' . $targetPath . ')'); + } + } + + private function writeEnvFile(View $template): void + { + $envFileName = $this->getEnvFileName(); + if (!\file_put_contents($this->path . '/' . $envFileName, $template->render(false))) { + throw new \Exception('Failed to save environment variables file'); + } + } + + private function copyMongoEntrypointIfNeeded(): void + { + $mongoEntrypoint = $this->buildFromProjectPath('/mongo-entrypoint.sh'); + + if (file_exists($mongoEntrypoint)) { + // Always use container path for file operations + copy($mongoEntrypoint, $this->path . '/mongo-entrypoint.sh'); + } + } + + protected function runDockerCompose(array $input, bool $isLocalInstall, bool $useExistingConfig, bool $isCLI): void + { + $env = ''; + if (!$useExistingConfig) { + foreach ($input as $key => $value) { + if ($value === null || $value === '') { + continue; + } + if (!preg_match(self::PATTERN_ENV_VAR_NAME, $key)) { + throw new \Exception("Invalid environment variable name: $key"); + } + $env .= $key . '=' . \escapeshellarg((string) $value) . ' '; + } + } + + if ($isCLI) { + Console::log("Running \"docker compose up -d --remove-orphans --renew-anon-volumes\""); + } + + $composeFileName = $this->getComposeFileName(); + $composeFile = $this->path . '/' . $composeFileName; + + $command = [ + 'docker', + 'compose', + '-f', + $composeFile, + ]; + + if ($isLocalInstall) { + $command[] = '--project-name'; + $command[] = 'appwrite'; + } + + $command[] = '--project-directory'; + $command[] = $this->path; + $command[] = 'up'; + $command[] = '-d'; + $command[] = '--remove-orphans'; + $command[] = '--renew-anon-volumes'; + $commandLine = $env . implode(' ', array_map(escapeshellarg(...), $command)) . ' 2>&1'; + \exec($commandLine, $output, $exit); + + if ($exit !== 0) { + $message = trim(implode("\n", $output)); + $previous = $message !== '' ? new \RuntimeException($message) : null; + throw new \RuntimeException('Failed to start containers', 0, $previous); + } + if ($isLocalInstall && $isCLI && !empty($output)) { + Console::log(implode("\n", $output)); + } + } + + protected function isLocalInstall(): bool + { + if ($this->isLocalInstall === null) { + $config = $this->readInstallerConfig(); + $this->isLocalInstall = !empty($config['isLocal']); + } + + return $this->isLocalInstall; + } + + protected function readInstallerConfig(): array + { + if ($this->installerConfig !== null) { + return $this->installerConfig; + } + + $this->installerConfig = []; + $decodeConfig = static function (string $json): ?array { + $decoded = json_decode($json, true); + return is_array($decoded) ? $decoded : null; + }; + + $json = getenv('APPWRITE_INSTALLER_CONFIG'); + $path = InstallerServer::INSTALLER_CONFIG_FILE; + $fileJson = file_exists($path) ? file_get_contents($path) : null; + + foreach ([$json, $fileJson] as $candidate) { + if (!is_string($candidate) || $candidate === '') { + continue; + } + + $decoded = $decodeConfig($candidate); + if ($decoded !== null) { + $this->installerConfig = $decoded; + return $this->installerConfig; + } + } + + return $this->installerConfig; + } + + protected function getInstallerHostPath(): string + { + $config = $this->readInstallerConfig(); + if (!empty($config['hostPath'])) { + return (string) $config['hostPath']; + } + + $cwd = getcwd(); + return $cwd !== false ? $cwd : '.'; + } + + protected function buildFromProjectPath(string $suffix): string + { + if ($suffix !== '' && $suffix[0] !== '/') { + $suffix = '/' . $suffix; + } + return dirname(__DIR__, 4) . $suffix; + } + + protected function applyLocalPaths(bool $isLocalInstall, bool $force = false): void + { + if (!$isLocalInstall) { + return; + } + if (!$force && $this->hostPath !== '') { + return; + } + $this->path = '/usr/src/code'; + $this->hostPath = $this->getInstallerHostPath(); + } + + protected function readExistingCompose(): string + { + $composeFile = $this->path . '/' . $this->getComposeFileName(); + $data = @file_get_contents($composeFile); + return !empty($data) ? $data : ''; + } + + protected function generatePasswordValue(string $varName, Password $password): string + { + $value = $password->generate(); + if (!\preg_match(self::PATTERN_DB_PASSWORD_VAR, $varName)) { + return $value; + } + + return rtrim(strtr(base64_encode(hash('sha256', $value, true)), '+/', '-_'), '='); + } + + protected function getComposeFileName(): string + { + return $this->isLocalInstall() ? 'docker-compose.web-installer.yml' : 'docker-compose.yml'; + } + + protected function getEnvFileName(): string + { + return $this->isLocalInstall() ? '.env.web-installer' : '.env'; + } + + private function isInstallationComplete(int $port): bool + { + $maxAttempts = 7200; // 2 hours maximum + $attempt = 0; + while ($attempt < $maxAttempts) { + if (file_exists(InstallerServer::INSTALLER_COMPLETE_FILE)) { + return true; + } + $handle = @fsockopen('localhost', $port, $errno, $errstr, 1); + if ($handle === false) { + return false; + } + \fclose($handle); + \sleep(1); + $attempt++; + } + return false; + } + + private function waitForWebServer(int $port): bool + { + for ($attempt = 0; $attempt < self::WEB_SERVER_CHECK_ATTEMPTS; $attempt++) { + $handle = @fsockopen('localhost', $port, $errno, $errstr, 1); + if ($handle !== false) { + \fclose($handle); + return true; + } + \sleep(self::WEB_SERVER_CHECK_DELAY_SECONDS); + } + return false; + } } diff --git a/src/Appwrite/Platform/Tasks/Migrate.php b/src/Appwrite/Platform/Tasks/Migrate.php index cec2f6ec27..b952808998 100644 --- a/src/Appwrite/Platform/Tasks/Migrate.php +++ b/src/Appwrite/Platform/Tasks/Migrate.php @@ -31,7 +31,7 @@ class Migrate extends Action ->inject('dbForPlatform') ->inject('getProjectDB') ->inject('register') - ->inject('authorisation') + ->inject('authorization') ->callback($this->action(...)); } @@ -64,8 +64,43 @@ class Migrate extends Action /** @var Migration $migration */ $migration = new $class(); + // Disable subquery filters that reference new schema columns not yet migrated + $subQueries = [ + 'subQueryAccountKeys', + 'subQueryAttributes', + 'subQueryAuthenticators', + 'subQueryChallenges', + 'subQueryDevKeys', + 'subQueryIndexes', + 'subQueryKeys', + 'subQueryMemberships', + 'subQueryOrganizationKeys', + 'subQueryPlatforms', + 'subQueryProjectVariables', + 'subQuerySessions', + 'subQueryTargets', + 'subQueryTokens', + 'subQueryTopicTargets', + 'subQueryVariables', + 'subQueryWebhooks', + ]; + foreach ($subQueries as $name) { + Database::addFilter( + $name, + fn () => null, + fn () => [] + ); + } + + $dbForPlatform->disableValidation(); + $dbForPlatform->purgeCachedCollection('projects'); + $count = 0; - $total = $dbForPlatform->count('projects') + 1; + try { + $total = $dbForPlatform->count('projects') + 1; + } catch (\Throwable) { + $total = 0; + } $dbForPlatform->foreach('projects', function (Document $project) use ($dbForPlatform, $getProjectDB, $register, $migration, &$count, $total, $authorization) { /** @var Database $dbForProject */ @@ -74,9 +109,14 @@ class Migrate extends Action try { $migration - ->setProject($project, $dbForProject, $dbForPlatform, $authorization, $getProjectDB) - ->setPDO($register->get('db', true)) - ->execute(); + ->setProject($project, $dbForProject, $dbForPlatform, $authorization, $getProjectDB); + + $db = $register->get('db', true); + if ($db instanceof \Utopia\Database\PDO) { + $migration->setPDO($db); + } + + $migration->execute(); } catch (\Throwable $th) { Console::error('Failed to migrate project "' . $project->getId() . '" with error: ' . $th->getMessage()); throw $th; @@ -89,9 +129,14 @@ class Migrate extends Action try { $migration - ->setProject($console, $getProjectDB($console), $dbForPlatform, $authorization, $getProjectDB) - ->setPDO($register->get('db', true)) - ->execute(); + ->setProject($console, $getProjectDB($console), $dbForPlatform, $authorization, $getProjectDB); + + $db = $register->get('db', true); + if ($db instanceof \Utopia\Database\PDO) { + $migration->setPDO($db); + } + + $migration->execute(); } catch (\Throwable $th) { Console::error('Failed to migrate project "console" with error: ' . $th->getMessage()); throw $th; diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index e68656f55d..606c03bf10 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -159,7 +159,25 @@ class Specs extends Action 'name' => 'X-Appwrite-Dev-Key', 'description' => 'Your secret dev API key', 'in' => 'header', - ] + ], + 'ImpersonateUserId' => [ + 'type' => 'apiKey', + 'name' => 'X-Appwrite-Impersonate-User-Id', + 'description' => 'Impersonate a user by ID on an already user-authenticated request. Requires the current request to be authenticated as a user with impersonator capability; X-Appwrite-Key alone is not sufficient. Impersonator users are intentionally granted users.read so they can discover a target before impersonation begins. Internal audit logs still attribute actions to the original impersonator and record the impersonated target only in internal audit payload data.', + 'in' => 'header', + ], + 'ImpersonateUserEmail' => [ + 'type' => 'apiKey', + 'name' => 'X-Appwrite-Impersonate-User-Email', + 'description' => 'Impersonate a user by email on an already user-authenticated request. Requires the current request to be authenticated as a user with impersonator capability; X-Appwrite-Key alone is not sufficient. Impersonator users are intentionally granted users.read so they can discover a target before impersonation begins. Internal audit logs still attribute actions to the original impersonator and record the impersonated target only in internal audit payload data.', + 'in' => 'header', + ], + 'ImpersonateUserPhone' => [ + 'type' => 'apiKey', + 'name' => 'X-Appwrite-Impersonate-User-Phone', + 'description' => 'Impersonate a user by phone on an already user-authenticated request. Requires the current request to be authenticated as a user with impersonator capability; X-Appwrite-Key alone is not sufficient. Impersonator users are intentionally granted users.read so they can discover a target before impersonation begins. Internal audit logs still attribute actions to the original impersonator and record the impersonated target only in internal audit payload data.', + 'in' => 'header', + ], ], APP_SDK_PLATFORM_SERVER => [ 'Project' => [ @@ -198,6 +216,24 @@ class Specs extends Action 'description' => 'The user agent string of the client that made the request', 'in' => 'header', ], + 'ImpersonateUserId' => [ + 'type' => 'apiKey', + 'name' => 'X-Appwrite-Impersonate-User-Id', + 'description' => 'Impersonate a user by ID on an already user-authenticated request. Requires the current request to be authenticated as a user with impersonator capability; X-Appwrite-Key alone is not sufficient. Impersonator users are intentionally granted users.read so they can discover a target before impersonation begins. Internal audit logs still attribute actions to the original impersonator and record the impersonated target only in internal audit payload data.', + 'in' => 'header', + ], + 'ImpersonateUserEmail' => [ + 'type' => 'apiKey', + 'name' => 'X-Appwrite-Impersonate-User-Email', + 'description' => 'Impersonate a user by email on an already user-authenticated request. Requires the current request to be authenticated as a user with impersonator capability; X-Appwrite-Key alone is not sufficient. Impersonator users are intentionally granted users.read so they can discover a target before impersonation begins. Internal audit logs still attribute actions to the original impersonator and record the impersonated target only in internal audit payload data.', + 'in' => 'header', + ], + 'ImpersonateUserPhone' => [ + 'type' => 'apiKey', + 'name' => 'X-Appwrite-Impersonate-User-Phone', + 'description' => 'Impersonate a user by phone on an already user-authenticated request. Requires the current request to be authenticated as a user with impersonator capability; X-Appwrite-Key alone is not sufficient. Impersonator users are intentionally granted users.read so they can discover a target before impersonation begins. Internal audit logs still attribute actions to the original impersonator and record the impersonated target only in internal audit payload data.', + 'in' => 'header', + ], ], APP_SDK_PLATFORM_CONSOLE => [ 'Project' => [ @@ -236,6 +272,24 @@ class Specs extends Action 'description' => 'The user cookie to authenticate with', 'in' => 'header', ], + 'ImpersonateUserId' => [ + 'type' => 'apiKey', + 'name' => 'X-Appwrite-Impersonate-User-Id', + 'description' => 'Impersonate a user by ID on an already user-authenticated request. Requires the current request to be authenticated as a user with impersonator capability; X-Appwrite-Key alone is not sufficient. Impersonator users are intentionally granted users.read so they can discover a target before impersonation begins. Internal audit logs still attribute actions to the original impersonator and record the impersonated target only in internal audit payload data.', + 'in' => 'header', + ], + 'ImpersonateUserEmail' => [ + 'type' => 'apiKey', + 'name' => 'X-Appwrite-Impersonate-User-Email', + 'description' => 'Impersonate a user by email on an already user-authenticated request. Requires the current request to be authenticated as a user with impersonator capability; X-Appwrite-Key alone is not sufficient. Impersonator users are intentionally granted users.read so they can discover a target before impersonation begins. Internal audit logs still attribute actions to the original impersonator and record the impersonated target only in internal audit payload data.', + 'in' => 'header', + ], + 'ImpersonateUserPhone' => [ + 'type' => 'apiKey', + 'name' => 'X-Appwrite-Impersonate-User-Phone', + 'description' => 'Impersonate a user by phone on an already user-authenticated request. Requires the current request to be authenticated as a user with impersonator capability; X-Appwrite-Key alone is not sufficient. Impersonator users are intentionally granted users.read so they can discover a target before impersonation begins. Internal audit logs still attribute actions to the original impersonator and record the impersonated target only in internal audit payload data.', + 'in' => 'header', + ], ], ]; } diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php index 2e77ddd885..1d61180963 100644 --- a/src/Appwrite/Platform/Tasks/Upgrade.php +++ b/src/Appwrite/Platform/Tasks/Upgrade.php @@ -5,12 +5,13 @@ namespace Appwrite\Platform\Tasks; use Appwrite\Docker\Compose; use Appwrite\Docker\Env; use Utopia\Console; -use Utopia\System\System; use Utopia\Validator\Boolean; use Utopia\Validator\Text; class Upgrade extends Install { + private ?string $lockedDatabase = null; + public static function getName(): string { return 'upgrade'; @@ -18,6 +19,8 @@ class Upgrade extends Install public function __construct() { + parent::__construct(); + $this ->desc('Upgrade Appwrite') ->param('http-port', '', new Text(4), 'Server HTTP port', true) @@ -30,20 +33,32 @@ class Upgrade extends Install ->callback($this->action(...)); } - public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart, string $database): void - { + public function action( + string $httpPort, + string $httpsPort, + string $organization, + string $image, + string $interactive, + bool $noStart, + string $database + ): void { + $this->isUpgrade = true; + $isLocalInstall = $this->isLocalInstall(); + $this->applyLocalPaths($isLocalInstall, true); + // Check for previous installation - $data = @file_get_contents($this->path . '/docker-compose.yml'); + $data = $this->readExistingCompose(); if (empty($data)) { Console::error('Appwrite installation not found.'); Console::log('The command was not run in the parent folder of your appwrite installation.'); Console::log('Please navigate to the parent directory of the Appwrite installation and try again.'); Console::log(' parent_directory <= you run the command in this directory'); Console::log(' └── appwrite'); - Console::log(' └── docker-compose.yml'); - Console::exit(1); + Console::log(' └── ' . $this->getComposeFileName()); + return; } + // Detect database from existing installation (CLI param is intentionally ignored) $database = null; $compose = new Compose($data); foreach ($compose->getServices() as $service) { @@ -58,7 +73,7 @@ class Upgrade extends Install } if ($database === null) { - $envData = @file_get_contents($this->path . '/.env'); + $envData = @file_get_contents($this->path . '/' . $this->getEnvFileName()); if ($envData !== false) { $envFile = new Env($envData); $database = $envFile->list()['_APP_DB_ADAPTER'] ?? null; @@ -66,10 +81,35 @@ class Upgrade extends Install } if ($database === null) { - // TODO: Change default to 'mongodb' after next release - $database = System::getEnv('_APP_DB_ADAPTER', 'mariadb'); + // Pre-1.9.0 installations only supported MariaDB + $database = 'mariadb'; + Console::info('No _APP_DB_ADAPTER found in existing configuration, defaulting to mariadb.'); } + $this->lockedDatabase = $database; + parent::action($httpPort, $httpsPort, $organization, $image, $interactive, $noStart, $database); } + + protected function startWebServer( + string $defaultHttpPort, + string $defaultHttpsPort, + string $organization, + string $image, + bool $noStart, + array $vars, + bool $isUpgrade = false, + ?string $lockedDatabase = null + ): void { + parent::startWebServer( + $defaultHttpPort, + $defaultHttpsPort, + $organization, + $image, + $noStart, + $vars, + true, + $this->lockedDatabase + ); + } } diff --git a/src/Appwrite/Platform/Workers/Audits.php b/src/Appwrite/Platform/Workers/Audits.php index d7c57c7fed..55ec39026b 100644 --- a/src/Appwrite/Platform/Workers/Audits.php +++ b/src/Appwrite/Platform/Workers/Audits.php @@ -82,22 +82,31 @@ class Audits extends Action $ip = $payload['ip'] ?? ''; $user = new Document($payload['user'] ?? []); - $userName = $user->getAttribute('name', ''); - $userEmail = $user->getAttribute('email', ''); + $impersonatorUserId = $user->getAttribute('impersonatorUserId'); + $actorUserId = $impersonatorUserId ?: $user->getId(); + $actorUserInternalId = $impersonatorUserId + ? $user->getAttribute('impersonatorUserInternalId') + : $user->getSequence(); + $actorUserName = $impersonatorUserId + ? $user->getAttribute('impersonatorUserName', '') + : $user->getAttribute('name', ''); + $actorUserEmail = $impersonatorUserId + ? $user->getAttribute('impersonatorUserEmail', '') + : $user->getAttribute('email', ''); $userType = $user->getAttribute('type', ACTIVITY_TYPE_USER); // Create event data $eventData = [ - 'userId' => $user->getSequence(), + 'userId' => $actorUserInternalId, 'event' => $event, 'resource' => $resource, 'userAgent' => $userAgent, 'ip' => $ip, 'location' => '', 'data' => [ - 'userId' => $user->getId(), - 'userName' => $userName, - 'userEmail' => $userEmail, + 'userId' => $actorUserId, + 'userName' => $actorUserName, + 'userEmail' => $actorUserEmail, 'userType' => $userType, 'mode' => $mode, 'data' => $auditPayload, @@ -105,6 +114,21 @@ class Audits extends Action 'time' => date("Y-m-d H:i:s", $message->getTimestamp()), ]; + if (!empty($impersonatorUserId)) { + $eventData['data']['data'] = \is_array($auditPayload) + ? \array_merge($auditPayload, [ + 'impersonatedUserId' => $user->getId(), + 'impersonatedUserName' => $user->getAttribute('name', ''), + 'impersonatedUserEmail' => $user->getAttribute('email', ''), + ]) + : [ + 'payload' => $auditPayload, + 'impersonatedUserId' => $user->getId(), + 'impersonatedUserName' => $user->getAttribute('name', ''), + 'impersonatedUserEmail' => $user->getAttribute('email', ''), + ]; + } + if (isset($this->logs[$project->getSequence()])) { $this->logs[$project->getSequence()]['logs'][] = $eventData; } else { diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 25a899bf12..3065b2377f 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -1008,7 +1008,6 @@ class Deletes extends Action */ Console::info("Deleting rules for site " . $siteId); $this->deleteByGroup('rules', [ - Query::equal('type', ['deployment']), Query::equal('deploymentResourceType', ['site']), Query::equal('deploymentResourceInternalId', [$siteInternalId]), Query::equal('projectInternalId', [$project->getSequence()]) @@ -1094,7 +1093,6 @@ class Deletes extends Action */ Console::info("Deleting rules for function " . $functionId); $this->deleteByGroup('rules', [ - Query::equal('type', ['deployment']), Query::equal('deploymentResourceType', ['function']), Query::equal('deploymentResourceInternalId', [$functionInternalId]), Query::equal('projectInternalId', [$project->getSequence()]), diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 29ccb0ef09..3f19abdf22 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -513,7 +513,7 @@ class Functions extends Action $command = $runtime['startCommand']; if (!empty($deployment->getAttribute('startCommand', ''))) { - $command = 'cd /usr/local/server/src/function/ && ' . $deployment->getAttribute('startCommand', ''); + $command = 'cd /usr/local/server/src/function/ && ' . str_replace(['"', '`', '$'], ['\\"', '\\`', '\\$'], $deployment->getAttribute('startCommand', '')); } $source = $deployment->getAttribute('buildPath', ''); diff --git a/src/Appwrite/Platform/Workers/Messaging.php b/src/Appwrite/Platform/Workers/Messaging.php index d866cc2bd0..af7d2027e3 100644 --- a/src/Appwrite/Platform/Workers/Messaging.php +++ b/src/Appwrite/Platform/Workers/Messaging.php @@ -2,8 +2,10 @@ namespace Appwrite\Platform\Workers; -use Appwrite\Event\StatsUsage; +use Appwrite\Event\Message\Usage; +use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Messaging\Status as MessageStatus; +use Appwrite\Usage\Context as UsageContext; use libphonenumber\NumberParseException; use libphonenumber\PhoneNumberUtil; use Swoole\Runtime; @@ -71,7 +73,7 @@ class Messaging extends Action ->inject('log') ->inject('dbForProject') ->inject('deviceForFiles') - ->inject('queueForStatsUsage') + ->inject('publisherForUsage') ->callback($this->action(...)); } @@ -81,7 +83,7 @@ class Messaging extends Action * @param Log $log * @param Database $dbForProject * @param Device $deviceForFiles - * @param StatsUsage $queueForStatsUsage + * @param UsagePublisher $publisherForUsage * @return void * @throws \Exception */ @@ -91,7 +93,7 @@ class Messaging extends Action Log $log, Database $dbForProject, Device $deviceForFiles, - StatsUsage $queueForStatsUsage + UsagePublisher $publisherForUsage ): void { Runtime::setHookFlags(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP); $payload = $message->getPayload() ?? []; @@ -115,7 +117,7 @@ class Messaging extends Action case MESSAGE_SEND_TYPE_EXTERNAL: $message = $dbForProject->getDocument('messages', $payload['messageId']); - $this->sendExternalMessage($dbForProject, $message, $deviceForFiles, $project, $queueForStatsUsage); + $this->sendExternalMessage($dbForProject, $message, $deviceForFiles, $project, $publisherForUsage); break; default: throw new \Exception('Unknown message type: ' . $type); @@ -133,7 +135,7 @@ class Messaging extends Action Document $message, Device $deviceForFiles, Document $project, - StatsUsage $queueForStatsUsage + UsagePublisher $publisherForUsage ): void { $topicIds = $message->getAttribute('topics', []); $targetIds = $message->getAttribute('targets', []); @@ -239,8 +241,8 @@ class Messaging extends Action /** * @var array $results */ - $results = batch(\array_map(function ($providerId) use ($identifiers, &$providers, $default, $message, $dbForProject, $deviceForFiles, $project, $queueForStatsUsage) { - return function () use ($providerId, $identifiers, &$providers, $default, $message, $dbForProject, $deviceForFiles, $project, $queueForStatsUsage) { + $results = batch(\array_map(function ($providerId) use ($identifiers, &$providers, $default, $message, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { + return function () use ($providerId, $identifiers, &$providers, $default, $message, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { if (\array_key_exists($providerId, $providers)) { $provider = $providers[$providerId]; } else { @@ -267,8 +269,8 @@ class Messaging extends Action $adapter->getMaxMessagesPerRequest() ); - return batch(\array_map(function ($batch) use ($message, $provider, $adapter, $dbForProject, $deviceForFiles, $project, $queueForStatsUsage) { - return function () use ($batch, $message, $provider, $adapter, $dbForProject, $deviceForFiles, $project, $queueForStatsUsage) { + return batch(\array_map(function ($batch) use ($message, $provider, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { + return function () use ($batch, $message, $provider, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { $deliveredTotal = 0; $deliveryErrors = []; $messageData = clone $message; @@ -308,8 +310,8 @@ class Messaging extends Action $deliveryErrors[] = 'Failed sending to targets with error: ' . $e->getMessage(); } finally { $errorTotal = \count($deliveryErrors); - $queueForStatsUsage - ->setProject($project) + $usage = new UsageContext(); + $usage ->addMetric(METRIC_MESSAGES, ($deliveredTotal + $errorTotal)) ->addMetric(METRIC_MESSAGES_SENT, $deliveredTotal) ->addMetric(METRIC_MESSAGES_FAILED, $errorTotal) @@ -318,8 +320,12 @@ class Messaging extends Action ->addMetric(str_replace('{type}', $provider->getAttribute('type'), METRIC_MESSAGES_TYPE_FAILED), $errorTotal) ->addMetric(str_replace(['{type}', '{provider}'], [$provider->getAttribute('type'), $provider->getAttribute('provider')], METRIC_MESSAGES_TYPE_PROVIDER), ($deliveredTotal + $errorTotal)) ->addMetric(str_replace(['{type}', '{provider}'], [$provider->getAttribute('type'), $provider->getAttribute('provider')], METRIC_MESSAGES_TYPE_PROVIDER_SENT), $deliveredTotal) - ->addMetric(str_replace(['{type}', '{provider}'], [$provider->getAttribute('type'), $provider->getAttribute('provider')], METRIC_MESSAGES_TYPE_PROVIDER_FAILED), $errorTotal) - ->trigger(); + ->addMetric(str_replace(['{type}', '{provider}'], [$provider->getAttribute('type'), $provider->getAttribute('provider')], METRIC_MESSAGES_TYPE_PROVIDER_FAILED), $errorTotal); + + $publisherForUsage->enqueue(new Usage( + project: $project, + metrics: $usage->getMetrics(), + )); return [ 'deliveredTotal' => $deliveredTotal, diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index c4cb9ce415..25d5bfa027 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -4,10 +4,12 @@ namespace Appwrite\Platform\Workers; use Ahc\Jwt\JWT; use Appwrite\Event\Mail; +use Appwrite\Event\Message\Usage as UsageMessage; +use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; -use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\Template\Template; +use Appwrite\Usage\Context; use Utopia\Compression\Compression; use Utopia\Config\Config; use Utopia\Console; @@ -84,7 +86,8 @@ class Migrations extends Action ->inject('deviceForMigrations') ->inject('deviceForFiles') ->inject('queueForMails') - ->inject('queueForStatsUsage') + ->inject('usage') + ->inject('publisherForUsage') ->inject('plan') ->inject('authorization') ->callback($this->action(...)); @@ -103,7 +106,8 @@ class Migrations extends Action Device $deviceForMigrations, Device $deviceForFiles, Mail $queueForMails, - StatsUsage $queueForStatsUsage, + Context $usage, + UsagePublisher $publisherForUsage, array $plan, Authorization $authorization, ): void { @@ -147,7 +151,8 @@ class Migrations extends Action $migration, $queueForRealtime, $queueForMails, - $queueForStatsUsage, + $usage, + $publisherForUsage, $platform, $authorization ); @@ -327,6 +332,8 @@ class Migrations extends Action 'messages.write', 'targets.read', 'targets.write', + 'webhooks.read', + 'webhooks.write' ] ]); @@ -345,7 +352,8 @@ class Migrations extends Action Document $migration, Realtime $queueForRealtime, Mail $queueForMails, - StatsUsage $queueForStatsUsage, + Context $usage, + UsagePublisher $publisherForUsage, array $platform, Authorization $authorization, ): void { @@ -360,7 +368,7 @@ class Migrations extends Action throw new \Exception('_APP_MIGRATION_HOST is not set'); } - $endpoint = 'http://'.$host.'/v1'; + $endpoint = 'http://' . $host . '/v1'; try { $credentials = $migration->getAttribute('credentials', []); @@ -463,7 +471,7 @@ class Migrations extends Action $migration->setAttribute('status', 'failed'); $migration->setAttribute('stage', 'finished'); - call_user_func($this->logError, $th, 'appwrite-worker', 'appwrite-queue-'.self::getName(), [ + call_user_func($this->logError, $th, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [ 'migrationId' => $migration->getId(), 'source' => $migration->getAttribute('source') ?? '', 'destination' => $migration->getAttribute('destination') ?? '', @@ -474,7 +482,7 @@ class Migrations extends Action $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().')'); + Console::error('Migration(' . $migration->getSequence() . ':' . $migration->getId() . ') failed, Project(' . $this->project->getSequence() . ':' . $this->project->getId() . ')'); $sourceErrors = $source?->getErrors() ?? []; $destinationErrors = $destination?->getErrors() ?? []; @@ -500,8 +508,9 @@ class Migrations extends Action foreach ($aggregatedResources as $resource) { $this->processMigrationResourceStats( $resource, - $queueForStatsUsage, + $usage, $project, + $publisherForUsage, $migration->getAttribute('source'), $authorization, $migration->getAttribute('resourceId') @@ -802,7 +811,7 @@ class Migrations extends Action return $errors; } - private function processMigrationResourceStats(array $resources, StatsUsage $queueForStatsUsage, Document $projectDocument, string $source, Authorization $authorization, ?string $resourceId) + private function processMigrationResourceStats(array $resources, Context $usage, Document $projectDocument, UsagePublisher $publisherForUsage, string $source, Authorization $authorization, ?string $resourceId) { $resourceName = $resources['name']; $count = $resources['count']; @@ -819,11 +828,11 @@ class Migrations extends Action switch ($resourceName) { case ResourceDatabase::getName(): - $queueForStatsUsage->addMetric(METRIC_DATABASES, $count); + $usage->addMetric(METRIC_DATABASES, $count); break; case ResourceTable::getName(): - $queueForStatsUsage + $usage ->addMetric(METRIC_COLLECTIONS, $count) ->addMetric( str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_COLLECTIONS), @@ -832,7 +841,7 @@ class Migrations extends Action break; case ResourceRow::getName(): - $queueForStatsUsage + $usage ->addMetric( str_replace( ['{databaseInternalId}','{collectionInternalId}'], @@ -852,7 +861,12 @@ class Migrations extends Action break; } - $queueForStatsUsage->setProject($projectDocument)->trigger(); - $queueForStatsUsage->reset(); + $message = new UsageMessage( + project: $projectDocument, + metrics: $usage->getMetrics(), + reduce: $usage->getReduce() + ); + $publisherForUsage->enqueue($message); + $usage->reset(); } } diff --git a/src/Appwrite/Platform/Workers/StatsUsage.php b/src/Appwrite/Platform/Workers/StatsUsage.php index 07051d1f15..76be33d06b 100644 --- a/src/Appwrite/Platform/Workers/StatsUsage.php +++ b/src/Appwrite/Platform/Workers/StatsUsage.php @@ -479,7 +479,8 @@ class StatsUsage extends Action } } $documentClone = clone $stat; - $documentClone->setAttribute('$tenant', (int) $project->getSequence()); + $dbForLogs = ($this->getLogsDB)(); + $documentClone->setAttribute('$tenant', $project->getSequence()); $this->statDocuments[] = $documentClone; } diff --git a/src/Appwrite/Platform/Workers/Webhooks.php b/src/Appwrite/Platform/Workers/Webhooks.php index 4855a1d4d8..fce3c7b149 100644 --- a/src/Appwrite/Platform/Workers/Webhooks.php +++ b/src/Appwrite/Platform/Workers/Webhooks.php @@ -3,8 +3,10 @@ namespace Appwrite\Platform\Workers; use Appwrite\Event\Mail; -use Appwrite\Event\StatsUsage; +use Appwrite\Event\Message\Usage as UsageMessage; +use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Template\Template; +use Appwrite\Usage\Context as UsageContext; use Exception; use Utopia\Database\Database; use Utopia\Database\Document; @@ -35,7 +37,7 @@ class Webhooks extends Action ->inject('project') ->inject('dbForPlatform') ->inject('queueForMails') - ->inject('queueForStatsUsage') + ->inject('publisherForUsage') ->inject('log') ->inject('plan') ->callback($this->action(...)); @@ -46,13 +48,13 @@ class Webhooks extends Action * @param Document $project * @param Database $dbForPlatform * @param Mail $queueForMails - * @param StatsUsage $queueForStatsUsage + * @param UsagePublisher $publisherForUsage * @param Log $log * @param array $plan * @return void * @throws Exception */ - public function action(Message $message, Document $project, Database $dbForPlatform, Mail $queueForMails, StatsUsage $queueForStatsUsage, Log $log, array $plan): void + public function action(Message $message, Document $project, Database $dbForPlatform, Mail $queueForMails, UsagePublisher $publisherForUsage, Log $log, array $plan): void { $this->errors = []; $payload = $message->getPayload() ?? []; @@ -71,7 +73,7 @@ class Webhooks extends Action foreach ($project->getAttribute('webhooks', []) as $webhook) { if (array_intersect($webhook->getAttribute('events', []), $events)) { - $this->execute($events, $webhookPayload, $webhook, $user, $project, $dbForPlatform, $queueForMails, $queueForStatsUsage, $plan); + $this->execute($events, $webhookPayload, $webhook, $user, $project, $dbForPlatform, $queueForMails, $publisherForUsage, $plan); } } @@ -91,7 +93,7 @@ class Webhooks extends Action * @param array $plan * @return void */ - private function execute(array $events, string $payload, Document $webhook, Document $user, Document $project, Database $dbForPlatform, Mail $queueForMails, StatsUsage $queueForStatsUsage, array $plan): void + private function execute(array $events, string $payload, Document $webhook, Document $user, Document $project, Database $dbForPlatform, Mail $queueForMails, UsagePublisher $publisherForUsage, array $plan): void { if ($webhook->getAttribute('enabled') !== true) { return; @@ -180,26 +182,23 @@ class Webhooks extends Action $dbForPlatform->purgeCachedDocument('projects', $project->getId()); $this->errors[] = $logs; - $queueForStatsUsage + $usage = (new UsageContext()) ->addMetric(METRIC_WEBHOOKS_FAILED, 1) - ->addMetric(str_replace('{webhookInternalId}', $webhook->getSequence(), METRIC_WEBHOOK_ID_FAILED), 1) - ; - - + ->addMetric(str_replace('{webhookInternalId}', $webhook->getSequence(), METRIC_WEBHOOK_ID_FAILED), 1); } else { $dbForPlatform->updateDocument('webhooks', $webhook->getId(), new Document([ 'attempts' => 0, ])); $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - $queueForStatsUsage + $usage = (new UsageContext()) ->addMetric(METRIC_WEBHOOKS_SENT, 1) - ->addMetric(str_replace('{webhookInternalId}', $webhook->getSequence(), METRIC_WEBHOOK_ID_SENT), 1) - ; + ->addMetric(str_replace('{webhookInternalId}', $webhook->getSequence(), METRIC_WEBHOOK_ID_SENT), 1); } - $queueForStatsUsage - ->setProject($project) - ->trigger(); + $publisherForUsage->enqueue(new UsageMessage( + project: $project, + metrics: $usage->getMetrics(), + )); } /** diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 7c7fc96b2f..8c77da413f 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -450,7 +450,7 @@ class OpenAPI3 extends Format Database::VAR_POLYGON => '[[[1, 2], [3, 4], [5, 6], [1, 2]]]', }; break; - case \Appwrite\Network\Validator\Email::class: + case \Utopia\Emails\Validator\Email::class: $node['schema']['type'] = $validator->getType(); $node['schema']['format'] = 'email'; $node['schema']['x-example'] = ($param['example'] ?? '') ?: 'email@example.com'; @@ -843,8 +843,7 @@ class OpenAPI3 extends Format break; case 'id': - $type = 'integer'; - $format = $rule['format'] ?? 'int32'; + $type = 'string'; break; case 'enum': diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 558e911c33..d0815d8cad 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -455,7 +455,7 @@ class Swagger2 extends Format Database::VAR_POLYGON => '[[[1, 2], [3, 4], [5, 6], [1, 2]]]', }; break; - case \Appwrite\Network\Validator\Email::class: + case \Utopia\Emails\Validator\Email::class: $node['type'] = $validator->getType(); $node['format'] = 'email'; $node['x-example'] = ($param['example'] ?? '') ?: 'email@example.com'; @@ -823,8 +823,7 @@ class Swagger2 extends Format break; case 'id': - $type = 'integer'; - $format = $rule['format'] ?? 'int32'; + $type = 'string'; break; case 'enum': diff --git a/src/Appwrite/Usage/Context.php b/src/Appwrite/Usage/Context.php new file mode 100644 index 0000000000..7283cff836 --- /dev/null +++ b/src/Appwrite/Usage/Context.php @@ -0,0 +1,74 @@ +metrics[] = [ + 'key' => $key, + 'value' => $value, + ]; + + return $this; + } + + /** + * Add a document to reduce + */ + public function addReduce(Document $document): self + { + $this->reduce[] = $document; + + return $this; + } + + /** + * Get all metrics + * + * @return array + */ + public function getMetrics(): array + { + return $this->metrics; + } + + /** + * Get all reduce documents + * + * @return array + */ + public function getReduce(): array + { + return $this->reduce; + } + + /** + * Check if context is empty + */ + public function isEmpty(): bool + { + return empty($this->metrics) && empty($this->reduce); + } + + /** + * Reset the context + */ + public function reset(): self + { + $this->metrics = []; + $this->reduce = []; + + return $this; + } +} diff --git a/src/Appwrite/Utopia/Database/Validator/Attributes.php b/src/Appwrite/Utopia/Database/Validator/Attributes.php index e9bd009217..aac5ec2f37 100644 --- a/src/Appwrite/Utopia/Database/Validator/Attributes.php +++ b/src/Appwrite/Utopia/Database/Validator/Attributes.php @@ -5,8 +5,8 @@ namespace Appwrite\Utopia\Database\Validator; use Utopia\Database\Database; use Utopia\Database\Validator\Datetime as DatetimeValidator; use Utopia\Database\Validator\Key; +use Utopia\Emails\Validator\Email; use Utopia\Validator; -use Utopia\Validator\Email; use Utopia\Validator\IP; use Utopia\Validator\Range; use Utopia\Validator\Text; diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Users.php b/src/Appwrite/Utopia/Database/Validator/Queries/Users.php index d9ecfc6a12..4fdae0e93c 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Users.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Users.php @@ -14,6 +14,7 @@ class Users extends Base 'emailVerification', 'phoneVerification', 'labels', + 'impersonator', ]; /** diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php new file mode 100644 index 0000000000..fa20bf34ef --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php @@ -0,0 +1,26 @@ +fillWebhookid($content); + break; case 'functions.createTemplateDeployment': case 'sites.createTemplateDeployment': $content = $this->convertVersionToTypeAndReference($content); @@ -48,4 +51,10 @@ class V21 extends Filter return $content; } + + protected function fillWebhookid(array $content): array + { + $content['webhookId'] = $content['webhookId'] ?? 'unique()'; + return $content; + } } diff --git a/src/Appwrite/Utopia/Response/Filters/V21.php b/src/Appwrite/Utopia/Response/Filters/V21.php index 4a414a9418..b65e26a8b0 100644 --- a/src/Appwrite/Utopia/Response/Filters/V21.php +++ b/src/Appwrite/Utopia/Response/Filters/V21.php @@ -10,8 +10,6 @@ class V21 extends Filter { public function parse(array $content, string $model): array { - $parsedResponse = $content; - return match ($model) { Response::MODEL_SITE => $this->parseSite($content), Response::MODEL_SITE_LIST => $this->handleList( @@ -25,7 +23,19 @@ class V21 extends Filter "functions", fn ($item) => $this->parseFunction($item), ), - default => $parsedResponse, + Response::MODEL_DOCUMENT => $this->parseDocument($content), + Response::MODEL_DOCUMENT_LIST => $this->handleList( + $content, + "documents", + fn ($item) => $this->parseDocument($item), + ), + Response::MODEL_ROW => $this->parseRow($content), + Response::MODEL_ROW_LIST => $this->handleList( + $content, + "rows", + fn ($item) => $this->parseRow($item), + ), + default => $content, }; } @@ -48,4 +58,39 @@ class V21 extends Filter unset($content['runtimeSpecification']); return $content; } + + protected function parseDocument(array $content): array + { + return $this->castSequence($content); + } + + protected function parseRow(array $content): array + { + return $this->castSequence($content); + } + + protected function castSequence(array $content): array + { + if (isset($content['$sequence'])) { + $content['$sequence'] = \is_numeric($content['$sequence']) + ? (int)$content['$sequence'] + : 0; + } + + foreach ($content as $key => $value) { + if (\is_array($value)) { + if (isset($value['$id'])) { + $content[$key] = $this->castSequence($value); + } else { + foreach ($value as $i => $item) { + if (\is_array($item) && isset($item['$id'])) { + $content[$key][$i] = $this->castSequence($item); + } + } + } + } + } + + return $content; + } } diff --git a/src/Appwrite/Utopia/Response/Model/Account.php b/src/Appwrite/Utopia/Response/Model/Account.php index 2ccbd2e480..aaaf501a88 100644 --- a/src/Appwrite/Utopia/Response/Model/Account.php +++ b/src/Appwrite/Utopia/Response/Model/Account.php @@ -109,6 +109,20 @@ class Account extends Model 'default' => '', 'example' => self::TYPE_DATETIME_EXAMPLE, ]) + ->addRule('impersonator', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether the user can impersonate other users.', + 'required' => false, + 'default' => false, + 'example' => false, + ]) + ->addRule('impersonatorUserId', [ + 'type' => self::TYPE_STRING, + 'description' => 'ID of the original actor performing the impersonation. Present only when the current request is impersonating another user. Internal audit logs attribute the action to this user, while the impersonated target is recorded only in internal audit payload data.', + 'required' => false, + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) ; } diff --git a/src/Appwrite/Utopia/Response/Model/Document.php b/src/Appwrite/Utopia/Response/Model/Document.php index e0aca32b3b..3be003418d 100644 --- a/src/Appwrite/Utopia/Response/Model/Document.php +++ b/src/Appwrite/Utopia/Response/Model/Document.php @@ -83,8 +83,8 @@ class Document extends Any $document->removeAttribute('$collection'); $document->removeAttribute('$tenant'); - if (!$document->isEmpty() && \is_numeric($document->getAttribute('$sequence', 0))) { - $document->setAttribute('$sequence', (int)$document->getAttribute('$sequence', 0)); + if (!$document->isEmpty()) { + $document->setAttribute('$sequence', (string)$document->getAttribute('$sequence', '')); } foreach ($document->getAttributes() as $attribute) { diff --git a/src/Appwrite/Utopia/Response/Model/Log.php b/src/Appwrite/Utopia/Response/Model/Log.php index bc2c923494..a8c00280d3 100644 --- a/src/Appwrite/Utopia/Response/Model/Log.php +++ b/src/Appwrite/Utopia/Response/Model/Log.php @@ -18,19 +18,19 @@ class Log extends Model ]) ->addRule('userId', [ 'type' => self::TYPE_STRING, - 'description' => 'User ID.', + 'description' => 'User ID of the actor recorded for this log. During impersonation, this is the original impersonator, not the impersonated target user.', 'default' => '', 'example' => '610fc2f985ee0', ]) ->addRule('userEmail', [ 'type' => self::TYPE_STRING, - 'description' => 'User Email.', + 'description' => 'User email of the actor recorded for this log. During impersonation, this is the original impersonator.', 'default' => '', 'example' => 'john@appwrite.io', ]) ->addRule('userName', [ 'type' => self::TYPE_STRING, - 'description' => 'User Name.', + 'description' => 'User name of the actor recorded for this log. During impersonation, this is the original impersonator.', 'default' => '', 'example' => 'John Doe', ]) diff --git a/src/Appwrite/Utopia/Response/Model/Row.php b/src/Appwrite/Utopia/Response/Model/Row.php index a96df44bc3..4c7426c4bc 100644 --- a/src/Appwrite/Utopia/Response/Model/Row.php +++ b/src/Appwrite/Utopia/Response/Model/Row.php @@ -83,8 +83,8 @@ class Row extends Any $document->removeAttribute('$collection'); $document->removeAttribute('$tenant'); - if (!$document->isEmpty() && \is_numeric($document->getAttribute('$sequence', 0))) { - $document->setAttribute('$sequence', (int)$document->getAttribute('$sequence', 0)); + if (!$document->isEmpty()) { + $document->setAttribute('$sequence', (string)$document->getAttribute('$sequence', '')); } foreach ($document->getAttributes() as $column) { diff --git a/src/Appwrite/Utopia/Response/Model/User.php b/src/Appwrite/Utopia/Response/Model/User.php index 672b8885a0..476778e68b 100644 --- a/src/Appwrite/Utopia/Response/Model/User.php +++ b/src/Appwrite/Utopia/Response/Model/User.php @@ -139,6 +139,20 @@ class User extends Model 'default' => '', 'example' => self::TYPE_DATETIME_EXAMPLE, ]) + ->addRule('impersonator', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether the user can impersonate other users.', + 'required' => false, + 'default' => false, + 'example' => false, + ]) + ->addRule('impersonatorUserId', [ + 'type' => self::TYPE_STRING, + 'description' => 'ID of the original actor performing the impersonation. Present only when the current request is impersonating another user. Internal audit logs attribute the action to this user, while the impersonated target is recorded only in internal audit payload data.', + 'required' => false, + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) ; } diff --git a/src/Appwrite/Utopia/Response/Model/Webhook.php b/src/Appwrite/Utopia/Response/Model/Webhook.php index af1e23447e..517ad4807d 100644 --- a/src/Appwrite/Utopia/Response/Model/Webhook.php +++ b/src/Appwrite/Utopia/Response/Model/Webhook.php @@ -10,7 +10,7 @@ class Webhook extends Model /** * @var bool */ - protected bool $public = false; + protected bool $public = true; public function __construct() { diff --git a/tests/e2e/General/UsageTest.php b/tests/e2e/General/UsageTest.php index de5933ce8a..0e484d4dcf 100644 --- a/tests/e2e/General/UsageTest.php +++ b/tests/e2e/General/UsageTest.php @@ -531,10 +531,7 @@ class UsageTest extends Scope $attr = $this->client->call( Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/name', - array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'] - ], $this->getHeaders()) + $this->getConsoleHeaders() ); $this->assertEquals(200, $attr['headers']['status-code']); $this->assertEquals('available', $attr['body']['status']); @@ -784,10 +781,7 @@ class UsageTest extends Scope $attr = $this->client->call( Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', - array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'] - ], $this->getHeaders()) + $this->getConsoleHeaders() ); $this->assertEquals(200, $attr['headers']['status-code']); $this->assertEquals('available', $attr['body']['status']); @@ -1062,9 +1056,7 @@ class UsageTest extends Scope $response = $this->client->call( Client::METHOD_GET, '/functions/' . $functionId . '/executions/' . $executionId, - array_merge([ - 'x-appwrite-project' => $this->getProject()['$id'] - ], $this->getHeaders()), + $this->getConsoleHeaders(), ); $this->assertContains($response['body']['status'], ['completed', 'failed']); }, 30_000, 500); diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index c7e3a520b6..b8b6f38643 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -161,6 +161,8 @@ trait ProjectCustom 'migrations.read', 'tokens.read', 'tokens.write', + 'webhooks.read', + 'webhooks.write', ], ]); @@ -191,12 +193,14 @@ trait ProjectCustom $this->assertNotEmpty($devKey['body']); $this->assertNotEmpty($devKey['body']['secret']); - $webhook = $this->client->call(Client::METHOD_POST, '/projects/' . $project['body']['$id'] . '/webhooks', [ + $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - 'x-appwrite-project' => 'console', + 'x-appwrite-project' => $project['body']['$id'], + 'x-appwrite-mode' => 'admin' ], [ + 'webhookId' => 'unique()', 'name' => 'Webhook Test', 'events' => [ 'databases.*', diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index 7f23f2966c..5f8ac7dd94 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -2566,9 +2566,7 @@ trait DatabasesBase $this->assertEquals($document1['body']['birthDay'], '1975-06-12T12:12:55.000+00:00'); $this->assertTrue(array_key_exists('$sequence', $document1['body'])); - $this->getSupportForIntegerIds() - ? $this->assertIsInt($document1['body']['$sequence']) - : $this->assertIsString($document1['body']['$sequence']); + $this->assertIsString($document1['body']['$sequence']); $this->assertEquals(201, $document2['headers']['status-code']); $this->assertEquals($data['moviesId'], $document2['body'][$this->getContainerIdResponseKey()]); @@ -2640,9 +2638,7 @@ trait DatabasesBase /** * Resubmit same document, nothing to update */ - $this->getSupportForIntegerIds() - ? $this->assertIsInt($document['body']['$sequence']) - : $this->assertIsString($document['body']['$sequence']); + $this->assertIsString($document['body']['$sequence']); $upsertData = [ 'title' => 'Thor: Ragnarok', @@ -3522,6 +3518,62 @@ trait DatabasesBase $this->assertEquals(200, $response['headers']['status-code']); } + public function testQueryBySequenceType(): void + { + $data = $this->setupDocuments(); + $databaseId = $data['databaseId']; + + $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('$id', $data['documentIds'])->toString(), + ], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertGreaterThan(0, count($documents['body'][$this->getRecordResource()])); + + $sequence = $documents['body'][$this->getRecordResource()][0]['$sequence']; + $this->assertIsString($sequence); + + // Query with string $sequence value (supported by all adapters) + $response = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('$sequence', [$sequence])->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body'][$this->getRecordResource()]); + $this->assertIsString($response['body'][$this->getRecordResource()][0]['$sequence']); + $this->assertSame($sequence, $response['body'][$this->getRecordResource()][0]['$sequence']); + + // Query with int $sequence value (supported by SQL adapters, rejected by MongoDB) + $intSequence = (int)$sequence; + $response = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('$sequence', [$intSequence])->toString(), + ], + ]); + + $adapter = getenv('_APP_DB_ADAPTER'); + if ($adapter === 'mongodb') { + $this->assertEquals(400, $response['headers']['status-code']); + } else { + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body'][$this->getRecordResource()]); + $this->assertIsString($response['body'][$this->getRecordResource()][0]['$sequence']); + } + } + public function testListDocumentsAfterPagination(): void { $data = $this->setupDocuments(); diff --git a/tests/e2e/Services/Databases/Permissions/DatabasesPermissionsBase.php b/tests/e2e/Services/Databases/Permissions/DatabasesPermissionsBase.php index 499f2ee265..d6869cc650 100644 --- a/tests/e2e/Services/Databases/Permissions/DatabasesPermissionsBase.php +++ b/tests/e2e/Services/Databases/Permissions/DatabasesPermissionsBase.php @@ -59,7 +59,7 @@ trait DatabasesPermissionsBase 'password' => $password ]); - $this->assertEquals(201, $user['headers']['status-code']); + $this->assertContains($user['headers']['status-code'], [201, 409]); $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ 'origin' => 'http://localhost', @@ -72,9 +72,12 @@ trait DatabasesPermissionsBase $session = $session['cookies']['a_session_' . $this->getProject()['$id']]; + $userId = $user['headers']['status-code'] === 201 ? $user['body']['$id'] : $id; + $userEmail = $user['headers']['status-code'] === 201 ? $user['body']['email'] : $email; + $user = [ - '$id' => $user['body']['$id'], - 'email' => $user['body']['email'], + '$id' => $userId, + 'email' => $userEmail, 'session' => $session, ]; $this->users[$id] = $user; @@ -94,6 +97,12 @@ trait DatabasesPermissionsBase 'teamId' => $id, 'name' => $name ]); + $this->assertContains($team['headers']['status-code'], [201, 409]); + + if ($team['headers']['status-code'] === 409) { + $team = $this->client->call(Client::METHOD_GET, '/teams/' . $id, $this->getServerHeader()); + } + $this->teams[$id] = $team['body']; return $team['body']; diff --git a/tests/e2e/Services/Databases/Permissions/LegacyPermissionsTeamTest.php b/tests/e2e/Services/Databases/Permissions/LegacyPermissionsTeamTest.php index c0d880e739..b5c5e854a1 100644 --- a/tests/e2e/Services/Databases/Permissions/LegacyPermissionsTeamTest.php +++ b/tests/e2e/Services/Databases/Permissions/LegacyPermissionsTeamTest.php @@ -22,7 +22,7 @@ class LegacyPermissionsTeamTest extends Scope use SchemaPolling; public array $collections = []; - public string $databaseId = 'testpermissiondb'; + public string $databaseId = 'testpermdb_legacy'; public function createTeams(): array { diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index e0002fdafb..508ddede4a 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -1727,6 +1727,70 @@ class FunctionsCustomServerTest extends Scope $this->assertEquals(404, $function['headers']['status-code']); } + public function testDeleteFunctionRulesCleanup(): void + { + $functionId = $this->setupFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test Rules Cleanup Function', + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 15, + ]); + + $this->assertNotEmpty($functionId); + + // Create a manual deployment rule (type = 'deployment') + $domain = $this->setupFunctionDomain($functionId); + $this->assertNotEmpty($domain); + + // Create a redirect rule (type = 'redirect') + $redirectDomain = \uniqid() . '-redirect-cleanup.custom.localhost'; + $redirectRule = $this->client->call(Client::METHOD_POST, '/proxy/rules/redirect', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'domain' => $redirectDomain, + 'url' => 'https://appwrite.io', + 'statusCode' => 301, + 'resourceType' => 'function', + 'resourceId' => $functionId, + ]); + + $this->assertEquals(201, $redirectRule['headers']['status-code']); + $this->assertNotEmpty($redirectRule['body']['$id']); + + // Verify both rules exist (no type filter — catches all rule types) + $rules = $this->client->call(Client::METHOD_GET, '/proxy/rules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('deploymentResourceId', [$functionId])->toString() + ] + ]); + + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertGreaterThanOrEqual(2, $rules['body']['total']); + + // Delete the function + $this->cleanupFunction($functionId); + + // Verify ALL rules (deployment + redirect) are cleaned up + $this->assertEventually(function () use ($functionId) { + $rules = $this->client->call(Client::METHOD_GET, '/proxy/rules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('deploymentResourceId', [$functionId])->toString() + ] + ]); + + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertEquals(0, $rules['body']['total']); + }, 5000, 500); + } + public function testExecutionTimeout() { $functionId = $this->setupFunction([ @@ -2018,7 +2082,7 @@ class FunctionsCustomServerTest extends Scope $functionId = $this->setupFunction([ 'functionId' => ID::unique(), 'name' => 'Test Scopes executions', - 'commands' => 'bash setup.sh && npm install', + 'commands' => 'bash setup.sh && npm ci', 'runtime' => 'node-22', 'entrypoint' => 'index.js', 'scopes' => ['users.read'], diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index d5fe7753a4..0d992c472e 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1651,9 +1651,9 @@ trait MigrationsBase }, 30_000, 500); // Check that email was sent with download link - $lastEmail = $this->getLastEmail(); - $this->assertNotEmpty($lastEmail); - $this->assertEquals('Your CSV export is ready', $lastEmail['subject']); + $lastEmail = $this->getLastEmail(probe: function ($email) { + $this->assertEquals('Your CSV export is ready', $email['subject']); + }); $this->assertStringContainsStringIgnoringCase('Your data export has been completed successfully', $lastEmail['text']); // Extract download URL from email HTML diff --git a/tests/e2e/Services/ProjectWebhooks/WebhooksBase.php b/tests/e2e/Services/ProjectWebhooks/WebhooksBase.php new file mode 100644 index 0000000000..0f1ff7eab3 --- /dev/null +++ b/tests/e2e/Services/ProjectWebhooks/WebhooksBase.php @@ -0,0 +1,1838 @@ +assertEventually(function () use ($functionId, $deploymentId) { + $deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/deployments/' . $deploymentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertEquals('ready', $deployment['body']['status'], \json_encode($deployment['body'])); + }, 120000, 500); + } + + /** + * Create a probe callback that filters webhooks by event pattern. + */ + private function webhookEventProbe(string $eventPattern): callable + { + return function (array $request) use ($eventPattern) { + $this->assertStringContainsString( + $eventPattern, + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }; + } + + public static function getWebhookSignature(array $webhook, string $signatureKey): string + { + $payload = json_encode($webhook['data']); + $url = $webhook['url']; + return base64_encode(hash_hmac('sha1', $url . $payload, $signatureKey, true)); + } + + /** + * Creates a database and collection with proper attributes for document operations. + * + * @return array Array containing 'databaseId' and 'actorsId' + */ + protected function setupCollectionWithAttributes(): array + { + // Create database + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Actors DB', + ]); + + $databaseId = $database['body']['$id']; + + // Create collection + $actors = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Actors', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + + $actorsId = $actors['body']['$id']; + + // Create attributes + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'firstName', + 'size' => 256, + 'required' => true, + ]); + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'lastName', + 'size' => 256, + 'required' => true, + ]); + + // Wait for attributes to be available + $this->assertEventually(function () use ($databaseId, $actorsId) { + $collection = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $actorsId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + $this->assertCount(2, $collection['body']['attributes']); + $this->assertEquals('available', $collection['body']['attributes'][0]['status']); + $this->assertEquals('available', $collection['body']['attributes'][1]['status']); + }, 15000, 500); + + return ['databaseId' => $databaseId, 'actorsId' => $actorsId]; + } + + /** + * Creates a database and table with proper columns for row operations. + * + * @return array Array containing 'databaseId' and 'actorsId' + */ + protected function setupTableWithColumns(): array + { + // Create database + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Actors DB', + ]); + + $databaseId = $database['body']['$id']; + + // Create table + $actors = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'tableId' => ID::unique(), + 'name' => 'Actors', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'rowSecurity' => true, + ]); + + $actorsId = $actors['body']['$id']; + + // Create columns + $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'firstName', + 'size' => 256, + 'required' => true, + ]); + + $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'lastName', + 'size' => 256, + 'required' => true, + ]); + + // Wait for columns to be available + $this->assertEventually(function () use ($databaseId, $actorsId) { + $table = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $actorsId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + $this->assertCount(2, $table['body']['columns']); + $this->assertEquals('available', $table['body']['columns'][0]['status']); + $this->assertEquals('available', $table['body']['columns'][1]['status']); + }, 15000, 500); + + return ['databaseId' => $databaseId, 'actorsId' => $actorsId]; + } + + /** + * Creates an enabled storage bucket. + * + * @return array Array containing 'bucketId' + */ + protected function setupStorageBucket(): array + { + $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'bucketId' => ID::unique(), + 'name' => 'Test Bucket', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'fileSecurity' => true, + 'enabled' => true, + ]); + + return ['bucketId' => $bucket['body']['$id']]; + } + + /** + * Creates a team and returns its ID. + * + * @param string $name Team name + * @return array Array containing 'teamId' + */ + protected function setupTeam(string $name = 'Arsenal'): array + { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => $name + ]); + + return ['teamId' => $team['body']['$id']]; + } + + /** + * Creates a team membership and returns membership details including secret. + * + * @param string $teamId The team ID + * @return array Array containing 'teamId', 'membershipId', 'userId', 'secret' + */ + protected function setupTeamMembership(string $teamId): array + { + $email = uniqid() . 'friend@localhost.test'; + + // Create user first to ensure team event is triggered after user event + $this->client->call(Client::METHOD_POST, '/account', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => 'password', + 'name' => 'Friend User', + ]); + + // Create membership + $team = $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'email' => $email, + 'roles' => ['admin', 'editor'], + 'url' => 'http://localhost:5000/join-us#title' + ]); + + $membershipId = $team['body']['$id']; + $userId = $team['body']['userId']; + + // Get the secret from email (use probe to match correct email by recipient address) + $lastEmail = $this->getLastEmail(1, function ($msg) use ($email) { + $this->assertEquals($email, $msg['to'][0]['address'] ?? ''); + }); + $tokens = $this->extractQueryParamsFromEmailLink($lastEmail['html'] ?? ''); + $secret = $tokens['secret'] ?? ''; + + return [ + 'teamId' => $teamId, + 'membershipId' => $membershipId, + 'userId' => $userId, + 'secret' => $secret, + ]; + } + + /** + * Creates a document in a collection. + * + * @param string $databaseId Database ID + * @param string $collectionId Collection ID + * @return array Array containing document details including 'documentId' + */ + protected function setupDocument(string $databaseId, string $collectionId): array + { + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'firstName' => 'Chris', + 'lastName' => 'Evans', + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + return ['documentId' => $document['body']['$id']]; + } + + /** + * Creates a row in a table. + * + * @param string $databaseId Database ID + * @param string $tableId Table ID + * @return array Array containing row details including 'rowId' + */ + protected function setupRow(string $databaseId, string $tableId): array + { + $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'rowId' => ID::unique(), + 'data' => [ + 'firstName' => 'Chris', + 'lastName' => 'Evans', + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + return ['rowId' => $row['body']['$id']]; + } + + /** + * Creates a file in a bucket. + * + * @param string $bucketId Bucket ID + * @return array Array containing file details including 'fileId' + */ + protected function setupBucketFile(string $bucketId): array + { + $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()), + ], + 'folderId' => ID::custom('xyz'), + ]); + + return ['fileId' => $file['body']['$id']]; + } + + // Collection APIs + public function testCreateCollection(): void + { + /** + * Create database + */ + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Actors DB', + ]); + + $databaseId = $database['body']['$id']; + + /** + * Test for SUCCESS + */ + $actors = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Actors', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + + $actorsId = $actors['body']['$id']; + + $this->assertEquals($actors['headers']['status-code'], 201); + $this->assertNotEmpty($actors['body']['$id']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$actorsId}.create")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), true); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals($webhook['data']['name'], 'Actors'); + $this->assertIsArray($webhook['data']['$permissions']); + $this->assertCount(4, $webhook['data']['$permissions']); + } + + public function testCreateAttributes(): void + { + /** + * Create database + */ + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Actors DB', + ]); + + $databaseId = $database['body']['$id']; + + /** + * Create collection + */ + $actors = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Actors', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + + $actorsId = $actors['body']['$id']; + + $firstName = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'firstName', + 'size' => 256, + 'required' => true, + ]); + + $lastName = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'lastName', + 'size' => 256, + 'required' => true, + ]); + + $extra = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'extra', + 'size' => 64, + 'required' => false, + ]); + + $attributeId = $extra['body']['key']; + + $this->assertEquals($firstName['headers']['status-code'], 202); + $this->assertEquals($firstName['body']['key'], 'firstName'); + $this->assertEquals($lastName['headers']['status-code'], 202); + $this->assertEquals($lastName['body']['key'], 'lastName'); + $this->assertEquals($extra['headers']['status-code'], 202); + $this->assertEquals($extra['body']['key'], 'extra'); + + // wait for database worker to kick in + $this->assertEventually(function () use ($databaseId, $actorsId) { + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$actorsId}.attributes.*.create")); + $this->assertNotEmpty($webhook); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.attributes.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.attributes.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.attributes.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.attributes.*.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertNotEmpty($webhook['data']['key']); + $this->assertEquals($webhook['data']['key'], 'extra'); + }, 15000, 500); + + $removed = $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/' . $extra['body']['key'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(204, $removed['headers']['status-code']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$actorsId}.attributes.*.update")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + // $this->assertEquals($webhook['method'], 'DELETE'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.attributes.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.attributes.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.attributes.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.attributes.*.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertNotEmpty($webhook['data']['key']); + $this->assertEquals($webhook['data']['key'], 'extra'); + } + + public function testCreateDocument(): void + { + // Set up collection with attributes + $data = $this->setupCollectionWithAttributes(); + $actorsId = $data['actorsId']; + $databaseId = $data['databaseId']; + + /** + * Test for SUCCESS + */ + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'firstName' => 'Chris', + 'lastName' => 'Evans', + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $documentId = $document['body']['$id']; + + $this->assertEquals($document['headers']['status-code'], 201); + $this->assertNotEmpty($document['body']['$id']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.create")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.documents.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.documents.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.*.documents.{$documentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.*.documents.{$documentId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.*.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals($webhook['data']['firstName'], 'Chris'); + $this->assertEquals($webhook['data']['lastName'], 'Evans'); + $this->assertIsArray($webhook['data']['$permissions']); + $this->assertCount(3, $webhook['data']['$permissions']); + } + + public function testUpdateDocument(): void + { + // Set up collection with attributes and create a document + $data = $this->setupCollectionWithAttributes(); + $actorsId = $data['actorsId']; + $databaseId = $data['databaseId']; + $documentData = $this->setupDocument($databaseId, $actorsId); + $documentId = $documentData['documentId']; + + /** + * Test for SUCCESS + */ + $document = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $actorsId . '/documents/' . $documentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'firstName' => 'Chris1', + 'lastName' => 'Evans2', + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $documentId = $document['body']['$id']; + + $this->assertEquals($document['headers']['status-code'], 200); + $this->assertNotEmpty($document['body']['$id']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.documents.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.documents.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.*.documents.{$documentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.*.documents.{$documentId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.*.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals($webhook['data']['firstName'], 'Chris1'); + $this->assertEquals($webhook['data']['lastName'], 'Evans2'); + $this->assertIsArray($webhook['data']['$permissions']); + $this->assertCount(3, $webhook['data']['$permissions']); + } + + #[Retry(count: 1)] + public function testDeleteDocument(): void + { + // Set up collection with attributes + $data = $this->setupCollectionWithAttributes(); + $actorsId = $data['actorsId']; + $databaseId = $data['databaseId']; + + /** + * Test for SUCCESS + */ + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'firstName' => 'Bradly', + 'lastName' => 'Cooper', + + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $documentId = $document['body']['$id']; + + $this->assertEquals($document['headers']['status-code'], 201); + $this->assertNotEmpty($document['body']['$id']); + + $document = $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/' . $actorsId . '/documents/' . $document['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals($document['headers']['status-code'], 204); + + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.delete")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.documents.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.documents.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.*.documents.{$documentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.*.documents.{$documentId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.*.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals($webhook['data']['firstName'], 'Bradly'); + $this->assertEquals($webhook['data']['lastName'], 'Cooper'); + $this->assertIsArray($webhook['data']['$permissions']); + $this->assertCount(3, $webhook['data']['$permissions']); + } + + // Table APIs + public function testCreateTable(): void + { + /** + * Create database + */ + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Actors DB', + ]); + + $databaseId = $database['body']['$id']; + + /** + * Test for SUCCESS + */ + $actors = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'tableId' => ID::unique(), + 'name' => 'Actors', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'rowSecurity' => true, + ]); + + $actorsId = $actors['body']['$id']; + + $this->assertEquals($actors['headers']['status-code'], 201); + $this->assertNotEmpty($actors['body']['$id']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$actorsId}.create")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), true); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals($webhook['data']['name'], 'Actors'); + $this->assertIsArray($webhook['data']['$permissions']); + $this->assertCount(4, $webhook['data']['$permissions']); + } + + public function testCreateColumns(): void + { + /** + * Create database + */ + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Actors DB', + ]); + + $databaseId = $database['body']['$id']; + + /** + * Create table + */ + $actors = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'tableId' => ID::unique(), + 'name' => 'Actors', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'rowSecurity' => true, + ]); + + $actorsId = $actors['body']['$id']; + + $firstName = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'firstName', + 'size' => 256, + 'required' => true, + ]); + + $lastName = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'lastName', + 'size' => 256, + 'required' => true, + ]); + + $extra = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'extra', + 'size' => 64, + 'required' => false, + ]); + + $this->assertEquals($firstName['headers']['status-code'], 202); + $this->assertEquals($firstName['body']['key'], 'firstName'); + $this->assertEquals($lastName['headers']['status-code'], 202); + $this->assertEquals($lastName['body']['key'], 'lastName'); + $this->assertEquals($extra['headers']['status-code'], 202); + $this->assertEquals($extra['body']['key'], 'extra'); + + // wait for database worker to kick in + $this->assertEventually(function () use ($databaseId, $actorsId) { + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$actorsId}.columns.*.create")); + $this->assertNotEmpty($webhook); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.columns.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.columns.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.columns.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.columns.*.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertNotEmpty($webhook['data']['key']); + $this->assertEquals($webhook['data']['key'], 'extra'); + }, 15000, 500); + + $removed = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/' . $extra['body']['key'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(204, $removed['headers']['status-code']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$actorsId}.columns.*.update")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + // $this->assertEquals($webhook['method'], 'DELETE'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.columns.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.columns.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.columns.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.columns.*.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertNotEmpty($webhook['data']['key']); + $this->assertEquals($webhook['data']['key'], 'extra'); + } + + public function testCreateRow(): void + { + // Set up table with columns + $data = $this->setupTableWithColumns(); + $actorsId = $data['actorsId']; + $databaseId = $data['databaseId']; + + /** + * Test for SUCCESS + */ + $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'rowId' => ID::unique(), + 'data' => [ + 'firstName' => 'Chris', + 'lastName' => 'Evans', + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $documentId = $row['body']['$id']; + + $this->assertEquals($row['headers']['status-code'], 201); + $this->assertNotEmpty($row['body']['$id']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$actorsId}.rows.{$documentId}.create")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.rows.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.rows.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.*.rows.{$documentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.*.rows.{$documentId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.*.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.{$documentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.{$documentId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals($webhook['data']['firstName'], 'Chris'); + $this->assertEquals($webhook['data']['lastName'], 'Evans'); + $this->assertIsArray($webhook['data']['$permissions']); + $this->assertCount(3, $webhook['data']['$permissions']); + } + + public function testUpdateRow(): void + { + // Set up table with columns and create a row + $data = $this->setupTableWithColumns(); + $actorsId = $data['actorsId']; + $databaseId = $data['databaseId']; + $rowData = $this->setupRow($databaseId, $actorsId); + $rowId = $rowData['rowId']; + + /** + * Test for SUCCESS + */ + $document = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/rows/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'firstName' => 'Chris1', + 'lastName' => 'Evans2', + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $rowId = $document['body']['$id']; + + $this->assertEquals($document['headers']['status-code'], 200); + $this->assertNotEmpty($document['body']['$id']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$actorsId}.rows.{$rowId}.update")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.rows.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.rows.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.*.rows.{$rowId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.*.rows.{$rowId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.*.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.{$rowId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.{$rowId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals($webhook['data']['firstName'], 'Chris1'); + $this->assertEquals($webhook['data']['lastName'], 'Evans2'); + $this->assertIsArray($webhook['data']['$permissions']); + $this->assertCount(3, $webhook['data']['$permissions']); + } + + #[Retry(count: 1)] + public function testDeleteRow(): void + { + // Set up table with columns + $data = $this->setupTableWithColumns(); + $actorsId = $data['actorsId']; + $databaseId = $data['databaseId']; + + /** + * Test for SUCCESS + */ + $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'rowId' => ID::unique(), + 'data' => [ + 'firstName' => 'Bradly', + 'lastName' => 'Cooper', + + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $rowId = $row['body']['$id']; + + $this->assertEquals($row['headers']['status-code'], 201); + $this->assertNotEmpty($row['body']['$id']); + + $row = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/rows/' . $row['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals($row['headers']['status-code'], 204); + + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$actorsId}.rows.{$rowId}.delete")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.rows.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.rows.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.*.rows.{$rowId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.*.rows.{$rowId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.*.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.{$rowId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.{$rowId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals($webhook['data']['firstName'], 'Bradly'); + $this->assertEquals($webhook['data']['lastName'], 'Cooper'); + $this->assertIsArray($webhook['data']['$permissions']); + $this->assertCount(3, $webhook['data']['$permissions']); + } + + public function testCreateStorageBucket(): void + { + /** + * Test for SUCCESS + */ + $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'bucketId' => ID::unique(), + 'name' => 'Test Bucket', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $bucketId = $bucket['body']['$id']; + + $this->assertEquals($bucket['headers']['status-code'], 201); + $this->assertNotEmpty($bucket['body']['$id']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("buckets.{$bucketId}.create")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('buckets.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('buckets.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), true); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals('Test Bucket', $webhook['data']['name']); + $this->assertEquals(true, $webhook['data']['enabled']); + $this->assertIsArray($webhook['data']['$permissions']); + } + + public function testUpdateStorageBucket(): void + { + // Set up a storage bucket + $data = $this->setupStorageBucket(); + $bucketId = $data['bucketId']; + + /** + * Test for SUCCESS + */ + $bucket = $this->client->call(Client::METHOD_PUT, '/storage/buckets/' . $bucketId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'name' => 'Test Bucket Updated', + 'fileSecurity' => true, + 'enabled' => false, + ]); + + $this->assertEquals($bucket['headers']['status-code'], 200); + $this->assertNotEmpty($bucket['body']['$id']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("buckets.{$bucketId}.update")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('buckets.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('buckets.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), true); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals('Test Bucket Updated', $webhook['data']['name']); + $this->assertEquals(false, $webhook['data']['enabled']); + $this->assertIsArray($webhook['data']['$permissions']); + } + + public function testCreateBucketFile(): void + { + // Set up an enabled storage bucket + $data = $this->setupStorageBucket(); + $bucketId = $data['bucketId']; + + /** + * Test for SUCCESS + */ + $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()), + ], + 'folderId' => ID::custom('xyz'), + ]); + + $fileId = $file['body']['$id']; + + $this->assertEquals($file['headers']['status-code'], 201); + $this->assertNotEmpty($file['body']['$id']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("buckets.{$bucketId}.files.{$fileId}.create")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('buckets.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('buckets.*.files.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('buckets.*.files.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.*.files.{$fileId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.*.files.{$fileId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}.files.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}.files.*.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}.files.{$fileId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}.files.{$fileId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertIsArray($webhook['data']['$permissions']); + $this->assertEquals($webhook['data']['name'], 'logo.png'); + $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['$createdAt'])); + $this->assertNotEmpty($webhook['data']['signature']); + $this->assertEquals($webhook['data']['mimeType'], 'image/png'); + $this->assertEquals($webhook['data']['sizeOriginal'], 47218); + } + + public function testUpdateBucketFile(): void + { + // Set up an enabled storage bucket and create a file + $data = $this->setupStorageBucket(); + $bucketId = $data['bucketId']; + $fileData = $this->setupBucketFile($bucketId); + $fileId = $fileData['fileId']; + + /** + * Test for SUCCESS + */ + $file = $this->client->call(Client::METHOD_PUT, '/storage/buckets/' . $bucketId . '/files/' . $fileId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals($file['headers']['status-code'], 200); + $this->assertNotEmpty($file['body']['$id']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("buckets.{$bucketId}.files.{$fileId}.update")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('buckets.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('buckets.*.files.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('buckets.*.files.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.*.files.{$fileId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.*.files.{$fileId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}.files.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}.files.*.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}.files.{$fileId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}.files.{$fileId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertIsArray($webhook['data']['$permissions']); + $this->assertEquals($webhook['data']['name'], 'logo.png'); + $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['$createdAt'])); + $this->assertNotEmpty($webhook['data']['signature']); + $this->assertEquals($webhook['data']['mimeType'], 'image/png'); + $this->assertEquals($webhook['data']['sizeOriginal'], 47218); + } + + public function testDeleteBucketFile(): void + { + // Set up an enabled storage bucket and create a file + $data = $this->setupStorageBucket(); + $bucketId = $data['bucketId']; + $fileData = $this->setupBucketFile($bucketId); + $fileId = $fileData['fileId']; + + /** + * Test for SUCCESS + */ + $file = $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId . '/files/' . $fileId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $file['headers']['status-code']); + $this->assertEmpty($file['body']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("buckets.{$bucketId}.files.{$fileId}.delete")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('buckets.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('buckets.*.files.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('buckets.*.files.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.*.files.{$fileId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.*.files.{$fileId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}.files.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}.files.*.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}.files.{$fileId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}.files.{$fileId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertIsArray($webhook['data']['$permissions']); + $this->assertEquals($webhook['data']['name'], 'logo.png'); + $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['$createdAt'])); + $this->assertNotEmpty($webhook['data']['signature']); + $this->assertEquals($webhook['data']['mimeType'], 'image/png'); + $this->assertEquals($webhook['data']['sizeOriginal'], 47218); + } + + public function testDeleteStorageBucket(): void + { + // Set up an enabled storage bucket + $data = $this->setupStorageBucket(); + $bucketId = $data['bucketId']; + + // Update bucket name before deleting to make test self-sufficient + // (In parallel execution, testUpdateStorageBucket may not have run) + $this->client->call(Client::METHOD_PUT, '/storage/buckets/' . $bucketId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'name' => 'Test Bucket Updated', + 'fileSecurity' => true, + ]); + + /** + * Test for SUCCESS + */ + $bucket = $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals($bucket['headers']['status-code'], 204); + $this->assertEmpty($bucket['body']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("buckets.{$bucketId}.delete")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('buckets.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('buckets.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("buckets.{$bucketId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), true); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals('Test Bucket Updated', $webhook['data']['name']); + $this->assertEquals(true, $webhook['data']['enabled']); + $this->assertIsArray($webhook['data']['$permissions']); + } + + public function testCreateTeam(): void + { + /** + * Test for SUCCESS + */ + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'Arsenal' + ]); + + $teamId = $team['body']['$id']; + + $this->assertEquals(201, $team['headers']['status-code']); + $this->assertNotEmpty($team['body']['$id']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("teams.{$teamId}.create")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('teams.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('teams.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$teamId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$teamId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals('Arsenal', $webhook['data']['name']); + $this->assertGreaterThan(-1, $webhook['data']['total']); + $this->assertIsInt($webhook['data']['total']); + $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['$createdAt'])); + } + + public function testUpdateTeam(): void + { + // Set up a team + $data = $this->setupTeam(); + $teamId = $data['teamId']; + /** + * Test for SUCCESS + */ + $team = $this->client->call(Client::METHOD_PUT, '/teams/' . $teamId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Demo New' + ]); + + $this->assertEquals(200, $team['headers']['status-code']); + $this->assertNotEmpty($team['body']['$id']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("teams.{$teamId}.update")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('teams.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('teams.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$teamId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$teamId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals('Demo New', $webhook['data']['name']); + $this->assertGreaterThan(-1, $webhook['data']['total']); + $this->assertIsInt($webhook['data']['total']); + $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['$createdAt'])); + } + + public function testUpdateTeamPrefs(): void + { + // Set up a team + $data = $this->setupTeam(); + $id = $data['teamId']; + + $team = $this->client->call(Client::METHOD_PUT, '/teams/' . $id . '/prefs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'prefs' => [ + 'prefKey1' => 'prefValue1', + 'prefKey2' => 'prefValue2', + ] + ]); + + $this->assertEquals($team['headers']['status-code'], 200); + $this->assertIsArray($team['body']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("teams.{$id}.update.prefs")); + $signatureKey = $this->getProject()['signatureKey']; + $payload = json_encode($webhook['data']); + $url = $webhook['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $signatureKey, true)); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('teams.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('teams.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('teams.*.update.prefs', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$id}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$id}.update.prefs", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertEquals($webhook['data'], [ + 'prefKey1' => 'prefValue1', + 'prefKey2' => 'prefValue2', + ]); + } + + public function testDeleteTeam(): void + { + /** + * Test for SUCCESS + */ + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'Chelsea' + ]); + + $teamId = $team['body']['$id']; + + $this->assertEquals(201, $team['headers']['status-code']); + $this->assertNotEmpty($team['body']['$id']); + + $team = $this->client->call(Client::METHOD_DELETE, '/teams/' . $team['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $webhook = $this->getLastRequest($this->webhookEventProbe("teams.{$teamId}.delete")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('teams.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('teams.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$teamId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$teamId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals('Chelsea', $webhook['data']['name']); + $this->assertGreaterThan(-1, $webhook['data']['total']); + $this->assertIsInt($webhook['data']['total']); + $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['$createdAt'])); + } + + public function testCreateTeamMembership(): void + { + // Set up a team + $data = $this->setupTeam(); + $teamId = $data['teamId']; + $email = uniqid() . 'friend@localhost.test'; + + // Create user to ensure team event is triggered after user event + $user = $this->client->call(Client::METHOD_POST, '/account', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => 'password', + 'name' => 'Friend User', + ]); + + /** + * Test for SUCCESS + */ + $team = $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'email' => $email, + 'roles' => ['admin', 'editor'], + 'url' => 'http://localhost:5000/join-us#title' + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + $this->assertNotEmpty($team['body']['$id']); + + $lastEmail = $this->getLastEmail(); + + // `$isAppUser` — no email expected; + $tokens = $this->extractQueryParamsFromEmailLink($lastEmail['html'] ?? ''); + + $secret = $tokens['secret'] ?? ''; + $membershipId = $team['body']['$id']; + + $webhook = $this->getLastRequest($this->webhookEventProbe("teams.{$teamId}.memberships.{$membershipId}.create")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('teams.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('teams.*.memberships.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('teams.*.memberships.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.*.memberships.{$membershipId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.*.memberships.{$membershipId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$teamId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$teamId}.memberships.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$teamId}.memberships.*.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$teamId}.memberships.{$membershipId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$teamId}.memberships.{$membershipId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertNotEmpty($webhook['data']['userId']); + $this->assertNotEmpty($webhook['data']['teamId']); + $this->assertCount(2, $webhook['data']['roles']); + $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['invited'])); + $this->assertEquals(('server' === $this->getSide()), $webhook['data']['confirm']); + } + + public function testDeleteTeamMembership(): void + { + // Set up a team + $data = $this->setupTeam(); + $teamId = $data['teamId']; + $email = uniqid() . 'friend@localhost.test'; + + /** + * Test for SUCCESS + */ + $team = $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'email' => $email, + 'name' => 'Friend User', + 'roles' => ['admin', 'editor'], + 'url' => 'http://localhost:5000/join-us#title' + ]); + + $membershipId = $team['body']['$id'] ?? ''; + + $this->assertEquals(201, $team['headers']['status-code']); + $this->assertNotEmpty($team['body']['$id']); + + $team = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId . '/memberships/' . $team['body']['$id'], array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $team['headers']['status-code']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("teams.{$teamId}.memberships.{$membershipId}.delete")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('teams.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('teams.*.memberships.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('teams.*.memberships.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.*.memberships.{$membershipId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.*.memberships.{$membershipId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$teamId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$teamId}.memberships.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$teamId}.memberships.*.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$teamId}.memberships.{$membershipId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$teamId}.memberships.{$membershipId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertNotEmpty($webhook['data']['userId']); + $this->assertNotEmpty($webhook['data']['teamId']); + $this->assertCount(2, $webhook['data']['roles']); + $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['invited'])); + $this->assertEquals(('server' === $this->getSide()), $webhook['data']['confirm']); + } + + public function testCreateWebhookWithPrivateDomain(): void + { + /** + * Test for FAILURE + */ + $projectId = $this->getProject()['$id']; + $webhook = $this->client->call(Client::METHOD_POST, '/projects/' . $projectId . '/webhooks', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + 'X-Appwrite-Response-Format' => '1.8.0' + ], [ + 'name' => 'Webhook Test', + 'enabled' => true, + 'events' => [ + 'databases.*', + 'functions.*', + 'buckets.*', + 'teams.*', + 'users.*' + ], + 'url' => 'http://localhost/webhook', // private domains not allowed + 'security' => false, + ]); + + $this->assertEquals(400, $webhook['headers']['status-code']); + } + + public function testUpdateWebhookWithPrivateDomain(): void + { + /** + * Test for FAILURE + */ + $projectId = $this->getProject()['$id']; + $webhookId = $this->getProject()['webhookId']; + $webhook = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId . '/webhooks/' . $webhookId, [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + 'X-Appwrite-Response-Format' => '1.8.0' + ], [ + 'name' => 'Webhook Test', + 'enabled' => true, + 'events' => [ + 'databases.*', + 'functions.*', + 'buckets.*', + 'teams.*', + 'users.*' + ], + 'url' => 'http://localhost/webhook', // private domains not allowed + 'security' => false, + ]); + + $this->assertEquals(400, $webhook['headers']['status-code']); + } + + public function testWebhookAutoDisable(): void + { + $projectId = $this->getProject()['$id']; + $webhookId = $this->getProject()['webhookId']; + + // Create a database for this test + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'AutoDisable DB', + ]); + + $databaseId = $database['body']['$id']; + + $webhook = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId . '/webhooks/' . $webhookId, [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + 'X-Appwrite-Response-Format' => '1.8.0' + ], [ + 'name' => 'Webhook Test', + 'enabled' => true, + 'events' => [ + 'databases.*', + 'functions.*', + 'buckets.*', + 'teams.*', + 'users.*' + ], + 'url' => 'http://appwrite-non-existing-domain.com', // set non-existent URL + 'security' => false, + ]); + + $this->assertEquals(200, $webhook['headers']['status-code']); + $this->assertNotEmpty($webhook['body']); + + // trigger webhook for failure event 10 times + for ($i = 0; $i < 10; $i++) { + $newCollection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'newCollection' . $i, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + + $this->assertEquals($newCollection['headers']['status-code'], 201); + $this->assertNotEmpty($newCollection['body']['$id']); + } + + $this->assertEventually(function () use ($projectId, $webhookId) { + $webhook = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/webhooks/' . $webhookId, array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + 'X-Appwrite-Response-Format' => '1.8.0' + ])); + + // assert that the webhook is now disabled after 10 consecutive failures + $this->assertEquals($webhook['body']['enabled'], false); + $this->assertEquals($webhook['body']['attempts'], 10); + }, 15000, 500); + } +} diff --git a/tests/e2e/Services/Webhooks/WebhooksCustomClientTest.php b/tests/e2e/Services/ProjectWebhooks/WebhooksCustomClientTest.php similarity index 99% rename from tests/e2e/Services/Webhooks/WebhooksCustomClientTest.php rename to tests/e2e/Services/ProjectWebhooks/WebhooksCustomClientTest.php index 7d01095a36..a6bda320a4 100644 --- a/tests/e2e/Services/Webhooks/WebhooksCustomClientTest.php +++ b/tests/e2e/Services/ProjectWebhooks/WebhooksCustomClientTest.php @@ -1,6 +1,6 @@ client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => $name, + ]); + + return [ + 'userId' => $user['body']['$id'], + 'name' => $user['body']['name'], + 'email' => $user['body']['email'], + ]; + } + + /** + * Creates a function and returns function details. + * + * @return array Array containing 'functionId' + */ + protected function setupFunction(): array + { + $function = $this->client->call(Client::METHOD_POST, '/functions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'functionId' => ID::unique(), + 'name' => 'Test', + 'execute' => [Role::any()->toString()], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 10, + ]); + + return ['functionId' => $function['body']['$id']]; + } + + /** + * Creates a function deployment and waits for it to be built. + * + * @param string $functionId Function ID + * @return array Array containing 'functionId', 'deploymentId' + */ + protected function setupDeployment(string $functionId): array + { + $stderr = ''; + $stdout = ''; + $folder = 'timeout'; + $code = realpath(__DIR__ . '/../../../resources/functions') . "/{$folder}/code.tar.gz"; + Console::execute('cd ' . realpath(__DIR__ . "/../../../resources/functions") . "/{$folder} && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr); + + // Create variable first + $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/variables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'key' => 'key1', + 'value' => 'value1', + ]); + + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'entrypoint' => 'index.js', + 'code' => new CURLFile($code, 'application/x-gzip', \basename($code)), + 'activate' => true + ]); + + $deploymentId = $deployment['body']['$id']; + + // Wait for deployment to be built + $this->awaitDeploymentIsBuilt($functionId, $deploymentId); + + return [ + 'functionId' => $functionId, + 'deploymentId' => $deploymentId, + ]; + } + + // Collection APIs + public function testUpdateCollection(): void + { + // Set up collection with attributes + $data = $this->setupCollectionWithAttributes(); + $id = $data['actorsId']; + $databaseId = $data['databaseId']; + + /** + * Test for SUCCESS + */ + $actors = $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId . '/collections/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'name' => 'Actors1', + 'documentSecurity' => true, + ]); + + $this->assertEquals(200, $actors['headers']['status-code']); + $this->assertNotEmpty($actors['body']['$id']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$id}.update")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals('POST', $webhook['method']); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$id}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertTrue(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? '')); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals('Actors1', $webhook['data']['name']); + $this->assertIsArray($webhook['data']['$permissions']); + $this->assertCount(4, $webhook['data']['$permissions']); + } + + public function testCreateDeleteIndexes(): void + { + // Set up collection with attributes + $data = $this->setupCollectionWithAttributes(); + $actorsId = $data['actorsId']; + $databaseId = $data['databaseId']; + + $index = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/indexes', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'fullname', + 'type' => 'key', + 'attributes' => ['lastName', 'firstName'], + 'orders' => ['ASC', 'ASC'], + ]); + + $this->assertEquals(202, $index['headers']['status-code']); + $this->assertEquals('fullname', $index['body']['key']); + + // wait for database worker to create index + $this->assertEventually(function () use ($databaseId, $actorsId) { + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$actorsId}.indexes.*.create")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals('POST', $webhook['method']); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.indexes.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.indexes.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.indexes.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.indexes.*.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertTrue(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? '')); + }, 10000, 500); + + // Remove index + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/' . $actorsId . '/indexes/' . $index['body']['key'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + // // wait for database worker to remove index + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$actorsId}.indexes.*.update")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + // $this->assertEquals($webhook['method'], 'DELETE'); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.indexes.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.indexes.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.indexes.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.indexes.*.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertTrue(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? '')); + } + + public function testDeleteCollection(): void + { + /** + * Create database + */ + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], $this->getHeaders()), [ + 'databaseId' => ID::unique(), + 'name' => 'Actors DB', + ]); + + $databaseId = $database['body']['$id']; + + /** + * Test for SUCCESS + */ + $actors = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Demo', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + + $id = $actors['body']['$id']; + + $this->assertEquals(201, $actors['headers']['status-code']); + $this->assertNotEmpty($actors['body']['$id']); + + $actors = $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/' . $actors['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), []); + + $this->assertEquals(204, $actors['headers']['status-code']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$id}.delete")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals('POST', $webhook['method']); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.collections.{$id}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertTrue(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? '')); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals('Demo', $webhook['data']['name']); + $this->assertIsArray($webhook['data']['$permissions']); + $this->assertCount(4, $webhook['data']['$permissions']); + } + + // Table APIs + public function testUpdateTable(): void + { + // Set up table with columns + $data = $this->setupTableWithColumns(); + $id = $data['actorsId']; + $databaseId = $data['databaseId']; + + /** + * Test for SUCCESS + */ + $actors = $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'name' => 'Actors1', + 'rowSecurity' => true, + ]); + + $this->assertEquals(200, $actors['headers']['status-code']); + $this->assertNotEmpty($actors['body']['$id']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$id}.update")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals('POST', $webhook['method']); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$id}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEmpty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals('Actors1', $webhook['data']['name']); + $this->assertIsArray($webhook['data']['$permissions']); + $this->assertCount(4, $webhook['data']['$permissions']); + } + + public function testCreateDeleteColumnIndexes(): void + { + // Set up table with columns + $data = $this->setupTableWithColumns(); + $actorsId = $data['actorsId']; + $databaseId = $data['databaseId']; + + $index = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/indexes', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'fullname', + 'type' => 'key', + 'columns' => ['lastName', 'firstName'], + 'orders' => ['ASC', 'ASC'], + ]); + + $this->assertEquals(202, $index['headers']['status-code']); + $this->assertEquals('fullname', $index['body']['key']); + + // wait for database worker to create index + $this->assertEventually(function () use ($databaseId, $actorsId) { + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$actorsId}.indexes.*.create")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals('POST', $webhook['method']); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.indexes.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.indexes.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.indexes.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.indexes.*.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertTrue(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? '')); + }, 10000, 500); + + // Remove index + $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/indexes/' . $index['body']['key'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + // // wait for database worker to remove index + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$actorsId}.indexes.*.update")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + // $this->assertEquals($webhook['method'], 'DELETE'); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.indexes.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.indexes.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.indexes.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.indexes.*.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertTrue(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? '')); + } + + public function testDeleteTable(): void + { + /** + * Create database + */ + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], $this->getHeaders()), [ + 'databaseId' => ID::unique(), + 'name' => 'Actors DB', + ]); + + $databaseId = $database['body']['$id']; + + /** + * Test for SUCCESS + */ + $actors = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'tableId' => ID::unique(), + 'name' => 'Demo', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'rowSecurity' => true, + ]); + + $id = $actors['body']['$id']; + + $this->assertEquals(201, $actors['headers']['status-code']); + $this->assertNotEmpty($actors['body']['$id']); + + $actors = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $actors['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(204, $actors['headers']['status-code']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$id}.delete")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals('POST', $webhook['method']); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("databases.{$databaseId}.tables.{$id}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEmpty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals('Demo', $webhook['data']['name']); + $this->assertIsArray($webhook['data']['$permissions']); + $this->assertCount(4, $webhook['data']['$permissions']); + } + + public function testCreateUser(): void + { + $email = uniqid() . 'user@localhost.test'; + $password = 'password'; + $name = 'User Name'; + + /** + * Test for SUCCESS + */ + $user = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => $name, + ]); + + $this->assertEquals(201, $user['headers']['status-code']); + $this->assertNotEmpty($user['body']['$id']); + + $id = $user['body']['$id']; + + $webhook = $this->getLastRequest($this->webhookEventProbe("users.{$id}.create")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals('POST', $webhook['method']); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + $this->assertStringContainsString('users.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('users.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("users.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("users.{$id}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals($webhook['data']['name'], $name); + $this->assertTrue((new DatetimeValidator())->isValid($webhook['data']['registration'])); + $this->assertTrue($webhook['data']['status']); + $this->assertEquals($webhook['data']['email'], $email); + $this->assertFalse($webhook['data']['emailVerification']); + $this->assertEquals([], $webhook['data']['prefs']); + } + + public function testUpdateUserPrefs(): void + { + // Set up a user + $data = $this->setupUser(); + $id = $data['userId']; + + /** + * Test for SUCCESS + */ + $user = $this->client->call(Client::METHOD_PATCH, '/users/' . $id . '/prefs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'prefs' => ['a' => 'b'] + ]); + + $this->assertEquals(200, $user['headers']['status-code']); + $this->assertEquals('b', $user['body']['a']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("users.{$id}.update.prefs")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals('POST', $webhook['method']); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + $this->assertStringContainsString('users.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('users.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('users.*.update.prefs', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("users.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("users.{$id}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("users.{$id}.update.prefs", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertEquals('b', $webhook['data']['a']); + } + + public function testUpdateUserStatus(): void + { + // Set up a user + $data = $this->setupUser(); + $id = $data['userId']; + + /** + * Test for SUCCESS + */ + $user = $this->client->call(Client::METHOD_PATCH, '/users/' . $data['userId'] . '/status', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'status' => false, + ]); + + $this->assertEquals(200, $user['headers']['status-code']); + $this->assertNotEmpty($user['body']['$id']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("users.{$id}.update.status")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals('POST', $webhook['method']); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + $this->assertStringContainsString('users.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('users.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('users.*.update.status', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("users.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("users.{$id}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("users.{$id}.update.status", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals($webhook['data']['name'], $data['name']); + $this->assertTrue((new DatetimeValidator())->isValid($webhook['data']['registration'])); + $this->assertFalse($webhook['data']['status']); + $this->assertEquals($webhook['data']['email'], $data['email']); + $this->assertFalse($webhook['data']['emailVerification']); + } + + public function testDeleteUser(): void + { + // Set up a user + $data = $this->setupUser(); + $id = $data['userId']; + + /** + * Test for SUCCESS + */ + $user = $this->client->call(Client::METHOD_DELETE, '/users/' . $data['userId'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $user['headers']['status-code']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("users.{$id}.delete")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals('POST', $webhook['method']); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + $this->assertStringContainsString('users.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('users.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("users.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("users.{$id}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertNotEmpty($webhook['data']['$id']); + $this->assertEquals($webhook['data']['name'], $data['name']); + $this->assertTrue((new DatetimeValidator())->isValid($webhook['data']['registration'])); + // User is created with status=true by default, so webhook shows that status at deletion + $this->assertTrue($webhook['data']['status']); + $this->assertEquals($webhook['data']['email'], $data['email']); + $this->assertFalse($webhook['data']['emailVerification']); + } + + public function testCreateFunction(): void + { + /** + * Test for SUCCESS + */ + $function = $this->client->call(Client::METHOD_POST, '/functions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'functionId' => ID::unique(), + 'name' => 'Test', + 'execute' => [Role::any()->toString()], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 10, + ]); + + $id = $function['body']['$id'] ?? ''; + + $this->assertEquals(201, $function['headers']['status-code']); + $this->assertNotEmpty($function['body']['$id']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("functions.{$id}.create")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals('POST', $webhook['method']); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + } + + public function testUpdateFunction(): void + { + // Set up a function + $data = $this->setupFunction(); + $id = $data['functionId']; + + /** + * Test for SUCCESS + */ + $function = $this->client->call(Client::METHOD_PUT, '/functions/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Test', + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'execute' => [Role::any()->toString()], + 'vars' => [ + 'key1' => 'value1', + ] + ]); + + $this->assertEquals(200, $function['headers']['status-code']); + $this->assertEquals($function['body']['$id'], $id); + + // Create variable + $variable = $this->client->call(Client::METHOD_POST, '/functions/' . $id . '/variables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'key' => 'key1', + 'value' => 'value1', + ]); + + $this->assertEquals(201, $variable['headers']['status-code']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("functions.{$id}.update")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals('POST', $webhook['method']); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + // $this->assertStringContainsString('functions.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString('functions.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); TODO @christyjacob4 : enable test once we allow functions.* events + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + } + + public function testCreateDeployment(): void + { + // Set up a function + $data = $this->setupFunction(); + $functionId = $data['functionId']; + + /** + * Test for SUCCESS + */ + $stderr = ''; + $stdout = ''; + $folder = 'timeout'; + $code = realpath(__DIR__ . '/../../../resources/functions') . "/{$folder}/code.tar.gz"; + Console::execute('cd ' . realpath(__DIR__ . "/../../../resources/functions") . "/{$folder} && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr); + + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'entrypoint' => 'index.js', + 'code' => new CURLFile($code, 'application/x-gzip', \basename($code)), + 'activate' => true + ]); + + $deploymentId = $deployment['body']['$id'] ?? ''; + + $this->assertEquals(202, $deployment['headers']['status-code']); + $this->assertNotEmpty($deployment['body']['$id']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("functions.{$functionId}.deployments.{$deploymentId}.create")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals('POST', $webhook['method']); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + // $this->assertStringContainsString('functions.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString('functions.*.deployments.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.*.deployments.{$deploymentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.deployments.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.deployments.{$deploymentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); TODO @christyjacob4 : enable test once we allow functions.* events + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + + $this->awaitDeploymentIsBuilt($functionId, $deploymentId); + } + + public function testUpdateDeployment(): void + { + // Set up a function with deployment + $data = $this->setupFunction(); + $deploymentData = $this->setupDeployment($data['functionId']); + $id = $deploymentData['functionId']; + $deploymentId = $deploymentData['deploymentId']; + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_PATCH, '/functions/' . $id . '/deployments/' . $deploymentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + + // Wait for deployment to be built. + $this->assertEventually(function () use ($deploymentId, $id) { + $webhook = $this->getLastRequest($this->webhookEventProbe("functions.{$id}.deployments.{$deploymentId}.update")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals('POST', $webhook['method']); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + // $this->assertStringContainsString('functions.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString('functions.*.deployments.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString('functions.*.deployments.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.*.deployments.{$deploymentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.*.deployments.{$deploymentId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.deployments.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.deployments.*.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.deployments.{$deploymentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.deployments.{$deploymentId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); TODO @christyjacob4 : enable test once we allow functions.* events + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + }, 10000, 500); + + } + + public function testExecutions(): void + { + // Set up a function with deployment + $data = $this->setupFunction(); + $deploymentData = $this->setupDeployment($data['functionId']); + $id = $deploymentData['functionId']; + + /** + * Test for SUCCESS + */ + $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $id . '/executions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'async' => true + ]); + + $executionId = $execution['body']['$id'] ?? ''; + + $this->assertEquals(202, $execution['headers']['status-code']); + $this->assertNotEmpty($execution['body']['$id']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("functions.{$id}.executions.{$executionId}.create")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + $this->assertEquals('POST', $webhook['method']); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + // $this->assertStringContainsString('functions.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString('functions.*.executions.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString('functions.*.executions.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.*.executions.{$executionId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.*.executions.{$executionId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.executions.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.executions.*.create", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.executions.{$executionId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.executions.{$executionId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); TODO @christyjacob4 : enable test once we allow functions.* events + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + + // wait for timeout function to complete + $this->assertEventually(function () use ($executionId, $id) { + $webhook = $this->getLastRequest($this->webhookEventProbe("functions.{$id}.executions.{$executionId}.update")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals('POST', $webhook['method']); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + // $this->assertStringContainsString('functions.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString('functions.*.executions.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString('functions.*.executions.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.*.executions.{$executionId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.*.executions.{$executionId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.executions.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.executions.*.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.executions.{$executionId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.executions.{$executionId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); TODO @christyjacob4 : enable test once we allow functions.* events + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + }, 30000, 500); + } + + public function testDeleteDeployment(): void + { + // Set up a function with deployment + $data = $this->setupFunction(); + $deploymentData = $this->setupDeployment($data['functionId']); + $id = $deploymentData['functionId']; + $deploymentId = $deploymentData['deploymentId']; + /** + * Test for SUCCESS + */ + $deployment = $this->client->call(Client::METHOD_DELETE, '/functions/' . $id . '/deployments/' . $deploymentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $deployment['headers']['status-code']); + $this->assertEmpty($deployment['body']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("functions.{$id}.deployments.{$deploymentId}.delete")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals('POST', $webhook['method']); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + // $this->assertStringContainsString('functions.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString('functions.*.deployments.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString('functions.*.deployments.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.*.deployments.{$deploymentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.*.deployments.{$deploymentId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.deployments.*", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.deployments.*.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.deployments.{$deploymentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.deployments.{$deploymentId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); TODO @christyjacob4 : enable test once we allow functions.* events + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + } + + public function testDeleteFunction(): void + { + // Set up a function + $data = $this->setupFunction(); + $id = $data['functionId']; + + /** + * Test for SUCCESS + */ + $function = $this->client->call(Client::METHOD_DELETE, '/functions/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $function['headers']['status-code']); + $this->assertEmpty($function['body']); + + $webhook = $this->getLastRequest($this->webhookEventProbe("functions.{$id}.delete")); + $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + + $this->assertEquals('POST', $webhook['method']); + $this->assertEquals('application/json', $webhook['headers']['Content-Type']); + $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); + // $this->assertStringContainsString('functions.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString('functions.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); + // $this->assertStringContainsString("functions.{$id}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); TODO @christyjacob4 : enable test once we allow functions.* events + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + } +} diff --git a/tests/e2e/Services/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php index dc31b7aa85..231ec302de 100644 --- a/tests/e2e/Services/Projects/ProjectsBase.php +++ b/tests/e2e/Services/Projects/ProjectsBase.php @@ -81,10 +81,12 @@ trait ProjectsBase $projectData = $this->setupProjectData(); $id = $projectData['projectId']; - $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/webhooks', array_merge([ + $response = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + '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', diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 52e59f3e72..d4945f8407 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -2743,10 +2743,12 @@ class ProjectsConsoleClientTest extends Scope $data = $this->setupProjectData(); $id = $data['projectId']; - $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/webhooks', array_merge([ + $response = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + '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', @@ -2768,10 +2770,12 @@ class ProjectsConsoleClientTest extends Scope /** * Test for FAILURE */ - $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/webhooks', array_merge([ + $response = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin' ], $this->getHeaders()), [ + 'webhookId' => 'unique()', 'name' => 'Webhook Test', 'events' => ['account.unknown', 'users.*.update.email'], 'url' => 'https://appwrite.io', @@ -2782,10 +2786,12 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(400, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/webhooks', array_merge([ + $response = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin' ], $this->getHeaders()), [ + 'webhookId' => 'unique()', 'name' => 'Webhook Test', 'events' => ['users.*.create', 'users.*.update.email'], 'url' => 'invalid://appwrite.io', @@ -2799,9 +2805,10 @@ class ProjectsConsoleClientTest extends Scope $data = $this->setupProjectWithWebhook(); $id = $data['projectId']; - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/webhooks', array_merge([ + $response = $this->client->call(Client::METHOD_GET, '/webhooks', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin' ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); @@ -2819,9 +2826,10 @@ class ProjectsConsoleClientTest extends Scope $id = $data['projectId']; $webhookId = $data['webhookId']; - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/webhooks/' . $webhookId, array_merge([ + $response = $this->client->call(Client::METHOD_GET, '/webhooks/' . $webhookId, array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin' ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); @@ -2837,9 +2845,10 @@ class ProjectsConsoleClientTest extends Scope /** * Test for FAILURE */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/webhooks/error', array_merge([ + $response = $this->client->call(Client::METHOD_GET, '/webhooks/error', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin' ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -2851,9 +2860,10 @@ class ProjectsConsoleClientTest extends Scope $id = $data['projectId']; $webhookId = $data['webhookId']; - $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/webhooks/' . $webhookId, array_merge([ + $response = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin' ], $this->getHeaders()), [ 'name' => 'Webhook Test Update', 'events' => ['users.*.delete', 'users.*.sessions.*.delete', 'buckets.*.files.*.create'], @@ -2875,9 +2885,10 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('', $response['body']['httpUser']); $this->assertEquals('', $response['body']['httpPass']); - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/webhooks/' . $webhookId, array_merge([ + $response = $this->client->call(Client::METHOD_GET, '/webhooks/' . $webhookId, array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin' ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); @@ -2897,9 +2908,10 @@ class ProjectsConsoleClientTest extends Scope /** * Test for FAILURE */ - $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/webhooks/' . $webhookId, array_merge([ + $response = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin' ], $this->getHeaders()), [ 'name' => 'Webhook Test Update', 'events' => ['users.*.delete', 'users.*.sessions.*.delete', 'buckets.*.files.*.unknown'], @@ -2909,9 +2921,10 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(400, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/webhooks/' . $webhookId, array_merge([ + $response = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin' ], $this->getHeaders()), [ 'name' => 'Webhook Test Update', 'events' => ['users.*.delete', 'users.*.sessions.*.delete', 'buckets.*.files.*.create'], @@ -2921,9 +2934,10 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(400, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_PUT, '/projects/' . $id . '/webhooks/' . $webhookId, array_merge([ + $response = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin' ], $this->getHeaders()), [ 'name' => 'Webhook Test Update', 'events' => ['users.*.delete', 'users.*.sessions.*.delete', 'buckets.*.files.*.create'], @@ -2940,9 +2954,10 @@ class ProjectsConsoleClientTest extends Scope $webhookId = $data['webhookId']; $signatureKey = $data['signatureKey']; - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/webhooks/' . $webhookId . '/signature', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/signature', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin' ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); @@ -2957,10 +2972,12 @@ class ProjectsConsoleClientTest extends Scope $id = $projectData['projectId']; // Create a webhook to delete - $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/webhooks', array_merge([ + $response = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin' ], $this->getHeaders()), [ + 'webhookId' => 'unique()', 'name' => 'Webhook To Delete', 'events' => ['users.*.create'], 'url' => 'https://appwrite.io', @@ -2972,17 +2989,19 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $webhookId = $response['body']['$id']; - $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/webhooks/' . $webhookId, array_merge([ + $response = $this->client->call(Client::METHOD_DELETE, '/webhooks/' . $webhookId, array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin' ], $this->getHeaders()), []); $this->assertEquals(204, $response['headers']['status-code']); $this->assertEmpty($response['body']); - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/webhooks/' . $webhookId, array_merge([ + $response = $this->client->call(Client::METHOD_GET, '/webhooks/' . $webhookId, array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin' ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -2990,9 +3009,10 @@ class ProjectsConsoleClientTest extends Scope /** * Test for FAILURE */ - $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/webhooks/error', array_merge([ + $response = $this->client->call(Client::METHOD_DELETE, '/webhooks/error', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin' ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); @@ -4350,9 +4370,10 @@ class ProjectsConsoleClientTest extends Scope /** * Test for FAILURE */ - $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/webhooks/error', array_merge([ + $response = $this->client->call(Client::METHOD_DELETE, '/webhooks/error', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin' ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index c5fb71fcb6..d1d7d0d054 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -2289,7 +2289,7 @@ class RealtimeCustomClientTest extends Scope ]); $this->assertEquals('ready', $deployment['body']['status'], \json_encode($deployment['body'])); - }); + }, 240_000, 500); $response = $this->client->call(Client::METHOD_PATCH, '/functions/' . $functionId . '/deployments/' . $deploymentId, array_merge([ 'content-type' => 'application/json', @@ -3743,7 +3743,51 @@ class RealtimeCustomClientTest extends Scope $session = $user['session'] ?? ''; $projectId = $this->getProject()['$id']; - Coroutine\run(function () use ($session, $projectId) { + // Setup DB/collection/attribute outside coroutine to avoid fatal errors on assertion failure + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Concurrent DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Concurrent Collection', + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 64, + 'required' => true, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/name', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status'] ?? null); + }, 30000, 250); + + Coroutine\run(function () use ($session, $projectId, $databaseId, $collectionId) { $headers = [ 'origin' => 'http://localhost', 'cookie' => 'a_session_' . $projectId . '=' . $session @@ -3760,50 +3804,6 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('connected', $response['type']); } - // Setup DB/collection/attribute - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Concurrent DB', - ]); - $databaseId = $database['body']['$id']; - - $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Concurrent Collection', - 'permissions' => [ - Permission::create(Role::user($this->getUser()['$id'])), - ], - 'documentSecurity' => true, - ]); - $collectionId = $collection['body']['$id']; - - $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 64, - 'required' => true, - ]); - - $this->assertEventually(function () use ($databaseId, $collectionId) { - $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/name', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ])); - $this->assertEquals('available', $response['body']['status']); - }, 30000, 250); - $creates = [ ['name' => 'Doc A'], ['name' => 'Doc B'], diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 9bb4a34ff5..69dbd7fdf0 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -342,7 +342,7 @@ class SitesCustomServerTest extends Scope 'buildRuntime' => 'node-22', 'outputDirectory' => './dist', 'buildCommand' => 'npm run build', - 'installCommand' => 'npm install', + 'installCommand' => 'npm ci', 'fallbackFile' => '', ]); @@ -404,7 +404,7 @@ class SitesCustomServerTest extends Scope 'buildRuntime' => 'node-22', 'outputDirectory' => './dist', 'buildCommand' => 'npm run build', - 'installCommand' => 'npm install', + 'installCommand' => 'npm ci', ]); $this->assertNotEmpty($siteId); @@ -445,7 +445,7 @@ class SitesCustomServerTest extends Scope 'buildRuntime' => 'node-22', 'outputDirectory' => './dist', 'buildCommand' => 'npm run build', - 'installCommand' => 'npm install', + 'installCommand' => 'npm ci', ]); $this->assertNotEmpty($siteId); @@ -569,7 +569,7 @@ class SitesCustomServerTest extends Scope 'buildRuntime' => 'node-22', 'outputDirectory' => './dist', 'buildCommand' => 'npm run build', - 'installCommand' => 'npm install', + 'installCommand' => 'npm ci', ]); $this->assertNotEmpty($siteId); @@ -598,7 +598,7 @@ class SitesCustomServerTest extends Scope 'buildRuntime' => 'node-22', 'outputDirectory' => './dist', 'buildCommand' => 'npm run build', - 'installCommand' => 'npm install', + 'installCommand' => 'npm ci', 'adapter' => 'ssr', 'fallbackFile' => '', '$id' => $siteId, @@ -849,7 +849,7 @@ class SitesCustomServerTest extends Scope 'providerBranch' => 'main', 'providerRootDirectory' => './', '$id' => $siteId, - 'installCommand' => 'npm install' + 'installCommand' => 'npm ci' ]); $dateValidator = new DatetimeValidator(); @@ -859,7 +859,7 @@ class SitesCustomServerTest extends Scope $this->assertEquals('Test Site Updated', $site['body']['name']); $this->assertEquals(true, $dateValidator->isValid($site['body']['$createdAt'])); $this->assertEquals(true, $dateValidator->isValid($site['body']['$updatedAt'])); - $this->assertEquals('npm install', $site['body']['installCommand']); + $this->assertEquals('npm ci', $site['body']['installCommand']); $this->cleanupSite($siteId); } @@ -1439,6 +1439,71 @@ class SitesCustomServerTest extends Scope $this->assertEquals(404, $function['headers']['status-code']); } + public function testDeleteSiteRulesCleanup(): void + { + $siteId = $this->setupSite([ + 'siteId' => ID::unique(), + 'name' => 'Test Rules Cleanup Site', + 'framework' => 'other', + 'buildRuntime' => 'node-22', + 'outputDirectory' => './', + 'fallbackFile' => '', + ]); + + $this->assertNotEmpty($siteId); + + // Create a manual deployment rule (type = 'deployment') + $domain = $this->setupSiteDomain($siteId); + $this->assertNotEmpty($domain); + + // Create a redirect rule (type = 'redirect') + $redirectDomain = \uniqid() . '-redirect-cleanup.custom.localhost'; + $redirectRule = $this->client->call(Client::METHOD_POST, '/proxy/rules/redirect', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'domain' => $redirectDomain, + 'url' => 'https://appwrite.io', + 'statusCode' => 301, + 'resourceType' => 'site', + 'resourceId' => $siteId, + ]); + + $this->assertEquals(201, $redirectRule['headers']['status-code']); + $this->assertNotEmpty($redirectRule['body']['$id']); + + // Verify both rules exist (no type filter — catches all rule types) + $rules = $this->client->call(Client::METHOD_GET, '/proxy/rules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('deploymentResourceId', [$siteId])->toString() + ] + ]); + + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertGreaterThanOrEqual(2, $rules['body']['total']); + + // Delete the site + $this->cleanupSite($siteId); + + // Verify ALL rules (deployment + redirect) are cleaned up + $this->assertEventually(function () use ($siteId) { + $rules = $this->client->call(Client::METHOD_GET, '/proxy/rules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('deploymentResourceId', [$siteId])->toString() + ] + ]); + + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertEquals(0, $rules['body']['total']); + }, 5000, 500); + } + public function testGetFrameworks(): void { $frameworks = $this->client->call(Client::METHOD_GET, '/sites/frameworks', array_merge([ @@ -2076,7 +2141,7 @@ class SitesCustomServerTest extends Scope 'buildRuntime' => 'node-22', 'outputDirectory' => './dist', 'buildCommand' => 'npm run build', - 'installCommand' => 'npm install', + 'installCommand' => 'npm ci', 'fallbackFile' => '', ]); @@ -2176,7 +2241,7 @@ class SitesCustomServerTest extends Scope 'buildRuntime' => 'node-22', 'outputDirectory' => './dist', 'buildCommand' => 'npm run build', - 'installCommand' => 'npm install', + 'installCommand' => 'npm ci', 'fallbackFile' => '', 'logging' => false // set logging to false ] @@ -2605,7 +2670,7 @@ class SitesCustomServerTest extends Scope 'buildRuntime' => 'node-22', 'outputDirectory' => './dist', 'buildCommand' => 'npm run build', - 'installCommand' => 'npm install', + 'installCommand' => 'npm ci', ]); $this->assertNotEmpty($siteId); @@ -2965,7 +3030,7 @@ class SitesCustomServerTest extends Scope 'buildRuntime' => 'node-22', 'outputDirectory' => './dist', 'buildCommand' => 'npm run build', - 'installCommand' => 'echo "custom error" && npm install', + 'installCommand' => 'echo "custom error" && npm ci', 'adapter' => 'ssr', ]); $this->assertNotEmpty($siteId); @@ -3008,7 +3073,7 @@ class SitesCustomServerTest extends Scope 'buildRuntime' => 'node-22', 'outputDirectory' => './dist', 'buildCommand' => 'npm run build', - 'installCommand' => 'npm install', + 'installCommand' => 'npm ci', 'fallbackFile' => '', ]); @@ -3050,7 +3115,7 @@ class SitesCustomServerTest extends Scope 'buildRuntime' => 'node-22', 'outputDirectory' => './dist', 'buildCommand' => 'npm run build', - 'installCommand' => 'npm install', + 'installCommand' => 'npm ci', 'fallbackFile' => '', ]); diff --git a/tests/e2e/Services/Databases/TablesDB/DatabasesStringTypesTest.php b/tests/e2e/Services/TablesDB/DatabasesStringTypesTest.php similarity index 99% rename from tests/e2e/Services/Databases/TablesDB/DatabasesStringTypesTest.php rename to tests/e2e/Services/TablesDB/DatabasesStringTypesTest.php index e85c9be984..44af63fb22 100644 --- a/tests/e2e/Services/Databases/TablesDB/DatabasesStringTypesTest.php +++ b/tests/e2e/Services/TablesDB/DatabasesStringTypesTest.php @@ -1,6 +1,6 @@ getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $user = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'impersonator-update-test@appwrite.io', + 'password' => 'password', + 'name' => 'Impersonator Update Test', + ]); + $this->assertEquals(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + + $this->assertFalse($user['body']['impersonator'] ?? false); + + $updated = $this->client->call(Client::METHOD_PATCH, '/users/' . $userId . '/impersonator', $headers, [ + 'impersonator' => true, + ]); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertTrue($updated['body']['impersonator']); + $this->assertEquals($userId, $updated['body']['$id']); + + $get = $this->client->call(Client::METHOD_GET, '/users/' . $userId, $headers); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['impersonator']); + + $updated = $this->client->call(Client::METHOD_PATCH, '/users/' . $userId . '/impersonator', $headers, [ + 'impersonator' => false, + ]); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertFalse($updated['body']['impersonator']); + + $get = $this->client->call(Client::METHOD_GET, '/users/' . $userId, $headers); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertFalse($get['body']['impersonator']); + } + + /** + * Test impersonation by user ID: session auth + header → act as target user, account returns target + impersonatorUserId + */ + public function testImpersonateByUserId(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'impersonator-a@appwrite.io', + 'password' => 'password', + 'name' => 'User A Impersonator', + ]); + $this->assertEquals(201, $userA['headers']['status-code']); + $idA = $userA['body']['$id']; + + $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'impersonator-target-b@appwrite.io', + 'password' => 'password', + 'name' => 'User B Target', + ]); + $this->assertEquals(201, $userB['headers']['status-code']); + $idB = $userB['body']['$id']; + + $patch = $this->client->call(Client::METHOD_PATCH, '/users/' . $idA . '/impersonator', $headers, ['impersonator' => true]); + $this->assertEquals(200, $patch['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); + $this->assertEquals(201, $session['headers']['status-code']); + $sessionSecret = $session['body']['secret']; + + $accountHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $sessionSecret, + 'x-appwrite-impersonate-user-id' => $idB, + ]; + + $account = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertEquals(200, $account['headers']['status-code']); + $this->assertEquals($idB, $account['body']['$id']); + $this->assertEquals('User B Target', $account['body']['name']); + $this->assertEquals($idA, $account['body']['impersonatorUserId']); + + $withoutHeader = $this->client->call(Client::METHOD_GET, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $sessionSecret, + ]); + $this->assertEquals(200, $withoutHeader['headers']['status-code']); + $this->assertEquals($idA, $withoutHeader['body']['$id']); + $this->assertEquals('User A Impersonator', $withoutHeader['body']['name']); + $this->assertArrayHasKey('impersonatorUserId', $withoutHeader['body']); + $this->assertNull($withoutHeader['body']['impersonatorUserId']); + } + + /** + * Test impersonation by email header + */ + public function testImpersonateByEmail(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'impersonate-by-email-actor@appwrite.io', + 'password' => 'password', + 'name' => 'Actor', + ]); + $this->assertEquals(201, $userA['headers']['status-code']); + $idA = $userA['body']['$id']; + + $targetEmail = 'impersonate-by-email-target@appwrite.io'; + $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => $targetEmail, + 'password' => 'password', + 'name' => 'Target By Email', + ]); + $this->assertEquals(201, $userB['headers']['status-code']); + $idB = $userB['body']['$id']; + + $this->client->call(Client::METHOD_PATCH, '/users/' . $idA . '/impersonator', $headers, ['impersonator' => true]); + $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); + $this->assertEquals(201, $session['headers']['status-code']); + + $account = $this->client->call(Client::METHOD_GET, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $session['body']['secret'], + 'x-appwrite-impersonate-user-email' => $targetEmail, + ]); + $this->assertEquals(200, $account['headers']['status-code']); + $this->assertEquals($idB, $account['body']['$id']); + $this->assertEquals($idA, $account['body']['impersonatorUserId']); + } + + /** + * Test impersonation by phone header + */ + public function testImpersonateByPhone(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'impersonate-by-phone-actor@appwrite.io', + 'password' => 'password', + 'name' => 'Actor Phone', + ]); + $this->assertEquals(201, $userA['headers']['status-code']); + $idA = $userA['body']['$id']; + + $targetPhone = '+1555010200'; + $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'phone' => $targetPhone, + 'name' => 'Target By Phone', + ]); + $this->assertEquals(201, $userB['headers']['status-code']); + $idB = $userB['body']['$id']; + + $this->client->call(Client::METHOD_PATCH, '/users/' . $idA . '/impersonator', $headers, ['impersonator' => true]); + $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); + $this->assertEquals(201, $session['headers']['status-code']); + + $account = $this->client->call(Client::METHOD_GET, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $session['body']['secret'], + 'x-appwrite-impersonate-user-phone' => $targetPhone, + ]); + $this->assertEquals(200, $account['headers']['status-code']); + $this->assertEquals($idB, $account['body']['$id']); + $this->assertEquals($idA, $account['body']['impersonatorUserId']); + } + + /** + * Test that user without impersonator capability does not get swapped when sending impersonation header + */ + public function testImpersonationRequiresCapability(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'non-impersonator@appwrite.io', + 'password' => 'password', + 'name' => 'Non Impersonator', + ]); + $this->assertEquals(201, $userA['headers']['status-code']); + $idA = $userA['body']['$id']; + + $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'other-user@appwrite.io', + 'password' => 'password', + 'name' => 'Other User', + ]); + $this->assertEquals(201, $userB['headers']['status-code']); + $idB = $userB['body']['$id']; + + $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); + $this->assertEquals(201, $session['headers']['status-code']); + + $account = $this->client->call(Client::METHOD_GET, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $session['body']['secret'], + 'x-appwrite-impersonate-user-id' => $idB, + ]); + $this->assertEquals(200, $account['headers']['status-code']); + $this->assertEquals($idA, $account['body']['$id']); + $this->assertEquals('Non Impersonator', $account['body']['name']); + $this->assertArrayHasKey('impersonatorUserId', $account['body']); + $this->assertNull($account['body']['impersonatorUserId']); + } + + /** + * Test that a normal user does not gain users.read while sending impersonation headers + */ + public function testImpersonationUsersReadScopeRequiresCapability(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'non-impersonator-scope@appwrite.io', + 'password' => 'password', + 'name' => 'Non Impersonator Scope', + ]); + $this->assertEquals(201, $userA['headers']['status-code']); + $idA = $userA['body']['$id']; + + $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'non-impersonator-scope-target@appwrite.io', + 'password' => 'password', + 'name' => 'Non Impersonator Scope Target', + ]); + $this->assertEquals(201, $userB['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); + $this->assertEquals(201, $session['headers']['status-code']); + + $users = $this->client->call(Client::METHOD_GET, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $session['body']['secret'], + 'x-appwrite-impersonate-user-id' => $userB['body']['$id'], + ]); + $this->assertEquals(401, $users['headers']['status-code']); + $this->assertEquals('general_unauthorized_scope', $users['body']['type']); + } + + /** + * Test that email and phone impersonation headers are ignored for users without impersonator capability + */ + public function testImpersonationByEmailAndPhoneRequireCapability(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'non-impersonator-headers@appwrite.io', + 'password' => 'password', + 'name' => 'Non Impersonator Headers', + ]); + $this->assertEquals(201, $userA['headers']['status-code']); + $idA = $userA['body']['$id']; + + $targetEmail = 'non-impersonator-target-email@appwrite.io'; + $targetByEmail = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => $targetEmail, + 'password' => 'password', + 'name' => 'Target Email', + ]); + $this->assertEquals(201, $targetByEmail['headers']['status-code']); + + $targetPhone = '+1555010300'; + $targetByPhone = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'phone' => $targetPhone, + 'name' => 'Target Phone', + ]); + $this->assertEquals(201, $targetByPhone['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); + $this->assertEquals(201, $session['headers']['status-code']); + + $accountByEmail = $this->client->call(Client::METHOD_GET, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $session['body']['secret'], + 'x-appwrite-impersonate-user-email' => $targetEmail, + ]); + $this->assertEquals(200, $accountByEmail['headers']['status-code']); + $this->assertEquals($idA, $accountByEmail['body']['$id']); + $this->assertEquals('Non Impersonator Headers', $accountByEmail['body']['name']); + $this->assertArrayHasKey('impersonatorUserId', $accountByEmail['body']); + $this->assertNull($accountByEmail['body']['impersonatorUserId']); + + $accountByPhone = $this->client->call(Client::METHOD_GET, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $session['body']['secret'], + 'x-appwrite-impersonate-user-phone' => $targetPhone, + ]); + $this->assertEquals(200, $accountByPhone['headers']['status-code']); + $this->assertEquals($idA, $accountByPhone['body']['$id']); + $this->assertEquals('Non Impersonator Headers', $accountByPhone['body']['name']); + $this->assertArrayHasKey('impersonatorUserId', $accountByPhone['body']); + $this->assertNull($accountByPhone['body']['impersonatorUserId']); + } + + /** + * Test that impersonator users get users.read before selecting a target + */ + public function testImpersonatorUsersReadScopeWithoutActiveImpersonation(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $user = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'impersonator-browse-users@appwrite.io', + 'password' => 'password', + 'name' => 'Impersonator Browse Users', + ]); + $this->assertEquals(201, $user['headers']['status-code']); + + $patch = $this->client->call( + Client::METHOD_PATCH, + '/users/' . $user['body']['$id'] . '/impersonator', + $headers, + ['impersonator' => true] + ); + $this->assertEquals(200, $patch['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/users/' . $user['body']['$id'] . '/sessions', $headers); + $this->assertEquals(201, $session['headers']['status-code']); + + $users = $this->client->call(Client::METHOD_GET, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $session['body']['secret'], + ]); + $this->assertEquals(200, $users['headers']['status-code']); + $this->assertIsArray($users['body']['users']); + $this->assertGreaterThanOrEqual(1, count($users['body']['users'])); + } + + /** + * Test that when impersonating, users.read scope is granted and list users succeeds + */ + public function testImpersonationUsersReadScope(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'impersonator-list-test@appwrite.io', + 'password' => 'password', + 'name' => 'Impersonator List Test', + ]); + $this->assertEquals(201, $userA['headers']['status-code']); + $idA = $userA['body']['$id']; + + $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'impersonator-list-target@appwrite.io', + 'password' => 'password', + 'name' => 'List Target', + ]); + $this->assertEquals(201, $userB['headers']['status-code']); + $idB = $userB['body']['$id']; + + $this->client->call(Client::METHOD_PATCH, '/users/' . $idA . '/impersonator', $headers, ['impersonator' => true]); + $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); + $this->assertEquals(201, $session['headers']['status-code']); + + $listHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $session['body']['secret'], + 'x-appwrite-impersonate-user-id' => $idB, + ]; + + $users = $this->client->call(Client::METHOD_GET, '/users', $listHeaders); + $this->assertEquals(200, $users['headers']['status-code']); + $this->assertIsArray($users['body']['users']); + $this->assertGreaterThanOrEqual(2, count($users['body']['users'])); + } + + /** + * Test list users with query filter on impersonator attribute + */ + public function testListUsersFilterByImpersonator(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $userWithImpersonator = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'filter-impersonator-true@appwrite.io', + 'password' => 'password', + 'name' => 'Has Impersonator', + ]); + $this->assertEquals(201, $userWithImpersonator['headers']['status-code']); + $this->client->call(Client::METHOD_PATCH, '/users/' . $userWithImpersonator['body']['$id'] . '/impersonator', $headers, ['impersonator' => true]); + + $response = $this->client->call(Client::METHOD_GET, '/users', $headers, [ + 'queries' => [ + Query::equal('impersonator', [true])->toString(), + ], + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsArray($response['body']['users']); + $userIds = array_column($response['body']['users'], '$id'); + $this->assertContains($userWithImpersonator['body']['$id'], $userIds); + + $response = $this->client->call(Client::METHOD_GET, '/users', $headers, [ + 'queries' => [ + Query::equal('impersonator', [false])->toString(), + ], + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsArray($response['body']['users']); + } + + /** + * Test PATCH /users/:userId/impersonator for non-existent user returns 404 + */ + public function testUpdateUserImpersonatorNotFound(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $response = $this->client->call(Client::METHOD_PATCH, '/users/nonexistentuserid123/impersonator', $headers, [ + 'impersonator' => true, + ]); + $this->assertEquals(404, $response['headers']['status-code']); + } } diff --git a/tests/e2e/Services/Webhooks/WebhooksBase.php b/tests/e2e/Services/Webhooks/WebhooksBase.php index 84748f98a5..7ad701b564 100644 --- a/tests/e2e/Services/Webhooks/WebhooksBase.php +++ b/tests/e2e/Services/Webhooks/WebhooksBase.php @@ -3,1832 +3,1610 @@ namespace Tests\E2E\Services\Webhooks; use Appwrite\Tests\Async; -use Appwrite\Tests\Retry; -use CURLFile; use Tests\E2E\Client; +use Utopia\Database\Document; use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Query; use Utopia\Database\Validator\Datetime as DatetimeValidator; trait WebhooksBase { use Async; - protected function awaitDeploymentIsBuilt($functionId, $deploymentId): void - { - $this->assertEventually(function () use ($functionId, $deploymentId) { - $deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/deployments/' . $deploymentId, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ]); + // Tests for all auth scenarios - $this->assertEquals(200, $deployment['headers']['status-code']); - $this->assertEquals('ready', $deployment['body']['status'], \json_encode($deployment['body'])); - }, 120000, 500); + public function testCreateWebhook(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Test Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertNotEmpty($webhook['body']['$id']); + $this->assertEquals('Test Webhook', $webhook['body']['name']); + $this->assertEquals('https://appwrite.io', $webhook['body']['url']); + $this->assertContains('users.*.create', $webhook['body']['events']); + $this->assertCount(1, $webhook['body']['events']); + $this->assertEquals(true, $webhook['body']['enabled']); + $this->assertEquals(false, $webhook['body']['security']); + $this->assertEquals('', $webhook['body']['httpUser']); + $this->assertEquals('', $webhook['body']['httpPass']); + $this->assertNotEmpty($webhook['body']['signatureKey']); + $this->assertEquals(128, \strlen($webhook['body']['signatureKey'])); + $this->assertEquals(0, $webhook['body']['attempts']); + $this->assertEquals('', $webhook['body']['logs']); + + $dateValidator = new DatetimeValidator(); + $this->assertEquals(true, $dateValidator->isValid($webhook['body']['$createdAt'])); + $this->assertEquals(true, $dateValidator->isValid($webhook['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getWebhook($webhook['body']['$id']); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals($webhook['body']['$id'], $get['body']['$id']); + $this->assertEquals('Test Webhook', $get['body']['name']); + + // Verify via LIST + $list = $this->listWebhooks(null, true); + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['webhooks'])); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); } - /** - * Create a probe callback that filters webhooks by event pattern. - */ - private function webhookEventProbe(string $eventPattern): callable + public function testCreateWebhookWithSecurity(): void { - return function (array $request) use ($eventPattern) { - $this->assertStringContainsString( - $eventPattern, - $request['headers']['X-Appwrite-Webhook-Events'] ?? '' - ); - }; + $webhook = $this->createWebhook( + ID::unique(), + 'Webhook With Security', + ['users.*.create'], + null, + 'https://appwrite.io', + true, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertNotEmpty($webhook['body']['$id']); + $this->assertEquals(true, $webhook['body']['security']); + $this->assertIsBool($webhook['body']['security']); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); } - public static function getWebhookSignature(array $webhook, string $signatureKey): string + public function testCreateWebhookWithHttpAuth(): void { - $payload = json_encode($webhook['data']); - $url = $webhook['url']; - return base64_encode(hash_hmac('sha1', $url . $payload, $signatureKey, true)); + $webhook = $this->createWebhook( + ID::unique(), + 'Webhook With HTTP Auth', + ['users.*.create'], + null, + 'https://appwrite.io', + true, + 'username', + 'password' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertNotEmpty($webhook['body']['$id']); + $this->assertEquals('username', $webhook['body']['httpUser']); + $this->assertEquals('password', $webhook['body']['httpPass']); + $this->assertEquals(true, $webhook['body']['security']); + + // Verify via GET + $get = $this->getWebhook($webhook['body']['$id']); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals('username', $get['body']['httpUser']); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); } - /** - * Creates a database and collection with proper attributes for document operations. - * - * @return array Array containing 'databaseId' and 'actorsId' - */ - protected function setupCollectionWithAttributes(): array + public function testCreateWebhookEnabled(): void { - // Create database - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Actors DB', - ]); + // Create disabled webhook + $webhook = $this->createWebhook( + ID::unique(), + 'Disabled Webhook', + ['users.*.create'], + false, + 'https://appwrite.io', + null, + null, + null + ); - $databaseId = $database['body']['$id']; + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertEquals(false, $webhook['body']['enabled']); + $this->assertIsBool($webhook['body']['enabled']); - // Create collection - $actors = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Actors', - 'permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'documentSecurity' => true, - ]); + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); - $actorsId = $actors['body']['$id']; + // Create enabled webhook explicitly + $webhook = $this->createWebhook( + ID::unique(), + 'Enabled Webhook', + ['users.*.create'], + true, + 'https://appwrite.io', + null, + null, + null + ); - // Create attributes - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'firstName', - 'size' => 256, - 'required' => true, - ]); + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertEquals(true, $webhook['body']['enabled']); - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'lastName', - 'size' => 256, - 'required' => true, - ]); - - // Wait for attributes to be available - $this->assertEventually(function () use ($databaseId, $actorsId) { - $collection = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $actorsId, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ]); - $this->assertCount(2, $collection['body']['attributes']); - $this->assertEquals('available', $collection['body']['attributes'][0]['status']); - $this->assertEquals('available', $collection['body']['attributes'][1]['status']); - }, 15000, 500); - - return ['databaseId' => $databaseId, 'actorsId' => $actorsId]; + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); } - /** - * Creates a database and table with proper columns for row operations. - * - * @return array Array containing 'databaseId' and 'actorsId' - */ - protected function setupTableWithColumns(): array + public function testCreateWebhookWithoutAuthentication(): void { - // Create database - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + $response = $this->client->call(Client::METHOD_POST, '/webhooks', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Actors DB', + ], [ + 'webhookId' => ID::unique(), + 'name' => 'Test Webhook', + 'events' => ['users.*.create'], + 'url' => 'https://appwrite.io', ]); - $databaseId = $database['body']['$id']; - - // Create table - $actors = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'tableId' => ID::unique(), - 'name' => 'Actors', - 'permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'rowSecurity' => true, - ]); - - $actorsId = $actors['body']['$id']; - - // Create columns - $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'firstName', - 'size' => 256, - 'required' => true, - ]); - - $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'lastName', - 'size' => 256, - 'required' => true, - ]); - - // Wait for columns to be available - $this->assertEventually(function () use ($databaseId, $actorsId) { - $table = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $actorsId, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ]); - $this->assertCount(2, $table['body']['columns']); - $this->assertEquals('available', $table['body']['columns'][0]['status']); - $this->assertEquals('available', $table['body']['columns'][1]['status']); - }, 15000, 500); - - return ['databaseId' => $databaseId, 'actorsId' => $actorsId]; + $this->assertEquals(401, $response['headers']['status-code']); } - /** - * Creates an enabled storage bucket. - * - * @return array Array containing 'bucketId' - */ - protected function setupStorageBucket(): array + public function testCreateWebhookInvalidId(): void { - $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'bucketId' => ID::unique(), - 'name' => 'Test Bucket', - 'permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'fileSecurity' => true, - 'enabled' => true, - ]); + $webhook = $this->createWebhook( + '!invalid-id!', + 'Test Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); - return ['bucketId' => $bucket['body']['$id']]; + $this->assertEquals(400, $webhook['headers']['status-code']); } - /** - * Creates a team and returns its ID. - * - * @param string $name Team name - * @return array Array containing 'teamId' - */ - protected function setupTeam(string $name = 'Arsenal'): array + public function testCreateWebhookMissingName(): void { - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + $response = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'teamId' => ID::unique(), - 'name' => $name + 'webhookId' => ID::unique(), + 'events' => ['users.*.create'], + 'url' => 'https://appwrite.io', ]); - return ['teamId' => $team['body']['$id']]; + $this->assertEquals(400, $response['headers']['status-code']); } - /** - * Creates a team membership and returns membership details including secret. - * - * @param string $teamId The team ID - * @return array Array containing 'teamId', 'membershipId', 'userId', 'secret' - */ - protected function setupTeamMembership(string $teamId): array + public function testCreateWebhookMissingUrl(): void { - $email = uniqid() . 'friend@localhost.test'; - - // Create user first to ensure team event is triggered after user event - $this->client->call(Client::METHOD_POST, '/account', array_merge([ + $response = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'userId' => ID::unique(), - 'email' => $email, - 'password' => 'password', - 'name' => 'Friend User', + 'webhookId' => ID::unique(), + 'name' => 'Test Webhook', + 'events' => ['users.*.create'], ]); - // Create membership - $team = $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', array_merge([ + $this->assertEquals(400, $response['headers']['status-code']); + } + + public function testCreateWebhookMissingEvents(): void + { + $response = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'email' => $email, - 'roles' => ['admin', 'editor'], - 'url' => 'http://localhost:5000/join-us#title' + 'webhookId' => ID::unique(), + 'name' => 'Test Webhook', + 'url' => 'https://appwrite.io', ]); - $membershipId = $team['body']['$id']; - $userId = $team['body']['userId']; - - // Get the secret from email (use probe to match correct email by recipient address) - $lastEmail = $this->getLastEmail(1, function ($msg) use ($email) { - $this->assertEquals($email, $msg['to'][0]['address'] ?? ''); - }); - $tokens = $this->extractQueryParamsFromEmailLink($lastEmail['html'] ?? ''); - $secret = $tokens['secret'] ?? ''; - - return [ - 'teamId' => $teamId, - 'membershipId' => $membershipId, - 'userId' => $userId, - 'secret' => $secret, - ]; + $this->assertEquals(400, $response['headers']['status-code']); } - /** - * Creates a document in a collection. - * - * @param string $databaseId Database ID - * @param string $collectionId Collection ID - * @return array Array containing document details including 'documentId' - */ - protected function setupDocument(string $databaseId, string $collectionId): array + public function testCreateWebhookDuplicateId(): void { - $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + $webhookId = ID::unique(); + + $webhook = $this->createWebhook( + $webhookId, + 'Test Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + + // Attempt to create with same ID + $duplicate = $this->createWebhook( + $webhookId, + 'Duplicate Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(409, $duplicate['headers']['status-code']); + $this->assertEquals('webhook_already_exists', $duplicate['body']['type']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testCreateWebhookAudit(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Audit Webhook', + ['users.*.create', 'users.*.update.email'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertNotEmpty($webhook['body']['$id']); + $this->assertContains('users.*.create', $webhook['body']['events']); + $this->assertContains('users.*.update.email', $webhook['body']['events']); + $this->assertCount(2, $webhook['body']['events']); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + } + + public function testUpdateWebhook(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Original Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // Update the webhook + $updated = $this->updateWebhook( + $webhookId, + 'Updated Webhook', + ['users.*.delete', 'users.*.sessions.*.delete'], + null, + 'https://appwrite.io/new', + null, + null, + null + ); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($webhookId, $updated['body']['$id']); + $this->assertEquals('Updated Webhook', $updated['body']['name']); + $this->assertEquals('https://appwrite.io/new', $updated['body']['url']); + $this->assertContains('users.*.delete', $updated['body']['events']); + $this->assertContains('users.*.sessions.*.delete', $updated['body']['events']); + $this->assertCount(2, $updated['body']['events']); + + // Verify update persisted via GET + $get = $this->getWebhook($webhookId); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals('Updated Webhook', $get['body']['name']); + $this->assertEquals('https://appwrite.io/new', $get['body']['url']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testUpdateWebhookWithSecurity(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Security Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + false, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertEquals(false, $webhook['body']['security']); + $webhookId = $webhook['body']['$id']; + + // Update to enable security + $updated = $this->updateWebhook( + $webhookId, + 'Security Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + true, + null, + null + ); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals(true, $updated['body']['security']); + $this->assertIsBool($updated['body']['security']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testUpdateWebhookWithHttpAuth(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'HTTP Auth Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + true, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertEquals('', $webhook['body']['httpUser']); + $this->assertEquals('', $webhook['body']['httpPass']); + $webhookId = $webhook['body']['$id']; + + // Update with HTTP auth credentials + $updated = $this->updateWebhook( + $webhookId, + 'HTTP Auth Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + true, + 'newuser', + 'newpass' + ); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals('newuser', $updated['body']['httpUser']); + $this->assertEquals('newpass', $updated['body']['httpPass']); + + // Verify via GET + $get = $this->getWebhook($webhookId); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals('newuser', $get['body']['httpUser']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testUpdateWebhookEnabled(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Enabled Webhook', + ['users.*.create'], + true, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertEquals(true, $webhook['body']['enabled']); + $webhookId = $webhook['body']['$id']; + + // Disable the webhook + $updated = $this->updateWebhook( + $webhookId, + 'Enabled Webhook', + ['users.*.create'], + false, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals(false, $updated['body']['enabled']); + $this->assertIsBool($updated['body']['enabled']); + + // Re-enable the webhook (should reset attempts to 0) + $updated = $this->updateWebhook( + $webhookId, + 'Enabled Webhook', + ['users.*.create'], + true, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals(true, $updated['body']['enabled']); + $this->assertEquals(0, $updated['body']['attempts']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testUpdateWebhookWithoutAuthentication(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Auth Test Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // Attempt update without authentication + $response = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'name' => 'Updated Webhook', + 'events' => ['users.*.create'], + 'url' => 'https://appwrite.io', + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testUpdateWebhookInvalidId(): void + { + $updated = $this->updateWebhook( + 'non-existent-id', + 'Updated Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(404, $updated['headers']['status-code']); + $this->assertEquals('webhook_not_found', $updated['body']['type']); + } + + public function testUpdateWebhookMissingName(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Missing Name Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + $response = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'firstName' => 'Chris', - 'lastName' => 'Evans', - ], - 'permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], + 'events' => ['users.*.create'], + 'url' => 'https://appwrite.io', ]); - return ['documentId' => $document['body']['$id']]; + $this->assertEquals(400, $response['headers']['status-code']); + + // Cleanup + $this->deleteWebhook($webhookId); } - /** - * Creates a row in a table. - * - * @param string $databaseId Database ID - * @param string $tableId Table ID - * @return array Array containing row details including 'rowId' - */ - protected function setupRow(string $databaseId, string $tableId): array + public function testUpdateWebhookMissingUrl(): void { - $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([ + $webhook = $this->createWebhook( + ID::unique(), + 'Missing URL Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + $response = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'rowId' => ID::unique(), - 'data' => [ - 'firstName' => 'Chris', - 'lastName' => 'Evans', - ], - 'permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], + 'name' => 'Missing URL Webhook', + 'events' => ['users.*.create'], ]); - return ['rowId' => $row['body']['$id']]; + $this->assertEquals(400, $response['headers']['status-code']); + + // Cleanup + $this->deleteWebhook($webhookId); } - /** - * Creates a file in a bucket. - * - * @param string $bucketId Bucket ID - * @return array Array containing file details including 'fileId' - */ - protected function setupBucketFile(string $bucketId): array + public function testUpdateWebhookMissingEvents(): void { - $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()), - ], - 'folderId' => ID::custom('xyz'), - ]); + $webhook = $this->createWebhook( + ID::unique(), + 'Missing Events Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); - return ['fileId' => $file['body']['$id']]; - } + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; - // Collection APIs - public function testCreateCollection(): void - { - /** - * Create database - */ - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Actors DB', - ]); - - $databaseId = $database['body']['$id']; - - /** - * Test for SUCCESS - */ - $actors = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Actors', - 'permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'documentSecurity' => true, - ]); - - $actorsId = $actors['body']['$id']; - - $this->assertEquals($actors['headers']['status-code'], 201); - $this->assertNotEmpty($actors['body']['$id']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$actorsId}.create")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), true); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals($webhook['data']['name'], 'Actors'); - $this->assertIsArray($webhook['data']['$permissions']); - $this->assertCount(4, $webhook['data']['$permissions']); - } - - public function testCreateAttributes(): void - { - /** - * Create database - */ - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Actors DB', - ]); - - $databaseId = $database['body']['$id']; - - /** - * Create collection - */ - $actors = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Actors', - 'permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'documentSecurity' => true, - ]); - - $actorsId = $actors['body']['$id']; - - $firstName = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'firstName', - 'size' => 256, - 'required' => true, - ]); - - $lastName = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'lastName', - 'size' => 256, - 'required' => true, - ]); - - $extra = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'extra', - 'size' => 64, - 'required' => false, - ]); - - $attributeId = $extra['body']['key']; - - $this->assertEquals($firstName['headers']['status-code'], 202); - $this->assertEquals($firstName['body']['key'], 'firstName'); - $this->assertEquals($lastName['headers']['status-code'], 202); - $this->assertEquals($lastName['body']['key'], 'lastName'); - $this->assertEquals($extra['headers']['status-code'], 202); - $this->assertEquals($extra['body']['key'], 'extra'); - - // wait for database worker to kick in - $this->assertEventually(function () use ($databaseId, $actorsId) { - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$actorsId}.attributes.*.create")); - $this->assertNotEmpty($webhook); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.attributes.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.attributes.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.attributes.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.attributes.*.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertNotEmpty($webhook['data']['key']); - $this->assertEquals($webhook['data']['key'], 'extra'); - }, 15000, 500); - - $removed = $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/' . $actorsId . '/attributes/' . $extra['body']['key'], array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ])); - - $this->assertEquals(204, $removed['headers']['status-code']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$actorsId}.attributes.*.update")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - // $this->assertEquals($webhook['method'], 'DELETE'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.attributes.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.attributes.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.attributes.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.attributes.*.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertNotEmpty($webhook['data']['key']); - $this->assertEquals($webhook['data']['key'], 'extra'); - } - - public function testCreateDocument(): void - { - // Set up collection with attributes - $data = $this->setupCollectionWithAttributes(); - $actorsId = $data['actorsId']; - $databaseId = $data['databaseId']; - - /** - * Test for SUCCESS - */ - $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/documents', array_merge([ + $response = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'firstName' => 'Chris', - 'lastName' => 'Evans', - ], - 'permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], + 'name' => 'Missing Events Webhook', + 'url' => 'https://appwrite.io', ]); - $documentId = $document['body']['$id']; + $this->assertEquals(400, $response['headers']['status-code']); - $this->assertEquals($document['headers']['status-code'], 201); - $this->assertNotEmpty($document['body']['$id']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.create")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.documents.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.documents.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.*.documents.{$documentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.*.documents.{$documentId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.*.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals($webhook['data']['firstName'], 'Chris'); - $this->assertEquals($webhook['data']['lastName'], 'Evans'); - $this->assertIsArray($webhook['data']['$permissions']); - $this->assertCount(3, $webhook['data']['$permissions']); + // Cleanup + $this->deleteWebhook($webhookId); } - public function testUpdateDocument(): void + public function testUpdateWebhookDuplicateId(): void { - // Set up collection with attributes and create a document - $data = $this->setupCollectionWithAttributes(); - $actorsId = $data['actorsId']; - $databaseId = $data['databaseId']; - $documentData = $this->setupDocument($databaseId, $actorsId); - $documentId = $documentData['documentId']; + // Update endpoint doesn't change the ID, so this tests updating a non-existent webhook + $updated = $this->updateWebhook( + 'non-existent-id', + 'Duplicate Test Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); - /** - * Test for SUCCESS - */ - $document = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $actorsId . '/documents/' . $documentId, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'data' => [ - 'firstName' => 'Chris1', - 'lastName' => 'Evans2', - ], - 'permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - ]); - - $documentId = $document['body']['$id']; - - $this->assertEquals($document['headers']['status-code'], 200); - $this->assertNotEmpty($document['body']['$id']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.documents.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.documents.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.*.documents.{$documentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.*.documents.{$documentId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.*.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals($webhook['data']['firstName'], 'Chris1'); - $this->assertEquals($webhook['data']['lastName'], 'Evans2'); - $this->assertIsArray($webhook['data']['$permissions']); - $this->assertCount(3, $webhook['data']['$permissions']); + $this->assertEquals(404, $updated['headers']['status-code']); + $this->assertEquals('webhook_not_found', $updated['body']['type']); } - #[Retry(count: 1)] - public function testDeleteDocument(): void + public function testUpdateWebhookAudit(): void { - // Set up collection with attributes - $data = $this->setupCollectionWithAttributes(); - $actorsId = $data['actorsId']; - $databaseId = $data['databaseId']; + $webhook = $this->createWebhook( + ID::unique(), + 'Audit Update Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); - /** - * Test for SUCCESS - */ - $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'firstName' => 'Bradly', - 'lastName' => 'Cooper', + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; - ], - 'permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - ]); + // Update with multiple events + $updated = $this->updateWebhook( + $webhookId, + 'Audit Update Webhook Updated', + ['users.*.delete', 'users.*.sessions.*.delete', 'buckets.*.files.*.create'], + null, + 'https://appwrite.io/updated', + true, + 'user', + 'pass' + ); - $documentId = $document['body']['$id']; + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($webhookId, $updated['body']['$id']); + $this->assertEquals('Audit Update Webhook Updated', $updated['body']['name']); + $this->assertContains('users.*.delete', $updated['body']['events']); + $this->assertContains('users.*.sessions.*.delete', $updated['body']['events']); + $this->assertContains('buckets.*.files.*.create', $updated['body']['events']); + $this->assertCount(3, $updated['body']['events']); + $this->assertEquals('https://appwrite.io/updated', $updated['body']['url']); + $this->assertEquals(true, $updated['body']['security']); + $this->assertEquals('user', $updated['body']['httpUser']); + $this->assertEquals('pass', $updated['body']['httpPass']); - $this->assertEquals($document['headers']['status-code'], 201); - $this->assertNotEmpty($document['body']['$id']); - - $document = $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/' . $actorsId . '/documents/' . $document['body']['$id'], array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals($document['headers']['status-code'], 204); - - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.delete")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.documents.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.documents.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.*.documents.{$documentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.*.documents.{$documentId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.*.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals($webhook['data']['firstName'], 'Bradly'); - $this->assertEquals($webhook['data']['lastName'], 'Cooper'); - $this->assertIsArray($webhook['data']['$permissions']); - $this->assertCount(3, $webhook['data']['$permissions']); + // Cleanup + $this->deleteWebhook($webhookId); } - // Table APIs - public function testCreateTable(): void + public function testUpdateWebhookSignature(): void { - /** - * Create database - */ - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Actors DB', - ]); + $webhook = $this->createWebhook( + ID::unique(), + 'Signature Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); - $databaseId = $database['body']['$id']; + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $originalSignatureKey = $webhook['body']['signatureKey']; - /** - * Test for SUCCESS - */ - $actors = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'tableId' => ID::unique(), - 'name' => 'Actors', - 'permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'rowSecurity' => true, - ]); + $this->assertNotEmpty($originalSignatureKey); + $this->assertEquals(128, \strlen($originalSignatureKey)); - $actorsId = $actors['body']['$id']; + // Update signature + $updated = $this->updateWebhookSignature($webhookId); - $this->assertEquals($actors['headers']['status-code'], 201); - $this->assertNotEmpty($actors['body']['$id']); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($webhookId, $updated['body']['$id']); + $this->assertNotEmpty($updated['body']['signatureKey']); + $this->assertEquals(128, \strlen($updated['body']['signatureKey'])); + $this->assertNotEquals($originalSignatureKey, $updated['body']['signatureKey']); - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$actorsId}.create")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); + // Verify new signature persisted via GET + $get = $this->getWebhook($webhookId); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertNotEquals($originalSignatureKey, $get['body']['signatureKey']); - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), true); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals($webhook['data']['name'], 'Actors'); - $this->assertIsArray($webhook['data']['$permissions']); - $this->assertCount(4, $webhook['data']['$permissions']); + // Test signature update on non-existent webhook + $notFound = $this->updateWebhookSignature('non-existent-id'); + $this->assertEquals(404, $notFound['headers']['status-code']); + $this->assertEquals('webhook_not_found', $notFound['body']['type']); + + // Cleanup + $this->deleteWebhook($webhookId); } - public function testCreateColumns(): void - { - /** - * Create database - */ - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'Actors DB', - ]); - - $databaseId = $database['body']['$id']; - - /** - * Create table - */ - $actors = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'tableId' => ID::unique(), - 'name' => 'Actors', - 'permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'rowSecurity' => true, - ]); - - $actorsId = $actors['body']['$id']; - - $firstName = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'firstName', - 'size' => 256, - 'required' => true, - ]); - - $lastName = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'lastName', - 'size' => 256, - 'required' => true, - ]); - - $extra = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'extra', - 'size' => 64, - 'required' => false, - ]); - - $this->assertEquals($firstName['headers']['status-code'], 202); - $this->assertEquals($firstName['body']['key'], 'firstName'); - $this->assertEquals($lastName['headers']['status-code'], 202); - $this->assertEquals($lastName['body']['key'], 'lastName'); - $this->assertEquals($extra['headers']['status-code'], 202); - $this->assertEquals($extra['body']['key'], 'extra'); - - // wait for database worker to kick in - $this->assertEventually(function () use ($databaseId, $actorsId) { - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$actorsId}.columns.*.create")); - $this->assertNotEmpty($webhook); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.columns.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.columns.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.columns.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.columns.*.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertNotEmpty($webhook['data']['key']); - $this->assertEquals($webhook['data']['key'], 'extra'); - }, 15000, 500); - - $removed = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/' . $extra['body']['key'], array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ])); - - $this->assertEquals(204, $removed['headers']['status-code']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$actorsId}.columns.*.update")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - // $this->assertEquals($webhook['method'], 'DELETE'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.columns.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.columns.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.columns.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.columns.*.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertNotEmpty($webhook['data']['key']); - $this->assertEquals($webhook['data']['key'], 'extra'); - } - - public function testCreateRow(): void - { - // Set up table with columns - $data = $this->setupTableWithColumns(); - $actorsId = $data['actorsId']; - $databaseId = $data['databaseId']; - - /** - * Test for SUCCESS - */ - $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/rows', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'rowId' => ID::unique(), - 'data' => [ - 'firstName' => 'Chris', - 'lastName' => 'Evans', - ], - 'permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - ]); - - $documentId = $row['body']['$id']; - - $this->assertEquals($row['headers']['status-code'], 201); - $this->assertNotEmpty($row['body']['$id']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$actorsId}.rows.{$documentId}.create")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.rows.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.rows.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.*.rows.{$documentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.*.rows.{$documentId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.*.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.{$documentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.{$documentId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals($webhook['data']['firstName'], 'Chris'); - $this->assertEquals($webhook['data']['lastName'], 'Evans'); - $this->assertIsArray($webhook['data']['$permissions']); - $this->assertCount(3, $webhook['data']['$permissions']); - } - - public function testUpdateRow(): void - { - // Set up table with columns and create a row - $data = $this->setupTableWithColumns(); - $actorsId = $data['actorsId']; - $databaseId = $data['databaseId']; - $rowData = $this->setupRow($databaseId, $actorsId); - $rowId = $rowData['rowId']; - - /** - * Test for SUCCESS - */ - $document = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/rows/' . $rowId, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'data' => [ - 'firstName' => 'Chris1', - 'lastName' => 'Evans2', - ], - 'permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - ]); - - $rowId = $document['body']['$id']; - - $this->assertEquals($document['headers']['status-code'], 200); - $this->assertNotEmpty($document['body']['$id']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$actorsId}.rows.{$rowId}.update")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.rows.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.rows.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.*.rows.{$rowId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.*.rows.{$rowId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.*.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.{$rowId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.{$rowId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals($webhook['data']['firstName'], 'Chris1'); - $this->assertEquals($webhook['data']['lastName'], 'Evans2'); - $this->assertIsArray($webhook['data']['$permissions']); - $this->assertCount(3, $webhook['data']['$permissions']); - } - - #[Retry(count: 1)] - public function testDeleteRow(): void - { - // Set up table with columns - $data = $this->setupTableWithColumns(); - $actorsId = $data['actorsId']; - $databaseId = $data['databaseId']; - - /** - * Test for SUCCESS - */ - $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/rows', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'rowId' => ID::unique(), - 'data' => [ - 'firstName' => 'Bradly', - 'lastName' => 'Cooper', - - ], - 'permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - ]); - - $rowId = $row['body']['$id']; - - $this->assertEquals($row['headers']['status-code'], 201); - $this->assertNotEmpty($row['body']['$id']); - - $row = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/rows/' . $row['body']['$id'], array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals($row['headers']['status-code'], 204); - - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$actorsId}.rows.{$rowId}.delete")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.rows.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.rows.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.*.rows.{$rowId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.*.rows.{$rowId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.*.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.{$rowId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.rows.{$rowId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals($webhook['data']['firstName'], 'Bradly'); - $this->assertEquals($webhook['data']['lastName'], 'Cooper'); - $this->assertIsArray($webhook['data']['$permissions']); - $this->assertCount(3, $webhook['data']['$permissions']); - } - - public function testCreateStorageBucket(): void - { - /** - * Test for SUCCESS - */ - $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'bucketId' => ID::unique(), - 'name' => 'Test Bucket', - 'permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - ]); - - $bucketId = $bucket['body']['$id']; - - $this->assertEquals($bucket['headers']['status-code'], 201); - $this->assertNotEmpty($bucket['body']['$id']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("buckets.{$bucketId}.create")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('buckets.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('buckets.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), true); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals('Test Bucket', $webhook['data']['name']); - $this->assertEquals(true, $webhook['data']['enabled']); - $this->assertIsArray($webhook['data']['$permissions']); - } - - public function testUpdateStorageBucket(): void - { - // Set up a storage bucket - $data = $this->setupStorageBucket(); - $bucketId = $data['bucketId']; - - /** - * Test for SUCCESS - */ - $bucket = $this->client->call(Client::METHOD_PUT, '/storage/buckets/' . $bucketId, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'name' => 'Test Bucket Updated', - 'fileSecurity' => true, - 'enabled' => false, - ]); - - $this->assertEquals($bucket['headers']['status-code'], 200); - $this->assertNotEmpty($bucket['body']['$id']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("buckets.{$bucketId}.update")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('buckets.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('buckets.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), true); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals('Test Bucket Updated', $webhook['data']['name']); - $this->assertEquals(false, $webhook['data']['enabled']); - $this->assertIsArray($webhook['data']['$permissions']); - } - - public function testCreateBucketFile(): void - { - // Set up an enabled storage bucket - $data = $this->setupStorageBucket(); - $bucketId = $data['bucketId']; - - /** - * Test for SUCCESS - */ - $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()), - ], - 'folderId' => ID::custom('xyz'), - ]); - - $fileId = $file['body']['$id']; - - $this->assertEquals($file['headers']['status-code'], 201); - $this->assertNotEmpty($file['body']['$id']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("buckets.{$bucketId}.files.{$fileId}.create")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('buckets.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('buckets.*.files.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('buckets.*.files.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.*.files.{$fileId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.*.files.{$fileId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}.files.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}.files.*.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}.files.{$fileId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}.files.{$fileId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertIsArray($webhook['data']['$permissions']); - $this->assertEquals($webhook['data']['name'], 'logo.png'); - $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['$createdAt'])); - $this->assertNotEmpty($webhook['data']['signature']); - $this->assertEquals($webhook['data']['mimeType'], 'image/png'); - $this->assertEquals($webhook['data']['sizeOriginal'], 47218); - } - - public function testUpdateBucketFile(): void - { - // Set up an enabled storage bucket and create a file - $data = $this->setupStorageBucket(); - $bucketId = $data['bucketId']; - $fileData = $this->setupBucketFile($bucketId); - $fileId = $fileData['fileId']; - - /** - * Test for SUCCESS - */ - $file = $this->client->call(Client::METHOD_PUT, '/storage/buckets/' . $bucketId . '/files/' . $fileId, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - ]); - - $this->assertEquals($file['headers']['status-code'], 200); - $this->assertNotEmpty($file['body']['$id']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("buckets.{$bucketId}.files.{$fileId}.update")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('buckets.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('buckets.*.files.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('buckets.*.files.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.*.files.{$fileId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.*.files.{$fileId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}.files.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}.files.*.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}.files.{$fileId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}.files.{$fileId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertIsArray($webhook['data']['$permissions']); - $this->assertEquals($webhook['data']['name'], 'logo.png'); - $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['$createdAt'])); - $this->assertNotEmpty($webhook['data']['signature']); - $this->assertEquals($webhook['data']['mimeType'], 'image/png'); - $this->assertEquals($webhook['data']['sizeOriginal'], 47218); - } - - public function testDeleteBucketFile(): void - { - // Set up an enabled storage bucket and create a file - $data = $this->setupStorageBucket(); - $bucketId = $data['bucketId']; - $fileData = $this->setupBucketFile($bucketId); - $fileId = $fileData['fileId']; - - /** - * Test for SUCCESS - */ - $file = $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId . '/files/' . $fileId, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(204, $file['headers']['status-code']); - $this->assertEmpty($file['body']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("buckets.{$bucketId}.files.{$fileId}.delete")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('buckets.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('buckets.*.files.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('buckets.*.files.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.*.files.{$fileId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.*.files.{$fileId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}.files.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}.files.*.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}.files.{$fileId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}.files.{$fileId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertIsArray($webhook['data']['$permissions']); - $this->assertEquals($webhook['data']['name'], 'logo.png'); - $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['$createdAt'])); - $this->assertNotEmpty($webhook['data']['signature']); - $this->assertEquals($webhook['data']['mimeType'], 'image/png'); - $this->assertEquals($webhook['data']['sizeOriginal'], 47218); - } - - public function testDeleteStorageBucket(): void - { - // Set up an enabled storage bucket - $data = $this->setupStorageBucket(); - $bucketId = $data['bucketId']; - - // Update bucket name before deleting to make test self-sufficient - // (In parallel execution, testUpdateStorageBucket may not have run) - $this->client->call(Client::METHOD_PUT, '/storage/buckets/' . $bucketId, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'name' => 'Test Bucket Updated', - 'fileSecurity' => true, - ]); - - /** - * Test for SUCCESS - */ - $bucket = $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ])); - - $this->assertEquals($bucket['headers']['status-code'], 204); - $this->assertEmpty($bucket['body']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("buckets.{$bucketId}.delete")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('buckets.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('buckets.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("buckets.{$bucketId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), true); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals('Test Bucket Updated', $webhook['data']['name']); - $this->assertEquals(true, $webhook['data']['enabled']); - $this->assertIsArray($webhook['data']['$permissions']); - } - - public function testCreateTeam(): void - { - /** - * Test for SUCCESS - */ - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'teamId' => ID::unique(), - 'name' => 'Arsenal' - ]); - - $teamId = $team['body']['$id']; - - $this->assertEquals(201, $team['headers']['status-code']); - $this->assertNotEmpty($team['body']['$id']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("teams.{$teamId}.create")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('teams.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('teams.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$teamId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$teamId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals('Arsenal', $webhook['data']['name']); - $this->assertGreaterThan(-1, $webhook['data']['total']); - $this->assertIsInt($webhook['data']['total']); - $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['$createdAt'])); - } - - public function testUpdateTeam(): void - { - // Set up a team - $data = $this->setupTeam(); - $teamId = $data['teamId']; - /** - * Test for SUCCESS - */ - $team = $this->client->call(Client::METHOD_PUT, '/teams/' . $teamId, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'name' => 'Demo New' - ]); - - $this->assertEquals(200, $team['headers']['status-code']); - $this->assertNotEmpty($team['body']['$id']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("teams.{$teamId}.update")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('teams.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('teams.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$teamId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$teamId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals('Demo New', $webhook['data']['name']); - $this->assertGreaterThan(-1, $webhook['data']['total']); - $this->assertIsInt($webhook['data']['total']); - $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['$createdAt'])); - } - - public function testUpdateTeamPrefs(): void - { - // Set up a team - $data = $this->setupTeam(); - $id = $data['teamId']; - - $team = $this->client->call(Client::METHOD_PUT, '/teams/' . $id . '/prefs', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'prefs' => [ - 'prefKey1' => 'prefValue1', - 'prefKey2' => 'prefValue2', - ] - ]); - - $this->assertEquals($team['headers']['status-code'], 200); - $this->assertIsArray($team['body']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("teams.{$id}.update.prefs")); - $signatureKey = $this->getProject()['signatureKey']; - $payload = json_encode($webhook['data']); - $url = $webhook['url']; - $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $signatureKey, true)); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('teams.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('teams.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('teams.*.update.prefs', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$id}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$id}.update.prefs", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertEquals($webhook['data'], [ - 'prefKey1' => 'prefValue1', - 'prefKey2' => 'prefValue2', - ]); - } - - public function testDeleteTeam(): void - { - /** - * Test for SUCCESS - */ - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'teamId' => ID::unique(), - 'name' => 'Chelsea' - ]); - - $teamId = $team['body']['$id']; - - $this->assertEquals(201, $team['headers']['status-code']); - $this->assertNotEmpty($team['body']['$id']); - - $team = $this->client->call(Client::METHOD_DELETE, '/teams/' . $team['body']['$id'], array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $webhook = $this->getLastRequest($this->webhookEventProbe("teams.{$teamId}.delete")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('teams.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('teams.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$teamId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$teamId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals('Chelsea', $webhook['data']['name']); - $this->assertGreaterThan(-1, $webhook['data']['total']); - $this->assertIsInt($webhook['data']['total']); - $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['$createdAt'])); - } - - public function testCreateTeamMembership(): void - { - // Set up a team - $data = $this->setupTeam(); - $teamId = $data['teamId']; - $email = uniqid() . 'friend@localhost.test'; - - // Create user to ensure team event is triggered after user event - $user = $this->client->call(Client::METHOD_POST, '/account', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'userId' => ID::unique(), - 'email' => $email, - 'password' => 'password', - 'name' => 'Friend User', - ]); - - /** - * Test for SUCCESS - */ - $team = $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'email' => $email, - 'roles' => ['admin', 'editor'], - 'url' => 'http://localhost:5000/join-us#title' - ]); - - $this->assertEquals(201, $team['headers']['status-code']); - $this->assertNotEmpty($team['body']['$id']); - - $lastEmail = $this->getLastEmail(); - - // `$isAppUser` — no email expected; - $tokens = $this->extractQueryParamsFromEmailLink($lastEmail['html'] ?? ''); - - $secret = $tokens['secret'] ?? ''; - $membershipId = $team['body']['$id']; - - $webhook = $this->getLastRequest($this->webhookEventProbe("teams.{$teamId}.memberships.{$membershipId}.create")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('teams.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('teams.*.memberships.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('teams.*.memberships.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.*.memberships.{$membershipId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.*.memberships.{$membershipId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$teamId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$teamId}.memberships.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$teamId}.memberships.*.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$teamId}.memberships.{$membershipId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$teamId}.memberships.{$membershipId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertNotEmpty($webhook['data']['userId']); - $this->assertNotEmpty($webhook['data']['teamId']); - $this->assertCount(2, $webhook['data']['roles']); - $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['invited'])); - $this->assertEquals(('server' === $this->getSide()), $webhook['data']['confirm']); - } - - public function testDeleteTeamMembership(): void - { - // Set up a team - $data = $this->setupTeam(); - $teamId = $data['teamId']; - $email = uniqid() . 'friend@localhost.test'; - - /** - * Test for SUCCESS - */ - $team = $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'email' => $email, - 'name' => 'Friend User', - 'roles' => ['admin', 'editor'], - 'url' => 'http://localhost:5000/join-us#title' - ]); - - $membershipId = $team['body']['$id'] ?? ''; - - $this->assertEquals(201, $team['headers']['status-code']); - $this->assertNotEmpty($team['body']['$id']); - - $team = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId . '/memberships/' . $team['body']['$id'], array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(204, $team['headers']['status-code']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("teams.{$teamId}.memberships.{$membershipId}.delete")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals($webhook['method'], 'POST'); - $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); - $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); - $this->assertStringContainsString('teams.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('teams.*.memberships.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('teams.*.memberships.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.*.memberships.{$membershipId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.*.memberships.{$membershipId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$teamId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$teamId}.memberships.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$teamId}.memberships.*.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$teamId}.memberships.{$membershipId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("teams.{$teamId}.memberships.{$membershipId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertNotEmpty($webhook['data']['userId']); - $this->assertNotEmpty($webhook['data']['teamId']); - $this->assertCount(2, $webhook['data']['roles']); - $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['invited'])); - $this->assertEquals(('server' === $this->getSide()), $webhook['data']['confirm']); - } + // URL validation tests public function testCreateWebhookWithPrivateDomain(): void { - /** - * Test for FAILURE - */ - $projectId = $this->getProject()['$id']; - $webhook = $this->client->call(Client::METHOD_POST, '/projects/' . $projectId . '/webhooks', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - 'x-appwrite-project' => 'console', - ], [ - 'name' => 'Webhook Test', - 'enabled' => true, - 'events' => [ - 'databases.*', - 'functions.*', - 'buckets.*', - 'teams.*', - 'users.*' - ], - 'url' => 'http://localhost/webhook', // private domains not allowed - 'security' => false, - ]); + $webhook = $this->createWebhook( + ID::unique(), + 'Private Domain Webhook', + ['users.*.create'], + null, + 'http://localhost/webhook', + null, + null, + null + ); $this->assertEquals(400, $webhook['headers']['status-code']); } public function testUpdateWebhookWithPrivateDomain(): void { - /** - * Test for FAILURE - */ - $projectId = $this->getProject()['$id']; - $webhookId = $this->getProject()['webhookId']; - $webhook = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId . '/webhooks/' . $webhookId, [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - 'x-appwrite-project' => 'console', - ], [ - 'name' => 'Webhook Test', - 'enabled' => true, - 'events' => [ - 'databases.*', - 'functions.*', - 'buckets.*', - 'teams.*', - 'users.*' - ], - 'url' => 'http://localhost/webhook', // private domains not allowed - 'security' => false, - ]); + $webhook = $this->createWebhook( + ID::unique(), + 'Private Domain Update Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // Attempt to update URL to private domain + $updated = $this->updateWebhook( + $webhookId, + 'Private Domain Update Webhook', + ['users.*.create'], + null, + 'http://localhost/webhook', + null, + null, + null + ); + + $this->assertEquals(400, $updated['headers']['status-code']); + + // Verify original URL unchanged + $get = $this->getWebhook($webhookId); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals('https://appwrite.io', $get['body']['url']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testCreateWebhookInvalidUrlScheme(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Invalid Scheme Webhook', + ['users.*.create'], + null, + 'invalid://appwrite.io', + null, + null, + null + ); $this->assertEquals(400, $webhook['headers']['status-code']); } - public function testWebhookAutoDisable(): void + public function testUpdateWebhookInvalidUrlScheme(): void { - $projectId = $this->getProject()['$id']; - $webhookId = $this->getProject()['webhookId']; + $webhook = $this->createWebhook( + ID::unique(), + 'Scheme Update Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); - // Create a database for this test - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // Attempt to update URL to invalid scheme + $updated = $this->updateWebhook( + $webhookId, + 'Scheme Update Webhook', + ['users.*.create'], + null, + 'invalid://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(400, $updated['headers']['status-code']); + + // Verify original URL unchanged + $get = $this->getWebhook($webhookId); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals('https://appwrite.io', $get['body']['url']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + // Event validation tests + + public function testCreateWebhookInvalidEvents(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Invalid Events Webhook', + ['account.unknown'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(400, $webhook['headers']['status-code']); + } + + public function testUpdateWebhookInvalidEvents(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Invalid Events Update Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // Attempt to update with invalid event + $updated = $this->updateWebhook( + $webhookId, + 'Invalid Events Update Webhook', + ['account.unknown'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(400, $updated['headers']['status-code']); + + // Verify original events unchanged + $get = $this->getWebhook($webhookId); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertContains('users.*.create', $get['body']['events']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + // Custom ID test + + public function testCreateWebhookCustomId(): void + { + $customId = 'my-custom-webhook-id'; + + $webhook = $this->createWebhook( + $customId, + 'Custom ID Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertEquals($customId, $webhook['body']['$id']); + + // Verify via GET + $get = $this->getWebhook($customId); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals($customId, $get['body']['$id']); + + // Cleanup + $this->deleteWebhook($customId); + } + + // Get webhook tests + + public function testGetWebhook(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Get Test Webhook', + ['users.*.create', 'users.*.update.email'], + null, + 'https://appwrite.io', + true, + 'myuser', + 'mypass' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + $get = $this->getWebhook($webhookId); + + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals($webhookId, $get['body']['$id']); + $this->assertEquals('Get Test Webhook', $get['body']['name']); + $this->assertEquals('https://appwrite.io', $get['body']['url']); + $this->assertContains('users.*.create', $get['body']['events']); + $this->assertContains('users.*.update.email', $get['body']['events']); + $this->assertCount(2, $get['body']['events']); + $this->assertEquals(true, $get['body']['enabled']); + $this->assertEquals(true, $get['body']['security']); + $this->assertEquals('myuser', $get['body']['httpUser']); + $this->assertEquals('mypass', $get['body']['httpPass']); + $this->assertNotEmpty($get['body']['signatureKey']); + $this->assertEquals(128, \strlen($get['body']['signatureKey'])); + $this->assertEquals(0, $get['body']['attempts']); + $this->assertEquals('', $get['body']['logs']); + + $dateValidator = new DatetimeValidator(); + $this->assertEquals(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertEquals(true, $dateValidator->isValid($get['body']['$updatedAt'])); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testGetWebhookNotFound(): void + { + $get = $this->getWebhook('non-existent-id'); + + $this->assertEquals(404, $get['headers']['status-code']); + $this->assertEquals('webhook_not_found', $get['body']['type']); + } + + public function testGetWebhookWithoutAuthentication(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Auth Get Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // Attempt GET without authentication + $response = $this->client->call(Client::METHOD_GET, '/webhooks/' . $webhookId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'databaseId' => ID::unique(), - 'name' => 'AutoDisable DB', ]); - $databaseId = $database['body']['$id']; + $this->assertEquals(401, $response['headers']['status-code']); - $webhook = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId . '/webhooks/' . $webhookId, [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - 'x-appwrite-project' => 'console', - ], [ - 'name' => 'Webhook Test', - 'enabled' => true, - 'events' => [ - 'databases.*', - 'functions.*', - 'buckets.*', - 'teams.*', - 'users.*' - ], - 'url' => 'http://appwrite-non-existing-domain.com', // set non-existent URL - 'security' => false, - ]); + // Cleanup + $this->deleteWebhook($webhookId); + } - $this->assertEquals(200, $webhook['headers']['status-code']); - $this->assertNotEmpty($webhook['body']); + // List webhooks tests - // trigger webhook for failure event 10 times - for ($i = 0; $i < 10; $i++) { - $newCollection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'newCollection' . $i, - 'permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'documentSecurity' => true, - ]); + public function testListWebhooks(): void + { + // Create multiple webhooks + $webhook1 = $this->createWebhook( + ID::unique(), + 'List Webhook Alpha', + ['users.*.create'], + true, + 'https://appwrite.io/alpha', + false, + null, + null + ); + $this->assertEquals(201, $webhook1['headers']['status-code']); - $this->assertEquals($newCollection['headers']['status-code'], 201); - $this->assertNotEmpty($newCollection['body']['$id']); + $webhook2 = $this->createWebhook( + ID::unique(), + 'List Webhook Beta', + ['users.*.delete'], + false, + 'https://appwrite.io/beta', + true, + 'user', + 'pass' + ); + $this->assertEquals(201, $webhook2['headers']['status-code']); + + $webhook3 = $this->createWebhook( + ID::unique(), + 'List Webhook Gamma', + ['users.*.create', 'users.*.delete'], + true, + 'https://appwrite.io/gamma', + false, + null, + null + ); + $this->assertEquals(201, $webhook3['headers']['status-code']); + + // List all + $list = $this->listWebhooks(null, true); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(3, $list['body']['total']); + $this->assertGreaterThanOrEqual(3, \count($list['body']['webhooks'])); + $this->assertIsArray($list['body']['webhooks']); + + // Verify structure of returned webhooks + foreach ($list['body']['webhooks'] as $webhook) { + $this->assertArrayHasKey('$id', $webhook); + $this->assertArrayHasKey('$createdAt', $webhook); + $this->assertArrayHasKey('$updatedAt', $webhook); + $this->assertArrayHasKey('name', $webhook); + $this->assertArrayHasKey('url', $webhook); + $this->assertArrayHasKey('events', $webhook); + $this->assertArrayHasKey('security', $webhook); + $this->assertArrayHasKey('enabled', $webhook); + $this->assertArrayHasKey('signatureKey', $webhook); + $this->assertArrayHasKey('attempts', $webhook); + $this->assertArrayHasKey('logs', $webhook); } - $this->assertEventually(function () use ($projectId, $webhookId) { - $webhook = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/webhooks/' . $webhookId, array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - 'x-appwrite-project' => 'console', - ])); + // Cleanup + $this->deleteWebhook($webhook1['body']['$id']); + $this->deleteWebhook($webhook2['body']['$id']); + $this->deleteWebhook($webhook3['body']['$id']); + } - // assert that the webhook is now disabled after 10 consecutive failures - $this->assertEquals($webhook['body']['enabled'], false); - $this->assertEquals($webhook['body']['attempts'], 10); - }, 15000, 500); + public function testListWebhooksWithLimit(): void + { + $webhook1 = $this->createWebhook( + ID::unique(), + 'Limit Webhook 1', + ['users.*.create'], + null, + 'https://appwrite.io/one', + null, + null, + null + ); + $this->assertEquals(201, $webhook1['headers']['status-code']); + + $webhook2 = $this->createWebhook( + ID::unique(), + 'Limit Webhook 2', + ['users.*.create'], + null, + 'https://appwrite.io/two', + null, + null, + null + ); + $this->assertEquals(201, $webhook2['headers']['status-code']); + + // List with limit of 1 + $list = $this->listWebhooks([ + Query::limit(1)->toString(), + ], true); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertCount(1, $list['body']['webhooks']); + $this->assertGreaterThanOrEqual(2, $list['body']['total']); + + // Cleanup + $this->deleteWebhook($webhook1['body']['$id']); + $this->deleteWebhook($webhook2['body']['$id']); + } + + public function testListWebhooksWithOffset(): void + { + $webhook1 = $this->createWebhook( + ID::unique(), + 'Offset Webhook 1', + ['users.*.create'], + null, + 'https://appwrite.io/one', + null, + null, + null + ); + $this->assertEquals(201, $webhook1['headers']['status-code']); + + $webhook2 = $this->createWebhook( + ID::unique(), + 'Offset Webhook 2', + ['users.*.create'], + null, + 'https://appwrite.io/two', + null, + null, + null + ); + $this->assertEquals(201, $webhook2['headers']['status-code']); + + // List all to get total + $listAll = $this->listWebhooks(null, true); + $this->assertEquals(200, $listAll['headers']['status-code']); + $totalAll = \count($listAll['body']['webhooks']); + + // List with offset + $listOffset = $this->listWebhooks([ + Query::offset(1)->toString(), + ], true); + + $this->assertEquals(200, $listOffset['headers']['status-code']); + $this->assertCount($totalAll - 1, $listOffset['body']['webhooks']); + + // Cleanup + $this->deleteWebhook($webhook1['body']['$id']); + $this->deleteWebhook($webhook2['body']['$id']); + } + + public function testListWebhooksFilterByName(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'UniqueFilterName-XYZ', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + $this->assertEquals(201, $webhook['headers']['status-code']); + + $list = $this->listWebhooks([ + Query::equal('name', ['UniqueFilterName-XYZ'])->toString(), + ], true); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertEquals(1, $list['body']['total']); + $this->assertCount(1, $list['body']['webhooks']); + $this->assertEquals('UniqueFilterName-XYZ', $list['body']['webhooks'][0]['name']); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + } + + public function testListWebhooksFilterByEnabled(): void + { + $webhookEnabled = $this->createWebhook( + ID::unique(), + 'Enabled Filter Webhook', + ['users.*.create'], + true, + 'https://appwrite.io/enabled', + null, + null, + null + ); + $this->assertEquals(201, $webhookEnabled['headers']['status-code']); + + $webhookDisabled = $this->createWebhook( + ID::unique(), + 'Disabled Filter Webhook', + ['users.*.create'], + false, + 'https://appwrite.io/disabled', + null, + null, + null + ); + $this->assertEquals(201, $webhookDisabled['headers']['status-code']); + + // Filter by enabled=true + $listEnabled = $this->listWebhooks([ + Query::equal('enabled', [true])->toString(), + ], true); + + $this->assertEquals(200, $listEnabled['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $listEnabled['body']['total']); + foreach ($listEnabled['body']['webhooks'] as $webhook) { + $this->assertEquals(true, $webhook['enabled']); + } + + // Filter by enabled=false + $listDisabled = $this->listWebhooks([ + Query::equal('enabled', [false])->toString(), + ], true); + + $this->assertEquals(200, $listDisabled['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $listDisabled['body']['total']); + foreach ($listDisabled['body']['webhooks'] as $webhook) { + $this->assertEquals(false, $webhook['enabled']); + } + + // Cleanup + $this->deleteWebhook($webhookEnabled['body']['$id']); + $this->deleteWebhook($webhookDisabled['body']['$id']); + } + + public function testListWebhooksFilterByUrl(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'URL Filter Webhook', + ['users.*.create'], + null, + 'https://appwrite.io/unique-url-filter', + null, + null, + null + ); + $this->assertEquals(201, $webhook['headers']['status-code']); + + $list = $this->listWebhooks([ + Query::equal('url', ['https://appwrite.io/unique-url-filter'])->toString(), + ], true); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertEquals(1, $list['body']['total']); + $this->assertCount(1, $list['body']['webhooks']); + $this->assertEquals('https://appwrite.io/unique-url-filter', $list['body']['webhooks'][0]['url']); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + } + + public function testListWebhooksFilterBySecurity(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Security Filter Webhook', + ['users.*.create'], + null, + 'https://appwrite.io/sec', + true, + null, + null + ); + $this->assertEquals(201, $webhook['headers']['status-code']); + + $list = $this->listWebhooks([ + Query::equal('security', [true])->toString(), + ], true); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + foreach ($list['body']['webhooks'] as $w) { + $this->assertEquals(true, $w['security']); + } + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + } + + public function testListWebhooksWithoutTotal(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'No Total Webhook', + ['users.*.create'], + null, + 'https://appwrite.io/nototal', + null, + null, + null + ); + $this->assertEquals(201, $webhook['headers']['status-code']); + + // List with total=false + $list = $this->listWebhooks(null, false); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertEquals(0, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['webhooks'])); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + } + + public function testListWebhooksCursorPagination(): void + { + $webhook1 = $this->createWebhook( + ID::unique(), + 'Cursor Webhook 1', + ['users.*.create'], + null, + 'https://appwrite.io/cursor1', + null, + null, + null + ); + $this->assertEquals(201, $webhook1['headers']['status-code']); + + $webhook2 = $this->createWebhook( + ID::unique(), + 'Cursor Webhook 2', + ['users.*.create'], + null, + 'https://appwrite.io/cursor2', + null, + null, + null + ); + $this->assertEquals(201, $webhook2['headers']['status-code']); + + // Get first page with limit 1 + $page1 = $this->listWebhooks([ + Query::limit(1)->toString(), + ], true); + + $this->assertEquals(200, $page1['headers']['status-code']); + $this->assertCount(1, $page1['body']['webhooks']); + $cursorId = $page1['body']['webhooks'][0]['$id']; + + // Get next page using cursor + $page2 = $this->listWebhooks([ + Query::limit(1)->toString(), + Query::cursorAfter(new Document(['$id' => $cursorId]))->toString(), + ], true); + + $this->assertEquals(200, $page2['headers']['status-code']); + $this->assertCount(1, $page2['body']['webhooks']); + $this->assertNotEquals($cursorId, $page2['body']['webhooks'][0]['$id']); + + // Cleanup + $this->deleteWebhook($webhook1['body']['$id']); + $this->deleteWebhook($webhook2['body']['$id']); + } + + public function testListWebhooksWithoutAuthentication(): void + { + $response = $this->client->call(Client::METHOD_GET, '/webhooks', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + } + + public function testListWebhooksInvalidCursor(): void + { + $list = $this->listWebhooks([ + Query::cursorAfter(new Document(['$id' => 'non-existent-id']))->toString(), + ], true); + + $this->assertEquals(400, $list['headers']['status-code']); + } + + // Delete webhook tests + + public function testDeleteWebhook(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Delete Test Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // Verify it exists + $get = $this->getWebhook($webhookId); + $this->assertEquals(200, $get['headers']['status-code']); + + // Delete + $delete = $this->deleteWebhook($webhookId); + $this->assertEquals(204, $delete['headers']['status-code']); + $this->assertEmpty($delete['body']); + + // Verify it no longer exists + $get = $this->getWebhook($webhookId); + $this->assertEquals(404, $get['headers']['status-code']); + $this->assertEquals('webhook_not_found', $get['body']['type']); + } + + public function testDeleteWebhookNotFound(): void + { + $delete = $this->deleteWebhook('non-existent-id'); + + $this->assertEquals(404, $delete['headers']['status-code']); + $this->assertEquals('webhook_not_found', $delete['body']['type']); + } + + public function testDeleteWebhookWithoutAuthentication(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Delete Auth Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // Attempt DELETE without authentication + $response = $this->client->call(Client::METHOD_DELETE, '/webhooks/' . $webhookId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + + // Verify it still exists + $get = $this->getWebhook($webhookId); + $this->assertEquals(200, $get['headers']['status-code']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testDeleteWebhookRemovedFromList(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Delete List Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // Get list count before delete + $listBefore = $this->listWebhooks(null, true); + $this->assertEquals(200, $listBefore['headers']['status-code']); + $countBefore = $listBefore['body']['total']; + + // Delete + $delete = $this->deleteWebhook($webhookId); + $this->assertEquals(204, $delete['headers']['status-code']); + + // Get list count after delete + $listAfter = $this->listWebhooks(null, true); + $this->assertEquals(200, $listAfter['headers']['status-code']); + $this->assertEquals($countBefore - 1, $listAfter['body']['total']); + + // Verify the deleted webhook is not in the list + $ids = \array_column($listAfter['body']['webhooks'], '$id'); + $this->assertNotContains($webhookId, $ids); + } + + public function testDeleteWebhookDoubleDelete(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Double Delete Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // First delete succeeds + $delete = $this->deleteWebhook($webhookId); + $this->assertEquals(204, $delete['headers']['status-code']); + + // Second delete returns 404 + $delete = $this->deleteWebhook($webhookId); + $this->assertEquals(404, $delete['headers']['status-code']); + $this->assertEquals('webhook_not_found', $delete['body']['type']); + } + + // Helpers + + /** + * @param array|null $queries + */ + protected function listWebhooks(?array $queries, ?bool $total): mixed + { + $webhooks = $this->client->call(Client::METHOD_GET, '/webhooks', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'total' => $total + ]); + + return $webhooks; + } + + protected function getWebhook(string $webhookId): mixed + { + $webhook = $this->client->call(Client::METHOD_GET, '/webhooks/' . $webhookId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + return $webhook; + } + + protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $security, ?string $httpUser, ?string $httpPass): mixed + { + $params = [ + 'webhookId' => $webhookId, + 'name' => $name, + 'events' => $events, + 'url' => $url, + ]; + + if ($enabled !== null) { + $params['enabled'] = $enabled; + } + if ($security !== null) { + $params['security'] = $security; + } + if ($httpUser !== null) { + $params['httpUser'] = $httpUser; + } + if ($httpPass !== null) { + $params['httpPass'] = $httpPass; + } + + $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $webhook; + } + + protected function updateWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $security, ?string $httpUser, ?string $httpPass): mixed + { + $params = [ + 'name' => $name, + 'events' => $events, + 'url' => $url, + ]; + + if ($enabled !== null) { + $params['enabled'] = $enabled; + } + if ($security !== null) { + $params['security'] = $security; + } + if ($httpUser !== null) { + $params['httpUser'] = $httpUser; + } + if ($httpPass !== null) { + $params['httpPass'] = $httpPass; + } + + $webhook = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $webhook; + } + + protected function updateWebhookSignature(string $webhookId): mixed + { + $webhook = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/signature', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + return $webhook; + } + + protected function deleteWebhook(string $webhookId): mixed + { + $webhook = $this->client->call(Client::METHOD_DELETE, '/webhooks/' . $webhookId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + return $webhook; } } diff --git a/tests/e2e/Services/Webhooks/WebhooksConsoleClientTest.php b/tests/e2e/Services/Webhooks/WebhooksConsoleClientTest.php new file mode 100644 index 0000000000..b954ef8600 --- /dev/null +++ b/tests/e2e/Services/Webhooks/WebhooksConsoleClientTest.php @@ -0,0 +1,14 @@ +client->call(Client::METHOD_POST, '/users', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'userId' => ID::unique(), - 'email' => $email, - 'password' => $password, - 'name' => $name, - ]); - - return [ - 'userId' => $user['body']['$id'], - 'name' => $user['body']['name'], - 'email' => $user['body']['email'], - ]; - } - - /** - * Creates a function and returns function details. - * - * @return array Array containing 'functionId' - */ - protected function setupFunction(): array - { - $function = $this->client->call(Client::METHOD_POST, '/functions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'functionId' => ID::unique(), - 'name' => 'Test', - 'execute' => [Role::any()->toString()], - 'runtime' => 'node-22', - 'entrypoint' => 'index.js', - 'timeout' => 10, - ]); - - return ['functionId' => $function['body']['$id']]; - } - - /** - * Creates a function deployment and waits for it to be built. - * - * @param string $functionId Function ID - * @return array Array containing 'functionId', 'deploymentId' - */ - protected function setupDeployment(string $functionId): array - { - $stderr = ''; - $stdout = ''; - $folder = 'timeout'; - $code = realpath(__DIR__ . '/../../../resources/functions') . "/{$folder}/code.tar.gz"; - Console::execute('cd ' . realpath(__DIR__ . "/../../../resources/functions") . "/{$folder} && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr); - - // Create variable first - $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/variables', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'key' => 'key1', - 'value' => 'value1', - ]); - - $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ - 'content-type' => 'multipart/form-data', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'entrypoint' => 'index.js', - 'code' => new CURLFile($code, 'application/x-gzip', \basename($code)), - 'activate' => true - ]); - - $deploymentId = $deployment['body']['$id']; - - // Wait for deployment to be built - $this->awaitDeploymentIsBuilt($functionId, $deploymentId); - - return [ - 'functionId' => $functionId, - 'deploymentId' => $deploymentId, - ]; - } - - // Collection APIs - public function testUpdateCollection(): void - { - // Set up collection with attributes - $data = $this->setupCollectionWithAttributes(); - $id = $data['actorsId']; - $databaseId = $data['databaseId']; - - /** - * Test for SUCCESS - */ - $actors = $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId . '/collections/' . $id, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'name' => 'Actors1', - 'documentSecurity' => true, - ]); - - $this->assertEquals(200, $actors['headers']['status-code']); - $this->assertNotEmpty($actors['body']['$id']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$id}.update")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals('POST', $webhook['method']); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$id}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertTrue(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? '')); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals('Actors1', $webhook['data']['name']); - $this->assertIsArray($webhook['data']['$permissions']); - $this->assertCount(4, $webhook['data']['$permissions']); - } - - public function testCreateDeleteIndexes(): void - { - // Set up collection with attributes - $data = $this->setupCollectionWithAttributes(); - $actorsId = $data['actorsId']; - $databaseId = $data['databaseId']; - - $index = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $actorsId . '/indexes', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'fullname', - 'type' => 'key', - 'attributes' => ['lastName', 'firstName'], - 'orders' => ['ASC', 'ASC'], - ]); - - $indexKey = $index['body']['key']; - $this->assertEquals(202, $index['headers']['status-code']); - $this->assertEquals('fullname', $index['body']['key']); - - // wait for database worker to create index - $this->assertEventually(function () use ($databaseId, $actorsId) { - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$actorsId}.indexes.*.create")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals('POST', $webhook['method']); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.indexes.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.indexes.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.indexes.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.indexes.*.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertTrue(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? '')); - }, 10000, 500); - - // Remove index - $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/' . $actorsId . '/indexes/' . $index['body']['key'], array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ])); - - // // wait for database worker to remove index - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$actorsId}.indexes.*.update")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - // $this->assertEquals($webhook['method'], 'DELETE'); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.indexes.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.indexes.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.indexes.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$actorsId}.indexes.*.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertTrue(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? '')); - } - - public function testDeleteCollection(): void - { - /** - * Create database - */ - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], $this->getHeaders()), [ - 'databaseId' => ID::unique(), - 'name' => 'Actors DB', - ]); - - $databaseId = $database['body']['$id']; - - /** - * Test for SUCCESS - */ - $actors = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'collectionId' => ID::unique(), - 'name' => 'Demo', - 'permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'documentSecurity' => true, - ]); - - $id = $actors['body']['$id']; - - $this->assertEquals(201, $actors['headers']['status-code']); - $this->assertNotEmpty($actors['body']['$id']); - - $actors = $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/' . $actors['body']['$id'], array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), []); - - $this->assertEquals(204, $actors['headers']['status-code']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.collections.{$id}.delete")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals('POST', $webhook['method']); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.collections.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.collections.{$id}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertTrue(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? '')); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals('Demo', $webhook['data']['name']); - $this->assertIsArray($webhook['data']['$permissions']); - $this->assertCount(4, $webhook['data']['$permissions']); - } - - // Table APIs - public function testUpdateTable(): void - { - // Set up table with columns - $data = $this->setupTableWithColumns(); - $id = $data['actorsId']; - $databaseId = $data['databaseId']; - - /** - * Test for SUCCESS - */ - $actors = $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $id, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'name' => 'Actors1', - 'rowSecurity' => true, - ]); - - $this->assertEquals(200, $actors['headers']['status-code']); - $this->assertNotEmpty($actors['body']['$id']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$id}.update")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals('POST', $webhook['method']); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$id}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEmpty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals('Actors1', $webhook['data']['name']); - $this->assertIsArray($webhook['data']['$permissions']); - $this->assertCount(4, $webhook['data']['$permissions']); - } - - public function testCreateDeleteColumnIndexes(): void - { - // Set up table with columns - $data = $this->setupTableWithColumns(); - $actorsId = $data['actorsId']; - $databaseId = $data['databaseId']; - - $index = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/indexes', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'fullname', - 'type' => 'key', - 'columns' => ['lastName', 'firstName'], - 'orders' => ['ASC', 'ASC'], - ]); - - $this->assertEquals(202, $index['headers']['status-code']); - $this->assertEquals('fullname', $index['body']['key']); - - // wait for database worker to create index - $this->assertEventually(function () use ($databaseId, $actorsId) { - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$actorsId}.indexes.*.create")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals('POST', $webhook['method']); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.indexes.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.indexes.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.indexes.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.indexes.*.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertTrue(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? '')); - }, 10000, 500); - - // Remove index - $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/indexes/' . $index['body']['key'], array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ])); - - // // wait for database worker to remove index - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$actorsId}.indexes.*.update")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - // $this->assertEquals($webhook['method'], 'DELETE'); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.indexes.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.indexes.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.indexes.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$actorsId}.indexes.*.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertTrue(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? '')); - } - - public function testDeleteTable(): void - { - /** - * Create database - */ - $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], $this->getHeaders()), [ - 'databaseId' => ID::unique(), - 'name' => 'Actors DB', - ]); - - $databaseId = $database['body']['$id']; - - /** - * Test for SUCCESS - */ - $actors = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'tableId' => ID::unique(), - 'name' => 'Demo', - 'permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'rowSecurity' => true, - ]); - - $id = $actors['body']['$id']; - - $this->assertEquals(201, $actors['headers']['status-code']); - $this->assertNotEmpty($actors['body']['$id']); - - $actors = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $actors['body']['$id'], array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ])); - - $this->assertEquals(204, $actors['headers']['status-code']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("databases.{$databaseId}.tables.{$id}.delete")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals('POST', $webhook['method']); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('databases.' . $databaseId . '.tables.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("databases.{$databaseId}.tables.{$id}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEmpty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals('Demo', $webhook['data']['name']); - $this->assertIsArray($webhook['data']['$permissions']); - $this->assertCount(4, $webhook['data']['$permissions']); - } - - public function testCreateUser(): void - { - $email = uniqid() . 'user@localhost.test'; - $password = 'password'; - $name = 'User Name'; - - /** - * Test for SUCCESS - */ - $user = $this->client->call(Client::METHOD_POST, '/users', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'userId' => ID::unique(), - 'email' => $email, - 'password' => $password, - 'name' => $name, - ]); - - $this->assertEquals(201, $user['headers']['status-code']); - $this->assertNotEmpty($user['body']['$id']); - - $id = $user['body']['$id']; - - $webhook = $this->getLastRequest($this->webhookEventProbe("users.{$id}.create")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals('POST', $webhook['method']); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - $this->assertStringContainsString('users.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('users.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("users.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("users.{$id}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals($webhook['data']['name'], $name); - $this->assertTrue((new DatetimeValidator())->isValid($webhook['data']['registration'])); - $this->assertTrue($webhook['data']['status']); - $this->assertEquals($webhook['data']['email'], $email); - $this->assertFalse($webhook['data']['emailVerification']); - $this->assertEquals([], $webhook['data']['prefs']); - } - - public function testUpdateUserPrefs(): void - { - // Set up a user - $data = $this->setupUser(); - $id = $data['userId']; - - /** - * Test for SUCCESS - */ - $user = $this->client->call(Client::METHOD_PATCH, '/users/' . $id . '/prefs', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'prefs' => ['a' => 'b'] - ]); - - $this->assertEquals(200, $user['headers']['status-code']); - $this->assertEquals('b', $user['body']['a']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("users.{$id}.update.prefs")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals('POST', $webhook['method']); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - $this->assertStringContainsString('users.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('users.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('users.*.update.prefs', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("users.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("users.{$id}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("users.{$id}.update.prefs", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertEquals('b', $webhook['data']['a']); - } - - public function testUpdateUserStatus(): void - { - // Set up a user - $data = $this->setupUser(); - $id = $data['userId']; - - /** - * Test for SUCCESS - */ - $user = $this->client->call(Client::METHOD_PATCH, '/users/' . $data['userId'] . '/status', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'status' => false, - ]); - - $this->assertEquals(200, $user['headers']['status-code']); - $this->assertNotEmpty($user['body']['$id']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("users.{$id}.update.status")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals('POST', $webhook['method']); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - $this->assertStringContainsString('users.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('users.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('users.*.update.status', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("users.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("users.{$id}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("users.{$id}.update.status", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals($webhook['data']['name'], $data['name']); - $this->assertTrue((new DatetimeValidator())->isValid($webhook['data']['registration'])); - $this->assertFalse($webhook['data']['status']); - $this->assertEquals($webhook['data']['email'], $data['email']); - $this->assertFalse($webhook['data']['emailVerification']); - } - - public function testDeleteUser(): void - { - // Set up a user - $data = $this->setupUser(); - $id = $data['userId']; - - /** - * Test for SUCCESS - */ - $user = $this->client->call(Client::METHOD_DELETE, '/users/' . $data['userId'], array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(204, $user['headers']['status-code']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("users.{$id}.delete")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals('POST', $webhook['method']); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - $this->assertStringContainsString('users.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString('users.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("users.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertStringContainsString("users.{$id}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); - $this->assertNotEmpty($webhook['data']['$id']); - $this->assertEquals($webhook['data']['name'], $data['name']); - $this->assertTrue((new DatetimeValidator())->isValid($webhook['data']['registration'])); - // User is created with status=true by default, so webhook shows that status at deletion - $this->assertTrue($webhook['data']['status']); - $this->assertEquals($webhook['data']['email'], $data['email']); - $this->assertFalse($webhook['data']['emailVerification']); - } - - public function testCreateFunction(): void - { - /** - * Test for SUCCESS - */ - $function = $this->client->call(Client::METHOD_POST, '/functions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'functionId' => ID::unique(), - 'name' => 'Test', - 'execute' => [Role::any()->toString()], - 'runtime' => 'node-22', - 'entrypoint' => 'index.js', - 'timeout' => 10, - ]); - - $id = $function['body']['$id'] ?? ''; - - $this->assertEquals(201, $function['headers']['status-code']); - $this->assertNotEmpty($function['body']['$id']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("functions.{$id}.create")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals('POST', $webhook['method']); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - } - - public function testUpdateFunction(): void - { - // Set up a function - $data = $this->setupFunction(); - $id = $data['functionId']; - - /** - * Test for SUCCESS - */ - $function = $this->client->call(Client::METHOD_PUT, '/functions/' . $id, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'name' => 'Test', - 'runtime' => 'node-22', - 'entrypoint' => 'index.js', - 'execute' => [Role::any()->toString()], - 'vars' => [ - 'key1' => 'value1', - ] - ]); - - $this->assertEquals(200, $function['headers']['status-code']); - $this->assertEquals($function['body']['$id'], $id); - - // Create variable - $variable = $this->client->call(Client::METHOD_POST, '/functions/' . $id . '/variables', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'key' => 'key1', - 'value' => 'value1', - ]); - - $this->assertEquals(201, $variable['headers']['status-code']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("functions.{$id}.update")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals('POST', $webhook['method']); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - // $this->assertStringContainsString('functions.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString('functions.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); TODO @christyjacob4 : enable test once we allow functions.* events - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - } - - public function testCreateDeployment(): void - { - // Set up a function - $data = $this->setupFunction(); - $functionId = $data['functionId']; - - /** - * Test for SUCCESS - */ - $stderr = ''; - $stdout = ''; - $folder = 'timeout'; - $code = realpath(__DIR__ . '/../../../resources/functions') . "/{$folder}/code.tar.gz"; - Console::execute('cd ' . realpath(__DIR__ . "/../../../resources/functions") . "/{$folder} && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr); - - $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ - 'content-type' => 'multipart/form-data', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'entrypoint' => 'index.js', - 'code' => new CURLFile($code, 'application/x-gzip', \basename($code)), - 'activate' => true - ]); - - $deploymentId = $deployment['body']['$id'] ?? ''; - - $this->assertEquals(202, $deployment['headers']['status-code']); - $this->assertNotEmpty($deployment['body']['$id']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("functions.{$functionId}.deployments.{$deploymentId}.create")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals('POST', $webhook['method']); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - // $this->assertStringContainsString('functions.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString('functions.*.deployments.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.*.deployments.{$deploymentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.deployments.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.deployments.{$deploymentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); TODO @christyjacob4 : enable test once we allow functions.* events - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - - $this->awaitDeploymentIsBuilt($functionId, $deploymentId); - } - - public function testUpdateDeployment(): void - { - // Set up a function with deployment - $data = $this->setupFunction(); - $deploymentData = $this->setupDeployment($data['functionId']); - $id = $deploymentData['functionId']; - $deploymentId = $deploymentData['deploymentId']; - - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_PATCH, '/functions/' . $id . '/deployments/' . $deploymentId, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['$id']); - - // Wait for deployment to be built. - $this->assertEventually(function () use ($deploymentId, $id) { - $webhook = $this->getLastRequest($this->webhookEventProbe("functions.{$id}.deployments.{$deploymentId}.update")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals('POST', $webhook['method']); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - // $this->assertStringContainsString('functions.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString('functions.*.deployments.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString('functions.*.deployments.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.*.deployments.{$deploymentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.*.deployments.{$deploymentId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.deployments.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.deployments.*.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.deployments.{$deploymentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.deployments.{$deploymentId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); TODO @christyjacob4 : enable test once we allow functions.* events - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - }, 10000, 500); - - } - - public function testExecutions(): void - { - // Set up a function with deployment - $data = $this->setupFunction(); - $deploymentData = $this->setupDeployment($data['functionId']); - $id = $deploymentData['functionId']; - - /** - * Test for SUCCESS - */ - $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $id . '/executions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'async' => true - ]); - - $executionId = $execution['body']['$id'] ?? ''; - - $this->assertEquals(202, $execution['headers']['status-code']); - $this->assertNotEmpty($execution['body']['$id']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("functions.{$id}.executions.{$executionId}.create")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - $this->assertEquals('POST', $webhook['method']); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - // $this->assertStringContainsString('functions.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString('functions.*.executions.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString('functions.*.executions.*.create', $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.*.executions.{$executionId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.*.executions.{$executionId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.executions.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.executions.*.create", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.executions.{$executionId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.executions.{$executionId}.create", $webhook['headers']['X-Appwrite-Webhook-Events']); TODO @christyjacob4 : enable test once we allow functions.* events - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - - // wait for timeout function to complete - $this->assertEventually(function () use ($executionId, $id) { - $webhook = $this->getLastRequest($this->webhookEventProbe("functions.{$id}.executions.{$executionId}.update")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals('POST', $webhook['method']); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - // $this->assertStringContainsString('functions.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString('functions.*.executions.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString('functions.*.executions.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.*.executions.{$executionId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.*.executions.{$executionId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.executions.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.executions.*.update", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.executions.{$executionId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.executions.{$executionId}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); TODO @christyjacob4 : enable test once we allow functions.* events - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - }, 30000, 500); - } - - public function testDeleteDeployment(): void - { - // Set up a function with deployment - $data = $this->setupFunction(); - $deploymentData = $this->setupDeployment($data['functionId']); - $id = $deploymentData['functionId']; - $deploymentId = $deploymentData['deploymentId']; - /** - * Test for SUCCESS - */ - $deployment = $this->client->call(Client::METHOD_DELETE, '/functions/' . $id . '/deployments/' . $deploymentId, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(204, $deployment['headers']['status-code']); - $this->assertEmpty($deployment['body']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("functions.{$id}.deployments.{$deploymentId}.delete")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals('POST', $webhook['method']); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - // $this->assertStringContainsString('functions.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString('functions.*.deployments.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString('functions.*.deployments.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.*.deployments.{$deploymentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.*.deployments.{$deploymentId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.deployments.*", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.deployments.*.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.deployments.{$deploymentId}", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.deployments.{$deploymentId}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); TODO @christyjacob4 : enable test once we allow functions.* events - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - } - - public function testDeleteFunction(): void - { - // Set up a function - $data = $this->setupFunction(); - $id = $data['functionId']; - - /** - * Test for SUCCESS - */ - $function = $this->client->call(Client::METHOD_DELETE, '/functions/' . $id, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(204, $function['headers']['status-code']); - $this->assertEmpty($function['body']); - - $webhook = $this->getLastRequest($this->webhookEventProbe("functions.{$id}.delete")); - $signatureExpected = self::getWebhookSignature($webhook, $this->getProject()['signatureKey']); - - $this->assertEquals('POST', $webhook['method']); - $this->assertEquals('application/json', $webhook['headers']['Content-Type']); - $this->assertEquals('Appwrite-Server vdev. Please report abuse at security@appwrite.io', $webhook['headers']['User-Agent']); - // $this->assertStringContainsString('functions.*', $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString('functions.*.delete', $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); - // $this->assertStringContainsString("functions.{$id}.delete", $webhook['headers']['X-Appwrite-Webhook-Events']); TODO @christyjacob4 : enable test once we allow functions.* events - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); - $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); - } } diff --git a/tests/resources/functions/.gitignore b/tests/resources/functions/.gitignore new file mode 100644 index 0000000000..40b878db5b --- /dev/null +++ b/tests/resources/functions/.gitignore @@ -0,0 +1 @@ +node_modules/ \ No newline at end of file diff --git a/tests/resources/sites/.gitignore b/tests/resources/sites/.gitignore new file mode 100644 index 0000000000..40b878db5b --- /dev/null +++ b/tests/resources/sites/.gitignore @@ -0,0 +1 @@ +node_modules/ \ No newline at end of file diff --git a/tests/resources/sites/astro-custom-start-command/package-lock.json b/tests/resources/sites/astro-custom-start-command/package-lock.json index f2cf215af1..3a665bca3d 100644 --- a/tests/resources/sites/astro-custom-start-command/package-lock.json +++ b/tests/resources/sites/astro-custom-start-command/package-lock.json @@ -1528,17 +1528,6 @@ "@types/unist": "*" } }, - "node_modules/@types/node": { - "version": "25.0.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", - "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~7.16.0" - } - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -2620,9 +2609,9 @@ } }, "node_modules/devalue": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", - "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", "license": "MIT" }, "node_modules/devlop": { @@ -5128,14 +5117,6 @@ "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", "license": "MIT" }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", diff --git a/tests/resources/sites/astro-static/package-lock.json b/tests/resources/sites/astro-static/package-lock.json new file mode 100644 index 0000000000..b71881fc00 --- /dev/null +++ b/tests/resources/sites/astro-static/package-lock.json @@ -0,0 +1,5455 @@ +{ + "name": "my-astro-app", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "my-astro-app", + "version": "0.0.1", + "dependencies": { + "astro": "^5.2.5" + } + }, + "node_modules/@astrojs/compiler": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.1.tgz", + "integrity": "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==", + "license": "MIT" + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.6.tgz", + "integrity": "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q==", + "license": "MIT" + }, + "node_modules/@astrojs/markdown-remark": { + "version": "6.3.11", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.11.tgz", + "integrity": "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.7.6", + "@astrojs/prism": "3.3.0", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "import-meta-resolve": "^4.2.0", + "js-yaml": "^4.1.1", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "shiki": "^3.21.0", + "smol-toml": "^1.6.0", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/prism": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-3.3.0.tgz", + "integrity": "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz", + "integrity": "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^4.2.0", + "debug": "^4.4.0", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "is-docker": "^3.0.0", + "is-wsl": "^3.1.0", + "which-pm-runs": "^1.1.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.0.tgz", + "integrity": "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-iterate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz", + "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/astro": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/astro/-/astro-5.18.1.tgz", + "integrity": "sha512-m4VWilWZ+Xt6NPoYzC4CgGZim/zQUO7WFL0RHCH0AiEavF1153iC3+me2atDvXpf/yX4PyGUeD8wZLq1cirT3g==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^2.13.0", + "@astrojs/internal-helpers": "0.7.6", + "@astrojs/markdown-remark": "6.3.11", + "@astrojs/telemetry": "3.3.0", + "@capsizecss/unpack": "^4.0.0", + "@oslojs/encoding": "^1.1.0", + "@rollup/pluginutils": "^5.3.0", + "acorn": "^8.15.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "boxen": "8.0.1", + "ci-info": "^4.3.1", + "clsx": "^2.1.1", + "common-ancestor-path": "^1.0.1", + "cookie": "^1.1.1", + "cssesc": "^3.0.0", + "debug": "^4.4.3", + "deterministic-object-hash": "^2.0.2", + "devalue": "^5.6.2", + "diff": "^8.0.3", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "es-module-lexer": "^1.7.0", + "esbuild": "^0.27.3", + "estree-walker": "^3.0.3", + "flattie": "^1.1.1", + "fontace": "~0.4.0", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "import-meta-resolve": "^4.2.0", + "js-yaml": "^4.1.1", + "magic-string": "^0.30.21", + "magicast": "^0.5.1", + "mrmime": "^2.0.1", + "neotraverse": "^0.6.18", + "p-limit": "^6.2.0", + "p-queue": "^8.1.1", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.3", + "prompts": "^2.4.2", + "rehype": "^13.0.2", + "semver": "^7.7.3", + "shiki": "^3.21.0", + "smol-toml": "^1.6.0", + "svgo": "^4.0.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tsconfck": "^3.1.6", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.3", + "unist-util-visit": "^5.0.0", + "unstorage": "^1.17.4", + "vfile": "^6.0.3", + "vite": "^6.4.1", + "vitefu": "^1.1.1", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^21.1.1", + "yocto-spinner": "^0.2.3", + "zod": "^3.25.76", + "zod-to-json-schema": "^3.25.1", + "zod-to-ts": "^1.2.0" + }, + "bin": { + "astro": "astro.js" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.34.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/boxen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/common-ancestor-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", + "integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==", + "license": "ISC" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", + "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "license": "MIT" + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/deterministic-object-hash": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/deterministic-object-hash/-/deterministic-object-hash-2.0.2.tgz", + "integrity": "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==", + "license": "MIT", + "dependencies": { + "base-64": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", + "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, + "node_modules/h3": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.6.tgz", + "integrity": "sha512-oi15ESLW5LRthZ+qPCi5GNasY/gvynSKUQxgiovrY63bPAtG59wtM+LSrlcwvOHAXzGrXVLnI97brbkdPF9WoQ==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.2", + "crossws": "^0.3.5", + "defu": "^6.1.4", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", + "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neotraverse": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", + "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", + "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", + "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-limit": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-6.2.0.tgz", + "integrity": "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.1.1.tgz", + "integrity": "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/parse-latin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", + "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "@types/unist": "^3.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-modify-children": "^4.0.0", + "unist-util-visit-children": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rehype": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/rehype/-/rehype-13.0.2.tgz", + "integrity": "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "rehype-parse": "^9.0.0", + "rehype-stringify": "^10.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-smartypants": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.2.tgz", + "integrity": "sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==", + "license": "MIT", + "dependencies": { + "retext": "^9.0.0", + "retext-smartypants": "^6.0.0", + "unified": "^11.0.4", + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", + "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "retext-latin": "^4.0.0", + "retext-stringify": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-latin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz", + "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "parse-latin": "^7.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-stringify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz", + "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sax": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", + "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/smol-toml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/svgo": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", + "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz", + "integrity": "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.4.tgz", + "integrity": "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "ofetch": "^1.5.1", + "ohash": "^2.0.11" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-modify-children": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz", + "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "array-iterate": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-children": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz", + "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.17.4", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.4.tgz", + "integrity": "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.5", + "lru-cache": "^11.2.0", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/which-pm-runs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz", + "integrity": "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-spinner": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-0.2.3.tgz", + "integrity": "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==", + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, + "node_modules/zod-to-ts": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/zod-to-ts/-/zod-to-ts-1.2.0.tgz", + "integrity": "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==", + "peerDependencies": { + "typescript": "^4.9.4 || ^5.0.2", + "zod": "^3" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/tests/resources/sites/astro/package-lock.json b/tests/resources/sites/astro/package-lock.json index e9d14e26db..becadd9b82 100644 --- a/tests/resources/sites/astro/package-lock.json +++ b/tests/resources/sites/astro/package-lock.json @@ -2151,9 +2151,9 @@ } }, "node_modules/devalue": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", - "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", "license": "MIT" }, "node_modules/devlop": { diff --git a/tests/unit/Functions/Validator/HeadersBench.php b/tests/unit/Functions/Validator/HeadersBench.php deleted file mode 100644 index f95fa65f9b..0000000000 --- a/tests/unit/Functions/Validator/HeadersBench.php +++ /dev/null @@ -1,60 +0,0 @@ -validator = new Headers(); - } - - public function providers(): iterable - { - yield 'empty' => [ 'value' => [] ]; - - $value = []; - for ($i = 0; $i < 10; $i++) { - $value[bin2hex(random_bytes(8))] = bin2hex(random_bytes(8)); - } - yield 'items_10-size_320' => [ 'value' => $value ]; - - $value = []; - for ($i = 0; $i < 100; $i++) { - $value[bin2hex(random_bytes(8))] = bin2hex(random_bytes(8)); - } - yield 'items_100-size_3200' => [ 'value' => $value ]; - - $value = []; - for ($i = 0; $i < 100; $i++) { - $value[bin2hex(random_bytes(32))] = bin2hex(random_bytes(32)); - } - yield 'items_100-size_12800' => [ 'value' => $value ]; - } - - #[BeforeMethods('prepare')] - #[AfterMethods('tearDown')] - #[ParamProviders('providers')] - #[Iterations(50)] - #[Assert('mode(variant.time.avg) < 1 ms')] - public function benchHeadersValidator(array $data): void - { - $assertion = $this->validator->isValid($data['value']); - if (!$assertion) { - exit(1); - } - } -} diff --git a/tests/unit/Network/Validators/EmailTest.php b/tests/unit/Network/Validators/EmailTest.php deleted file mode 100755 index f629ed6ddc..0000000000 --- a/tests/unit/Network/Validators/EmailTest.php +++ /dev/null @@ -1,69 +0,0 @@ - - * @version 1.0 RC4 - * @license The MIT License (MIT) - */ - -namespace Tests\Unit\Network\Validators; - -use Appwrite\Network\Validator\Email; -use PHPUnit\Framework\TestCase; - -class EmailTest extends TestCase -{ - protected ?Email $email = null; - - public function setUp(): void - { - $this->email = new Email(); - } - - public function tearDown(): void - { - $this->email = null; - } - - public function testIsValid(): void - { - $this->assertEquals(true, $this->email->isValid('email@domain.com')); - $this->assertEquals(true, $this->email->isValid('firstname.lastname@domain.com')); - $this->assertEquals(true, $this->email->isValid('email@subdomain.domain.com')); - $this->assertEquals(true, $this->email->isValid('firstname+lastname@domain.com')); - $this->assertEquals(true, $this->email->isValid('email@[123.123.123.123]')); - $this->assertEquals(true, $this->email->isValid('"email"@domain.com')); - $this->assertEquals(true, $this->email->isValid('1234567890@domain.com')); - $this->assertEquals(true, $this->email->isValid('email@domain-one.com')); - $this->assertEquals(true, $this->email->isValid('_______@domain.com')); - $this->assertEquals(true, $this->email->isValid('email@domain.name')); - $this->assertEquals(true, $this->email->isValid('email@domain.co.jp')); - $this->assertEquals(true, $this->email->isValid('firstname-lastname@domain.com')); - $this->assertEquals(false, $this->email->isValid(false)); - $this->assertEquals(false, $this->email->isValid(['string', 'string'])); - $this->assertEquals(false, $this->email->isValid(1)); - $this->assertEquals(false, $this->email->isValid(1.2)); - $this->assertEquals(false, $this->email->isValid('plainaddress')); // Missing @ sign and domain - $this->assertEquals(false, $this->email->isValid('@domain.com')); // Missing username - $this->assertEquals(false, $this->email->isValid('#@%^%#$@#$@#.com')); // Garbage - $this->assertEquals(false, $this->email->isValid('Joe Smith ')); // Encoded html within email is invalid - $this->assertEquals(false, $this->email->isValid('email.domain.com')); // Missing @ - $this->assertEquals(false, $this->email->isValid('email@domain@domain.com')); // Two @ sign - $this->assertEquals(false, $this->email->isValid('.email@domain.com')); // Leading dot in address is not allowed - $this->assertEquals(false, $this->email->isValid('email.@domain.com')); // Trailing dot in address is not allowed - $this->assertEquals(false, $this->email->isValid('email..email@domain.com')); // Multiple dots - $this->assertEquals(false, $this->email->isValid('あいうえお@domain.com')); // Unicode char as address - $this->assertEquals(false, $this->email->isValid('email@domain.com (Joe Smith)')); // Text followed email is not allowed - $this->assertEquals(false, $this->email->isValid('email@domain')); // Missing top level domain (.com/.net/.org/etc) - $this->assertEquals(false, $this->email->isValid('email@-domain.com')); // Leading dash in front of domain is invalid - $this->assertEquals(false, $this->email->isValid('email@111.222.333.44444')); // Invalid IP format - $this->assertEquals(false, $this->email->isValid('email@domain..com')); // Multiple dot in the domain portion is invalid - $this->assertEquals($this->email->getType(), 'string'); - } -} diff --git a/tests/unit/Network/Validators/OriginTest.php b/tests/unit/Network/Validators/OriginTest.php index 7a19daecbf..64ce71951b 100644 --- a/tests/unit/Network/Validators/OriginTest.php +++ b/tests/unit/Network/Validators/OriginTest.php @@ -73,6 +73,10 @@ class OriginTest extends TestCase $this->assertEquals(false, $validator->isValid('ms-browser-extension://com.company.appname')); $this->assertEquals('Invalid Origin. Register your new client (com.company.appname) as a new Web (Edge Extension) platform on your project console dashboard', $validator->getDescription()); + $this->assertEquals(true, $validator->isValid('tauri://localhost')); + $this->assertEquals(false, $validator->isValid('tauri://example.com')); + $this->assertEquals('Invalid Origin. Register your new client (example.com) as a new Web (Tauri) platform on your project console dashboard', $validator->getDescription()); + $this->assertEquals(false, $validator->isValid('random-scheme://localhost')); $this->assertEquals('Invalid Scheme. The scheme used (random-scheme) in the Origin (random-scheme://localhost) is not supported. If you are using a custom scheme, please change it to `appwrite-callback-`', $validator->getDescription()); } diff --git a/tests/unit/Platform/Modules/Installer/ModuleTest.php b/tests/unit/Platform/Modules/Installer/ModuleTest.php new file mode 100644 index 0000000000..8df452d8de --- /dev/null +++ b/tests/unit/Platform/Modules/Installer/ModuleTest.php @@ -0,0 +1,291 @@ +module = new Module(); + } + + protected function tearDown(): void + { + $this->module = null; + } + + public function testModuleHasHttpService(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $this->assertCount(1, $services); + } + + public function testHttpServiceRegistersAllActions(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + $actions = $service->getActions(); + + $this->assertCount(6, $actions); + $this->assertArrayHasKey('installerView', $actions); + $this->assertArrayHasKey('installerStatus', $actions); + $this->assertArrayHasKey('installerValidate', $actions); + $this->assertArrayHasKey('installerComplete', $actions); + $this->assertArrayHasKey('installerShutdown', $actions); + $this->assertArrayHasKey('installerInstall', $actions); + } + + public function testViewAction(): void + { + $action = $this->getAction('installerView'); + + $this->assertEquals('installerView', View::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_GET, $action->getHttpMethod()); + $this->assertEquals('/', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionParams($action, ['step', 'partial']); + $this->assertActionInjects($action, ['request', 'response', 'installerConfig', 'installerPaths']); + } + + public function testStatusAction(): void + { + $action = $this->getAction('installerStatus'); + + $this->assertEquals('installerStatus', Status::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_GET, $action->getHttpMethod()); + $this->assertEquals('/install/status', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionParams($action, ['installId']); + $this->assertActionInjects($action, ['response', 'installerState']); + } + + public function testValidateAction(): void + { + $action = $this->getAction('installerValidate'); + + $this->assertEquals('installerValidate', Validate::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_POST, $action->getHttpMethod()); + $this->assertEquals('/install/validate', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionInjects($action, ['request', 'response']); + } + + public function testCompleteAction(): void + { + $action = $this->getAction('installerComplete'); + + $this->assertEquals('installerComplete', Complete::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_POST, $action->getHttpMethod()); + $this->assertEquals('/install/complete', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionParams($action, ['installId', 'sessionId', 'sessionSecret', 'sessionExpire']); + $this->assertActionInjects($action, ['request', 'response', 'installerState']); + } + + public function testShutdownAction(): void + { + $action = $this->getAction('installerShutdown'); + + $this->assertEquals('installerShutdown', Shutdown::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_POST, $action->getHttpMethod()); + $this->assertEquals('/install/shutdown', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionInjects($action, ['request', 'response', 'swooleServer']); + } + + public function testInstallAction(): void + { + $action = $this->getAction('installerInstall'); + + $this->assertEquals('installerInstall', Install::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_POST, $action->getHttpMethod()); + $this->assertEquals('/install', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionParams($action, [ + 'appDomain', 'httpPort', 'httpsPort', 'emailCertificates', 'opensslKey', + 'assistantOpenAIKey', 'accountEmail', 'accountPassword', 'database', + 'installId', 'retryStep', + ]); + $this->assertActionInjects($action, ['request', 'response', 'swooleResponse', 'installerState', 'installerConfig', 'installerPaths']); + } + + public function testErrorActionClass(): void + { + $error = new Error(); + + $this->assertEquals('installerError', Error::getName()); + $this->assertEquals(Action::TYPE_ERROR, $error->getType()); + $this->assertIsCallable($error->getCallback()); + } + + /** + * @runInSeparateProcess + */ + public function testRouteRegistration(): void + { + $platform = new class (new Module()) extends Platform {}; + $platform->init(Service::TYPE_HTTP); + + // If we get here without exceptions, route registration succeeded + $this->assertTrue(true); + } + + public function testModuleHasNoTaskServices(): void + { + $services = $this->module->getServicesByType(Service::TYPE_TASK); + $this->assertEmpty($services); + } + + public function testModuleHasNoWorkerServices(): void + { + $services = $this->module->getServicesByType(Service::TYPE_WORKER); + $this->assertEmpty($services); + } + + public function testAllDefaultActionsHaveDescriptions(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + foreach ($service->getActions() as $name => $action) { + $desc = $action->getDesc(); + $this->assertNotNull($desc, "Action '$name' should have a description"); + $this->assertNotEmpty($desc, "Action '$name' description should not be empty"); + } + } + + public function testAllActionsHaveCallableCallbacks(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + foreach ($service->getActions() as $name => $action) { + $callback = $action->getCallback(); + $this->assertIsCallable($callback, "Action '$name' callback should be callable"); + } + } + + public function testActionNamesAreUnique(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + $actions = $service->getActions(); + $names = array_keys($actions); + $this->assertEquals($names, array_unique($names)); + } + + public function testRoutePathsAreUniquePerMethod(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + $routes = []; + foreach ($service->getActions() as $action) { + $key = $action->getHttpMethod() . ' ' . $action->getHttpPath(); + $this->assertArrayNotHasKey($key, $routes, "Duplicate route: $key"); + $routes[$key] = true; + } + } + + public function testStaticGetNameValues(): void + { + $this->assertEquals('installerView', View::getName()); + $this->assertEquals('installerStatus', Status::getName()); + $this->assertEquals('installerValidate', Validate::getName()); + $this->assertEquals('installerComplete', Complete::getName()); + $this->assertEquals('installerShutdown', Shutdown::getName()); + $this->assertEquals('installerInstall', Install::getName()); + $this->assertEquals('installerError', Error::getName()); + } + + public function testActionInstanceTypes(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + $actions = $service->getActions(); + + $this->assertInstanceOf(View::class, $actions['installerView']); + $this->assertInstanceOf(Status::class, $actions['installerStatus']); + $this->assertInstanceOf(Validate::class, $actions['installerValidate']); + $this->assertInstanceOf(Complete::class, $actions['installerComplete']); + $this->assertInstanceOf(Shutdown::class, $actions['installerShutdown']); + $this->assertInstanceOf(Install::class, $actions['installerInstall']); + } + + public function testGetRoutesUseGetMethod(): void + { + $getActions = ['installerView', 'installerStatus']; + foreach ($getActions as $name) { + $action = $this->getAction($name); + $this->assertEquals( + Action::HTTP_REQUEST_METHOD_GET, + $action->getHttpMethod(), + "Action '$name' should use GET method" + ); + } + } + + public function testPostRoutesUsePostMethod(): void + { + $postActions = ['installerValidate', 'installerComplete', 'installerShutdown', 'installerInstall']; + foreach ($postActions as $name) { + $action = $this->getAction($name); + $this->assertEquals( + Action::HTTP_REQUEST_METHOD_POST, + $action->getHttpMethod(), + "Action '$name' should use POST method" + ); + } + } + + public function testValidateClassHasCsrfMethod(): void + { + $this->assertTrue( + method_exists(Validate::class, 'validateCsrf'), + 'Validate class should expose validateCsrf method' + ); + } + + private function getAction(string $name): Action + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + $actions = $service->getActions(); + $this->assertArrayHasKey($name, $actions); + return $actions[$name]; + } + + private function assertActionInjects(Action $action, array $expectedInjections): void + { + $injections = []; + foreach ($action->getOptions() as $option) { + if ($option['type'] === 'injection') { + $injections[] = $option['name']; + } + } + $this->assertEquals($expectedInjections, $injections); + } + + private function assertActionParams(Action $action, array $expectedParams): void + { + $params = []; + foreach ($action->getOptions() as $key => $option) { + if ($option['type'] === 'param') { + $params[] = substr($key, 6); // strip 'param:' prefix + } + } + $this->assertEquals($expectedParams, $params); + } +} diff --git a/tests/unit/Platform/Modules/Installer/Runtime/ConfigTest.php b/tests/unit/Platform/Modules/Installer/Runtime/ConfigTest.php new file mode 100644 index 0000000000..113bce5277 --- /dev/null +++ b/tests/unit/Platform/Modules/Installer/Runtime/ConfigTest.php @@ -0,0 +1,554 @@ +assertEquals('80', $config->getDefaultHttpPort()); + $this->assertEquals('443', $config->getDefaultHttpsPort()); + $this->assertEquals('appwrite', $config->getOrganization()); + $this->assertEquals('appwrite', $config->getImage()); + $this->assertFalse($config->getNoStart()); + $this->assertFalse($config->isUpgrade()); + $this->assertFalse($config->isLocal()); + $this->assertNull($config->getHostPath()); + $this->assertNull($config->getLockedDatabase()); + $this->assertEquals(['mongodb', 'mariadb'], $config->getEnabledDatabases()); + $this->assertEmpty($config->getVars()); + } + + public function testConstructorWithKnownKeys(): void + { + $config = new Config([ + 'defaultHttpPort' => '8080', + 'isUpgrade' => true, + 'organization' => 'myorg', + ]); + + $this->assertEquals('8080', $config->getDefaultHttpPort()); + $this->assertTrue($config->isUpgrade()); + $this->assertEquals('myorg', $config->getOrganization()); + } + + public function testConstructorWithUnknownKeysTreatsAsVars(): void + { + $vars = [ + '_APP_ENV' => 'production', + '_APP_DOMAIN' => 'example.com', + ]; + $config = new Config($vars); + + $this->assertEquals($vars, $config->getVars()); + // Defaults should remain + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testApplyAllFields(): void + { + $config = new Config(); + $config->apply([ + 'defaultHttpPort' => '3000', + 'defaultHttpsPort' => '3443', + 'organization' => 'testorg', + 'image' => 'testimage', + 'noStart' => true, + 'isUpgrade' => true, + 'isLocal' => true, + 'hostPath' => '/home/user', + 'lockedDatabase' => 'mariadb', + 'vars' => [['name' => '_APP_ENV', 'default' => 'production']], + ]); + + $this->assertEquals('3000', $config->getDefaultHttpPort()); + $this->assertEquals('3443', $config->getDefaultHttpsPort()); + $this->assertEquals('testorg', $config->getOrganization()); + $this->assertEquals('testimage', $config->getImage()); + $this->assertTrue($config->getNoStart()); + $this->assertTrue($config->isUpgrade()); + $this->assertTrue($config->isLocal()); + $this->assertEquals('/home/user', $config->getHostPath()); + $this->assertEquals('mariadb', $config->getLockedDatabase()); + $this->assertCount(1, $config->getVars()); + } + + public function testApplyIgnoresNullAndEmptyStringValues(): void + { + $config = new Config(['defaultHttpPort' => '9090']); + + $config->apply(['defaultHttpPort' => '']); + $this->assertEquals('9090', $config->getDefaultHttpPort()); + + $config->apply(['defaultHttpPort' => null]); + $this->assertEquals('9090', $config->getDefaultHttpPort()); + } + + public function testApplyHostPathCanBeSetToNull(): void + { + $config = new Config(); + $config->setHostPath('/some/path'); + $this->assertEquals('/some/path', $config->getHostPath()); + + $config->apply(['hostPath' => null]); + $this->assertNull($config->getHostPath()); + } + + public function testApplyPartialUpdate(): void + { + $config = new Config([ + 'defaultHttpPort' => '8080', + 'defaultHttpsPort' => '8443', + 'organization' => 'original', + ]); + + $config->apply(['organization' => 'updated']); + + $this->assertEquals('8080', $config->getDefaultHttpPort()); + $this->assertEquals('8443', $config->getDefaultHttpsPort()); + $this->assertEquals('updated', $config->getOrganization()); + } + + public function testToArrayRoundTrip(): void + { + $config = new Config(); + $config->apply([ + 'defaultHttpPort' => '3000', + 'defaultHttpsPort' => '3443', + 'organization' => 'testorg', + 'image' => 'testimage', + 'noStart' => true, + 'isUpgrade' => true, + 'isLocal' => true, + 'hostPath' => '/home/user', + 'lockedDatabase' => 'mongodb', + 'vars' => [['name' => 'KEY', 'default' => 'value']], + ]); + + $array = $config->toArray(); + + $this->assertEquals('3000', $array['defaultHttpPort']); + $this->assertEquals('3443', $array['defaultHttpsPort']); + $this->assertEquals('testorg', $array['organization']); + $this->assertEquals('testimage', $array['image']); + $this->assertTrue($array['noStart']); + $this->assertTrue($array['isUpgrade']); + $this->assertTrue($array['isLocal']); + $this->assertEquals('/home/user', $array['hostPath']); + $this->assertEquals('mongodb', $array['lockedDatabase']); + $this->assertCount(1, $array['vars']); + } + + public function testToArrayCanRecreateConfig(): void + { + $original = new Config([ + 'defaultHttpPort' => '5000', + 'isLocal' => true, + 'lockedDatabase' => 'mariadb', + ]); + + $rebuilt = new Config($original->toArray()); + + $this->assertEquals($original->getDefaultHttpPort(), $rebuilt->getDefaultHttpPort()); + $this->assertEquals($original->isLocal(), $rebuilt->isLocal()); + $this->assertEquals($original->getLockedDatabase(), $rebuilt->getLockedDatabase()); + $this->assertEquals($original->toArray(), $rebuilt->toArray()); + } + + public function testSetAndGetDefaultHttpPort(): void + { + $config = new Config(); + $config->setDefaultHttpPort('9090'); + $this->assertEquals('9090', $config->getDefaultHttpPort()); + } + + public function testSetAndGetDefaultHttpsPort(): void + { + $config = new Config(); + $config->setDefaultHttpsPort('9443'); + $this->assertEquals('9443', $config->getDefaultHttpsPort()); + } + + public function testSetAndGetOrganization(): void + { + $config = new Config(); + $config->setOrganization('myorg'); + $this->assertEquals('myorg', $config->getOrganization()); + } + + public function testSetAndGetImage(): void + { + $config = new Config(); + $config->setImage('myimage'); + $this->assertEquals('myimage', $config->getImage()); + } + + public function testSetAndGetNoStart(): void + { + $config = new Config(); + $config->setNoStart(true); + $this->assertTrue($config->getNoStart()); + $config->setNoStart(false); + $this->assertFalse($config->getNoStart()); + } + + public function testSetAndGetIsUpgrade(): void + { + $config = new Config(); + $config->setIsUpgrade(true); + $this->assertTrue($config->isUpgrade()); + $config->setIsUpgrade(false); + $this->assertFalse($config->isUpgrade()); + } + + public function testSetAndGetIsLocal(): void + { + $config = new Config(); + $config->setIsLocal(true); + $this->assertTrue($config->isLocal()); + $config->setIsLocal(false); + $this->assertFalse($config->isLocal()); + } + + public function testSetAndGetHostPath(): void + { + $config = new Config(); + $config->setHostPath('/some/path'); + $this->assertEquals('/some/path', $config->getHostPath()); + $config->setHostPath(null); + $this->assertNull($config->getHostPath()); + } + + public function testSetAndGetLockedDatabase(): void + { + $config = new Config(); + $config->setLockedDatabase('mariadb'); + $this->assertEquals('mariadb', $config->getLockedDatabase()); + $config->setLockedDatabase(null); + $this->assertNull($config->getLockedDatabase()); + } + + public function testSetAndGetVars(): void + { + $config = new Config(); + $vars = [ + ['name' => '_APP_ENV', 'default' => 'production'], + ['name' => '_APP_DOMAIN', 'default' => 'localhost'], + ]; + $config->setVars($vars); + $this->assertEquals($vars, $config->getVars()); + } + + public function testJsonRoundTrip(): void + { + $config = new Config([ + 'defaultHttpPort' => '5000', + 'isUpgrade' => true, + 'lockedDatabase' => 'mongodb', + ]); + + $json = json_encode($config->toArray(), JSON_UNESCAPED_SLASHES); + $this->assertIsString($json); + + $decoded = json_decode($json, true); + $this->assertIsArray($decoded); + + $rebuilt = new Config($decoded); + $this->assertEquals($config->toArray(), $rebuilt->toArray()); + } + + public function testConstructorWithEmptyArray(): void + { + $config = new Config([]); + // Empty array has no known keys, so it gets set as vars + // But empty vars is still empty + $this->assertEmpty($config->getVars()); + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testConstructorWithMixedKnownAndUnknownKeys(): void + { + // If at least one known key is found, apply() is used (not setVars) + $config = new Config([ + 'defaultHttpPort' => '9090', + 'unknownKey' => 'someValue', + ]); + // Known key should be applied + $this->assertEquals('9090', $config->getDefaultHttpPort()); + // Unknown key should be silently ignored by apply() + // Vars should remain empty since containsKnownKeys returns true + $this->assertEmpty($config->getVars()); + } + + public function testApplyWithEmptyArray(): void + { + $config = new Config(['defaultHttpPort' => '1234']); + $config->apply([]); + // Should not change anything + $this->assertEquals('1234', $config->getDefaultHttpPort()); + } + + public function testApplyBooleanCastingNoStart(): void + { + $config = new Config(); + + // Truthy int + $config->apply(['noStart' => 1]); + $this->assertTrue($config->getNoStart()); + + // Falsy int + $config->apply(['noStart' => 0]); + $this->assertFalse($config->getNoStart()); + } + + public function testApplyBooleanCastingIsUpgrade(): void + { + $config = new Config(); + + $config->apply(['isUpgrade' => 1]); + $this->assertTrue($config->isUpgrade()); + + $config->apply(['isUpgrade' => 0]); + $this->assertFalse($config->isUpgrade()); + } + + public function testApplyBooleanCastingIsLocal(): void + { + $config = new Config(); + + $config->apply(['isLocal' => 'true']); // string "true" is truthy + $this->assertTrue($config->isLocal()); + + $config->apply(['isLocal' => '']); // empty string is falsy + // But wait: the code checks $values['isLocal'] !== null first + // '' is not null, so (bool)'' = false + $this->assertFalse($config->isLocal()); + } + + public function testApplyNoStartWithNullDoesNotChange(): void + { + $config = new Config(); + $config->setNoStart(true); + $config->apply(['noStart' => null]); + // null is excluded by the null check + $this->assertTrue($config->getNoStart()); + } + + public function testApplyVarsWithNonArrayIgnored(): void + { + $config = new Config(); + $config->setVars([['name' => 'KEY', 'default' => 'val']]); + + $config->apply(['vars' => 'not an array']); + // Should not overwrite + $this->assertCount(1, $config->getVars()); + } + + public function testApplyVarsWithNullIgnored(): void + { + $config = new Config(); + $config->setVars([['name' => 'KEY', 'default' => 'val']]); + + $config->apply(['vars' => null]); + // is_array(null) = false, so should not overwrite + $this->assertCount(1, $config->getVars()); + } + + public function testApplyHostPathEmptyStringBecomesNull(): void + { + $config = new Config(); + $config->setHostPath('/some/path'); + + $config->apply(['hostPath' => '']); + // Empty string is handled: !== null && !== '' is false, so sets null + $this->assertNull($config->getHostPath()); + } + + public function testApplyLockedDatabaseIgnoresEmpty(): void + { + $config = new Config(); + $config->setLockedDatabase('mariadb'); + + $config->apply(['lockedDatabase' => '']); + // hasValidStringValue returns false for empty string + $this->assertEquals('mariadb', $config->getLockedDatabase()); + } + + public function testApplyLockedDatabaseIgnoresNull(): void + { + $config = new Config(); + $config->setLockedDatabase('mongodb'); + + $config->apply(['lockedDatabase' => null]); + // hasValidStringValue returns false for null + $this->assertEquals('mongodb', $config->getLockedDatabase()); + } + + public function testApplyPortWithIntegerValue(): void + { + $config = new Config(); + $config->apply(['defaultHttpPort' => 3000]); + // (string)3000 = '3000', not empty, so it should be applied + $this->assertEquals('3000', $config->getDefaultHttpPort()); + } + + public function testToArrayContainsAllExpectedKeys(): void + { + $config = new Config(); + $array = $config->toArray(); + + $expectedKeys = [ + 'defaultHttpPort', + 'defaultHttpsPort', + 'organization', + 'image', + 'noStart', + 'vars', + 'isUpgrade', + 'isLocal', + 'hostPath', + 'lockedDatabase', + 'enabledDatabases', + ]; + + foreach ($expectedKeys as $key) { + $this->assertArrayHasKey($key, $array, "Missing key: $key"); + } + $this->assertCount(count($expectedKeys), $array); + } + + public function testToArrayDefaultsMatchConstructorDefaults(): void + { + $config = new Config(); + $array = $config->toArray(); + + $this->assertEquals('80', $array['defaultHttpPort']); + $this->assertEquals('443', $array['defaultHttpsPort']); + $this->assertEquals('appwrite', $array['organization']); + $this->assertEquals('appwrite', $array['image']); + $this->assertFalse($array['noStart']); + $this->assertEmpty($array['vars']); + $this->assertFalse($array['isUpgrade']); + $this->assertFalse($array['isLocal']); + $this->assertNull($array['hostPath']); + $this->assertNull($array['lockedDatabase']); + $this->assertEquals(['mongodb', 'mariadb'], $array['enabledDatabases']); + } + + public function testMultipleApplyCallsAccumulate(): void + { + $config = new Config(); + + $config->apply(['defaultHttpPort' => '1111']); + $config->apply(['defaultHttpsPort' => '2222']); + $config->apply(['organization' => 'org']); + $config->apply(['isLocal' => true]); + + $this->assertEquals('1111', $config->getDefaultHttpPort()); + $this->assertEquals('2222', $config->getDefaultHttpsPort()); + $this->assertEquals('org', $config->getOrganization()); + $this->assertTrue($config->isLocal()); + } + + public function testApplyOverwritesPreviousValues(): void + { + $config = new Config(['defaultHttpPort' => '1111']); + $this->assertEquals('1111', $config->getDefaultHttpPort()); + + $config->apply(['defaultHttpPort' => '2222']); + $this->assertEquals('2222', $config->getDefaultHttpPort()); + + $config->apply(['defaultHttpPort' => '3333']); + $this->assertEquals('3333', $config->getDefaultHttpPort()); + } + + public function testSetVarsReplacesNotMerges(): void + { + $config = new Config(); + $config->setVars([['name' => 'A', 'default' => '1']]); + $config->setVars([['name' => 'B', 'default' => '2']]); + + $vars = $config->getVars(); + $this->assertCount(1, $vars); + $this->assertEquals('B', $vars[0]['name']); + } + + public function testApplyVarsReplacesNotMerges(): void + { + $config = new Config(); + $config->apply(['vars' => [['name' => 'A', 'default' => '1']]]); + $config->apply(['vars' => [['name' => 'B', 'default' => '2']]]); + + $vars = $config->getVars(); + $this->assertCount(1, $vars); + $this->assertEquals('B', $vars[0]['name']); + } + + public function testDefaultEnabledDatabases(): void + { + $config = new Config(); + $this->assertEquals(['mongodb', 'mariadb'], $config->getEnabledDatabases()); + $this->assertTrue($config->isDatabaseEnabled('mongodb')); + $this->assertTrue($config->isDatabaseEnabled('mariadb')); + $this->assertFalse($config->isDatabaseEnabled('postgresql')); + } + + public function testSetEnabledDatabases(): void + { + $config = new Config(); + $config->setEnabledDatabases(['mongodb', 'mariadb', 'postgresql']); + $this->assertEquals(['mongodb', 'mariadb', 'postgresql'], $config->getEnabledDatabases()); + $this->assertTrue($config->isDatabaseEnabled('postgresql')); + } + + public function testSetEnabledDatabasesFiltersInvalid(): void + { + $config = new Config(); + $config->setEnabledDatabases(['mongodb', '', null, 42, 'mariadb']); + $this->assertEquals(['mongodb', 'mariadb'], $config->getEnabledDatabases()); + } + + public function testSetEnabledDatabasesEmptyArrayPreservesExisting(): void + { + $config = new Config(); + $config->setEnabledDatabases(['mongodb', 'postgresql']); + $config->setEnabledDatabases([]); + $this->assertEquals(['mongodb', 'postgresql'], $config->getEnabledDatabases()); + } + + public function testApplyEnabledDatabases(): void + { + $config = new Config(); + $config->apply(['enabledDatabases' => ['mongodb', 'mariadb', 'postgresql']]); + $this->assertEquals(['mongodb', 'mariadb', 'postgresql'], $config->getEnabledDatabases()); + $this->assertTrue($config->isDatabaseEnabled('postgresql')); + } + + public function testApplyEnabledDatabasesNonArrayIgnored(): void + { + $config = new Config(); + $config->apply(['enabledDatabases' => 'mongodb']); + $this->assertEquals(['mongodb', 'mariadb'], $config->getEnabledDatabases()); + } + + public function testEnabledDatabasesInToArray(): void + { + $config = new Config(); + $config->setEnabledDatabases(['mongodb']); + $array = $config->toArray(); + $this->assertEquals(['mongodb'], $array['enabledDatabases']); + } + + public function testEnabledDatabasesRoundTrip(): void + { + $config = new Config(); + $config->setEnabledDatabases(['mongodb', 'postgresql']); + $rebuilt = new Config($config->toArray()); + $this->assertEquals(['mongodb', 'postgresql'], $rebuilt->getEnabledDatabases()); + } +} diff --git a/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php b/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php new file mode 100644 index 0000000000..6c36e6d732 --- /dev/null +++ b/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php @@ -0,0 +1,1160 @@ +tempDir = sys_get_temp_dir() . '/appwrite-installer-test-' . uniqid(); + mkdir($this->tempDir, 0755, true); + + $root = dirname(__DIR__, 6); + $this->state = new State([ + 'public' => $root . '/public', + 'init' => $root . '/app/init.php', + 'views' => $root . '/app/views/install', + 'vendor' => $root . '/vendor/autoload.php', + 'installPhp' => $root . '/src/Appwrite/Platform/Tasks/Install.php', + ]); + + // Preserve env state + $env = getenv('APPWRITE_INSTALLER_CONFIG'); + $this->savedEnv = $env !== false ? $env : null; + } + + protected function tearDown(): void + { + // Clean up temp files + $files = glob($this->tempDir . '/*'); + if (is_array($files)) { + foreach ($files as $file) { + @unlink($file); + } + } + @rmdir($this->tempDir); + + // Clean up progress files + foreach ($this->progressFiles as $file) { + @unlink($file); + } + + // Clean up lock file + @unlink(Server::INSTALLER_LOCK_FILE); + @unlink(Server::INSTALLER_CONFIG_FILE); + + // Restore env state + if ($this->savedEnv !== null) { + putenv('APPWRITE_INSTALLER_CONFIG=' . $this->savedEnv); + } else { + putenv('APPWRITE_INSTALLER_CONFIG'); + } + + $this->state = null; + } + + private function trackProgressFile(string $installId): void + { + $this->progressFiles[] = $this->state->progressFilePath($installId); + } + + public function testSanitizeInstallIdWithValidId(): void + { + $this->assertEquals('abc123', $this->state->sanitizeInstallId('abc123')); + } + + public function testSanitizeInstallIdWithSpecialChars(): void + { + $this->assertEquals('abc123', $this->state->sanitizeInstallId('abc!@#123')); + } + + public function testSanitizeInstallIdWithHyphensAndUnderscores(): void + { + $this->assertEquals('abc-123_def', $this->state->sanitizeInstallId('abc-123_def')); + } + + public function testSanitizeInstallIdTruncatesTo64Chars(): void + { + $long = str_repeat('a', 100); + $this->assertEquals(64, strlen($this->state->sanitizeInstallId($long))); + } + + public function testSanitizeInstallIdWithEmptyString(): void + { + $this->assertEquals('', $this->state->sanitizeInstallId('')); + } + + public function testSanitizeInstallIdWithNonString(): void + { + $this->assertEquals('', $this->state->sanitizeInstallId(123)); + $this->assertEquals('', $this->state->sanitizeInstallId(null)); + } + + public function testHashSensitiveValueProducesConsistentHash(): void + { + $hash1 = $this->state->hashSensitiveValue('secret'); + $hash2 = $this->state->hashSensitiveValue('secret'); + $this->assertEquals($hash1, $hash2); + } + + public function testHashSensitiveValueDifferentInputsDifferentHashes(): void + { + $hash1 = $this->state->hashSensitiveValue('secret1'); + $hash2 = $this->state->hashSensitiveValue('secret2'); + $this->assertNotEquals($hash1, $hash2); + } + + public function testHashSensitiveValueTrimsWhitespace(): void + { + $hash1 = $this->state->hashSensitiveValue('secret'); + $hash2 = $this->state->hashSensitiveValue(' secret '); + $this->assertEquals($hash1, $hash2); + } + + public function testHashSensitiveValueEmptyStringReturnsEmpty(): void + { + $this->assertEquals('', $this->state->hashSensitiveValue('')); + $this->assertEquals('', $this->state->hashSensitiveValue(' ')); + } + + public function testHashSensitiveValueReturnsSha256(): void + { + $hash = $this->state->hashSensitiveValue('test'); + $this->assertEquals(64, strlen($hash)); // SHA-256 produces 64 hex chars + $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $hash); + } + + public function testIsValidPortWithValidPorts(): void + { + $this->assertTrue($this->state->isValidPort('1')); + $this->assertTrue($this->state->isValidPort('80')); + $this->assertTrue($this->state->isValidPort('443')); + $this->assertTrue($this->state->isValidPort('8080')); + $this->assertTrue($this->state->isValidPort('65535')); + } + + public function testIsValidPortWithInvalidPorts(): void + { + $this->assertFalse($this->state->isValidPort('0')); + $this->assertFalse($this->state->isValidPort('65536')); + $this->assertFalse($this->state->isValidPort('-1')); + $this->assertFalse($this->state->isValidPort('abc')); + $this->assertFalse($this->state->isValidPort('')); + $this->assertFalse($this->state->isValidPort('80.5')); + $this->assertFalse($this->state->isValidPort('80abc')); + } + + public function testIsValidPortWithIntegerInput(): void + { + $this->assertTrue($this->state->isValidPort(80)); + $this->assertTrue($this->state->isValidPort(443)); + $this->assertFalse($this->state->isValidPort(0)); + } + + public function testIsValidEmailAddressWithValidEmails(): void + { + $this->assertTrue($this->state->isValidEmailAddress('user@example.com')); + $this->assertTrue($this->state->isValidEmailAddress('test.user@domain.org')); + $this->assertTrue($this->state->isValidEmailAddress('admin+tag@example.co.uk')); + } + + public function testIsValidEmailAddressWithInvalidEmails(): void + { + $this->assertFalse($this->state->isValidEmailAddress('')); + $this->assertFalse($this->state->isValidEmailAddress('notanemail')); + $this->assertFalse($this->state->isValidEmailAddress('@domain.com')); + $this->assertFalse($this->state->isValidEmailAddress('user@')); + } + + public function testIsValidPasswordWithValidPasswords(): void + { + $this->assertTrue($this->state->isValidPassword('12345678')); + $this->assertTrue($this->state->isValidPassword('abcdefgh')); + $this->assertTrue($this->state->isValidPassword('P@ssw0rd!')); + } + + public function testIsValidPasswordWithInvalidPasswords(): void + { + $this->assertFalse($this->state->isValidPassword('')); + $this->assertFalse($this->state->isValidPassword('short')); + $this->assertFalse($this->state->isValidPassword('1234567')); // 7 chars + $this->assertFalse($this->state->isValidPassword(' ')); // 8 spaces, no non-whitespace + } + + public function testIsValidSecretKeyWithValidKeys(): void + { + $this->assertTrue($this->state->isValidSecretKey('a')); + $this->assertTrue($this->state->isValidSecretKey('my-secret-key')); + $this->assertTrue($this->state->isValidSecretKey(str_repeat('x', 64))); + } + + public function testIsValidSecretKeyWithInvalidKeys(): void + { + $this->assertFalse($this->state->isValidSecretKey('')); + $this->assertFalse($this->state->isValidSecretKey(str_repeat('x', 65))); + } + + public function testIsValidAccountNameWithValidNames(): void + { + $this->assertTrue($this->state->isValidAccountName('John')); + $this->assertTrue($this->state->isValidAccountName('a')); + } + + public function testIsValidAccountNameWithInvalidNames(): void + { + $this->assertFalse($this->state->isValidAccountName('')); + $this->assertFalse($this->state->isValidAccountName(' ')); + } + + public function testIsValidAppDomainInputWithValidDomains(): void + { + $this->assertTrue($this->state->isValidAppDomainInput('localhost')); + $this->assertTrue($this->state->isValidAppDomainInput('example.com')); + $this->assertTrue($this->state->isValidAppDomainInput('sub.example.com')); + $this->assertTrue($this->state->isValidAppDomainInput('127.0.0.1')); + $this->assertTrue($this->state->isValidAppDomainInput('192.168.1.1')); + } + + public function testIsValidAppDomainInputWithPort(): void + { + $this->assertTrue($this->state->isValidAppDomainInput('localhost:8080')); + $this->assertTrue($this->state->isValidAppDomainInput('example.com:443')); + $this->assertTrue($this->state->isValidAppDomainInput('127.0.0.1:3000')); + } + + public function testIsValidAppDomainInputWithIpv6(): void + { + $this->assertTrue($this->state->isValidAppDomainInput('[::1]')); + $this->assertTrue($this->state->isValidAppDomainInput('[::1]:8080')); + } + + public function testIsValidAppDomainInputWithInvalidDomains(): void + { + $this->assertFalse($this->state->isValidAppDomainInput('')); + $this->assertFalse($this->state->isValidAppDomainInput(' ')); + $this->assertFalse($this->state->isValidAppDomainInput('localhost:99999')); + $this->assertFalse($this->state->isValidAppDomainInput('localhost:0')); + $this->assertFalse($this->state->isValidAppDomainInput('host:port:extra')); + } + + public function testIsValidDatabaseAdapterWithValidAdapters(): void + { + $this->assertTrue($this->state->isValidDatabaseAdapter('mongodb')); + $this->assertTrue($this->state->isValidDatabaseAdapter('mariadb')); + $this->assertTrue($this->state->isValidDatabaseAdapter('postgresql')); + } + + public function testIsValidDatabaseAdapterWithInvalidAdapters(): void + { + $this->assertFalse($this->state->isValidDatabaseAdapter('')); + $this->assertFalse($this->state->isValidDatabaseAdapter('mysql')); + $this->assertFalse($this->state->isValidDatabaseAdapter('postgres')); + $this->assertFalse($this->state->isValidDatabaseAdapter('PostgreSQL')); + $this->assertFalse($this->state->isValidDatabaseAdapter('MongoDB')); // case sensitive + } + + public function testProgressFilePathFormat(): void + { + $path = $this->state->progressFilePath('test123'); + $this->assertStringContainsString('appwrite-install-test123.json', $path); + $this->assertStringStartsWith(sys_get_temp_dir(), $path); + } + + public function testReadProgressFileReturnsDefaultForMissing(): void + { + $data = $this->state->readProgressFile('nonexistent-id-' . uniqid()); + $this->assertIsArray($data); + $this->assertArrayHasKey('installId', $data); + $this->assertArrayHasKey('steps', $data); + $this->assertEmpty($data['steps']); + } + + public function testWriteAndReadProgressFile(): void + { + $installId = 'test-' . uniqid(); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Writing environment variables', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertIsArray($data); + $this->assertArrayHasKey('steps', $data); + $this->assertArrayHasKey(Server::STEP_ENV_VARS, $data['steps']); + $this->assertEquals(Server::STATUS_IN_PROGRESS, $data['steps'][Server::STEP_ENV_VARS]['status']); + $this->assertEquals('Writing environment variables', $data['steps'][Server::STEP_ENV_VARS]['message']); + + // Cleanup + @unlink($this->state->progressFilePath($installId)); + } + + public function testWriteProgressFileAccumulatesSteps(): void + { + $installId = 'test-multi-' . uniqid(); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Done', + 'updatedAt' => time(), + ]); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_DOCKER_COMPOSE, + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Generating compose file', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertCount(2, $data['steps']); + $this->assertArrayHasKey(Server::STEP_ENV_VARS, $data['steps']); + $this->assertArrayHasKey(Server::STEP_DOCKER_COMPOSE, $data['steps']); + + // Cleanup + @unlink($this->state->progressFilePath($installId)); + } + + public function testWriteProgressFileStoresPayload(): void + { + $installId = 'test-payload-' . uniqid(); + + $this->state->writeProgressFile($installId, [ + 'payload' => [ + 'httpPort' => '80', + 'httpsPort' => '443', + 'database' => 'mariadb', + ], + 'step' => 'start', + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Started', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertArrayHasKey('payload', $data); + $this->assertEquals('80', $data['payload']['httpPort']); + $this->assertEquals('443', $data['payload']['httpsPort']); + $this->assertEquals('mariadb', $data['payload']['database']); + $this->assertArrayHasKey('startedAt', $data); + + // Cleanup + @unlink($this->state->progressFilePath($installId)); + } + + public function testWriteProgressFileStoresErrorMessage(): void + { + $installId = 'test-error-' . uniqid(); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_DOCKER_CONTAINERS, + 'status' => Server::STATUS_ERROR, + 'message' => 'Container failed to start', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertArrayHasKey('error', $data); + $this->assertEquals('Container failed to start', $data['error']); + + // Cleanup + @unlink($this->state->progressFilePath($installId)); + } + + public function testWriteProgressFileStoresDetails(): void + { + $installId = 'test-details-' . uniqid(); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_DOCKER_COMPOSE, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Done', + 'details' => ['composeFile' => '/path/to/docker-compose.yml'], + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertArrayHasKey('details', $data); + $this->assertArrayHasKey(Server::STEP_DOCKER_COMPOSE, $data['details']); + $this->assertEquals('/path/to/docker-compose.yml', $data['details'][Server::STEP_DOCKER_COMPOSE]['composeFile']); + + // Cleanup + @unlink($this->state->progressFilePath($installId)); + } + + public function testBuildConfigReturnsConfigInstance(): void + { + // Clear env to avoid interference + putenv('APPWRITE_INSTALLER_CONFIG'); + + $config = $this->state->buildConfig([], false); + $this->assertInstanceOf(Config::class, $config); + } + + public function testBuildConfigAppliesOverrides(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + + $config = $this->state->buildConfig(['defaultHttpPort' => '9090'], false); + $this->assertEquals('9090', $config->getDefaultHttpPort()); + } + + public function testBuildConfigFromEnvVar(): void + { + $envData = json_encode([ + 'defaultHttpPort' => '8888', + 'isUpgrade' => true, + ]); + putenv('APPWRITE_INSTALLER_CONFIG=' . $envData); + + $config = $this->state->buildConfig([], true); + $this->assertEquals('8888', $config->getDefaultHttpPort()); + $this->assertTrue($config->isUpgrade()); + + // Cleanup + putenv('APPWRITE_INSTALLER_CONFIG'); + } + + public function testBuildConfigOverridesEnv(): void + { + $envData = json_encode(['defaultHttpPort' => '8888']); + putenv('APPWRITE_INSTALLER_CONFIG=' . $envData); + + $config = $this->state->buildConfig(['defaultHttpPort' => '7777'], true); + $this->assertEquals('7777', $config->getDefaultHttpPort()); + + // Cleanup + putenv('APPWRITE_INSTALLER_CONFIG'); + } + + public function testSanitizeInstallIdWithOnlySpecialChars(): void + { + $this->assertEquals('', $this->state->sanitizeInstallId('!@#$%^&*()')); + } + + public function testSanitizeInstallIdWithUnicode(): void + { + // Unicode letters are stripped byte-by-byte, only ASCII alphanum + hyphen + underscore kept + // 'é' is 2 bytes (0xC3 0xA9), both stripped => 'héllo' becomes 'hllo' + $this->assertEquals('hllo', $this->state->sanitizeInstallId('héllo')); + } + + public function testSanitizeInstallIdWithExactly64Chars(): void + { + $exact = str_repeat('b', 64); + $this->assertEquals($exact, $this->state->sanitizeInstallId($exact)); + $this->assertEquals(64, strlen($this->state->sanitizeInstallId($exact))); + } + + public function testSanitizeInstallIdWithBooleanInput(): void + { + $this->assertEquals('', $this->state->sanitizeInstallId(true)); + $this->assertEquals('', $this->state->sanitizeInstallId(false)); + } + + public function testSanitizeInstallIdWithArrayInput(): void + { + $this->assertEquals('', $this->state->sanitizeInstallId([])); + } + + public function testSanitizeInstallIdPreservesCase(): void + { + $this->assertEquals('AbCdEf', $this->state->sanitizeInstallId('AbCdEf')); + } + + public function testIsValidPortBoundaryValues(): void + { + $this->assertTrue($this->state->isValidPort('1')); + $this->assertTrue($this->state->isValidPort('65535')); + $this->assertFalse($this->state->isValidPort('0')); + $this->assertFalse($this->state->isValidPort('65536')); + } + + public function testIsValidPortWithLeadingZeros(): void + { + // '080' is digits-only and parses to 80 which is in range + $this->assertTrue($this->state->isValidPort('080')); + // '00' parses to 0, which is out of range + $this->assertFalse($this->state->isValidPort('00')); + } + + public function testIsValidPortWithWhitespace(): void + { + // Contains non-digit characters + $this->assertFalse($this->state->isValidPort(' 80')); + $this->assertFalse($this->state->isValidPort('80 ')); + $this->assertFalse($this->state->isValidPort(' 80 ')); + } + + public function testIsValidPortWithNegativeNumber(): void + { + $this->assertFalse($this->state->isValidPort('-80')); + $this->assertFalse($this->state->isValidPort('-1')); + } + + public function testIsValidPortWithVeryLargeNumber(): void + { + $this->assertFalse($this->state->isValidPort('999999')); + $this->assertFalse($this->state->isValidPort('100000')); + } + + public function testIsValidPasswordExactly8Chars(): void + { + $this->assertTrue($this->state->isValidPassword('12345678')); + $this->assertFalse($this->state->isValidPassword('1234567')); + } + + public function testIsValidPasswordWithTabsAndNewlines(): void + { + // Tabs/newlines count as whitespace, but need at least one non-whitespace + $this->assertFalse($this->state->isValidPassword("\t\t\t\t\t\t\t\t")); // 8 tabs + $this->assertTrue($this->state->isValidPassword("\t\t\t\ttest")); // mixed + } + + public function testIsValidPasswordWithMixedWhitespaceAndChars(): void + { + $this->assertTrue($this->state->isValidPassword(' a ')); // has non-whitespace + } + + public function testIsValidSecretKeyExactly64Chars(): void + { + $this->assertTrue($this->state->isValidSecretKey(str_repeat('a', 64))); + } + + public function testIsValidSecretKeyWithWhitespace(): void + { + // Whitespace-only is still non-empty and <= 64 chars + $this->assertTrue($this->state->isValidSecretKey(' ')); + $this->assertTrue($this->state->isValidSecretKey(' ')); + } + + public function testIsValidAppDomainInputWithEmptyPort(): void + { + // "host:" splits to ['host', ''] - empty port with null check + $this->assertTrue($this->state->isValidAppDomainInput('localhost:')); + } + + public function testIsValidAppDomainInputWithIpv4Address(): void + { + $this->assertTrue($this->state->isValidAppDomainInput('10.0.0.1')); + $this->assertTrue($this->state->isValidAppDomainInput('255.255.255.255')); + $this->assertTrue($this->state->isValidAppDomainInput('0.0.0.0')); + } + + public function testIsValidAppDomainInputIpv6WithoutBrackets(): void + { + // Raw IPv6 without brackets: "::1" has two colons, so count($parts) > 2 => false + $this->assertFalse($this->state->isValidAppDomainInput('::1')); + $this->assertFalse($this->state->isValidAppDomainInput('fe80::1')); + } + + public function testIsValidAppDomainInputIpv6MalformedBrackets(): void + { + $this->assertFalse($this->state->isValidAppDomainInput('[')); + $this->assertFalse($this->state->isValidAppDomainInput('[]')); + $this->assertFalse($this->state->isValidAppDomainInput('[invalid')); + } + + public function testIsValidAppDomainInputWithSubdomains(): void + { + $this->assertTrue($this->state->isValidAppDomainInput('a.b.c.d.example.com')); + $this->assertTrue($this->state->isValidAppDomainInput('my-app.example.io:8080')); + } + + public function testIsValidAppDomainInputWithInvalidPortNumber(): void + { + $this->assertFalse($this->state->isValidAppDomainInput('localhost:abc')); + $this->assertFalse($this->state->isValidAppDomainInput('localhost:70000')); + $this->assertFalse($this->state->isValidAppDomainInput('[::1]:70000')); + } + + public function testIsValidDatabaseAdapterWithWhitespace(): void + { + $this->assertFalse($this->state->isValidDatabaseAdapter(' mongodb')); + $this->assertFalse($this->state->isValidDatabaseAdapter('mariadb ')); + $this->assertFalse($this->state->isValidDatabaseAdapter(' postgresql')); + } + + public function testIsValidDatabaseAdapterCaseSensitivity(): void + { + $this->assertFalse($this->state->isValidDatabaseAdapter('MongoDB')); + $this->assertFalse($this->state->isValidDatabaseAdapter('MariaDB')); + $this->assertFalse($this->state->isValidDatabaseAdapter('PostgreSQL')); + $this->assertFalse($this->state->isValidDatabaseAdapter('MONGODB')); + } + + public function testReadProgressFileWithCorruptedJson(): void + { + $installId = 'test-corrupt-' . uniqid(); + $this->trackProgressFile($installId); + $path = $this->state->progressFilePath($installId); + file_put_contents($path, 'not valid json {{{'); + + $data = $this->state->readProgressFile($installId); + $this->assertIsArray($data); + $this->assertArrayHasKey('installId', $data); + $this->assertArrayHasKey('steps', $data); + $this->assertEmpty($data['steps']); + } + + public function testReadProgressFileWithEmptyFile(): void + { + $installId = 'test-empty-' . uniqid(); + $this->trackProgressFile($installId); + $path = $this->state->progressFilePath($installId); + file_put_contents($path, ''); + + $data = $this->state->readProgressFile($installId); + $this->assertIsArray($data); + $this->assertArrayHasKey('installId', $data); + $this->assertEmpty($data['steps']); + } + + public function testReadProgressFileWithJsonScalar(): void + { + $installId = 'test-scalar-' . uniqid(); + $this->trackProgressFile($installId); + $path = $this->state->progressFilePath($installId); + file_put_contents($path, '"just a string"'); + + $data = $this->state->readProgressFile($installId); + $this->assertIsArray($data); + $this->assertEmpty($data['steps']); + } + + public function testWriteProgressFileOverwritesExistingStep(): void + { + $installId = 'test-overwrite-' . uniqid(); + $this->trackProgressFile($installId); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Working...', + 'updatedAt' => time(), + ]); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Done!', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertCount(1, $data['steps']); // Still 1 step, overwritten + $this->assertEquals(Server::STATUS_COMPLETED, $data['steps'][Server::STEP_ENV_VARS]['status']); + $this->assertEquals('Done!', $data['steps'][Server::STEP_ENV_VARS]['message']); + } + + public function testWriteProgressFileWithEmptyStep(): void + { + $installId = 'test-emptystep-' . uniqid(); + $this->trackProgressFile($installId); + + $this->state->writeProgressFile($installId, [ + 'step' => '', + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'No step name', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + // Empty step name treated as falsy, should not add to steps + $this->assertEmpty($data['steps']); + } + + public function testWriteProgressFilePreservesPayloadAcrossWrites(): void + { + $installId = 'test-persist-' . uniqid(); + $this->trackProgressFile($installId); + + $this->state->writeProgressFile($installId, [ + 'payload' => ['httpPort' => '80', 'database' => 'mongodb'], + 'step' => 'start', + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Starting', + 'updatedAt' => time(), + ]); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Env done', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + // Payload from first write should still be present + $this->assertArrayHasKey('payload', $data); + $this->assertEquals('80', $data['payload']['httpPort']); + $this->assertEquals('mongodb', $data['payload']['database']); + // Both steps should exist + $this->assertArrayHasKey('start', $data['steps']); + $this->assertArrayHasKey(Server::STEP_ENV_VARS, $data['steps']); + } + + public function testWriteProgressFileUpdatesTimestamp(): void + { + $installId = 'test-time-' . uniqid(); + $this->trackProgressFile($installId); + $now = time(); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'test', + 'updatedAt' => $now, + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertEquals($now, $data['updatedAt']); + } + + public function testWriteProgressFileStartedAtOnlySetOnce(): void + { + $installId = 'test-startedat-' . uniqid(); + $this->trackProgressFile($installId); + $firstTime = time() - 100; + + // First write with payload sets startedAt + $this->state->writeProgressFile($installId, [ + 'payload' => ['httpPort' => '80'], + 'step' => 'start', + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Starting', + 'updatedAt' => $firstTime, + ]); + + $data = $this->state->readProgressFile($installId); + $startedAt = $data['startedAt']; + + // Second write with payload should NOT overwrite startedAt + $this->state->writeProgressFile($installId, [ + 'payload' => ['httpPort' => '80'], + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Env', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertEquals($startedAt, $data['startedAt']); + } + + public function testReserveGlobalLockFirstLockSucceeds(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId = 'lock-test-' . uniqid(); + $result = $this->state->reserveGlobalLock($installId); + $this->assertEquals('ok', $result); + } + + public function testReserveGlobalLockSameIdCanRelock(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId = 'lock-relock-' . uniqid(); + + $result1 = $this->state->reserveGlobalLock($installId); + $this->assertEquals('ok', $result1); + + // Same ID can re-reserve + $result2 = $this->state->reserveGlobalLock($installId); + $this->assertEquals('ok', $result2); + } + + public function testReserveGlobalLockDifferentIdBlocked(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId1 = 'lock-id1-' . uniqid(); + $installId2 = 'lock-id2-' . uniqid(); + + $result1 = $this->state->reserveGlobalLock($installId1); + $this->assertEquals('ok', $result1); + + // Different ID should be blocked + $result2 = $this->state->reserveGlobalLock($installId2); + $this->assertEquals('locked', $result2); + } + + public function testReserveGlobalLockAfterCompleted(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId1 = 'lock-done-' . uniqid(); + $installId2 = 'lock-new-' . uniqid(); + + $this->state->reserveGlobalLock($installId1); + $this->state->updateGlobalLock($installId1, Server::STATUS_COMPLETED); + + // After completion, a new install should be able to lock + $result = $this->state->reserveGlobalLock($installId2); + $this->assertEquals('ok', $result); + } + + public function testReserveGlobalLockAfterError(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId1 = 'lock-err-' . uniqid(); + $installId2 = 'lock-retry-' . uniqid(); + + $this->state->reserveGlobalLock($installId1); + $this->state->updateGlobalLock($installId1, Server::STATUS_ERROR); + + // After error, a new install should be able to lock + $result = $this->state->reserveGlobalLock($installId2); + $this->assertEquals('ok', $result); + } + + public function testReserveGlobalLockExpiredLockAllowsNew(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + + // Manually write an expired lock (updatedAt way in the past) + $expiredLock = [ + 'installId' => 'expired-lock', + 'status' => Server::STATUS_IN_PROGRESS, + 'updatedAt' => time() - 7200, // 2 hours ago, timeout is 1 hour + ]; + file_put_contents(Server::INSTALLER_LOCK_FILE, json_encode($expiredLock)); + + $newId = 'lock-after-expired-' . uniqid(); + $result = $this->state->reserveGlobalLock($newId); + $this->assertEquals('ok', $result); + } + + public function testUpdateGlobalLockUpdatesOwnLock(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId = 'lock-update-' . uniqid(); + + $this->state->reserveGlobalLock($installId); + $this->state->updateGlobalLock($installId, Server::STATUS_COMPLETED); + + // Read lock file directly to verify + $contents = file_get_contents(Server::INSTALLER_LOCK_FILE); + $this->assertNotFalse($contents); + $lock = json_decode($contents, true); + $this->assertIsArray($lock); + $this->assertEquals($installId, $lock['installId']); + $this->assertEquals(Server::STATUS_COMPLETED, $lock['status']); + } + + public function testUpdateGlobalLockIgnoresDifferentId(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId1 = 'lock-owner-' . uniqid(); + $installId2 = 'lock-intruder-' . uniqid(); + + $this->state->reserveGlobalLock($installId1); + + // Attempt to update with a different ID should be silently ignored + $this->state->updateGlobalLock($installId2, Server::STATUS_COMPLETED); + + // Original lock should still be in progress + $contents = file_get_contents(Server::INSTALLER_LOCK_FILE); + $lock = json_decode($contents, true); + $this->assertEquals($installId1, $lock['installId']); + $this->assertEquals(Server::STATUS_IN_PROGRESS, $lock['status']); + } + + public function testApplyEnvConfigWithConfigObject(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + @unlink(Server::INSTALLER_CONFIG_FILE); + + $cfg = new Config(['defaultHttpPort' => '5555', 'isLocal' => true]); + $this->state->applyEnvConfig($cfg); + + // Verify env var was set + $envVal = getenv('APPWRITE_INSTALLER_CONFIG'); + $this->assertNotFalse($envVal); + + $decoded = json_decode($envVal, true); + $this->assertIsArray($decoded); + $this->assertEquals('5555', $decoded['defaultHttpPort']); + $this->assertTrue($decoded['isLocal']); + + // Verify config file was written + $this->assertFileExists(Server::INSTALLER_CONFIG_FILE); + $fileContents = file_get_contents(Server::INSTALLER_CONFIG_FILE); + $this->assertNotFalse($fileContents); + $fileDecoded = json_decode($fileContents, true); + $this->assertEquals('5555', $fileDecoded['defaultHttpPort']); + } + + public function testApplyEnvConfigWithArray(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + @unlink(Server::INSTALLER_CONFIG_FILE); + + $this->state->applyEnvConfig(['defaultHttpPort' => '6666']); + + $envVal = getenv('APPWRITE_INSTALLER_CONFIG'); + $this->assertNotFalse($envVal); + $decoded = json_decode($envVal, true); + $this->assertEquals('6666', $decoded['defaultHttpPort']); + } + + public function testApplyEnvConfigThenBuildConfigReadsIt(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + @unlink(Server::INSTALLER_CONFIG_FILE); + + $cfg = new Config(['defaultHttpPort' => '4444', 'isUpgrade' => true]); + $this->state->applyEnvConfig($cfg); + + // buildConfig with useEnv=true should pick up the env var + $rebuilt = $this->state->buildConfig([], true); + $this->assertEquals('4444', $rebuilt->getDefaultHttpPort()); + $this->assertTrue($rebuilt->isUpgrade()); + } + + public function testBuildConfigWithInvalidEnvJson(): void + { + putenv('APPWRITE_INSTALLER_CONFIG=not-valid-json'); + + // Should fall back to config file (or defaults if file doesn't exist) + @unlink(Server::INSTALLER_CONFIG_FILE); + $config = $this->state->buildConfig([], true); + // Should get defaults since both env and file are invalid/missing + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testBuildConfigWithEmptyEnvVar(): void + { + putenv('APPWRITE_INSTALLER_CONFIG='); + + @unlink(Server::INSTALLER_CONFIG_FILE); + $config = $this->state->buildConfig([], true); + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testBuildConfigFallsBackToConfigFile(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + + // Write a config file + $data = json_encode(['defaultHttpPort' => '3333']); + file_put_contents(Server::INSTALLER_CONFIG_FILE, $data); + + $config = $this->state->buildConfig([], true); + $this->assertEquals('3333', $config->getDefaultHttpPort()); + } + + public function testBuildConfigWithCorruptedConfigFile(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + + file_put_contents(Server::INSTALLER_CONFIG_FILE, 'garbage data {{{'); + + $config = $this->state->buildConfig([], true); + // Should get defaults + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testBuildConfigWithEmptyConfigFile(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + + file_put_contents(Server::INSTALLER_CONFIG_FILE, ''); + + $config = $this->state->buildConfig([], true); + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testBuildConfigUseEnvFalseIgnoresEnvAndFile(): void + { + putenv('APPWRITE_INSTALLER_CONFIG=' . json_encode(['defaultHttpPort' => '9999'])); + file_put_contents(Server::INSTALLER_CONFIG_FILE, json_encode(['defaultHttpPort' => '8888'])); + + $config = $this->state->buildConfig([], false); + // Neither env nor file should be used + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testBuildConfigWithJsonScalarEnvVar(): void + { + // A JSON scalar (string) is not an array, so decoding succeeds but is_array fails + putenv('APPWRITE_INSTALLER_CONFIG="just a string"'); + @unlink(Server::INSTALLER_CONFIG_FILE); + + $config = $this->state->buildConfig([], true); + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testHashSensitiveValueWithNewlines(): void + { + // Newlines are not stripped by trim but surrounding whitespace is + $hash1 = $this->state->hashSensitiveValue("line1\nline2"); + $hash2 = $this->state->hashSensitiveValue("line1\nline2"); + $this->assertEquals($hash1, $hash2); + $this->assertNotEmpty($hash1); + } + + public function testHashSensitiveValueWithOnlyNewline(): void + { + // A newline is not whitespace that trim() removes? Actually trim() removes \n + // "\n" trimmed becomes "" => should return '' + $this->assertEquals('', $this->state->hashSensitiveValue("\n")); + } + + public function testIsValidEmailAddressWithUnicodeLocal(): void + { + // PHP's FILTER_VALIDATE_EMAIL does not support internationalized emails + $this->assertFalse($this->state->isValidEmailAddress('ünïcödé@example.com')); + } + + public function testIsValidEmailAddressWithDoubleAt(): void + { + $this->assertFalse($this->state->isValidEmailAddress('user@@example.com')); + } + + public function testIsValidEmailAddressWithSpaces(): void + { + $this->assertFalse($this->state->isValidEmailAddress('user @example.com')); + $this->assertFalse($this->state->isValidEmailAddress('user@ example.com')); + } + + public function testIsValidAccountNameWithOnlyTabs(): void + { + $this->assertFalse($this->state->isValidAccountName("\t\t")); + } + + public function testIsValidAccountNameWithMixedWhitespace(): void + { + $this->assertTrue($this->state->isValidAccountName(" a ")); + } + + public function testProgressFilePathWithSpecialCharsInId(): void + { + // The ID would normally be sanitized before this call, but the method itself + // just concatenates + $path = $this->state->progressFilePath('test-with-special'); + $this->assertStringContainsString('appwrite-install-test-with-special.json', $path); + } + + public function testProgressFilePathWithEmptyId(): void + { + $path = $this->state->progressFilePath(''); + $this->assertStringContainsString('appwrite-install-.json', $path); + } + + public function testWriteProgressFileCompletedDoesNotSetError(): void + { + $installId = 'test-noerror-' . uniqid(); + $this->trackProgressFile($installId); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'All good', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertArrayNotHasKey('error', $data); + } + + public function testWriteProgressFileInProgressDoesNotSetError(): void + { + $installId = 'test-noerrip-' . uniqid(); + $this->trackProgressFile($installId); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_DOCKER_COMPOSE, + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Working', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertArrayNotHasKey('error', $data); + } + + public function testWriteProgressFileWithNoStep(): void + { + $installId = 'test-nostep-' . uniqid(); + $this->trackProgressFile($installId); + + $this->state->writeProgressFile($installId, [ + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'No step provided', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + // No step key means no step should be recorded + $this->assertEmpty($data['steps']); + // But updatedAt should still be set + $this->assertArrayHasKey('updatedAt', $data); + } + + public function testFullInstallationLifecycle(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId = 'lifecycle-' . uniqid(); + $this->trackProgressFile($installId); + + // 1. Reserve lock + $lockResult = $this->state->reserveGlobalLock($installId); + $this->assertEquals('ok', $lockResult); + + // 2. Write progress through multiple steps + $this->state->writeProgressFile($installId, [ + 'payload' => ['httpPort' => '80', 'database' => 'mongodb'], + 'step' => 'start', + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Started', + 'updatedAt' => time(), + ]); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Env vars written', + 'updatedAt' => time(), + ]); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_DOCKER_COMPOSE, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Compose generated', + 'updatedAt' => time(), + ]); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_DOCKER_CONTAINERS, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Containers started', + 'updatedAt' => time(), + ]); + + // 3. Verify progress + $data = $this->state->readProgressFile($installId); + $this->assertCount(4, $data['steps']); // start + 3 steps + $this->assertArrayHasKey('payload', $data); + $this->assertArrayHasKey('startedAt', $data); + + // 4. Complete the lock + $this->state->updateGlobalLock($installId, Server::STATUS_COMPLETED); + + // 5. Verify a new install can now proceed + $newId = 'lifecycle-new-' . uniqid(); + $this->trackProgressFile($newId); + $newResult = $this->state->reserveGlobalLock($newId); + $this->assertEquals('ok', $newResult); + } +} diff --git a/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php b/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php new file mode 100644 index 0000000000..c453dcade4 --- /dev/null +++ b/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php @@ -0,0 +1,168 @@ +validator = new AppDomain(); + } + + public function tearDown(): void + { + $this->validator = null; + } + + public function testDescription(): void + { + $this->assertNotEmpty($this->validator->getDescription()); + $this->assertIsString($this->validator->getDescription()); + } + + public function testIsArray(): void + { + $this->assertFalse($this->validator->isArray()); + } + + public function testType(): void + { + $this->assertEquals($this->validator::TYPE_STRING, $this->validator->getType()); + } + + public function testRejectsNonStringTypes(): void + { + $this->assertFalse($this->validator->isValid(null)); + $this->assertFalse($this->validator->isValid(false)); + $this->assertFalse($this->validator->isValid(true)); + $this->assertFalse($this->validator->isValid(123)); + $this->assertFalse($this->validator->isValid(12.34)); + $this->assertFalse($this->validator->isValid([])); + $this->assertFalse($this->validator->isValid(new \stdClass())); + } + + public function testRejectsEmptyString(): void + { + $this->assertFalse($this->validator->isValid('')); + } + + public function testRejectsWhitespaceOnly(): void + { + $this->assertFalse($this->validator->isValid(' ')); + $this->assertFalse($this->validator->isValid("\t")); + $this->assertFalse($this->validator->isValid("\n")); + } + + public function testAcceptsLocalhost(): void + { + $this->assertTrue($this->validator->isValid('localhost')); + } + + public function testAcceptsLocalhostWithPort(): void + { + $this->assertTrue($this->validator->isValid('localhost:8080')); + $this->assertTrue($this->validator->isValid('localhost:80')); + $this->assertTrue($this->validator->isValid('localhost:443')); + $this->assertTrue($this->validator->isValid('localhost:1')); + $this->assertTrue($this->validator->isValid('localhost:65535')); + } + + public function testAcceptsValidDomains(): void + { + $this->assertTrue($this->validator->isValid('example.com')); + $this->assertTrue($this->validator->isValid('sub.example.com')); + $this->assertTrue($this->validator->isValid('deep.sub.example.com')); + $this->assertTrue($this->validator->isValid('appwrite.io')); + $this->assertTrue($this->validator->isValid('my-app.example.org')); + } + + public function testAcceptsDomainsWithPort(): void + { + $this->assertTrue($this->validator->isValid('example.com:443')); + $this->assertTrue($this->validator->isValid('example.com:8080')); + $this->assertTrue($this->validator->isValid('sub.example.com:3000')); + } + + public function testAcceptsIPv4Addresses(): void + { + $this->assertTrue($this->validator->isValid('127.0.0.1')); + $this->assertTrue($this->validator->isValid('192.168.1.1')); + $this->assertTrue($this->validator->isValid('10.0.0.1')); + $this->assertTrue($this->validator->isValid('0.0.0.0')); + $this->assertTrue($this->validator->isValid('255.255.255.255')); + } + + public function testAcceptsIPv4WithPort(): void + { + $this->assertTrue($this->validator->isValid('127.0.0.1:8080')); + $this->assertTrue($this->validator->isValid('192.168.1.1:443')); + $this->assertTrue($this->validator->isValid('10.0.0.1:3000')); + } + + public function testAcceptsIPv6BracketNotation(): void + { + $this->assertTrue($this->validator->isValid('[::1]')); + $this->assertTrue($this->validator->isValid('[::1]:8080')); + $this->assertTrue($this->validator->isValid('[2001:db8::1]')); + $this->assertTrue($this->validator->isValid('[2001:db8::1]:443')); + // Scoped IPv6 with zone ID is not supported by FILTER_VALIDATE_IP + $this->assertFalse($this->validator->isValid('[fe80::1%25eth0]')); + } + + public function testRejectsInvalidDomains(): void + { + $this->assertFalse($this->validator->isValid('-invalid.com')); + $this->assertFalse($this->validator->isValid('invalid-.com')); + $this->assertFalse($this->validator->isValid('.example.com')); + } + + public function testRejectsInvalidPorts(): void + { + $this->assertFalse($this->validator->isValid('localhost:0')); + $this->assertFalse($this->validator->isValid('localhost:65536')); + $this->assertFalse($this->validator->isValid('localhost:99999')); + $this->assertFalse($this->validator->isValid('localhost:abc')); + $this->assertFalse($this->validator->isValid('localhost:-1')); + } + + public function testRejectsMultipleColonsWithoutBrackets(): void + { + $this->assertFalse($this->validator->isValid('::1')); + $this->assertFalse($this->validator->isValid('2001:db8::1')); + $this->assertFalse($this->validator->isValid('a:b:c')); + } + + public function testRejectsMalformedIPv6Brackets(): void + { + $this->assertFalse($this->validator->isValid('[')); + $this->assertFalse($this->validator->isValid('[]')); + $this->assertFalse($this->validator->isValid('[::1')); + $this->assertFalse($this->validator->isValid('::1]')); + $this->assertFalse($this->validator->isValid('[invalid')); + } + + public function testPortBoundaryValues(): void + { + $this->assertTrue($this->validator->isValid('localhost:1')); + $this->assertTrue($this->validator->isValid('localhost:65535')); + $this->assertFalse($this->validator->isValid('localhost:0')); + $this->assertFalse($this->validator->isValid('localhost:65536')); + } + + public function testTrimsWhitespace(): void + { + $this->assertTrue($this->validator->isValid(' localhost ')); + $this->assertTrue($this->validator->isValid(' example.com ')); + } + + public function testAcceptsEmptyPortSegment(): void + { + // 'localhost:' splits into host='localhost', port='' — empty port is skipped + $this->assertTrue($this->validator->isValid('localhost:')); + } +} diff --git a/tests/unit/Utopia/Request/Filters/V21Test.php b/tests/unit/Utopia/Request/Filters/V21Test.php new file mode 100644 index 0000000000..f514de71ee --- /dev/null +++ b/tests/unit/Utopia/Request/Filters/V21Test.php @@ -0,0 +1,376 @@ +filter = new V21(); + } + + public function tearDown(): void + { + } + + public static function functionsCreateTemplateDeploymentProvider(): array + { + return [ + 'convert version to type and reference' => [ + [ + 'templateId' => 'template123', + 'version' => '1.0.0', + ], + [ + 'templateId' => 'template123', + 'type' => 'tag', + 'reference' => '1.0.0', + ] + ], + 'handle version with semver string' => [ + [ + 'version' => 'v2.3.1', + 'templateId' => 'template456', + ], + [ + 'type' => 'tag', + 'reference' => 'v2.3.1', + 'templateId' => 'template456', + ] + ], + 'skip conversion when version is empty string' => [ + [ + 'templateId' => 'template123', + 'version' => '', + ], + [ + 'templateId' => 'template123', + 'version' => '', + ] + ], + 'skip conversion when version is missing' => [ + [ + 'templateId' => 'template123', + ], + [ + 'templateId' => 'template123', + ] + ], + 'preserve other fields when converting version' => [ + [ + 'templateId' => 'template123', + 'version' => '3.0.0', + 'activate' => true, + 'buildCommand' => 'npm run build', + ], + [ + 'templateId' => 'template123', + 'type' => 'tag', + 'reference' => '3.0.0', + 'activate' => true, + 'buildCommand' => 'npm run build', + ] + ], + ]; + } + + #[DataProvider('functionsCreateTemplateDeploymentProvider')] + public function testFunctionsCreateTemplateDeployment(array $content, array $expected): void + { + $model = 'functions.createTemplateDeployment'; + + $result = $this->filter->parse($content, $model); + + $this->assertEquals($expected, $result); + } + + public static function sitesCreateTemplateDeploymentProvider(): array + { + return [ + 'convert version to type and reference' => [ + [ + 'templateId' => 'site-template123', + 'version' => '2.0.0', + ], + [ + 'templateId' => 'site-template123', + 'type' => 'tag', + 'reference' => '2.0.0', + ] + ], + 'skip conversion when version is empty' => [ + [ + 'templateId' => 'site-template123', + 'version' => '', + ], + [ + 'templateId' => 'site-template123', + 'version' => '', + ] + ], + 'skip conversion when version is missing' => [ + [ + 'templateId' => 'site-template123', + ], + [ + 'templateId' => 'site-template123', + ] + ], + ]; + } + + #[DataProvider('sitesCreateTemplateDeploymentProvider')] + public function testSitesCreateTemplateDeployment(array $content, array $expected): void + { + $model = 'sites.createTemplateDeployment'; + + $result = $this->filter->parse($content, $model); + + $this->assertEquals($expected, $result); + } + + public static function functionsCreateProvider(): array + { + return [ + 'convert specification to buildSpecification and runtimeSpecification' => [ + [ + 'name' => 'test-function', + 'runtime' => 'node-18.0', + 'specification' => 's-1vcpu-512mb', + ], + [ + 'name' => 'test-function', + 'runtime' => 'node-18.0', + 'buildSpecification' => 's-1vcpu-512mb', + 'runtimeSpecification' => 's-1vcpu-512mb', + ] + ], + 'skip conversion when specification is empty' => [ + [ + 'name' => 'test-function', + 'runtime' => 'node-18.0', + 'specification' => '', + ], + [ + 'name' => 'test-function', + 'runtime' => 'node-18.0', + 'specification' => '', + ] + ], + 'skip conversion when specification is missing' => [ + [ + 'name' => 'test-function', + 'runtime' => 'node-18.0', + ], + [ + 'name' => 'test-function', + 'runtime' => 'node-18.0', + ] + ], + 'preserve other fields when converting specification' => [ + [ + 'name' => 'test-function', + 'specification' => 's-2vcpu-1gb', + 'timeout' => 30, + 'enabled' => true, + ], + [ + 'name' => 'test-function', + 'buildSpecification' => 's-2vcpu-1gb', + 'runtimeSpecification' => 's-2vcpu-1gb', + 'timeout' => 30, + 'enabled' => true, + ] + ], + ]; + } + + #[DataProvider('functionsCreateProvider')] + public function testFunctionsCreate(array $content, array $expected): void + { + $model = 'functions.create'; + + $result = $this->filter->parse($content, $model); + + $this->assertEquals($expected, $result); + } + + public static function sitesCreateProvider(): array + { + return [ + 'convert specification to buildSpecification and runtimeSpecification' => [ + [ + 'name' => 'test-site', + 'framework' => 'nextjs', + 'specification' => 's-1vcpu-512mb', + ], + [ + 'name' => 'test-site', + 'framework' => 'nextjs', + 'buildSpecification' => 's-1vcpu-512mb', + 'runtimeSpecification' => 's-1vcpu-512mb', + ] + ], + 'skip conversion when specification is empty' => [ + [ + 'name' => 'test-site', + 'specification' => '', + ], + [ + 'name' => 'test-site', + 'specification' => '', + ] + ], + 'skip conversion when specification is missing' => [ + [ + 'name' => 'test-site', + ], + [ + 'name' => 'test-site', + ] + ], + ]; + } + + #[DataProvider('sitesCreateProvider')] + public function testSitesCreate(array $content, array $expected): void + { + $model = 'sites.create'; + + $result = $this->filter->parse($content, $model); + + $this->assertEquals($expected, $result); + } + + public static function functionsUpdateProvider(): array + { + return [ + 'convert specification to buildSpecification and runtimeSpecification' => [ + [ + 'name' => 'updated-function', + 'specification' => 's-2vcpu-1gb', + ], + [ + 'name' => 'updated-function', + 'buildSpecification' => 's-2vcpu-1gb', + 'runtimeSpecification' => 's-2vcpu-1gb', + ] + ], + 'skip conversion when specification is missing' => [ + [ + 'name' => 'updated-function', + ], + [ + 'name' => 'updated-function', + ] + ], + ]; + } + + #[DataProvider('functionsUpdateProvider')] + public function testFunctionsUpdate(array $content, array $expected): void + { + $model = 'functions.update'; + + $result = $this->filter->parse($content, $model); + + $this->assertEquals($expected, $result); + } + + public static function sitesUpdateProvider(): array + { + return [ + 'convert specification to buildSpecification and runtimeSpecification' => [ + [ + 'name' => 'updated-site', + 'specification' => 's-2vcpu-1gb', + ], + [ + 'name' => 'updated-site', + 'buildSpecification' => 's-2vcpu-1gb', + 'runtimeSpecification' => 's-2vcpu-1gb', + ] + ], + 'skip conversion when specification is missing' => [ + [ + 'name' => 'updated-site', + ], + [ + 'name' => 'updated-site', + ] + ], + ]; + } + + #[DataProvider('sitesUpdateProvider')] + public function testSitesUpdate(array $content, array $expected): void + { + $model = 'sites.update'; + + $result = $this->filter->parse($content, $model); + + $this->assertEquals($expected, $result); + } + + public static function unmatchedModelProvider(): array + { + return [ + 'unmatched model passes through unchanged' => [ + 'databases.create', + [ + 'name' => 'test-database', + 'databaseId' => 'db123', + ], + [ + 'name' => 'test-database', + 'databaseId' => 'db123', + ] + ], + 'empty content for unmatched model' => [ + 'users.list', + [], + [] + ], + 'content with specification for unmatched model is not converted' => [ + 'deployments.create', + [ + 'specification' => 's-1vcpu-512mb', + 'name' => 'test', + ], + [ + 'specification' => 's-1vcpu-512mb', + 'name' => 'test', + ] + ], + 'content with version for unmatched model is not converted' => [ + 'databases.createDocument', + [ + 'version' => '1.0.0', + 'data' => 'test', + ], + [ + 'version' => '1.0.0', + 'data' => 'test', + ] + ], + ]; + } + + #[DataProvider('unmatchedModelProvider')] + public function testUnmatchedModel(string $model, array $content, array $expected): void + { + $result = $this->filter->parse($content, $model); + + $this->assertEquals($expected, $result); + } +} diff --git a/tests/unit/Utopia/Response/Filters/V21Test.php b/tests/unit/Utopia/Response/Filters/V21Test.php new file mode 100644 index 0000000000..a049b70d84 --- /dev/null +++ b/tests/unit/Utopia/Response/Filters/V21Test.php @@ -0,0 +1,557 @@ +filter = new V21(); + } + + public function tearDown(): void + { + } + + public static function functionProvider(): array + { + return [ + 'merge buildSpecification and runtimeSpecification into specification' => [ + [ + 'name' => 'test-function', + 'buildSpecification' => 's-1vcpu-512mb', + 'runtimeSpecification' => 's-1vcpu-256mb', + 'runtime' => 'node-18.0', + ], + [ + 'name' => 'test-function', + 'specification' => 's-1vcpu-512mb', + 'runtime' => 'node-18.0', + ] + ], + 'use buildSpecification when present' => [ + [ + 'buildSpecification' => 's-2vcpu-1gb', + 'runtimeSpecification' => 's-1vcpu-512mb', + ], + [ + 'specification' => 's-2vcpu-1gb', + ] + ], + 'fallback to specification when buildSpecification is missing' => [ + [ + 'specification' => 's-1vcpu-512mb', + 'runtimeSpecification' => 's-1vcpu-256mb', + ], + [ + 'specification' => 's-1vcpu-512mb', + ] + ], + 'handle missing both buildSpecification and specification' => [ + [ + 'name' => 'test-function', + 'runtimeSpecification' => 's-1vcpu-256mb', + ], + [ + 'name' => 'test-function', + 'specification' => null, + ] + ], + 'handle no spec fields at all' => [ + [ + 'name' => 'test-function', + 'runtime' => 'node-18.0', + ], + [ + 'name' => 'test-function', + 'specification' => null, + 'runtime' => 'node-18.0', + ] + ], + 'handle empty buildSpecification string' => [ + [ + 'buildSpecification' => '', + 'runtimeSpecification' => 's-1vcpu-256mb', + ], + [ + 'specification' => '', + ] + ], + ]; + } + + #[DataProvider('functionProvider')] + public function testFunction(array $content, array $expected): void + { + $model = Response::MODEL_FUNCTION; + + $result = $this->filter->parse($content, $model); + + $this->assertEquals($expected, $result); + } + + public static function functionListProvider(): array + { + return [ + 'convert list of functions' => [ + [ + 'total' => 2, + 'functions' => [ + [ + 'name' => 'function-1', + 'buildSpecification' => 's-1vcpu-512mb', + 'runtimeSpecification' => 's-1vcpu-256mb', + ], + [ + 'name' => 'function-2', + 'buildSpecification' => 's-2vcpu-1gb', + 'runtimeSpecification' => 's-1vcpu-512mb', + ], + ], + ], + [ + 'total' => 2, + 'functions' => [ + [ + 'name' => 'function-1', + 'specification' => 's-1vcpu-512mb', + ], + [ + 'name' => 'function-2', + 'specification' => 's-2vcpu-1gb', + ], + ], + ] + ], + 'handle empty function list' => [ + [ + 'total' => 0, + 'functions' => [], + ], + [ + 'total' => 0, + 'functions' => [], + ] + ], + 'handle single function in list' => [ + [ + 'total' => 1, + 'functions' => [ + [ + 'buildSpecification' => 's-1vcpu-512mb', + 'runtimeSpecification' => 's-1vcpu-256mb', + ], + ], + ], + [ + 'total' => 1, + 'functions' => [ + [ + 'specification' => 's-1vcpu-512mb', + ], + ], + ] + ], + ]; + } + + #[DataProvider('functionListProvider')] + public function testFunctionList(array $content, array $expected): void + { + $model = Response::MODEL_FUNCTION_LIST; + + $result = $this->filter->parse($content, $model); + + $this->assertEquals($expected, $result); + } + + public static function siteProvider(): array + { + return [ + 'merge buildSpecification and runtimeSpecification into specification' => [ + [ + 'name' => 'test-site', + 'buildSpecification' => 's-1vcpu-512mb', + 'runtimeSpecification' => 's-1vcpu-256mb', + 'framework' => 'nextjs', + ], + [ + 'name' => 'test-site', + 'specification' => 's-1vcpu-512mb', + 'framework' => 'nextjs', + ] + ], + 'use buildSpecification when present' => [ + [ + 'buildSpecification' => 's-2vcpu-1gb', + 'runtimeSpecification' => 's-1vcpu-512mb', + ], + [ + 'specification' => 's-2vcpu-1gb', + ] + ], + 'fallback to specification when buildSpecification is missing' => [ + [ + 'specification' => 's-1vcpu-512mb', + 'runtimeSpecification' => 's-1vcpu-256mb', + ], + [ + 'specification' => 's-1vcpu-512mb', + ] + ], + 'handle missing both buildSpecification and specification' => [ + [ + 'name' => 'test-site', + 'runtimeSpecification' => 's-1vcpu-256mb', + ], + [ + 'name' => 'test-site', + 'specification' => null, + ] + ], + 'handle no spec fields at all' => [ + [ + 'name' => 'test-site', + 'framework' => 'react', + ], + [ + 'name' => 'test-site', + 'specification' => null, + 'framework' => 'react', + ] + ], + ]; + } + + #[DataProvider('siteProvider')] + public function testSite(array $content, array $expected): void + { + $model = Response::MODEL_SITE; + + $result = $this->filter->parse($content, $model); + + $this->assertEquals($expected, $result); + } + + public static function siteListProvider(): array + { + return [ + 'convert list of sites' => [ + [ + 'total' => 2, + 'sites' => [ + [ + 'name' => 'site-1', + 'buildSpecification' => 's-1vcpu-512mb', + 'runtimeSpecification' => 's-1vcpu-256mb', + ], + [ + 'name' => 'site-2', + 'buildSpecification' => 's-2vcpu-1gb', + 'runtimeSpecification' => 's-1vcpu-512mb', + ], + ], + ], + [ + 'total' => 2, + 'sites' => [ + [ + 'name' => 'site-1', + 'specification' => 's-1vcpu-512mb', + ], + [ + 'name' => 'site-2', + 'specification' => 's-2vcpu-1gb', + ], + ], + ] + ], + 'handle empty site list' => [ + [ + 'total' => 0, + 'sites' => [], + ], + [ + 'total' => 0, + 'sites' => [], + ] + ], + 'handle single site in list' => [ + [ + 'total' => 1, + 'sites' => [ + [ + 'name' => 'my-site', + 'buildSpecification' => 's-1vcpu-512mb', + 'runtimeSpecification' => 's-1vcpu-256mb', + ], + ], + ], + [ + 'total' => 1, + 'sites' => [ + [ + 'name' => 'my-site', + 'specification' => 's-1vcpu-512mb', + ], + ], + ] + ], + ]; + } + + #[DataProvider('siteListProvider')] + public function testSiteList(array $content, array $expected): void + { + $model = Response::MODEL_SITE_LIST; + + $result = $this->filter->parse($content, $model); + + $this->assertEquals($expected, $result); + } + + public static function documentProvider(): array + { + return [ + 'cast $sequence to int' => [ + [ + '$id' => 'doc1', + '$sequence' => '123', + 'name' => 'test', + ], + [ + '$id' => 'doc1', + '$sequence' => 123, + 'name' => 'test', + ] + ], + 'non-numeric $sequence becomes 0' => [ + [ + '$id' => 'doc1', + '$sequence' => 'abc', + ], + [ + '$id' => 'doc1', + '$sequence' => 0, + ] + ], + 'nested relationship document' => [ + [ + '$id' => 'doc1', + '$sequence' => '1', + 'author' => [ + '$id' => 'doc2', + '$sequence' => '2', + 'name' => 'John', + ], + ], + [ + '$id' => 'doc1', + '$sequence' => 1, + 'author' => [ + '$id' => 'doc2', + '$sequence' => 2, + 'name' => 'John', + ], + ] + ], + 'nested array of relationship documents' => [ + [ + '$id' => 'doc1', + '$sequence' => '1', + 'comments' => [ + [ + '$id' => 'doc2', + '$sequence' => '2', + 'text' => 'hello', + ], + [ + '$id' => 'doc3', + '$sequence' => '3', + 'text' => 'world', + ], + ], + ], + [ + '$id' => 'doc1', + '$sequence' => 1, + 'comments' => [ + [ + '$id' => 'doc2', + '$sequence' => 2, + 'text' => 'hello', + ], + [ + '$id' => 'doc3', + '$sequence' => 3, + 'text' => 'world', + ], + ], + ] + ], + 'deeply nested relationships' => [ + [ + '$id' => 'doc1', + '$sequence' => '1', + 'author' => [ + '$id' => 'doc2', + '$sequence' => '2', + 'profile' => [ + '$id' => 'doc3', + '$sequence' => '3', + ], + ], + ], + [ + '$id' => 'doc1', + '$sequence' => 1, + 'author' => [ + '$id' => 'doc2', + '$sequence' => 2, + 'profile' => [ + '$id' => 'doc3', + '$sequence' => 3, + ], + ], + ] + ], + ]; + } + + #[DataProvider('documentProvider')] + public function testDocument(array $content, array $expected): void + { + $result = $this->filter->parse($content, Response::MODEL_DOCUMENT); + + $this->assertSame($expected, $result); + } + + #[DataProvider('documentProvider')] + public function testRow(array $content, array $expected): void + { + $result = $this->filter->parse($content, Response::MODEL_ROW); + + $this->assertSame($expected, $result); + } + + public static function documentListProvider(): array + { + return [ + 'cast $sequence in document list' => [ + [ + 'total' => 2, + 'documents' => [ + [ + '$id' => 'doc1', + '$sequence' => '10', + 'name' => 'first', + ], + [ + '$id' => 'doc2', + '$sequence' => '20', + 'name' => 'second', + ], + ], + ], + [ + 'total' => 2, + 'documents' => [ + [ + '$id' => 'doc1', + '$sequence' => 10, + 'name' => 'first', + ], + [ + '$id' => 'doc2', + '$sequence' => 20, + 'name' => 'second', + ], + ], + ] + ], + 'handle empty document list' => [ + [ + 'total' => 0, + 'documents' => [], + ], + [ + 'total' => 0, + 'documents' => [], + ] + ], + ]; + } + + #[DataProvider('documentListProvider')] + public function testDocumentList(array $content, array $expected): void + { + $result = $this->filter->parse($content, Response::MODEL_DOCUMENT_LIST); + + $this->assertSame($expected, $result); + } + + #[DataProvider('documentListProvider')] + public function testRowList(array $content, array $expected): void + { + $content['rows'] = $content['documents']; + unset($content['documents']); + $expected['rows'] = $expected['documents']; + unset($expected['documents']); + + $result = $this->filter->parse($content, Response::MODEL_ROW_LIST); + + $this->assertSame($expected, $result); + } + + public static function defaultPassthroughProvider(): array + { + return [ + 'unmatched model passes through unchanged' => [ + Response::MODEL_DOCUMENT, + [ + 'name' => 'test-doc', + '$id' => 'doc123', + 'data' => 'some-value', + ], + [ + 'name' => 'test-doc', + '$id' => 'doc123', + 'data' => 'some-value', + ] + ], + 'empty content passes through unchanged' => [ + Response::MODEL_DOCUMENT, + [], + [] + ], + 'deployment model passes through unchanged' => [ + Response::MODEL_DEPLOYMENT, + [ + 'id' => 'deployment123', + 'buildSpecification' => 's-1vcpu-512mb', + 'runtimeSpecification' => 's-1vcpu-256mb', + ], + [ + 'id' => 'deployment123', + 'buildSpecification' => 's-1vcpu-512mb', + 'runtimeSpecification' => 's-1vcpu-256mb', + ] + ], + ]; + } + + #[DataProvider('defaultPassthroughProvider')] + public function testDefaultPassthrough(string $model, array $content, array $expected): void + { + $result = $this->filter->parse($content, $model); + + $this->assertEquals($expected, $result); + } +}