diff --git a/.claude/parallel-chunk-upload-storage-plan.md b/.claude/parallel-chunk-upload-storage-plan.md
new file mode 100644
index 0000000000..289fa97f5a
--- /dev/null
+++ b/.claude/parallel-chunk-upload-storage-plan.md
@@ -0,0 +1,174 @@
+# Parallel Chunk Upload Support for utopia-php/storage
+
+## Context
+
+The Appwrite API now supports out-of-order chunked uploads (chunks can arrive in any sequence). The next step is **parallel uploads** — multiple chunks uploaded simultaneously via separate HTTP requests. The SDK guarantees the first chunk is sent before any parallel chunks, so the document creation race is handled at the API layer. However, the storage device layer has a race condition that must be fixed.
+
+## Problem: `Local::joinChunks()` Race
+
+When two requests upload the final missing chunks in parallel, both can observe `countChunks() == $chunks` and call `joinChunks()` simultaneously.
+
+### Current behavior (loser throws)
+
+```php
+// Local::joinChunks()
+$dest = \fopen($tmpAssemble, 'wb');
+// ... stream all parts into $tmpAssemble ...
+
+if (! \rename($tmpAssemble, $path)) {
+ \unlink($tmpAssemble);
+ throw new Exception('Failed to finalize assembled file '.$path);
+}
+```
+
+The winner succeeds with `rename()`. The loser gets `false` from `rename()` (file already exists at `$path`) and throws a 500-error exception. The client that lost the race receives an error even though the file is fully assembled.
+
+### Required behavior
+
+If `$path` already exists, another request already assembled the file. The loser should **silently succeed** — the file is complete, nothing more to do.
+
+## Proposed Changes
+
+### 1. `Local::joinChunks()` — Handle assembly race
+
+Before opening `$tmpAssemble`, check if the final file already exists. If it does, skip assembly entirely.
+
+```php
+private function joinChunks(string $path, int $chunks): void
+{
+ // Race winner already assembled the file
+ if (\file_exists($path)) {
+ return;
+ }
+
+ $tmp = \dirname($path).DIRECTORY_SEPARATOR.'tmp_'.asename($path);
+ $tmpAssemble = \dirname($path).DIRECTORY_SEPARATOR.'tmp_assemble_'.asename($path);
+
+ // ... rest of assembly logic ...
+
+ if (! \rename($tmpAssemble, $path)) {
+ // Another request may have won the race between fclose and rename
+ if (\file_exists($path)) {
+ \unlink($tmpAssemble);
+ return;
+ }
+ \unlink($tmpAssemble);
+ throw new Exception('Failed to finalize assembled file '.$path);
+ }
+
+ // ... cleanup ...
+}
+```
+
+### 2. `Local::countChunks()` — Reliability under concurrent writes
+
+`countChunks()` uses `glob()` on the temp directory. Under heavy parallel load, `glob()` might miss files or return inconsistent counts. The current implementation is already fairly robust (it validates `.part.\d+` suffix), but we should document that the return value is a best-effort snapshot.
+
+No code change needed here unless tests reveal issues.
+
+### 3. Tests — Concurrent chunk uploads
+
+Add a test that simulates two parallel requests completing a multi-chunk upload:
+
+```php
+public function testParallelChunkUpload(): void
+{
+ $storage = $this->makeJoinTestStorage();
+ $dest = $storage->getRoot().DIRECTORY_SEPARATOR.'parallel.dat';
+
+ // Upload chunk 1 (creates temp directory)
+ $storage->uploadData('AAAA', $dest, 'application/octet-stream', 1, 2);
+
+ // Simulate two parallel requests uploading the last chunk
+ // In a real test, use pcntl_fork() or pthreads for true concurrency
+ // For the test suite, sequential calls are sufficient if we verify
+ // the second call doesn't throw after the first completed assembly
+ $storage->uploadData('BBBB', $dest, 'application/octet-stream', 2, 2);
+
+ // Verify file exists and is correct
+ $this->assertTrue(\file_exists($dest));
+ $this->assertSame('AAAABBBB', \file_get_contents($dest));
+
+ // Verify second assembly attempt doesn't throw
+ // (This simulates the race where another request already assembled)
+ try {
+ $storage->uploadData('BBBB', $dest, 'application/octet-stream', 2, 2);
+ } catch (\Exception $e) {
+ $this->fail('Duplicate assembly should not throw: '.$e->getMessage());
+ }
+
+ $storage->delete($storage->getRoot(), true);
+}
+```
+
+A more realistic concurrent test using `pcntl_fork()`:
+
+```php
+public function testParallelChunkUploadWithFork(): void
+{
+ if (!\function_exists('pcntl_fork')) {
+ $this->markTestSkipped('pcntl extension required for fork-based concurrency test');
+ }
+
+ $storage = $this->makeJoinTestStorage();
+ $dest = $storage->getRoot().DIRECTORY_SEPARATOR.'parallel-fork.dat';
+
+ // Pre-upload chunk 1
+ $storage->uploadData('AAAA', $dest, 'application/octet-stream', 1, 2);
+
+ $pid = pcntl_fork();
+ if ($pid === -1) {
+ $this->fail('Failed to fork');
+ } elseif ($pid === 0) {
+ // Child process: upload chunk 2
+ try {
+ $storage->uploadData('BBBB', $dest, 'application/octet-stream', 2, 2);
+ exit(0);
+ } catch (\Exception $e) {
+ exit(1);
+ }
+ }
+
+ // Parent process: also upload chunk 2 (race condition)
+ $parentSuccess = true;
+ try {
+ $storage->uploadData('BBBB', $dest, 'application/octet-stream', 2, 2);
+ } catch (\Exception $e) {
+ $parentSuccess = false;
+ }
+
+ pcntl_waitpid($pid, $status);
+ $childSuccess = pcntl_wexitstatus($status) === 0;
+
+ // At least one should succeed
+ $this->assertTrue($parentSuccess || $childSuccess, 'At least one parallel upload should succeed');
+
+ // File should be correctly assembled
+ $this->assertTrue(\file_exists($dest));
+ $this->assertSame('AAAABBBB', \file_get_contents($dest));
+
+ $storage->delete($storage->getRoot(), true);
+}
+```
+
+## S3 Device
+
+S3 already handles out-of-order multipart uploads natively. The `completeMultipartUpload` call with `ksort()` sorts parts by number regardless of upload order. However, parallel `completeMultipartUpload` calls for the same `uploadId` would still be problematic.
+
+This is an **API-layer concern** — the Appwrite API should ensure only one request calls `completeMultipartUpload` per upload. The S3 device itself does not need changes.
+
+## Files to Change
+
+| File | Change |
+|------|--------|
+| `src/Storage/Device/Local.php` | Add `file_exists($path)` guard at start of `joinChunks()` and in `rename()` failure handler |
+| `tests/Storage/Device/LocalTest.php` | Add `testParallelChunkUpload` and `testParallelChunkUploadWithFork` |
+
+## Backwards Compatibility
+
+Fully backwards compatible. The change only affects the error path when `rename()` fails due to an existing file. Previously it threw; now it returns silently. No public API signatures change.
+
+## Related PRs
+
+- Appwrite server PR: https://github.com/appwrite/appwrite/pull/12138 (out-of-order upload support)
+- This storage PR is a prerequisite for the follow-up Appwrite PR that enables parallel chunk uploads at the API level.
diff --git a/.claude/skills/patch-release-checklist/SKILL.md b/.claude/skills/patch-release-checklist/SKILL.md
new file mode 100644
index 0000000000..000fcd065a
--- /dev/null
+++ b/.claude/skills/patch-release-checklist/SKILL.md
@@ -0,0 +1,29 @@
+# Patch Release Checklist for Appwrite
+
+When bumping a patch version (e.g., `1.9.0` -> `1.9.1`), follow this checklist.
+
+## Checklist
+
+### Bump console image
+
+Update the console Docker image tag in both files:
+- [ ] `docker-compose.yml` -- update `image: appwrite/console:X.Y.Z`
+- [ ] `app/views/install/compose.phtml` -- update `image: /console:X.Y.Z`
+
+### Bump Appwrite version
+
+- [ ] **`app/init/constants.php`** -- update `APP_VERSION_STABLE` to the new version (e.g., `'1.9.1'`). In same file, increment `APP_CACHE_BUSTER` by 1.
+- [ ] **`README.md`** -- update the Docker image tag `appwrite/appwrite:X.Y.Z` in all 3 install code blocks (Unix, Windows CMD, PowerShell).
+- [ ] **`README-CN.md`** -- same Docker image tag update in all 3 install code blocks.
+- [ ] **`src/Appwrite/Migration/Migration.php`** -- add the new version to the `$versions` array, mapping it to a migration class. If new class exists, use that, otherwise use sle same class as previous version
+
+### Update CHANGES.md
+
+- [ ] Add a new `# Version X.Y.Z` section at the top of `CHANGES.md` with subsections: `### Notable changes`, `### Fixes`, `### Miscellaneous`
+
+## Final review
+
+- [ ] Ask user to review changes before commiting
+- [ ] Ask user to update `CHANGES.md` with PRs
+- [ ] Ask user to generate specs, if needed
+- [ ] Ask user to add request and response filters, if needed
diff --git a/.env b/.env
index 4903a8b491..4a6a3ac344 100644
--- a/.env
+++ b/.env
@@ -40,13 +40,15 @@ _APP_REDIS_PORT=6379
_APP_REDIS_PASS=
_APP_REDIS_USER=
COMPOSE_PROFILES=mariadb,mongodb,postgresql
-_APP_DB_ADAPTER=mariadb
-_APP_DB_HOST=mariadb
-_APP_DB_PORT=3306
+_APP_DB_ADAPTER=mongodb
+_APP_DB_HOST=mongodb
+_APP_DB_PORT=27017
_APP_DB_SCHEMA=appwrite
_APP_DB_USER=user
_APP_DB_PASS=password
_APP_DB_ROOT_PASS=rootsecretpassword
+_APP_DATABASE_SHARED_TABLES=
+_APP_DATABASE_SHARED_NAMESPACE=
_APP_DB_ADAPTER_DOCUMENTSDB=mongodb
_APP_DB_HOST_DOCUMENTSDB=mongodb
_APP_DB_PORT_DOCUMENTSDB=27017
@@ -146,3 +148,5 @@ _APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main
_APP_TRUSTED_HEADERS=x-forwarded-for
_APP_POOL_ADAPTER=stack
_APP_WORKER_SCREENSHOTS_ROUTER=http://appwrite
+_TESTS_OAUTH2_GITHUB_CLIENT_ID=
+_TESTS_OAUTH2_GITHUB_CLIENT_SECRET=
diff --git a/.github/workflows/ai-moderator.yml b/.github/workflows/ai-moderator.yml
index d0b180985f..948fa6c0c1 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:
@@ -27,6 +25,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: AI Moderator
- uses: github/ai-moderator@v1
+ uses: github/ai-moderator@81159c370785e295c97461ade67d7c33576e9319 # v1.1.4
with:
token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/auto-label-issue.yml b/.github/workflows/auto-label-issue.yml
index e0eb0de98d..0151c2f9c1 100644
--- a/.github/workflows/auto-label-issue.yml
+++ b/.github/workflows/auto-label-issue.yml
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Issue Labeler
- uses: github/issue-labeler@v3.4
+ uses: github/issue-labeler@c1b0f9f52a63158c4adc09425e858e87b32e9685 # v3.4
with:
configuration-path: .github/labeler.yml
enable-versioned-regex: false
diff --git a/.github/workflows/benchmark-comment.js b/.github/workflows/benchmark-comment.js
new file mode 100644
index 0000000000..f25116c4f2
--- /dev/null
+++ b/.github/workflows/benchmark-comment.js
@@ -0,0 +1,349 @@
+const fs = require('fs');
+
+const marker = '';
+const serviceLabels = ['Account', 'TablesDB', 'Storage', 'Functions'];
+
+module.exports = async ({ github, context, core }) => {
+ const body = buildComment(core);
+ fs.writeFileSync('benchmark-comment.txt', body);
+
+ const pullRequest = context.payload.pull_request;
+ if (!pullRequest || pullRequest.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) {
+ return;
+ }
+
+ const comments = await github.paginate(github.rest.issues.listComments, {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: pullRequest.number,
+ per_page: 100,
+ });
+
+ const existing = comments.find((comment) => {
+ return comment.user?.type === 'Bot' && comment.body?.includes(marker);
+ }) || comments.find((comment) => {
+ return comment.user?.type === 'Bot' && comment.body?.includes('Benchmark results');
+ });
+
+ if (existing) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: existing.id,
+ body,
+ });
+ return;
+ }
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: pullRequest.number,
+ body,
+ });
+};
+
+function buildComment(core) {
+ const before = readSummary('benchmark-before-summary.json', core);
+ const after = readSummary('benchmark-after-summary.json', core);
+ const beforeSamples = readSamples('benchmark-before-samples.json', core);
+ const afterSamples = readSamples('benchmark-after-samples.json', core);
+ const baseRef = markdownText(process.env.BENCHMARK_BASE_REF || 'base');
+ const headRef = markdownText(process.env.BENCHMARK_HEAD_REF || 'head');
+ const rows = benchmarkRows(before, after, beforeSamples, afterSamples);
+ const topWaits = topSamples(afterSamples, 'appwrite_api_waiting', 3);
+ const lines = [
+ marker,
+ '## :sparkles: Benchmark results',
+ '',
+ `Comparing ${baseRef} (before) to ${headRef} (after).`,
+ '',
+ ];
+
+ if (before === null) {
+ lines.push('> Before benchmark did not complete; showing current branch metrics only.', '');
+ }
+ if (after === null) {
+ lines.push('> Current branch benchmark did not complete; showing available metrics only.', '');
+ }
+
+ lines.push(
+ '**Before**',
+ '',
+ metricTable(rows, 'before'),
+ '',
+ '**After**',
+ '',
+ metricTable(rows, 'after'),
+ '',
+ '**Delta**',
+ '',
+ '| Scenario | P95 delta (ms) |',
+ '| --- | ---: |',
+ ...rows.map(deltaRow),
+ '',
+ '',
+ 'Top API waits ',
+ '',
+ ' ',
+ '',
+ '| API request | Max wait (ms) |',
+ '| --- | ---: |',
+ ...topWaitRows(topWaits),
+ '',
+ ' ',
+ );
+
+ return `${lines.join('\n')}\n`;
+}
+
+function readSummary(path, core) {
+ if (!fs.existsSync(path)) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(fs.readFileSync(path, 'utf8'));
+ } catch (error) {
+ core?.warning(`Invalid benchmark summary ${path}: ${error.message}`);
+ return null;
+ }
+}
+
+function readSamples(path, core) {
+ if (!fs.existsSync(path)) {
+ return [];
+ }
+
+ const contents = fs.readFileSync(path, 'utf8').trim();
+ if (contents === '') {
+ return [];
+ }
+
+ return contents
+ .split('\n')
+ .filter(Boolean)
+ .flatMap((line) => {
+ try {
+ return [JSON.parse(line)];
+ } catch (error) {
+ core?.warning(`Invalid benchmark sample in ${path}: ${error.message}`);
+ return [];
+ }
+ });
+}
+
+function benchmarkRows(before, after, beforeSamples, afterSamples) {
+ const beforeServices = serviceStats(beforeSamples);
+ const afterServices = serviceStats(afterSamples);
+ return [
+ {
+ label: 'API total',
+ before: apiSampleStats(beforeSamples) || summaryStats(before, 'appwrite_api_duration'),
+ after: apiSampleStats(afterSamples) || summaryStats(after, 'appwrite_api_duration'),
+ },
+ ...serviceLabels.map((label) => ({
+ label,
+ before: beforeServices.get(label) || null,
+ after: afterServices.get(label) || null,
+ })),
+ ];
+}
+
+function summaryStats(summary, durationMetric, iterationsMetric = null, rpsMetric = null) {
+ const values = metricValues(summary, durationMetric);
+ if (!values) {
+ return null;
+ }
+
+ return {
+ p50: values.med ?? null,
+ p95: values['p(95)'] ?? null,
+ iterations: iterationsMetric ? metricValue(summary, iterationsMetric, 'count') : values.count ?? null,
+ rps: rpsMetric ? metricValue(summary, rpsMetric, 'rate') : null,
+ };
+}
+
+function serviceStats(samples) {
+ const apiSamples = samples.filter((sample) => {
+ return sample.metric === 'appwrite_api_duration' && typeof sample.data?.value === 'number';
+ });
+ const groups = new Map();
+
+ for (const sample of apiSamples) {
+ const service = serviceFromName(sample.data.tags?.name || '');
+ if (!service) {
+ continue;
+ }
+
+ const serviceSamples = groups.get(service) || [];
+ serviceSamples.push(sample);
+ groups.set(service, serviceSamples);
+ }
+
+ return new Map([...groups.entries()].map(([service, serviceSamples]) => {
+ const values = serviceSamples.map((sample) => sample.data.value);
+ const durationSeconds = sampleWindowSeconds(serviceSamples);
+ return [service, {
+ p50: percentile(values, 50),
+ p95: percentile(values, 95),
+ iterations: values.length,
+ rps: durationSeconds ? values.length / durationSeconds : null,
+ }];
+ }));
+}
+
+function apiSampleStats(samples) {
+ const apiSamples = samples.filter((sample) => {
+ return sample.metric === 'appwrite_api_duration' && typeof sample.data?.value === 'number';
+ });
+ const values = apiSamples.map((sample) => sample.data.value);
+ if (values.length === 0) {
+ return null;
+ }
+
+ const durationSeconds = sampleWindowSeconds(apiSamples);
+ return {
+ p50: percentile(values, 50),
+ p95: percentile(values, 95),
+ iterations: values.length,
+ rps: durationSeconds ? values.length / durationSeconds : null,
+ };
+}
+
+function serviceFromName(name) {
+ if (name.startsWith('account.')) {
+ return 'Account';
+ }
+ if (name.startsWith('tablesdb.')) {
+ return 'TablesDB';
+ }
+ if (name.startsWith('storage.') || name.startsWith('tokens.')) {
+ return 'Storage';
+ }
+ if (name.startsWith('functions.')) {
+ return 'Functions';
+ }
+ return null;
+}
+
+function sampleWindowSeconds(samples) {
+ const times = samples
+ .map((sample) => Date.parse(sample.data?.time))
+ .filter((value) => !Number.isNaN(value));
+ if (times.length < 2) {
+ return null;
+ }
+
+ return Math.max((Math.max(...times) - Math.min(...times)) / 1000, 1);
+}
+
+function percentile(values, percentileValue) {
+ if (values.length === 0) {
+ return null;
+ }
+
+ const sorted = [...values].sort((left, right) => left - right);
+ const index = Math.ceil((percentileValue / 100) * sorted.length) - 1;
+ return sorted[Math.max(0, Math.min(index, sorted.length - 1))];
+}
+
+function metricValues(data, metric) {
+ return data?.metrics?.[metric]?.values ?? null;
+}
+
+function metricValue(data, metric, stat) {
+ return metricValues(data, metric)?.[stat] ?? null;
+}
+
+function metricTable(rows, side) {
+ return [
+ '| Scenario | P50 (ms) | P95 (ms) | Requests | RPS |',
+ '| --- | ---: | ---: | ---: | ---: |',
+ ...rows.map((row) => metricRow(row, side)),
+ ].join('\n');
+}
+
+function metricRow(row, side) {
+ const values = row[side];
+ return `| ${row.label} | ${formatMs(values?.p50)} | ${formatMs(values?.p95)} | ${formatCount(values?.iterations)} | ${formatRate(values?.rps)} |`;
+}
+
+function deltaRow(row) {
+ return `| ${row.label} | ${formatDelta(row.before?.p95, row.after?.p95)} |`;
+}
+
+function topSamples(samples, metric, limit) {
+ const byName = samples.reduce((result, sample) => {
+ if (sample.metric !== metric || typeof sample.data?.value !== 'number') {
+ return result;
+ }
+
+ const name = sample.data.tags?.name || 'unknown';
+ const current = result.get(name);
+ if (!current || sample.data.value > current.value) {
+ result.set(name, { name, value: sample.data.value });
+ }
+
+ return result;
+ }, new Map());
+
+ return [...byName.values()]
+ .sort((left, right) => right.value - left.value)
+ .slice(0, limit);
+}
+
+function topWaitRows(samples) {
+ if (samples.length === 0) {
+ return ['| n/a | n/a |'];
+ }
+
+ return samples.map((sample) => {
+ return `| ${markdownText(sample.name).replace(/\|/g, '\\|')} | ${formatMs(sample.value)} |`;
+ });
+}
+
+function markdownText(value) {
+ return String(value || '').replace(/[\r\n]/g, ' ').replace(/[&<>"']/g, (char) => {
+ return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char];
+ });
+}
+
+function formatMs(value) {
+ return formatNumber(value, 2);
+}
+
+function formatRate(value) {
+ return formatNumber(value, 2);
+}
+
+function formatCount(value) {
+ if (value === null || value === undefined || Number.isNaN(value)) {
+ return 'n/a';
+ }
+
+ return `${Math.round(value)}`;
+}
+
+function formatDelta(before, after) {
+ if (before === null || before === undefined || after === null || after === undefined || Number.isNaN(before) || Number.isNaN(after)) {
+ return 'n/a';
+ }
+
+ const difference = Number((after - before).toFixed(2));
+ return `${difference > 0 ? '+' : ''}${trimNumber(difference)}`;
+}
+
+function formatNumber(value, decimals) {
+ if (value === null || value === undefined || Number.isNaN(value)) {
+ return 'n/a';
+ }
+
+ return trimNumber(Number(value).toFixed(decimals));
+}
+
+function trimNumber(value) {
+ const text = String(value);
+ const trimmed = text.includes('.') ? text.replace(/\.?0+$/, '') : text;
+ return trimmed === '' ? '0' : trimmed;
+}
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..ac16c80264
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,866 @@
+name: CI
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ COMPOSE_FILE: docker-compose.yml
+ IMAGE: appwrite-dev
+ REGISTRY_IMAGE: ghcr.io/${{ github.repository }}/appwrite-dev
+ K6_VERSION: '0.53.0'
+
+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: ''
+
+permissions:
+ contents: read
+ packages: write
+
+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@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
+
+ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0
+ submodules: 'recursive'
+
+ - name: Build the Docker image
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ with:
+ context: .
+ push: false
+ load: true
+ tags: pr_image:${{ github.sha }}
+ target: production
+
+ - name: Run Trivy vulnerability scanner on image
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.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@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.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@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
+ if: always() && hashFiles('trivy-image-results.sarif') != ''
+ with:
+ sarif_file: 'trivy-image-results.sarif'
+ category: 'trivy-image'
+
+ - name: Upload source code scan results
+ uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
+ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
+ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 2
+
+ - run: git checkout HEAD^2
+ if: github.event_name == 'pull_request'
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
+ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
+ with:
+ php-version: '8.3'
+ tools: composer:v2
+ coverage: none
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress --ignore-platform-reqs
+
+ - name: Cache PHPStan result cache
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: .phpstan-cache
+ key: phpstan-${{ github.sha }}
+ restore-keys: |
+ phpstan-
+
+ - name: Run PHPStan
+ run: composer analyze -- --no-progress
+
+ specs:
+ name: Checks / Specs
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out the repo
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
+ with:
+ php-version: '8.3'
+ extensions: swoole
+ tools: composer:v2
+ coverage: none
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress --ignore-platform-reqs
+
+ - name: Generate specs
+ run: _APP_STORAGE_LIMIT=5368709120 php app/cli.php specs --version=latest --git=no
+
+ locale:
+ name: Checks / Locale
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out the repo
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Setup Node
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ 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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const allDatabases = ['MariaDB', 'PostgreSQL', 'MongoDB'];
+ const allModes = ['dedicated', 'shared'];
+
+ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ submodules: recursive
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ username: ${{ vars.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Login to GHCR
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
+
+ - name: Build and push Appwrite
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ with:
+ context: .
+ push: true
+ tags: ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ target: development
+ build-args: |
+ DEBUG=false
+ TESTING=true
+ VERSION=dev
+
+ unit:
+ name: Tests / Unit
+ runs-on: ubuntu-latest
+ needs: build
+ permissions:
+ contents: read
+ pull-requests: write
+ packages: read
+ steps:
+ - name: checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ username: ${{ vars.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Login to GHCR
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Pull Docker Image
+ run: |
+ docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
+ docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
+
+ - name: Load and Start Appwrite
+ timeout-minutes: 5
+ run: |
+ docker 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@d6bef45a8bff490babfb613e33b00d133e4062f0 # 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
+ packages: read
+ steps:
+ - name: checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ username: ${{ vars.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Login to GHCR
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Pull Docker Image
+ run: |
+ docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
+ docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
+
+ - name: Load and Start Appwrite
+ timeout-minutes: 5
+ run: |
+ docker 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@d6bef45a8bff490babfb613e33b00d133e4062f0 # 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 || format('runs-on={0}/runner=4cpu-linux-x64/volume=120g/spot=false', github.run_id) }}
+ needs: [build, matrix]
+ permissions:
+ contents: read
+ pull-requests: write
+ packages: read
+ 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,
+ Advisor,
+ Locale,
+ Projects,
+ Realtime,
+ Sites,
+ Proxy,
+ Storage,
+ Tokens,
+ Teams,
+ Users,
+ ProjectWebhooks,
+ Webhooks,
+ VCS,
+ Messaging,
+ Migrations,
+ Project
+ ]
+ include:
+ - service: Databases
+ runner: runs-on=${{ github.run_id }}/runner=8cpu-linux-x64/volume=120g/spot=false
+ paratest_processes: 3
+ timeout_minutes: 30
+ - service: TablesDB
+ runner: runs-on=${{ github.run_id }}/runner=8cpu-linux-x64/volume=120g/spot=false
+ paratest_processes: 3
+ timeout_minutes: 30
+ - service: Migrations
+ paratest_processes: 1
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Set environment
+ run: |
+ echo "_APP_OPTIONS_ROUTER_PROTECTION=enabled" >> $GITHUB_ENV
+
+ if [ "${{ matrix.database }}" = "MariaDB" ]; then
+ echo "COMPOSE_PROFILES=mariadb" >> $GITHUB_ENV
+ echo "_APP_DB_ADAPTER=mariadb" >> $GITHUB_ENV
+ 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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ username: ${{ vars.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Login to GHCR
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Pull Docker Image
+ run: |
+ docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
+ docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
+
+ - name: Load and Start Appwrite
+ timeout-minutes: 5
+ env:
+ _APP_BROWSER_HOST: http://invalid-browser/v1
+ _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
+ _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
+ _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
+ run: |
+ 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@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
+ with:
+ max_attempts: 2
+ retry_wait_seconds: 60
+ timeout_minutes: ${{ matrix.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|GraphQL|ProjectWebhooks) FUNCTIONAL_FLAG="" ;;
+ esac
+
+ PARATEST_PROCESSES="${{ matrix.paratest_processes }}"
+ if [ -z "$PARATEST_PROCESSES" ]; then
+ PARATEST_PROCESSES="$(nproc)"
+ fi
+
+ docker compose exec -T \
+ -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
+ -e _TESTS_OAUTH2_GITHUB_CLIENT_ID="${{ secrets.TESTS_OAUTH2_GITHUB_CLIENT_ID }}" \
+ -e _TESTS_OAUTH2_GITHUB_CLIENT_SECRET="${{ secrets.TESTS_OAUTH2_GITHUB_CLIENT_SECRET }}" \
+ appwrite vendor/bin/paratest --processes "$PARATEST_PROCESSES" $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml
+
+ - name: Failure Logs
+ 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
+ packages: read
+ strategy:
+ fail-fast: false
+ matrix:
+ mode: ${{ fromJSON(needs.matrix.outputs.modes) }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 1
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ username: ${{ vars.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Login to GHCR
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Pull Docker Image
+ run: |
+ docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
+ docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
+
+ - name: Load and Start Appwrite
+ timeout-minutes: 5
+ env:
+ _APP_OPTIONS_ABUSE: enabled
+ _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
+ _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
+ _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
+ run: |
+ docker compose pull --quiet --ignore-buildable
+ docker compose up -d --quiet-pull --wait
+
+ - name: Run tests
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # 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
+ packages: read
+ strategy:
+ fail-fast: false
+ matrix:
+ mode: ${{ fromJSON(needs.matrix.outputs.modes) }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ username: ${{ vars.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Login to GHCR
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Pull Docker Image
+ run: |
+ docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
+ docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
+
+ - name: Load and Start Appwrite
+ timeout-minutes: 5
+ env:
+ _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
+ _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }}
+ _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }}
+ run: |
+ 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@d6bef45a8bff490babfb613e33b00d133e4062f0 # 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
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ needs: build
+ permissions:
+ actions: read
+ contents: read
+ issues: write
+ pull-requests: write
+ packages: read
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 1
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ username: ${{ vars.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Login to GHCR
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Pull Appwrite image
+ run: |
+ docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
+ docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}
+ docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}:after
+
+ - name: Setup k6
+ uses: grafana/setup-k6-action@db07bd9765aac508ef18982e52ab937fe633a065 # v1.2.1
+ with:
+ k6-version: ${{ env.K6_VERSION }}
+
+ - name: Prepare benchmark before
+ id: benchmark_before_prepare
+ continue-on-error: true
+ run: |
+ git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
+ git worktree add --detach /tmp/appwrite-benchmark-before ${{ github.event.pull_request.base.sha }}
+ docker build \
+ --cache-from ${{ env.IMAGE }}:after \
+ --target development \
+ --build-arg DEBUG=false \
+ --build-arg TESTING=true \
+ --build-arg VERSION=dev \
+ --tag ${{ env.IMAGE }}:before \
+ /tmp/appwrite-benchmark-before
+
+ - name: Start before Appwrite
+ id: benchmark_before_start
+ if: steps.benchmark_before_prepare.outcome == 'success'
+ continue-on-error: true
+ working-directory: /tmp/appwrite-benchmark-before
+ env:
+ _APP_DOMAIN: localhost
+ _APP_CONSOLE_DOMAIN: localhost
+ _APP_DOMAIN_FUNCTIONS: functions.localhost
+ _APP_OPTIONS_ABUSE: disabled
+ run: |
+ docker tag ${{ env.IMAGE }}:before ${{ env.IMAGE }}
+ docker compose up -d --wait --no-build
+
+ - name: Prepare benchmark files
+ run: rm -f benchmark-before-summary.json benchmark-after-summary.json benchmark-before-samples.json benchmark-after-samples.json
+
+ - name: Benchmark before
+ if: steps.benchmark_before_start.outcome == 'success'
+ continue-on-error: true
+ uses: grafana/run-k6-action@de51a7390bdf0ac85a3bef493691bd71d4c7c158 # v1.4.0
+ env:
+ APPWRITE_ENDPOINT: 'http://localhost/v1'
+ APPWRITE_BENCHMARK_ITERATIONS: '5'
+ APPWRITE_BENCHMARK_VUS: '1'
+ APPWRITE_WORKER_TIMEOUT_MS: '120000'
+ APPWRITE_BENCHMARK_SUMMARY_PATH: 'benchmark-before-summary.json'
+ with:
+ path: tests/benchmarks/http.js
+ flags: --quiet --out json=benchmark-before-samples.json
+ cloud-comment-on-pr: false
+ debug: true
+
+ - name: Stop before Appwrite
+ if: always()
+ run: |
+ if [ -d /tmp/appwrite-benchmark-before ]; then
+ cd /tmp/appwrite-benchmark-before
+ docker compose down -v || true
+ fi
+
+ - name: Wait for benchmark ports
+ if: always()
+ run: |
+ for port in 80 443 8080 9503; do
+ for attempt in $(seq 1 30); do
+ if ! ss -ltn | awk '{print $4}' | grep -Eq "[:.]${port}$"; then
+ break
+ fi
+ sleep 1
+ done
+
+ if ss -ltn | awk '{print $4}' | grep -Eq "[:.]${port}$"; then
+ echo "Port ${port} is still in use after stopping the before stack"
+ ss -ltn
+ exit 1
+ fi
+ done
+
+ - name: Start after Appwrite
+ env:
+ _APP_DOMAIN: localhost
+ _APP_CONSOLE_DOMAIN: localhost
+ _APP_DOMAIN_FUNCTIONS: functions.localhost
+ _APP_OPTIONS_ABUSE: disabled
+ run: |
+ docker tag ${{ env.IMAGE }}:after ${{ env.IMAGE }}
+ docker compose up -d --wait --no-build
+
+ - name: Benchmark after
+ id: benchmark_after
+ continue-on-error: true
+ uses: grafana/run-k6-action@de51a7390bdf0ac85a3bef493691bd71d4c7c158 # v1.4.0
+ env:
+ APPWRITE_ENDPOINT: 'http://localhost/v1'
+ APPWRITE_BENCHMARK_ITERATIONS: '5'
+ APPWRITE_BENCHMARK_VUS: '1'
+ APPWRITE_WORKER_TIMEOUT_MS: '120000'
+ APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH: '../../benchmark-before-summary.json'
+ APPWRITE_BENCHMARK_SUMMARY_PATH: 'benchmark-after-summary.json'
+ with:
+ path: tests/benchmarks/http.js
+ flags: --quiet --out json=benchmark-after-samples.json
+ cloud-comment-on-pr: false
+ debug: true
+
+ - name: Stop after Appwrite
+ if: always()
+ run: docker compose down -v || true
+
+ - name: Comment on PR
+ if: always()
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ BENCHMARK_BASE_REF: ${{ github.event.pull_request.base.ref }}
+ BENCHMARK_HEAD_REF: ${{ github.event.pull_request.head.ref }}
+ with:
+ script: |
+ const comment = require('./.github/workflows/benchmark-comment.js');
+ await comment({ github, context, core });
+
+ - name: Save results
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ if: ${{ !cancelled() }}
+ with:
+ name: benchmark-results
+ path: |
+ benchmark-comment.txt
+ benchmark-before-summary.json
+ benchmark-after-summary.json
+ benchmark-before-samples.json
+ benchmark-after-samples.json
+ retention-days: 7
+
+ - name: Fail benchmark
+ if: always() && steps.benchmark_after.outcome != 'success'
+ run: exit 1
diff --git a/.github/workflows/cleanup-cache.yml b/.github/workflows/cleanup-cache.yml
index 8f9f05a38c..e4f28816be 100644
--- a/.github/workflows/cleanup-cache.yml
+++ b/.github/workflows/cleanup-cache.yml
@@ -5,12 +5,17 @@ on:
types:
- closed
+permissions:
+ actions: write
+ contents: read
+ packages: write
+
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Check out code
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cleanup
run: |
@@ -36,4 +41,29 @@ jobs:
done
done
env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Cleanup GHCR image
+ continue-on-error: true
+ run: |
+ package_path="${GITHUB_REPOSITORY#*/}/appwrite-dev"
+ encoded_path="$(printf '%s' "$package_path" | jq -Rr @uri)"
+
+ gh api --paginate "/repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.pull_request.number }}/commits" --jq '.[].sha' | while read -r sha; do
+ version_ids=$(gh api --paginate -H "Accept: application/vnd.github+json" \
+ "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions" \
+ --jq ".[] | select(.metadata.container.tags | index(\"${sha}\")) | .id")
+
+ if [ -z "$version_ids" ]; then
+ echo "No GHCR version found for SHA ${sha}"
+ continue
+ fi
+
+ echo "$version_ids" | while read -r version_id; do
+ gh api --method DELETE -H "Accept: application/vnd.github+json" \
+ "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions/${version_id}"
+ echo "Deleted ${package_path}:${sha} (version ${version_id})"
+ done
+ done
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 7edfde0aae..cb9b09b496 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -34,7 +34,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -47,14 +47,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@v2
+ uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@v2
+ uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -68,4 +68,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
+ uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
diff --git a/.github/workflows/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/nightly.yml b/.github/workflows/nightly.yml
index cd9b3827e7..0a49f658ac 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -10,13 +10,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
- name: Build the Docker image
run: DOCKER_BUILDKIT=1 docker build . --target production -t appwrite_image:latest
- name: Run Trivy vulnerability scanner on image
- uses: aquasecurity/trivy-action@0.20.0
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: 'appwrite_image:latest'
format: 'sarif'
@@ -24,24 +24,28 @@ jobs:
ignore-unfixed: 'false'
severity: 'CRITICAL,HIGH'
- name: Upload Docker Image Scan Results
- uses: github/codeql-action/upload-sarif@v2
+ uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
+ if: always() && hashFiles('trivy-image-results.sarif') != ''
with:
sarif_file: 'trivy-image-results.sarif'
+ category: 'trivy-image'
scan-code:
name: Scan Code
runs-on: ubuntu-latest
steps:
- name: Check out code
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run Trivy vulnerability scanner on filesystem
- uses: aquasecurity/trivy-action@0.20.0
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: 'fs'
format: 'sarif'
output: 'trivy-fs-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Code Scan Results
- uses: github/codeql-action/upload-sarif@v2
+ uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
+ if: always() && hashFiles('trivy-fs-results.sarif') != ''
with:
sarif_file: 'trivy-fs-results.sarif'
+ category: 'trivy-source'
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/publish.yml b/.github/workflows/publish.yml
index 692861d44d..68ab657213 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -12,33 +12,33 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2
submodules: recursive
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to Docker Hub
- uses: docker/login-action@v3
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
- uses: docker/metadata-action@v4
+ uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: appwrite/cloud
tags: |
type=ref,event=tag
- name: Build & Publish to DockerHub
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64,linux/arm64
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 84fc4c9fba..ed4e46d811 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -20,20 +20,20 @@ jobs:
submodules: recursive
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to Docker Hub
- uses: docker/login-action@v3
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
- uses: docker/metadata-action@v4
+ uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: appwrite/appwrite
tags: |
@@ -42,7 +42,7 @@ jobs:
type=semver,pattern={{major}}
- name: Build and push
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64,linux/arm64
diff --git a/.github/workflows/sdk-preview.yml b/.github/workflows/sdk-preview.yml
index f81346a7d1..dacc37a64a 100644
--- a/.github/workflows/sdk-preview.yml
+++ b/.github/workflows/sdk-preview.yml
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set SDK type
id: set-sdk
@@ -49,7 +49,7 @@ jobs:
docker compose exec appwrite sdks --platform=${{ steps.set-sdk.outputs.platform }} --sdk=${{ steps.set-sdk.outputs.sdk_type }} --version=latest --git=no
sudo chown -R $USER:$USER ./app/sdks/${{ steps.set-sdk.outputs.platform }}-${{ steps.set-sdk.outputs.sdk_type }}
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml
index 6f377354d5..85c76bacd3 100644
--- a/.github/workflows/specs.yml
+++ b/.github/workflows/specs.yml
@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 5987eeeb0c..73b767aafe 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/stale@v9
+ - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been labeled as a 'question', indicating that it requires additional information from the requestor. It has been inactive for 7 days. If no further activity occurs, this issue will be closed in 14 days."
diff --git a/.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 0cff6288e2..0000000000
--- a/.github/workflows/tests.yml
+++ /dev/null
@@ -1,691 +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: 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
-
- 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@v4
- with:
- key: ${{ env.CACHE_KEY }}
- path: /tmp/${{ env.IMAGE }}.tar
- fail-on-cache-miss: true
-
- - name: Load and Start Appwrite
- timeout-minutes: 3
- run: |
- docker load --input /tmp/${{ env.IMAGE }}.tar
- docker compose up -d
- until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
- echo "Waiting for Appwrite to be ready..."
- sleep 2
- done
-
- - 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: 300
- 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@v4
- with:
- key: ${{ env.CACHE_KEY }}
- path: /tmp/${{ env.IMAGE }}.tar
- fail-on-cache-miss: true
-
- - name: Load and Start Appwrite
- timeout-minutes: 3
- run: |
- docker load --input /tmp/${{ env.IMAGE }}.tar
- docker compose up -d
- until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
- echo "Waiting for Appwrite to be ready..."
- sleep 2
- done
-
- - 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: 300
- 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 --debug
-
- - 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@v4
- 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: Load and Start Appwrite
- timeout-minutes: 3
- env:
- _APP_BROWSER_HOST: http://invalid-browser/v1
- run: |
- docker load --input /tmp/${{ env.IMAGE }}.tar
- docker compose up -d
- until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
- echo "Waiting for Appwrite to be ready..."
- sleep 2
- done
-
- - 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: 300
- timeout_minutes: 20
- job_id: ${{ job.check_run_id }}
- github_token: ${{ secrets.GITHUB_TOKEN }}
- test_dir: tests/e2e/Services/${{ matrix.service }}
- command: |
- echo "Using project tables"
- 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
-
- echo "Running with paratest (parallel) for: ${{ matrix.service }} ${FUNCTIONAL_FLAG:+(functional)}"
- docker compose exec -T \
- -e _APP_DATABASE_SHARED_TABLES="" \
- -e _APP_DATABASE_SHARED_TABLES_V1="" \
- -e _APP_DB_ADAPTER="${{ env._APP_DB_ADAPTER }}" \
- -e _APP_DB_HOST="${{ env._APP_DB_HOST }}" \
- -e _APP_DB_PORT="${{ env._APP_DB_PORT }}" \
- -e _APP_DB_SCHEMA=appwrite \
- -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@v4
- with:
- key: ${{ env.CACHE_KEY }}
- path: /tmp/${{ env.IMAGE }}.tar
- fail-on-cache-miss: true
-
- - name: Load and Start Appwrite
- timeout-minutes: 3
- run: |
- docker load --input /tmp/${{ env.IMAGE }}.tar
- docker compose up -d
- until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
- echo "Waiting for Appwrite to be ready..."
- sleep 2
- done
-
- - 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: 300
- timeout_minutes: 20
- job_id: ${{ job.check_run_id }}
- github_token: ${{ secrets.GITHUB_TOKEN }}
- test_dir: tests/e2e/Services/${{ matrix.service }}
- command: |
- if [ "${{ matrix.tables-mode }}" == "Shared V1" ]; then
- echo "Using shared tables V1"
- export _APP_DATABASE_SHARED_TABLES=database_db_main
- export _APP_DATABASE_SHARED_TABLES_V1=database_db_main
- elif [ "${{ matrix.tables-mode }}" == "Shared V2" ]; then
- echo "Using shared tables V2"
- export _APP_DATABASE_SHARED_TABLES=database_db_main
- export _APP_DATABASE_SHARED_TABLES_V1=
- fi
-
- 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_DATABASE_SHARED_TABLES \
- -e _APP_DATABASE_SHARED_TABLES_V1 \
- -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 enabled)
- 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@v4
- with:
- key: ${{ env.CACHE_KEY }}
- path: /tmp/${{ env.IMAGE }}.tar
- fail-on-cache-miss: true
-
- - name: Load and Start Appwrite
- timeout-minutes: 3
- run: |
- docker load --input /tmp/${{ env.IMAGE }}.tar
- sed -i 's/_APP_OPTIONS_ABUSE=disabled/_APP_OPTIONS_ABUSE=enabled/' .env
- docker compose up -d
- until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
- echo "Waiting for Appwrite to be ready..."
- sleep 2
- done
-
- - name: Run Projects tests in dedicated table mode
- uses: itznotabug/php-retry@v3
- with:
- max_attempts: 2
- retry_wait_seconds: 300
- timeout_minutes: 15
- job_id: ${{ job.check_run_id }}
- github_token: ${{ secrets.GITHUB_TOKEN }}
- test_dir: tests/e2e/Services/Projects
- command: |
- echo "Using project tables"
- export _APP_DATABASE_SHARED_TABLES=
- export _APP_DATABASE_SHARED_TABLES_V1=
-
- docker compose exec -T \
- -e _APP_DATABASE_SHARED_TABLES \
- -e _APP_DATABASE_SHARED_TABLES_V1 \
- -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
- appwrite test /usr/src/code/tests/e2e/Services/Projects --debug --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 enabled)
- 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@v4
- with:
- key: ${{ env.CACHE_KEY }}
- path: /tmp/${{ env.IMAGE }}.tar
- fail-on-cache-miss: true
-
- - name: Load and Start Appwrite
- timeout-minutes: 3
- run: |
- docker load --input /tmp/${{ env.IMAGE }}.tar
- sed -i 's/_APP_OPTIONS_ABUSE=disabled/_APP_OPTIONS_ABUSE=enabled/' .env
- docker compose up -d
- until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
- echo "Waiting for Appwrite to be ready..."
- sleep 2
- done
-
- - name: Run Projects tests in ${{ matrix.tables-mode }} table mode
- uses: itznotabug/php-retry@v3
- with:
- max_attempts: 2
- retry_wait_seconds: 300
- timeout_minutes: 15
- job_id: ${{ job.check_run_id }}
- github_token: ${{ secrets.GITHUB_TOKEN }}
- test_dir: tests/e2e/Services/Projects
- command: |
- if [ "${{ matrix.tables-mode }}" == "Shared V1" ]; then
- echo "Using shared tables V1"
- export _APP_DATABASE_SHARED_TABLES=database_db_main
- export _APP_DATABASE_SHARED_TABLES_V1=database_db_main
- elif [ "${{ matrix.tables-mode }}" == "Shared V2" ]; then
- echo "Using shared tables V2"
- export _APP_DATABASE_SHARED_TABLES=database_db_main
- export _APP_DATABASE_SHARED_TABLES_V1=
- fi
-
- docker compose exec -T \
- -e _APP_DATABASE_SHARED_TABLES \
- -e _APP_DATABASE_SHARED_TABLES_V1 \
- -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
- appwrite test /usr/src/code/tests/e2e/Services/Projects --debug --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@v4
- with:
- key: ${{ env.CACHE_KEY }}
- path: /tmp/${{ env.IMAGE }}.tar
- fail-on-cache-miss: true
-
- - name: Load and Start Appwrite
- timeout-minutes: 3
- run: |
- docker load --input /tmp/${{ env.IMAGE }}.tar
- sed -i 's/_APP_OPTIONS_ABUSE=disabled/_APP_OPTIONS_ABUSE=enabled/' .env
- docker compose up -d
- until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
- echo "Waiting for Appwrite to be ready..."
- sleep 2
- done
-
- - 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: 300
- timeout_minutes: 15
- job_id: ${{ job.check_run_id }}
- github_token: ${{ secrets.GITHUB_TOKEN }}
- test_dir: tests/e2e/Services/Sites
- command: |
- echo "Keeping original value of _APP_BROWSER_HOST"
- echo "Using project tables"
- export _APP_DATABASE_SHARED_TABLES=
- export _APP_DATABASE_SHARED_TABLES_V1=
-
- docker compose exec -T \
- -e _APP_DATABASE_SHARED_TABLES \
- -e _APP_DATABASE_SHARED_TABLES_V1 \
- -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
- appwrite test /usr/src/code/tests/e2e/Services/Sites --debug --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@v4
- with:
- key: ${{ env.CACHE_KEY }}
- path: /tmp/${{ env.IMAGE }}.tar
- fail-on-cache-miss: true
-
- - name: Load and Start Appwrite
- timeout-minutes: 3
- run: |
- docker load --input /tmp/${{ env.IMAGE }}.tar
- sed -i 's/_APP_OPTIONS_ABUSE=disabled/_APP_OPTIONS_ABUSE=enabled/' .env
- docker compose up -d
- until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
- echo "Waiting for Appwrite to be ready..."
- sleep 2
- done
-
- - 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: 300
- timeout_minutes: 15
- job_id: ${{ job.check_run_id }}
- github_token: ${{ secrets.GITHUB_TOKEN }}
- test_dir: tests/e2e/Services/Sites
- command: |
- echo "Keeping original value of _APP_BROWSER_HOST"
- if [ "${{ matrix.tables-mode }}" == "Shared V1" ]; then
- echo "Using shared tables V1"
- export _APP_DATABASE_SHARED_TABLES=database_db_main
- export _APP_DATABASE_SHARED_TABLES_V1=database_db_main
- elif [ "${{ matrix.tables-mode }}" == "Shared V2" ]; then
- echo "Using shared tables V2"
- export _APP_DATABASE_SHARED_TABLES=database_db_main
- export _APP_DATABASE_SHARED_TABLES_V1=
- fi
-
- docker compose exec -T \
- -e _APP_DATABASE_SHARED_TABLES \
- -e _APP_DATABASE_SHARED_TABLES_V1 \
- -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
- appwrite test /usr/src/code/tests/e2e/Services/Sites --debug --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..6846e19aa7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,10 @@ appwrite.config.json
/app/config/specs/
/docs/examples/
.phpunit.cache
+.phpstan-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/AGENTS.md b/AGENTS.md
index bb24d9f4fe..b84bc89c3b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,107 +1,132 @@
-# AGENTS.md
+# Appwrite
-Appwrite is an end-to-end backend server for web, mobile, native, and backend apps. This guide provides context and instructions for AI coding agents working on the Appwrite codebase.
+Self-hosted Backend-as-a-Service platform. Hybrid monolithic-microservice architecture built with PHP 8.3+ on Swoole, delivered as Docker containers.
-## Project Overview
+## Commands
-Appwrite is a self-hosted Backend-as-a-Service (BaaS) platform that provides developers with a set of APIs and tools to build secure, scalable applications. The project uses a hybrid monolithic-microservice architecture built with PHP, running on Swoole for high performance.
+| Command | Purpose |
+|---------|---------|
+| `docker compose up -d --force-recreate --build` | Build and start all services |
+| `docker compose exec appwrite test tests/e2e/Services/[Service]` | Run E2E tests for a service |
+| `docker compose exec appwrite test tests/e2e/Services/[Service] --filter=[Method]` | Run a single test method |
+| `docker compose exec appwrite test tests/unit/` | Run unit tests |
+| `composer format` | Auto-format code (Pint, PSR-12) |
+| `composer format ` | Format a specific file |
+| `composer lint ` | Check formatting of a file |
+| `composer analyze` | Static analysis (PHPStan level 3) |
+| `composer check` | Same as `analyze` |
-**Key Technologies:**
-- **Backend:** PHP 8.3+, Swoole
-- **Libraries:** Utopia PHP
-- **Database:** MariaDB, Redis
-- **Cache:** Redis
-- **Queue:** Redis
-- **Containers:** Docker
+## Stack
-## Development Commands
+- PHP 8.3+, Swoole 6.x (async runtime, replaces PHP-FPM)
+- Utopia PHP framework (HTTP routing, CLI, DI, queue)
+- MongoDB (default), MariaDB, MySQL, PostgreSQL (adapters via utopia-php/database)
+- Redis (cache, queue, pub/sub)
+- Docker + Traefik (reverse proxy)
+- PHPUnit 12, Pint (PSR-12), PHPStan level 3
-```bash
-# Run Appwrite
-docker compose up -d --force-recreate --build
+## Project layout
-# Run specific test
-docker compose exec appwrite test /usr/src/code/tests/e2e/Services/[ServiceName] --filter=[FunctionName]
+- **src/Appwrite/Platform/Modules/** -- feature modules (Account, Avatars, Compute, Console, Databases, Functions, Health, Project, Projects, Proxy, Sites, Storage, Teams, Tokens, VCS, Webhooks)
+- **src/Appwrite/Platform/Workers/** -- background job workers
+- **src/Appwrite/Platform/Tasks/** -- CLI tasks
+- **app/init.php** -- bootstrap (registers services, resources, listeners)
+- **app/init/** -- configs, constants, locales, models, registers, resources, span, database filters/formats
+- **bin/** -- CLI entry points: `worker-*` (14 workers), `schedule-*`, `queue-*`, plus `doctor`, `install`, `migrate`, `realtime`, `upgrade`, `ssl`, `vars`, `maintenance`, `interval`, `specs`, `sdks`, etc.
+- **tests/e2e/** -- end-to-end tests per service
+- **tests/unit/** -- unit tests
+- **public/** -- static assets and generated SDKs
-# Format code
-composer format
+## Module structure
+
+Each module under `src/Appwrite/Platform/Modules/{Name}/` contains:
+
+```
+Module.php -- registers all services for the module
+Services/Http.php -- registers HTTP endpoints
+Services/Workers.php -- registers background workers
+Services/Tasks.php -- registers CLI tasks
+Http/{Service}/ -- endpoint actions (Create.php, Get.php, Update.php, Delete.php, XList.php)
+Workers/ -- worker implementations
+Tasks/ -- CLI task implementations
```
-## Code Style Guidelines
+HTTP endpoint nesting reflects the URL path. Sub-resources get subdirectories. For example, within the Functions module:
+`Http/Deployments/Template/Create.php` -> `POST /v1/functions/:functionId/deployments/template`
-- Follow [PSR-12](https://www.php-fig.org/psr/psr-12/) coding standard
-- Use PSR-4 autoloading
-- Strict type declarations where applicable
-- Comprehensive PHPDoc comments
+File names in Http directories must only be `Get.php`, `Create.php`, `Update.php`, `Delete.php`, or `XList.php`. For non-CRUD operations, model the endpoint as a property update. For example, updating a team membership status lives at `Teams/Http/Memberships/Status/Update.php` (`PATCH /v1/teams/:teamId/memberships/:membershipId/status`).
-### Naming Conventions
+Register new modules in `src/Appwrite/Platform/Appwrite.php`. Detailed module guide: `src/Appwrite/Platform/AGENTS.md`.
-#### `resourceType` Naming Rule
+## Action pattern (HTTP endpoints)
-When a collection has a combination of `resourceType`, `resourceId`, and/or `resourceInternalId`, the value of `resourceType` MUST always be **plural** - for example: `functions`, `sites`, `deployments`.
-
-Examples:
```php
-'resourceType' => 'functions'
-'resourceType' => 'sites'
-'resourceType' => 'deployments'
+class Create extends Action
+{
+ public static function getName(): string { return 'createTeam'; }
+
+ public function __construct()
+ {
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/teams')
+ ->desc('Create team')
+ ->groups(['api', 'teams'])
+ ->label('event', 'teams.[teamId].create')
+ ->label('scope', 'teams.write')
+ ->param('teamId', '', new CustomId(), 'Team ID.')
+ ->param('name', null, new Text(128), 'Team name.')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $teamId,
+ string $name,
+ Response $response,
+ Database $dbForProject,
+ Event $queueForEvents,
+ ): void {
+ // implementation
+ }
+}
```
-## Performance Patterns
+Common injections: `$response`, `$request`, `$dbForProject`, `$dbForPlatform`, `$user`, `$project`, `$queueForEvents`, `$queueForMails`, `$queueForDeletes`.
-### Document Update Optimization
+## Conventions
-When updating documents, always pass only the changed attributes as a sparse `Document` rather than the full document. This is more efficient because `updateDocument()` internally performs `array_merge($old, $new)`.
+- PSR-12 formatting enforced by Pint. PSR-4 autoloading.
+- `resourceType` values are always **plural**: `'functions'`, `'sites'`, `'deployments'`.
+- When updating documents, pass only changed attributes as a sparse Document:
+ ```php
+ // correct
+ $dbForProject->updateDocument('users', $user->getId(), new Document([
+ 'name' => $name,
+ ]));
+ // incorrect -- passing full document is inefficient
+ $user->setAttribute('name', $name);
+ $dbForProject->updateDocument('users', $user->getId(), $user);
+ ```
+ Exceptions: migrations, `array_merge()` with `getArrayCopy()`, updates where nearly all attributes change, complex nested relationship logic requiring full document state.
+- Avoid introducing dependencies outside the `utopia-php` ecosystem.
+- Never hardcode credentials -- use environment variables.
+- Code changes may require container restart. No central log location -- check relevant containers.
-**Correct Pattern:**
-```php
-// Good: Pass only changed attributes directly
-$user = $dbForProject->updateDocument('users', $user->getId(), new Document([
- 'name' => $name,
- 'email' => $email,
-]));
-```
+## Tracing with Utopia Span
-**Incorrect Pattern:**
-```php
-$user->setAttribute('name', $name);
-$user->setAttribute('email', $email);
+In handlers, only call `Span::add($key, $value)`. **Never** call `Span::init`, `Span::error`, or `Span::finish` -- lifecycle is owned by the entry-point harness (`app/http.php`, `app/worker.php`, `app/realtime.php`, `Bus::dispatch`). For selective export, filter in the sampler in `app/init/span.php`.
-// Bad: Passing full document is inefficient
-$user = $dbForProject->updateDocument('users', $user->getId(), $user);
-```
+Keys are `snake_case` with dots only for child relationships: `project.id` (id of project), `storage.bucket.id`. No dot otherwise: `inbound_bytes`, not `inbound.bytes`. No camelCase, no bare top-level keys (`function.id`, not `functionId`).
-**Exceptions:**
-- Migration files (need full document updates by design)
-- Cases already using `array_merge()` with `getArrayCopy()`
-- Updates where almost all attributes of the document change at once (sparse update provides little benefit compared to passing the full document)
-- Complex nested relationship logic where full document state is required
+Cross-cutting identifiers (`project.id`, `function.id`, `user.id`) live at the top level, not under a subsystem (no `realtime.project.id`). The trace sampler and downstream filters look them up by the canonical key.
-## Security Considerations
+## Patch release process
-### Critical Security Practices
+For bumping patch versions (e.g., `1.9.0` -> `1.9.1`), follow the checklist in `.claude/skills/patch-release-checklist/SKILL.md`. It covers the 4 files that must be updated, console image bumps, CHANGES.md updates, and common pitfalls to avoid.
-- **Never hardcode credentials** - Use environment variables
-- **Rate limiting** - Respect abuse prevention mechanisms
+## Cross-repo context
-## Dependencies
-
-Avoid introducing new dependencies other than utopia-php.
-
-## Adding new endpoints
-
-When adding new endpoints, make sure to use modules and follow its patterns. Find instruction in [Modules AGENTS.md](src/Appwrite/Platform/AGENTS.md) file.
-
-## Pull Request Guidelines
-### Before Submitting
-
-- Run `composer format`
-- Update documentation if adding features
-- Add/update tests for your changes
-- Check that Docker build succeeds
-`docs/specs/authentication.drawio.svg`
-
-## Known Issues and Gotchas
-
-- **Hot Reload:** Code changes require container restart in some cases
-- **Logging:** There is no central place for logs, so when debugging, ensure to check all possibly relevant containers
+Appwrite is the base server for `appwrite/cloud`. Changes to the Action pattern, module structure, DI system, or response models affect cloud. The `feat-dedicated-db` feature spans cloud, edge, and console.
diff --git a/CHANGES.md b/CHANGES.md
index e6dd04b556..6894322043 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
@@ -859,7 +892,7 @@
* Unset index length by @fogelito in https://github.com/appwrite/appwrite/pull/8978
* Update base to 0.9.5 by @basert in https://github.com/appwrite/appwrite/pull/9005
* Sync main into 1.6.x by @TorstenDittmann in https://github.com/appwrite/appwrite/pull/9011
-* Improved shared tables V2 by @abnegate in https://github.com/appwrite/appwrite/pull/9013
+* Improved shared tables by @abnegate in https://github.com/appwrite/appwrite/pull/9013
* Ensure backwards compatibility for 1.6.x by @christyjacob4 in https://github.com/appwrite/appwrite/pull/9018
# Version 1.6.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 266d4501d0..9a61635415 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.4.1 AS base
LABEL maintainer="team@appwrite.io"
@@ -24,6 +24,10 @@ ENV _APP_VERSION=$VERSION \
_APP_HOME=https://appwrite.io
RUN \
+ if [ "$DEBUG" != "true" ]; then \
+ rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
+ rm -f /usr/local/lib/php/extensions/no-debug-non-zts-*/xdebug.so; \
+ fi && \
if [ "$DEBUG" == "true" ]; then \
apk add boost boost-dev; \
fi
@@ -70,7 +74,7 @@ RUN chmod +x /usr/local/bin/doctor && \
chmod +x /usr/local/bin/sdks && \
chmod +x /usr/local/bin/specs && \
chmod +x /usr/local/bin/ssl && \
- chmod +x /usr/local/bin/time-travel && \
+ chmod +x /usr/local/bin/task-time-travel && \
chmod +x /usr/local/bin/screenshot && \
chmod +x /usr/local/bin/test && \
chmod +x /usr/local/bin/upgrade && \
@@ -100,7 +104,8 @@ RUN mkdir -p /etc/letsencrypt/live/ && chmod -Rf 755 /etc/letsencrypt/live/
FROM base AS production
RUN rm -rf /usr/src/code/app/config/specs && \
- rm -f /usr/local/lib/php/extensions/no-debug-non-zts-20240924/xdebug.so && \
+ rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini && \
+ rm -f /usr/local/lib/php/extensions/no-debug-non-zts-*/xdebug.so && \
find /usr -name '*.a' -delete 2>/dev/null || true && \
find /usr -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true && \
find /usr -name '*.pyc' -delete 2>/dev/null || true
@@ -121,5 +126,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..88d527f060 100644
--- a/README.md
+++ b/README.md
@@ -1,43 +1,28 @@
-> We just announced DB operators for Appwrite Databases - [Learn more](https://appwrite.io/blog/post/announcing-db-operators)
-
-> Appwrite Cloud is now Generally Available - [Learn more](https://appwrite.io/cloud-ga)
-
-> [Get started with Appwrite](https://apwr.dev/appcloud)
+
-
-
-
- Appwrite is a best-in-class, developer-first platform that gives builders everything they need to create scalable, stable, and production-ready software, fast.
+
Appwrite
+ Appwrite is an open-source, all-in-one development platform. Use built-in backend infrastructure and web hosting, all from a single place.
-
-
-[](https://appwrite.io/company/careers)
-[](https://hacktoberfest.appwrite.io)
-[](https://appwrite.io/discord?r=Github)
-[](https://github.com/appwrite/appwrite/actions)
-[](https://twitter.com/appwrite)
-
-
-
-
+[](https://appwrite.io/discord)
+[](https://x.com/appwrite)
+[](https://cloud.appwrite.io)
English | [简体中文](README-CN.md)
-Appwrite is an end-to-end platform for building Web, Mobile, Native, or Backend apps, packaged as a set of Docker microservices. It includes both a backend server and a fully integrated hosting solution for deploying static and server-side rendered frontends. Appwrite abstracts the complexity and repetitiveness required to build modern apps from scratch and allows you to build secure, full-stack applications faster.
+Appwrite is an open-source development platform for building web, mobile, and AI applications. It brings together backend infrastructure and web hosting in one place, so teams can build, ship, and scale without stitching together a fragmented stack. Appwrite is available as a managed cloud platform and can also be self-hosted on infrastructure you control.
-Using Appwrite, you can easily integrate your app with user authentication and multiple sign-in methods, a database for storing and querying users and team data, storage and file management, image manipulation, Cloud Functions, messaging, and [more services](https://appwrite.io/docs).
+With Appwrite, you can add authentication, databases, storage, functions, messaging, realtime capabilities, and integrated web app hosting through Sites. It is designed to reduce the repetitive backend work required to launch modern products while giving developers secure primitives and flexible APIs to build production-ready applications faster.
-
-
-Find out more at: [https://appwrite.io](https://appwrite.io).
+Find out more at [https://appwrite.io](https://appwrite.io).
Table of Contents:
+- [Products](#products)
- [Installation \& Setup](#installation--setup)
- [Self-Hosting](#self-hosting)
- [Unix](#unix)
@@ -47,17 +32,31 @@ Table of Contents:
- [Upgrade from an Older Version](#upgrade-from-an-older-version)
- [One-Click Setups](#one-click-setups)
- [Getting Started](#getting-started)
- - [Products](#products)
- [SDKs](#sdks)
- [Client](#client)
- [Server](#server)
- - [Community](#community)
- [Architecture](#architecture)
- [Contributing](#contributing)
- [Security](#security)
- [Follow Us](#follow-us)
- [License](#license)
+
+## Products
+
+- **[Appwrite Auth](https://appwrite.io/docs/products/auth)** - Secure user authentication with multiple login methods including email/password, SMS, OAuth, anonymous sessions, and magic links. Includes session management, multi-factor authentication, and user verification flows.
+
+- **[Appwrite Databases](https://appwrite.io/docs/products/databases)** - Scalable structured data storage with support for databases, tables, and rows. Includes querying, pagination, indexing, and relationships to model complex application data.
+
+- **[Appwrite Storage](https://appwrite.io/docs/products/storage)** - Secure file storage with support for uploads, downloads, encryption, compression, and file transformations for media and assets.
+
+- **[Appwrite Functions](https://appwrite.io/docs/products/functions)** - Serverless compute platform to run custom backend logic in isolated runtimes, triggered by events or scheduled jobs.15 runtimes supported.
+
+- **[Appwrite Messaging](https://appwrite.io/docs/products/messaging)** - Multi-channel messaging system for sending emails, SMS, and push notifications to users for engagement, alerts, and transactional workflows.
+
+- **[Appwrite Sites](https://appwrite.io/docs/products/sites)** - Integrated hosting platform to deploy and scale web applications with support for custom domains, SSR, and seamless backend integration. Git integration and previews are supported.
+
+
## Installation & Setup
The easiest way to get started with Appwrite is by [signing up for Appwrite Cloud](https://cloud.appwrite.io/). While Appwrite Cloud is in public beta, you can build with Appwrite completely free, and we won't collect your credit card information.
@@ -72,10 +71,11 @@ Before running the installation command, make sure you have [Docker](https://www
```bash
docker run -it --rm \
+ --publish 20080:20080 \
--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,20 +84,22 @@ docker run -it --rm \
```cmd
docker run -it --rm ^
+ --publish 20080:20080 ^
--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
```powershell
docker run -it --rm `
+ --publish 20080:20080 `
--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.
@@ -165,51 +167,29 @@ Getting started with Appwrite is as easy as creating a new project, choosing you
| | [Quick start for Kotlin](https://appwrite.io/docs/quick-starts/kotlin) |
| | [Quick start for Swift](https://appwrite.io/docs/quick-starts/swift) |
-### Products
-
-- [**Account**](https://appwrite.io/docs/references/cloud/client-web/account) - Manage current user authentication and account. Track and manage the user sessions, devices, sign-in methods, and security logs.
-- [**Users**](https://appwrite.io/docs/server/users) - Manage and list all project users when building backend integrations with Server SDKs.
-- [**Teams**](https://appwrite.io/docs/references/cloud/client-web/teams) - Manage and group users in teams. Manage memberships, invites, and user roles within a team.
-- [**Databases**](https://appwrite.io/docs/references/cloud/client-web/databases) - Manage databases, collections, and documents. Read, create, update, and delete documents and filter lists of document collections using advanced filters.
-- [**Storage**](https://appwrite.io/docs/references/cloud/client-web/storage) - Manage storage files. Read, create, delete, and preview files. Manipulate the preview of your files to perfectly fit your app. All files are scanned by ClamAV and stored in a secure and encrypted way.
-- [**Functions**](https://appwrite.io/docs/references/cloud/server-nodejs/functions) - Customize your Appwrite project by executing your custom code in a secure, isolated environment. You can trigger your code on any Appwrite system event either manually or using a CRON schedule.
-- [**Messaging**](https://appwrite.io/docs/references/cloud/client-web/messaging) - Communicate with your users through push notifications, emails, and SMS text messages using Appwrite Messaging.
-- [**Realtime**](https://appwrite.io/docs/realtime) - Listen to real-time events for any of your Appwrite services including users, storage, functions, databases, and more.
-- [**Locale**](https://appwrite.io/docs/references/cloud/client-web/locale) - Track your user's location and manage your app locale-based data.
-- [**Avatars**](https://appwrite.io/docs/references/cloud/client-web/avatars) - Manage your users' avatars, countries' flags, browser icons, and credit card symbols. Generate QR codes from links or plaintext strings.
-- [**MCP**](https://appwrite.io/docs/tooling/mcp) - Use Appwrite's Model Context Protocol (MCP) server to allow LLMs and AI tools like Claude Desktop, Cursor, and Windsurf Editor to directly interact with your Appwrite project through natural language.
-- [**Sites**](https://appwrite.io/docs/products/sites) - Develop, deploy, and scale your web applications directly from Appwrite, alongside your backend.
-
-For the complete API documentation, visit [https://appwrite.io/docs](https://appwrite.io/docs). For more tutorials, news and announcements check out our [blog](https://medium.com/appwrite-io) and [Discord Server](https://discord.gg/GSeTUeA).
-
### SDKs
Below is a list of currently supported platforms and languages. If you would like to help us add support to your platform of choice, you can go over to our [SDK Generator](https://github.com/appwrite/sdk-generator) project and view our [contribution guide](https://github.com/appwrite/sdk-generator/blob/master/CONTRIBUTING.md).
#### Client
-- :white_check_mark: [Web](https://github.com/appwrite/sdk-for-web) (Maintained by the Appwrite Team)
-- :white_check_mark: [Flutter](https://github.com/appwrite/sdk-for-flutter) (Maintained by the Appwrite Team)
-- :white_check_mark: [Apple](https://github.com/appwrite/sdk-for-apple) (Maintained by the Appwrite Team)
-- :white_check_mark: [Android](https://github.com/appwrite/sdk-for-android) (Maintained by the Appwrite Team)
-- :white_check_mark: [React Native](https://github.com/appwrite/sdk-for-react-native) - **Beta** (Maintained by the Appwrite Team)
+- :white_check_mark: [Web](https://github.com/appwrite/sdk-for-web)
+- :white_check_mark: [Flutter](https://github.com/appwrite/sdk-for-flutter)
+- :white_check_mark: [Apple](https://github.com/appwrite/sdk-for-apple)
+- :white_check_mark: [Android](https://github.com/appwrite/sdk-for-android)
+- :white_check_mark: [React Native](https://github.com/appwrite/sdk-for-react-native)
#### Server
-- :white_check_mark: [NodeJS](https://github.com/appwrite/sdk-for-node) (Maintained by the Appwrite Team)
-- :white_check_mark: [PHP](https://github.com/appwrite/sdk-for-php) (Maintained by the Appwrite Team)
-- :white_check_mark: [Dart](https://github.com/appwrite/sdk-for-dart) (Maintained by the Appwrite Team)
-- :white_check_mark: [Deno](https://github.com/appwrite/sdk-for-deno) (Maintained by the Appwrite Team)
-- :white_check_mark: [Ruby](https://github.com/appwrite/sdk-for-ruby) (Maintained by the Appwrite Team)
-- :white_check_mark: [Python](https://github.com/appwrite/sdk-for-python) (Maintained by the Appwrite Team)
-- :white_check_mark: [Kotlin](https://github.com/appwrite/sdk-for-kotlin) (Maintained by the Appwrite Team)
-- :white_check_mark: [Swift](https://github.com/appwrite/sdk-for-swift) (Maintained by the Appwrite Team)
-- :white_check_mark: [.NET](https://github.com/appwrite/sdk-for-dotnet) - **Beta** (Maintained by the Appwrite Team)
-
-#### Community
-
-- :white_check_mark: [Appcelerator Titanium](https://github.com/m1ga/ti.appwrite) (Maintained by [Michael Gangolf](https://github.com/m1ga/))
-- :white_check_mark: [Godot Engine](https://github.com/GodotNuts/appwrite-sdk) (Maintained by [fenix-hub @GodotNuts](https://github.com/fenix-hub))
+- :white_check_mark: [NodeJS](https://github.com/appwrite/sdk-for-node)
+- :white_check_mark: [PHP](https://github.com/appwrite/sdk-for-php)
+- :white_check_mark: [Dart](https://github.com/appwrite/sdk-for-dart)
+- :white_check_mark: [Deno](https://github.com/appwrite/sdk-for-deno)
+- :white_check_mark: [Ruby](https://github.com/appwrite/sdk-for-ruby)
+- :white_check_mark: [Python](https://github.com/appwrite/sdk-for-python)
+- :white_check_mark: [Kotlin](https://github.com/appwrite/sdk-for-kotlin)
+- :white_check_mark: [Swift](https://github.com/appwrite/sdk-for-swift)
+- :white_check_mark: [.NET](https://github.com/appwrite/sdk-for-dotnet)
Looking for more SDKs? - Help us by contributing a pull request to our [SDK Generator](https://github.com/appwrite/sdk-generator)!
diff --git a/app/cli.php b/app/cli.php
index 052643f004..8dfbaf4e9a 100644
--- a/app/cli.php
+++ b/app/cli.php
@@ -2,13 +2,15 @@
require_once __DIR__ . '/init.php';
-use Appwrite\Event\Certificate;
use Appwrite\Event\Delete;
-use Appwrite\Event\Func;
-use Appwrite\Event\StatsResources;
-use Appwrite\Event\StatsUsage;
+use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Certificate as CertificatePublisher;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
+use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher;
+use Appwrite\Event\Publisher\Usage as UsagePublisher;
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;
@@ -16,19 +18,22 @@ use Swoole\Timer;
use Utopia\Cache\Adapter\Pool as CachePool;
use Utopia\Cache\Adapter\Sharding;
use Utopia\Cache\Cache;
+use Utopia\CLI\Adapters\Generic;
+use Utopia\CLI\CLI;
use Utopia\Config\Config;
use Utopia\Console;
use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
-use Utopia\DI\Dependency;
+use Utopia\DI\Container;
use Utopia\DSN\DSN;
use Utopia\Logger\Log;
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;
@@ -44,30 +49,24 @@ require_once __DIR__ . '/controllers/general.php';
global $register;
$platform = new Appwrite();
-$args = $platform->getEnv('argv');
+$args = $_SERVER['argv'] ?? [];
\array_shift($args);
-if (!isset($args[0])) {
+if (! isset($args[0])) {
Console::error('Missing task name');
Console::exit(1);
}
$taskName = $args[0];
+$container = new Container();
+$cli = new CLI(new Generic(), $_SERVER['argv'] ?? [], $container);
+
+$platform->setCli($cli);
$platform->init(Service::TYPE_TASK);
-$cli = $platform->getCli();
-$setResource = function (string $name, callable $callback, array $injections = []) use ($cli) {
- $dependency = new Dependency();
- $dependency->setName($name)->setCallback($callback);
- foreach ($injections as $injection) {
- $dependency->inject($injection);
- }
- $cli->setResource($dependency);
-};
+$container->set('register', fn () => $register, []);
-$setResource('register', fn () => $register, []);
-
-$setResource('cache', function ($pools) {
+$container->set('cache', function ($pools) {
$list = Config::getParam('pools-cache', []);
$adapters = [];
@@ -78,17 +77,18 @@ $setResource('cache', function ($pools) {
return new Cache(new Sharding($adapters));
}, ['pools']);
-$setResource('pools', function (Registry $register) {
+$container->set('pools', function (Registry $register) {
return $register->get('pools');
}, ['register']);
-$setResource('authorization', function () {
+$container->set('authorization', function () {
$authorization = new Authorization();
$authorization->disable();
+
return $authorization;
}, []);
-$setResource('dbForPlatform', function ($pools, $cache, $authorization) {
+$container->set('dbForPlatform', function ($pools, $cache, $authorization) {
$sleep = 3;
$maxAttempts = 5;
$attempts = 0;
@@ -113,7 +113,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,26 +122,26 @@ $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;
}, ['pools', 'cache', 'authorization']);
-$setResource('console', function () {
+$container->set('console', function () {
return new Document(Config::getParam('console'));
}, []);
-$setResource(
+$container->set(
'isResourceBlocked',
fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false,
[]
);
-$setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache, $authorization) {
+$container->set('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache, $authorization) {
$databases = []; // TODO: @Meldiron This should probably be responsibility of utopia-php/pools
return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$databases) {
@@ -157,13 +157,20 @@ $setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $c
}
if (isset($databases[$dsn->getHost()])) {
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
$database = $databases[$dsn->getHost()];
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
- ->setTenant((int)$project->getSequence())
+ ->setGlobalCollections($projectsGlobalCollections)
+ ->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -182,9 +189,16 @@ $setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $c
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
$database
->setSharedTables(true)
- ->setTenant((int)$project->getSequence())
+ ->setTenant($project->getSequence())
+ ->setGlobalCollections($projectsGlobalCollections)
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -203,15 +217,20 @@ $setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $c
};
}, ['pools', 'dbForPlatform', 'cache', 'authorization']);
-$setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
+$container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
$database = null;
- return function (?Document $project = null) use ($pools, $cache, $database, $authorization) {
+ 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;
}
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $logsCollections = $collections['logs'] ?? [];
+ $logsCollections = array_keys($logsCollections);
+
$adapter = new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
@@ -220,51 +239,56 @@ $setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $a
->setAuthorization($authorization)
->setSharedTables(true)
->setNamespace('logsV1')
+ ->setGlobalCollections($logsCollections)
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_TASK)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
// set tenant
if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
- $database->setTenant((int)$project->getSequence());
+ $database->setTenant($project->getSequence());
}
return $database;
};
}, ['pools', 'cache', 'authorization']);
-$setResource('publisher', function (Group $pools) {
+$container->set('publisher', function (Group $pools) {
return new BrokerPool(publisher: $pools->get('publisher'));
}, ['pools']);
-$setResource('publisherDatabases', function (BrokerPool $publisher) {
+$container->set('publisherDatabases', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
-$setResource('publisherFunctions', function (BrokerPool $publisher) {
+$container->set('publisherFunctions', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
-$setResource('publisherMigrations', function (BrokerPool $publisher) {
+$container->set('publisherMigrations', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
-$setResource('publisherStatsUsage', function (BrokerPool $publisher) {
+$container->set('publisherMessaging', 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('queueForStatsResources', function (Publisher $publisher) {
- return new StatsResources($publisher);
-}, ['publisher']);
-$setResource('queueForFunctions', function (Publisher $publisher) {
- return new Func($publisher);
-}, ['publisher']);
-$setResource('queueForDeletes', function (Publisher $publisher) {
+$container->set('usage', function () {
+ return new UsageContext();
+}, []);
+$container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
+), ['publisher']);
+$container->set('publisherForCertificates', fn (Publisher $publisher) => new CertificatePublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME))
+), ['publisher']);
+$container->set('publisherForStatsResources', fn (Publisher $publisher) => new StatsResourcesPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME))
+), ['publisher']);
+$container->set('publisherForFunctions', fn (Publisher $publisher) => new FunctionPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', Event::FUNCTIONS_QUEUE_TTL)
+), ['publisher']);
+$container->set('queueForDeletes', function (Publisher $publisher) {
return new Delete($publisher);
}, ['publisher']);
-$setResource('queueForCertificates', function (Publisher $publisher) {
- return new Certificate($publisher);
-}, ['publisher']);
-$setResource('logError', function (Registry $register) {
+$container->set('logError', function (Registry $register) {
return function (Throwable $error, string $namespace, string $action) use ($register) {
Console::error('[Error] Timestamp: ' . date('c', time()));
Console::error('[Error] Type: ' . get_class($error));
@@ -316,25 +340,28 @@ $setResource('logError', function (Registry $register) {
};
}, ['register']);
-$setResource('executor', fn () => new Executor(), []);
+$container->set('executor', fn () => new Executor(), []);
-$setResource('bus', function (Registry $register) use ($cli) {
- return $register->get('bus')->setResolver(fn (string $name) => $cli->getResource($name));
+$container->set('bus', function (Registry $register) use ($container) {
+ return $register->get('bus')->setResolver(fn (string $name) => $container->get($name));
}, ['register']);
-$setResource('telemetry', fn () => new NoTelemetry(), []);
+$container->set('telemetry', fn () => new NoTelemetry(), []);
+
+$exitCode = 0;
$cli
->error()
->inject('error')
->inject('logError')
- ->action(function (Throwable $error, callable $logError) use ($taskName) {
+ ->action(function (Throwable $error, callable $logError) use ($taskName, &$exitCode) {
call_user_func_array($logError, [
$error,
'Task',
$taskName,
]);
+ $exitCode = 1;
Timer::clearAll();
});
@@ -343,3 +370,4 @@ $cli->shutdown()->action(fn () => Timer::clearAll());
Runtime::enableCoroutine(SWOOLE_HOOK_ALL);
require_once __DIR__ . '/init/span.php';
run($cli->run(...));
+Console::exit($exitCode);
diff --git a/app/config/collections/common.php b/app/config/collections/common.php
index 1845ef8a42..37fbcc8ca3 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' => [],
+ ],
],
],
@@ -1505,6 +1523,13 @@ return [
'lengths' => [],
'orders' => [Database::ORDER_ASC],
],
+ [
+ '$id' => ID::custom('_key_team_confirm'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['teamInternalId', 'confirm'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
],
],
diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php
index 84964ac96a..7496b7a9a7 100644
--- a/app/config/collections/platform.php
+++ b/app/config/collections/platform.php
@@ -404,6 +404,13 @@ $platformCollections = [
'lengths' => [],
'orders' => [],
],
+ [
+ '$id' => ID::custom('_key_teamInternalId'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['teamInternalId'],
+ 'lengths' => [Database::LENGTH_KEY],
+ 'orders' => [Database::ORDER_ASC],
+ ],
],
],
@@ -594,7 +601,7 @@ $platformCollections = [
'filters' => [],
],
[
- '$id' => ID::custom('key'),
+ '$id' => ID::custom('key'), // For app platforms
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
@@ -605,7 +612,7 @@ $platformCollections = [
'filters' => [],
],
[
- '$id' => ID::custom('store'),
+ '$id' => ID::custom('store'), // Unused at the moment
'type' => Database::VAR_STRING,
'format' => '',
'size' => 256,
@@ -616,7 +623,7 @@ $platformCollections = [
'filters' => [],
],
[
- '$id' => ID::custom('hostname'),
+ '$id' => ID::custom('hostname'), // For web platforms
'type' => Database::VAR_STRING,
'format' => '',
'size' => 256,
@@ -635,6 +642,13 @@ $platformCollections = [
'lengths' => [Database::LENGTH_KEY],
'orders' => [Database::ORDER_ASC],
],
+ [
+ '$id' => ID::custom('_key_project_id'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectId'],
+ 'lengths' => [Database::LENGTH_KEY],
+ 'orders' => [Database::ORDER_ASC],
+ ],
],
],
@@ -1007,7 +1021,14 @@ $platformCollections = [
'attributes' => ['projectInternalId'],
'lengths' => [Database::LENGTH_KEY],
'orders' => [Database::ORDER_ASC],
- ]
+ ],
+ [
+ '$id' => ID::custom('_key_project_id'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectId'],
+ 'lengths' => [Database::LENGTH_KEY],
+ 'orders' => [Database::ORDER_ASC],
+ ],
],
],
@@ -1935,6 +1956,440 @@ $platformCollections = [
'attributes' => [],
'indexes' => []
],
+
+ 'reports' => [
+ '$collection' => ID::custom(Database::METADATA),
+ '$id' => ID::custom('reports'),
+ 'name' => 'Reports',
+ 'attributes' => [
+ [
+ '$id' => ID::custom('projectInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('projectId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('appInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('appId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('type'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 64,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('title'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 256,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('summary'),
+ 'type' => Database::VAR_TEXT,
+ 'format' => '',
+ 'size' => 65535,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => '',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ // Resource type the report is about. Plural noun, e.g. databases, sites, urls.
+ '$id' => ID::custom('targetType'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 64,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ // Free-form target identifier (URL for lighthouse, resource ID for db).
+ // Indexed by `_key_project_target` with an explicit prefix length.
+ '$id' => ID::custom('target'),
+ 'type' => Database::VAR_TEXT,
+ 'format' => '',
+ 'size' => 65535,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ // Category strings, e.g. 'performance', 'accessibility'. Native array
+ // column — we never query on individual entries (MySQL JSON-array
+ // indexes are weak), this is read+rewrite only.
+ '$id' => ID::custom('categories'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 64,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => true,
+ 'filters' => [],
+ ],
+ [
+ // Virtual attribute — insights live in the `insights` collection
+ // back-referenced by `reportInternalId`. The subQuery filter joins
+ // them at read time.
+ '$id' => ID::custom('insights'),
+ 'type' => Database::VAR_TEXT,
+ 'format' => '',
+ 'size' => 65535,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => ['subQueryReportInsights'],
+ ],
+ [
+ '$id' => ID::custom('analyzedAt'),
+ 'type' => Database::VAR_DATETIME,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => false,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => ['datetime'],
+ ],
+ ],
+ 'indexes' => [
+ [
+ '$id' => ID::custom('_key_project_app_type'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'appInternalId', 'type'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_target'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'appInternalId', 'targetType', 'target'],
+ 'lengths' => [null, null, null, 700],
+ 'orders' => [],
+ ],
+ ],
+ ],
+
+ 'insights' => [
+ '$collection' => ID::custom(Database::METADATA),
+ '$id' => ID::custom('insights'),
+ 'name' => 'Insights',
+ 'attributes' => [
+ [
+ '$id' => ID::custom('projectInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('projectId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('reportInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('reportId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => '',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('type'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 64,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('severity'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 16,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('status'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 16,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => 'active',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('resourceType'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 64,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('resourceId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('resourceInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('parentResourceType'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 64,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => '',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('parentResourceId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => '',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('parentResourceInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('title'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 256,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('summary'),
+ 'type' => Database::VAR_TEXT,
+ 'format' => '',
+ 'size' => 65535,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => '',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('ctas'),
+ 'type' => Database::VAR_TEXT,
+ 'format' => '',
+ 'size' => 65535,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => ['json'],
+ ],
+ [
+ '$id' => ID::custom('analyzedAt'),
+ 'type' => Database::VAR_DATETIME,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => false,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => ['datetime'],
+ ],
+ [
+ '$id' => ID::custom('dismissedAt'),
+ 'type' => Database::VAR_DATETIME,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => false,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => ['datetime'],
+ ],
+ [
+ '$id' => ID::custom('dismissedBy'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => '',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ ],
+ 'indexes' => [
+ [
+ '$id' => ID::custom('_key_project_report'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'reportInternalId'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_resource'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'resourceType', 'resourceId'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_parent_resource'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'parentResourceType', 'parentResourceId'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_type'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'type'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_severity'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'severity'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_status'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'status'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_dismissedAt'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'dismissedAt'],
+ 'lengths' => [],
+ 'orders' => [Database::ORDER_ASC, Database::ORDER_DESC],
+ ],
+ ],
+ ],
+
];
// Organization API keys subquery
diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php
index e3d8d6baec..9568c59369 100644
--- a/app/config/collections/projects.php
+++ b/app/config/collections/projects.php
@@ -64,8 +64,8 @@ return [
[
'$id' => ID::custom('database'),
'type' => Database::VAR_STRING,
- 'size' => 128,
- 'required' => true,
+ 'size' => 2000,
+ 'required' => false,
'signed' => true,
'array' => false,
'filters' => [],
@@ -797,6 +797,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'),
@@ -1254,6 +1265,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/console.php b/app/config/console.php
index 24de8a8cbb..b7a3f2195a 100644
--- a/app/config/console.php
+++ b/app/config/console.php
@@ -34,11 +34,20 @@ $console = [
'legalAddress' => '',
'legalTaxId' => '',
'auths' => [
+ 'membershipsUserName' => true,
+ 'membershipsUserEmail' => true,
+ 'membershipsMfa' => true,
+ 'membershipsUserId' => true,
+ 'membershipsUserPhone' => true,
'mockNumbers' => [],
'invites' => System::getEnv('_APP_CONSOLE_INVITES', 'enabled') === 'enabled',
'limit' => (System::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled') === 'enabled') ? 1 : 0, // limit signup to 1 user
'duration' => TOKEN_EXPIRATION_LOGIN_LONG, // 1 Year in seconds
'sessionAlerts' => System::getEnv('_APP_CONSOLE_SESSION_ALERTS', 'disabled') === 'enabled',
+ // For email configuration, false means feature is disabled; false means these emails are allowed during sign-ups
+ 'disposableEmails' => false,
+ 'canonicalEmails' => false,
+ 'freeEmails' => false,
'invalidateSessions' => true
],
'authWhitelistEmails' => (!empty(System::getEnv('_APP_CONSOLE_WHITELIST_EMAILS', null))) ? \explode(',', System::getEnv('_APP_CONSOLE_WHITELIST_EMAILS', null)) : [],
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 293c289f74..42ce9ac91b 100644
--- a/app/config/errors.php
+++ b/app/config/errors.php
@@ -226,6 +226,21 @@ return [
'description' => 'A user with the same email already exists in the current project.',
'code' => 409,
],
+ Exception::USER_EMAIL_DISPOSABLE => [
+ 'name' => Exception::USER_EMAIL_DISPOSABLE,
+ 'description' => 'Disposable email addresses are not allowed. Please use a permanent email address.',
+ 'code' => 400,
+ ],
+ Exception::USER_EMAIL_FREE => [
+ 'name' => Exception::USER_EMAIL_FREE,
+ 'description' => 'Free email addresses are not allowed. Please use a business or custom-domain email address.',
+ 'code' => 400,
+ ],
+ Exception::USER_EMAIL_NOT_CANONICAL => [
+ 'name' => Exception::USER_EMAIL_NOT_CANONICAL,
+ 'description' => 'This email address must already be in its canonical form. Please remove aliases, tags, or provider-specific variations and try again.',
+ 'code' => 400,
+ ],
Exception::USER_PASSWORD_MISMATCH => [
'name' => Exception::USER_PASSWORD_MISMATCH,
'description' => 'Passwords do not match. Please check the password and confirm password.',
@@ -369,7 +384,7 @@ return [
],
Exception::API_KEY_EXPIRED => [
'name' => Exception::API_KEY_EXPIRED,
- 'description' => 'The dynamic API key has expired. Please don\'t use dynamic API keys for more than duration of the execution.',
+ 'description' => 'The ephemeral API key has expired. Please don\'t use ephemeral API keys for more than duration of the execution.',
'code' => 401,
],
@@ -608,6 +623,11 @@ return [
'description' => 'Synchronous function execution timed out. Use asynchronous execution instead, or ensure the execution duration doesn\'t exceed 30 seconds.',
'code' => 408,
],
+ Exception::FUNCTION_ASYNCHRONOUS_TIMEOUT => [
+ 'name' => Exception::FUNCTION_ASYNCHRONOUS_TIMEOUT,
+ 'description' => 'Asynchronous function execution timed out. Ensure the execution duration doesn\'t exceed the configured function timeout.',
+ 'code' => 408,
+ ],
Exception::FUNCTION_TEMPLATE_NOT_FOUND => [
'name' => Exception::FUNCTION_TEMPLATE_NOT_FOUND,
'description' => 'Function Template with the requested ID could not be found.',
@@ -672,6 +692,11 @@ return [
'description' => 'Build with the requested ID failed. Please check the logs for more information.',
'code' => 400,
],
+ Exception::BUILD_TIMEOUT => [
+ 'name' => Exception::BUILD_TIMEOUT,
+ 'description' => 'Build timed out. Increase the build timeout via the `_APP_COMPUTE_BUILD_TIMEOUT` environment variable, or simplify the build to complete within the limit.',
+ 'code' => 408,
+ ],
/** Deployments */
Exception::DEPLOYMENT_NOT_FOUND => [
@@ -1144,6 +1169,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.',
@@ -1159,6 +1189,16 @@ return [
'description' => 'Platform with the requested ID could not be found.',
'code' => 404,
],
+ Exception::PLATFORM_METHOD_UNSUPPORTED => [
+ 'name' => Exception::PLATFORM_METHOD_UNSUPPORTED,
+ 'description' => 'The requested platform has invalid type. Please use corresponding update method for the platform type.',
+ 'code' => 400,
+ ],
+ Exception::PLATFORM_ALREADY_EXISTS => [
+ 'name' => Exception::PLATFORM_ALREADY_EXISTS,
+ 'description' => 'Platform with the same ID already exists in this project. Try again with a different ID.',
+ 'code' => 409,
+ ],
Exception::VARIABLE_NOT_FOUND => [
'name' => Exception::VARIABLE_NOT_FOUND,
'description' => 'Variable with the requested ID could not be found.',
@@ -1206,6 +1246,26 @@ return [
'description' => 'The specified database type is not supported for CSV import or export operations.',
'code' => 400,
],
+ Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED => [
+ 'name' => Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED,
+ 'description' => 'A source projectId is required for Appwrite migrations. Provide it in the migration credentials.',
+ 'code' => 400,
+ ],
+ Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND => [
+ 'name' => Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND,
+ 'description' => 'The source project for the provided projectId was not found. Verify the projectId and the API key has access to it.',
+ 'code' => 404,
+ ],
+ Exception::MIGRATION_SOURCE_TYPE_INVALID => [
+ 'name' => Exception::MIGRATION_SOURCE_TYPE_INVALID,
+ 'description' => 'The migration source type is invalid. Use one of the supported source types.',
+ 'code' => 400,
+ ],
+ Exception::MIGRATION_DESTINATION_TYPE_INVALID => [
+ 'name' => Exception::MIGRATION_DESTINATION_TYPE_INVALID,
+ 'description' => 'The migration destination type is invalid. Use one of the supported destination types.',
+ 'code' => 400,
+ ],
/** Realtime */
Exception::REALTIME_MESSAGE_FORMAT_INVALID => [
@@ -1378,4 +1438,43 @@ return [
'description' => 'When using project API key, make sure to pass x-appwrite-project header with your project ID.',
'code' => 403,
],
+ Exception::MOCK_NUMBER_ALREADY_EXISTS => [
+ 'name' => Exception::MOCK_NUMBER_ALREADY_EXISTS,
+ 'description' => 'Mock number with the requested number already exists. Try again with a different number. or update OTP of existing mock number.',
+ 'code' => 409,
+ ],
+ Exception::MOCK_NUMBER_NOT_FOUND => [
+ 'name' => Exception::MOCK_NUMBER_NOT_FOUND,
+ 'description' => 'Mock number with the requested number could not be found.',
+ 'code' => 404,
+ ],
+ Exception::MOCK_NUMBER_LIMIT_EXCEEDED => [
+ 'name' => Exception::MOCK_NUMBER_LIMIT_EXCEEDED,
+ 'description' => 'The maximum number of mock phones for this project has been reached.',
+ 'code' => 400,
+ ],
+
+ /** Advisor */
+ Exception::INSIGHT_NOT_FOUND => [
+ 'name' => Exception::INSIGHT_NOT_FOUND,
+ 'description' => 'Insight with the requested ID could not be found.',
+ 'code' => 404,
+ ],
+ Exception::INSIGHT_ALREADY_EXISTS => [
+ 'name' => Exception::INSIGHT_ALREADY_EXISTS,
+ 'description' => 'Insight with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
+ 'code' => 409,
+ ],
+
+ /** Reports */
+ Exception::REPORT_NOT_FOUND => [
+ 'name' => Exception::REPORT_NOT_FOUND,
+ 'description' => 'Report with the requested ID could not be found.',
+ 'code' => 404,
+ ],
+ Exception::REPORT_ALREADY_EXISTS => [
+ 'name' => Exception::REPORT_ALREADY_EXISTS,
+ 'description' => 'Report with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
+ 'code' => 409,
+ ],
];
diff --git a/app/config/events.php b/app/config/events.php
index 11dc2e0e4a..2825562ab7 100644
--- a/app/config/events.php
+++ b/app/config/events.php
@@ -426,5 +426,33 @@ return [
'update' => [
'$description' => 'This event triggers when a proxy rule is updated.',
]
- ]
+ ],
+ 'reports' => [
+ '$model' => Response::MODEL_REPORT,
+ '$resource' => true,
+ '$description' => 'This event triggers on any report event.',
+ 'create' => [
+ '$description' => 'This event triggers when a report is created.',
+ ],
+ 'update' => [
+ '$description' => 'This event triggers when a report is updated.',
+ ],
+ 'delete' => [
+ '$description' => 'This event triggers when a report is deleted.',
+ ],
+ 'insights' => [
+ '$model' => Response::MODEL_INSIGHT,
+ '$resource' => true,
+ '$description' => 'This event triggers on any insight event.',
+ 'create' => [
+ '$description' => 'This event triggers when an insight is created.',
+ ],
+ 'update' => [
+ '$description' => 'This event triggers when an insight is updated.',
+ ],
+ 'delete' => [
+ '$description' => 'This event triggers when an insight is deleted.',
+ ],
+ ],
+ ],
];
diff --git a/app/config/locale/templates.php b/app/config/locale/templates.php
index 6aa376678a..680034554b 100644
--- a/app/config/locale/templates.php
+++ b/app/config/locale/templates.php
@@ -9,11 +9,5 @@ return [
'mfaChallenge',
'sessionAlert',
'otpSession'
- ],
- 'sms' => [
- 'verification',
- 'login',
- 'invitation',
- 'mfaChallenge'
]
];
diff --git a/app/config/locale/translations/en.json b/app/config/locale/translations/en.json
index 8e59c40123..3d667a36ad 100644
--- a/app/config/locale/translations/en.json
+++ b/app/config/locale/translations/en.json
@@ -57,21 +57,21 @@
"emails.recovery.thanks": "Thanks,",
"emails.recovery.buttonText": "Reset password",
"emails.recovery.signature": "{{project}} team",
- "emails.csvExport.success.subject": "Your CSV export is ready",
- "emails.csvExport.success.preview": "Your data export has been completed successfully.",
- "emails.csvExport.success.hello": "Hello {{user}},",
- "emails.csvExport.success.body": "Your CSV export is ready to download. Click the button below to download your data export.",
- "emails.csvExport.success.footer": "This download link will expire in 1 hour.",
- "emails.csvExport.success.thanks": "Thanks,",
- "emails.csvExport.success.buttonText": "Download CSV",
- "emails.csvExport.success.signature": "Appwrite team",
- "emails.csvExport.failure.subject": "Your CSV export failed - file too large",
- "emails.csvExport.failure.preview": "Your data export failed because the file size exceeds your plan limit.",
- "emails.csvExport.failure.hello": "Hello {{user}},",
- "emails.csvExport.failure.body": "Your CSV export could not be completed because the export file size ({{size}}MB) exceeds your plan limit. Please consider upgrading your plan or exporting a smaller dataset.",
- "emails.csvExport.failure.footer": "If you have any questions, please contact our support team.",
- "emails.csvExport.failure.thanks": "Thanks,",
- "emails.csvExport.failure.signature": "{{project}} team",
+ "emails.dataExport.success.subject": "Your {{type}} export is ready",
+ "emails.dataExport.success.preview": "Your data export has been completed successfully.",
+ "emails.dataExport.success.hello": "Hello {{user}},",
+ "emails.dataExport.success.body": "Your {{type}} export is ready to download. Click the button below to download your data export.",
+ "emails.dataExport.success.footer": "This download link will expire in 1 hour.",
+ "emails.dataExport.success.thanks": "Thanks,",
+ "emails.dataExport.success.buttonText": "Download {{type}}",
+ "emails.dataExport.success.signature": "Appwrite team",
+ "emails.dataExport.failure.subject": "Your {{type}} export failed - file too large",
+ "emails.dataExport.failure.preview": "Your data export failed because the file size exceeds your plan limit.",
+ "emails.dataExport.failure.hello": "Hello {{user}},",
+ "emails.dataExport.failure.body": "Your {{type}} export could not be completed because the export file size ({{size}}MB) exceeds your plan limit. Please consider upgrading your plan or exporting a smaller dataset.",
+ "emails.dataExport.failure.footer": "If you have any questions, please contact our support team.",
+ "emails.dataExport.failure.thanks": "Thanks,",
+ "emails.dataExport.failure.signature": "{{project}} team",
"emails.invitation.subject": "Invitation to {{team}} Team at {{project}}",
"emails.invitation.preview": "{{owner}} invited you to join {{team}} at {{project}}",
"emails.invitation.hello": "Hello {{user}},",
diff --git a/app/config/locale/translations/es.json b/app/config/locale/translations/es.json
index 21a406b418..1bbc8062be 100644
--- a/app/config/locale/translations/es.json
+++ b/app/config/locale/translations/es.json
@@ -28,6 +28,16 @@
"emails.invitation.thanks": "Gracias.,",
"emails.invitation.buttonText": "Aceptar invitación a {{team}}",
"emails.invitation.signature": "El equipo de {{project}}",
+ "emails.sessionAlert.subject": "Alerta de seguridad: nueva sesión en tu cuenta de {{project}}",
+ "emails.sessionAlert.preview": "Nuevo inicio de sesión detectado en {{project}} a las {{time}} UTC.",
+ "emails.sessionAlert.hello": "Hola {{user}},",
+ "emails.sessionAlert.body": "Se ha creado una nueva sesión en tu cuenta de {{b}}{{project}}{{/b}}, {{b}}el {{date}} de {{year}} a las {{time}} UTC{{/b}}.\nEstos son los detalles de la nueva sesión:",
+ "emails.sessionAlert.listDevice": "Dispositivo: {{b}}{{device}}{{/b}}",
+ "emails.sessionAlert.listIpAddress": "Dirección IP: {{b}}{{ipAddress}}{{/b}}",
+ "emails.sessionAlert.listCountry": "País: {{b}}{{country}}{{/b}}",
+ "emails.sessionAlert.footer": "Si has sido tú, no tienes que hacer nada más.\nSi no has iniciado esta sesión o sospechas actividad no autorizada, protege tu cuenta.",
+ "emails.sessionAlert.thanks": "Gracias,",
+ "emails.sessionAlert.signature": "El equipo de {{project}}",
"locale.country.unknown": "Desconocido",
"countries.af": "Afganistán",
"countries.ao": "Angola",
diff --git a/app/config/oAuthProviders.php b/app/config/oAuthProviders.php
index e6acd08c54..3b492fd8bf 100644
--- a/app/config/oAuthProviders.php
+++ b/app/config/oAuthProviders.php
@@ -167,6 +167,17 @@ return [
'mock' => false,
'class' => 'Appwrite\\Auth\\OAuth2\\Figma',
],
+ 'fusionauth' => [
+ 'name' => 'FusionAuth',
+ 'developers' => 'https://fusionauth.io/docs/',
+ 'icon' => 'icon-fusionauth',
+ 'enabled' => true,
+ 'sandbox' => false,
+ 'form' => 'fusionauth.phtml',
+ 'beta' => false,
+ 'mock' => false,
+ 'class' => 'Appwrite\\Auth\\OAuth2\\FusionAuth',
+ ],
'github' => [
'name' => 'GitHub',
'developers' => 'https://developer.github.com/',
@@ -200,6 +211,28 @@ return [
'mock' => false,
'class' => 'Appwrite\\Auth\\OAuth2\\Google',
],
+ 'keycloak' => [
+ 'name' => 'Keycloak',
+ 'developers' => 'https://www.keycloak.org/documentation',
+ 'icon' => 'icon-keycloak',
+ 'enabled' => true,
+ 'sandbox' => false,
+ 'form' => 'keycloak.phtml',
+ 'beta' => false,
+ 'mock' => false,
+ 'class' => 'Appwrite\\Auth\\OAuth2\\Keycloak',
+ ],
+ 'kick' => [
+ 'name' => 'Kick',
+ 'developers' => 'https://docs.kick.com/',
+ 'icon' => 'icon-kick',
+ 'enabled' => true,
+ 'sandbox' => false,
+ 'form' => false,
+ 'beta' => false,
+ 'mock' => false,
+ 'class' => 'Appwrite\\Auth\\OAuth2\\Kick',
+ ],
'linkedin' => [
'name' => 'LinkedIn',
'developers' => 'https://developer.linkedin.com/',
@@ -376,6 +409,17 @@ return [
'mock' => false,
'class' => 'Appwrite\\Auth\\OAuth2\\Wordpress',
],
+ 'x' => [
+ 'name' => 'X',
+ 'developers' => 'https://docs.x.com/fundamentals/authentication/oauth-2-0/authorization-code',
+ 'icon' => 'icon-twitter',
+ 'enabled' => true,
+ 'sandbox' => false,
+ 'form' => false,
+ 'beta' => false,
+ 'mock' => false,
+ 'class' => 'Appwrite\\Auth\\OAuth2\\X',
+ ],
'yahoo' => [
'name' => 'Yahoo',
'developers' => 'https://developer.yahoo.com/oauth2/guide/flows_authcode/',
diff --git a/app/config/apis.php b/app/config/protocols.php
similarity index 69%
rename from app/config/apis.php
rename to app/config/protocols.php
index a625999682..bb12453712 100644
--- a/app/config/apis.php
+++ b/app/config/protocols.php
@@ -9,8 +9,8 @@ return [
'key' => 'graphql',
'name' => 'GraphQL',
],
- 'realtime' => [
- 'key' => 'realtime',
- 'name' => 'Realtime',
+ 'websocket' => [
+ 'key' => 'websocket',
+ 'name' => 'Websocket',
],
];
diff --git a/app/config/roles.php b/app/config/roles.php
index 4473176c23..cb4b178a29 100644
--- a/app/config/roles.php
+++ b/app/config/roles.php
@@ -21,8 +21,8 @@ $member = [
'projects.read',
'locale.read',
'avatars.read',
- 'execution.read',
- 'execution.write',
+ 'executions.read',
+ 'executions.write',
'targets.read',
'targets.write',
'subscribers.write',
@@ -55,6 +55,14 @@ $admins = [
'tables.write',
'platforms.read',
'platforms.write',
+ 'oauth2.read',
+ 'oauth2.write',
+ 'mocks.read',
+ 'mocks.write',
+ 'project.policies.read',
+ 'project.policies.write',
+ 'templates.read',
+ 'templates.write',
'projects.write',
'keys.read',
'keys.write',
@@ -62,6 +70,8 @@ $admins = [
'devKeys.write',
'webhooks.read',
'webhooks.write',
+ 'project.read',
+ 'project.write',
'locale.read',
'avatars.read',
'health.read',
@@ -71,8 +81,8 @@ $admins = [
'sites.write',
'log.read',
'log.write',
- 'execution.read',
- 'execution.write',
+ 'executions.read',
+ 'executions.write',
'rules.read',
'rules.write',
'migrations.read',
@@ -93,6 +103,10 @@ $admins = [
'tokens.write',
'schedules.read',
'schedules.write',
+ 'insights.read',
+ 'insights.write',
+ 'reports.read',
+ 'reports.write',
];
return [
@@ -113,7 +127,7 @@ return [
'files.write',
'locale.read',
'avatars.read',
- 'execution.write',
+ 'executions.write',
],
],
User::ROLE_USERS => [
diff --git a/app/config/scopes/consoleProject.php b/app/config/scopes/consoleProject.php
deleted file mode 100644
index f19f7998be..0000000000
--- a/app/config/scopes/consoleProject.php
+++ /dev/null
@@ -1,9 +0,0 @@
- 'platforms.read',
- 'keys' => 'keys.read',
- 'keysWrite' => 'keys.write',
-];
diff --git a/app/config/scopes/organization.php b/app/config/scopes/organization.php
index ca4160881d..228a1437f2 100644
--- a/app/config/scopes/organization.php
+++ b/app/config/scopes/organization.php
@@ -3,13 +3,6 @@
// List of scopes for organization (teams) API keys
return [
- "platforms.read" => [
- "description" => 'Access to read project\'s platforms',
- ],
- "platforms.write" => [
- "description" =>
- 'Access to create, update, and delete project\'s platforms',
- ],
"projects.read" => [
"description" => 'Access to read organization\'s projects',
],
@@ -17,13 +10,6 @@ return [
"description" =>
"Access to create, update, and delete projects in organization",
],
- "keys.read" => [
- "description" => 'Access to read project\'s API keys',
- ],
- "keys.write" => [
- "description" =>
- "Access to create, update, and delete project\'s API keys",
- ],
"devKeys.read" => [
"description" => 'Access to read project\'s development keys',
],
@@ -31,12 +17,4 @@ return [
"description" =>
"Access to create, update, and delete project\'s development keys",
],
- "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/config/scopes/project.php b/app/config/scopes/project.php
index 0a3feba9e7..3d8998fb2f 100644
--- a/app/config/scopes/project.php
+++ b/app/config/scopes/project.php
@@ -1,175 +1,382 @@
[
- 'description' => 'Access to create, update, and delete user sessions',
+// List of publicly visible scopes
+return [
+ // Project
+ "project.read" => [
+ "description" =>
+ "Access to read project\'s information",
+ "category" => "Project",
],
+ "project.write" => [
+ "description" =>
+ "Access to update project\'s information",
+ "category" => "Project",
+ ],
+ "keys.read" => [
+ "description" =>
+ "Access to read project\'s keys",
+ "category" => "Project",
+ ],
+ "keys.write" => [
+ "description" =>
+ "Access to create, update, and delete project\'s keys",
+ "category" => "Project",
+ ],
+ "platforms.read" => [
+ "description" =>
+ "Access to read project\'s platforms",
+ "category" => "Project",
+ ],
+ "platforms.write" => [
+ "description" =>
+ "Access to create, update, and delete project\'s platforms",
+ "category" => "Project",
+ ],
+ "mocks.read" => [
+ "description" =>
+ "Access to read project\'s mocks",
+ "category" => "Project",
+ ],
+ "mocks.write" => [
+ "description" =>
+ "Access to create, update, and delete project\'s mocks",
+ "category" => "Project",
+ ],
+ "policies.read" => [
+ "description" =>
+ "Access to read project\'s policies. Replaced by \'project.policies.read\' for more granular control",
+ "category" => "Project",
+ 'deprecated' => true,
+ ],
+ "policies.write" => [
+ "description" =>
+ "Access to update project\'s policies. Replaces by \'project.policies.write\' for more granular control",
+ "category" => "Project",
+ 'deprecated' => true,
+ ],
+ "project.policies.read" => [
+ "description" =>
+ "Access to read project\'s policies",
+ "category" => "Project",
+ ],
+ "project.policies.write" => [
+ "description" =>
+ "Access to update project\'s policies",
+ "category" => "Project",
+ ],
+ "templates.read" => [
+ "description" =>
+ "Access to read project\'s templates",
+ "category" => "Project",
+ ],
+ "templates.write" => [
+ "description" =>
+ "Access to create, update, and delete project\'s templates",
+ "category" => "Project",
+ ],
+ "oauth2.read" => [
+ "description" =>
+ "Access to read project\'s OAuth2 configuration",
+ "category" => "Project",
+ ],
+ "oauth2.write" => [
+ "description" =>
+ "Access to update project\'s OAuth2 configuration",
+ "category" => "Project",
+ ],
+
+ // Auth
'users.read' => [
- 'description' => 'Access to read your project\'s users',
+ 'description' => 'Access to read users',
+ 'category' => 'Auth',
],
'users.write' => [
- 'description' => 'Access to create, update, and delete your project\'s users',
+ 'description' => 'Access to create, update, and delete users',
+ 'category' => 'Auth',
+ ],
+ 'sessions.read' => [
+ 'description' => 'Access to read user sessions',
+ 'category' => 'Auth',
+ ],
+ 'sessions.write' => [
+ 'description' => 'Access to create, update, and delete user sessions',
+ 'category' => 'Auth',
],
'teams.read' => [
- 'description' => 'Access to read your project\'s teams',
+ 'description' => 'Access to read teams',
+ 'category' => 'Auth',
],
'teams.write' => [
- 'description' => 'Access to create, update, and delete your project\'s teams',
+ 'description' => 'Access to create, update, and delete teams',
+ 'category' => 'Auth',
],
+
+ // Databases
'databases.read' => [
- 'description' => 'Access to read your project\'s databases',
+ 'description' => 'Access to read databases',
+ 'category' => 'Databases',
],
'databases.write' => [
- 'description' => 'Access to create, update, and delete your project\'s databases',
- ],
- 'collections.read' => [
- 'description' => 'Access to read your project\'s database collections',
- ],
- 'collections.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database collections',
+ 'description' => 'Access to create, update, and delete databases',
+ 'category' => 'Databases',
],
'tables.read' => [
- 'description' => 'Access to read your project\'s database tables',
+ 'description' => 'Access to read database tables',
+ 'category' => 'Databases',
],
'tables.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database tables',
- ],
- 'attributes.read' => [
- 'description' => 'Access to read your project\'s database collection\'s attributes',
- ],
- 'attributes.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database collection\'s attributes',
+ 'description' => 'Access to create, update, and delete database tables',
+ 'category' => 'Databases',
],
'columns.read' => [
- 'description' => 'Access to read your project\'s database table\'s columns',
+ 'description' => 'Access to read database table columns',
+ 'category' => 'Databases',
],
'columns.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database table\'s columns',
+ 'description' => 'Access to create, update, and delete database table columns',
+ 'category' => 'Databases',
],
'indexes.read' => [
- 'description' => 'Access to read your project\'s database table\'s indexes',
+ 'description' => 'Access to read database table indexes',
+ 'category' => 'Databases',
],
'indexes.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database table\'s indexes',
- ],
- 'documents.read' => [
- 'description' => 'Access to read your project\'s database documents',
- ],
- 'documents.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database documents',
+ 'description' => 'Access to create, update, and delete database table indexes',
+ 'category' => 'Databases',
],
'rows.read' => [
- 'description' => 'Access to read your project\'s database rows',
+ 'description' => 'Access to read database table rows',
+ 'category' => 'Databases',
],
'rows.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database rows',
+ 'description' => 'Access to create, update, and delete database table rows',
+ 'category' => 'Databases',
],
- 'files.read' => [
- 'description' => 'Access to read your project\'s storage files and preview images',
+ 'collections.read' => [
+ 'description' => 'Access to read database collections',
+ 'category' => 'Databases',
+ 'deprecated' => true,
],
- 'files.write' => [
- 'description' => 'Access to create, update, and delete your project\'s storage files',
+ 'collections.write' => [
+ 'description' => 'Access to create, update, and delete database collections',
+ 'category' => 'Databases',
+ 'deprecated' => true,
],
+ 'attributes.read' => [
+ 'description' => 'Access to read database collection attributes',
+ 'category' => 'Databases',
+ 'deprecated' => true,
+ ],
+ 'attributes.write' => [
+ 'description' => 'Access to create, update, and delete database collection attributes',
+ 'category' => 'Databases',
+ 'deprecated' => true,
+ ],
+ 'documents.read' => [
+ 'description' => 'Access to read database collection documents',
+ 'category' => 'Databases',
+ 'deprecated' => true,
+ ],
+ 'documents.write' => [
+ 'description' => 'Access to create, update, and delete database collection documents',
+ 'category' => 'Databases',
+ 'deprecated' => true,
+ ],
+
+ // Storage
'buckets.read' => [
- 'description' => 'Access to read your project\'s storage buckets',
+ 'description' => 'Access to read storage buckets',
+ 'category' => 'Storage',
],
'buckets.write' => [
- 'description' => 'Access to create, update, and delete your project\'s storage buckets',
+ 'description' => 'Access to create, update, and delete storage buckets',
+ 'category' => 'Storage',
],
- 'functions.read' => [
- 'description' => 'Access to read your project\'s functions and code deployments',
+ 'files.read' => [
+ 'description' => 'Access to read storage files and preview images',
+ 'category' => 'Storage',
],
- 'functions.write' => [
- 'description' => 'Access to create, update, and delete your project\'s functions and code deployments',
- ],
- 'sites.read' => [
- 'description' => 'Access to read your project\'s sites and deployments',
- ],
- 'sites.write' => [
- 'description' => 'Access to create, update, and delete your project\'s sites and deployments',
- ],
- 'log.read' => [
- 'description' => 'Access to read your site\'s logs',
- ],
- 'log.write' => [
- 'description' => 'Access to update, and delete your site\'s logs',
- ],
- 'execution.read' => [
- 'description' => 'Access to read your project\'s execution logs',
- ],
- 'execution.write' => [
- 'description' => 'Access to execute your project\'s functions',
- ],
- 'locale.read' => [
- 'description' => 'Access to access your project\'s Locale service',
- ],
- 'avatars.read' => [
- 'description' => 'Access to access your project\'s Avatars service',
- ],
- 'health.read' => [
- 'description' => 'Access to read your project\'s health status',
- ],
- 'providers.read' => [
- 'description' => 'Access to read your project\'s providers',
- ],
- 'providers.write' => [
- 'description' => 'Access to create, update, and delete your project\'s providers',
- ],
- 'messages.read' => [
- 'description' => 'Access to read your project\'s messages',
- ],
- 'messages.write' => [
- 'description' => 'Access to create, update, and delete your project\'s messages',
- ],
- 'topics.read' => [
- 'description' => 'Access to read your project\'s topics',
- ],
- 'topics.write' => [
- 'description' => 'Access to create, update, and delete your project\'s topics',
- ],
- 'subscribers.read' => [
- 'description' => 'Access to read your project\'s subscribers',
- ],
- 'subscribers.write' => [
- 'description' => 'Access to create, update, and delete your project\'s subscribers',
- ],
- 'targets.read' => [
- 'description' => 'Access to read your project\'s targets',
- ],
- 'targets.write' => [
- 'description' => 'Access to create, update, and delete your project\'s targets',
- ],
- 'rules.read' => [
- 'description' => 'Access to read your project\'s proxy rules',
- ],
- 'rules.write' => [
- 'description' => 'Access to create, update, and delete your project\'s proxy rules',
- ],
- 'schedules.read' => [
- 'description' => 'Access to read your project\'s schedules',
- ],
- 'schedules.write' => [
- 'description' => 'Access to create, update, and delete your project\'s schedules',
- ],
- 'migrations.read' => [
- 'description' => 'Access to read your project\'s migrations',
- ],
- 'migrations.write' => [
- 'description' => 'Access to create, update, and delete your project\'s migrations.',
- ],
- 'vcs.read' => [
- 'description' => 'Access to read your project\'s VCS repositories',
- ],
- 'vcs.write' => [
- 'description' => 'Access to create, update, and delete your project\'s VCS repositories',
- ],
- 'assistant.read' => [
- 'description' => 'Access to read the Assistant service',
+ 'files.write' => [
+ 'description' => 'Access to create, update, and delete storage files',
+ 'category' => 'Storage',
],
'tokens.read' => [
- 'description' => 'Access to read your project\'s tokens',
+ 'description' => 'Access to read storage file tokens',
+ 'category' => 'Storage',
],
'tokens.write' => [
- 'description' => 'Access to create, update, and delete your project\'s tokens',
+ 'description' => 'Access to create, update, and delete storage file tokens',
+ 'category' => 'Storage',
+ ],
+
+ // Functions
+ 'functions.read' => [
+ 'description' => 'Access to read functions and deployments',
+ 'category' => 'Functions',
+ ],
+ 'functions.write' => [
+ 'description' => 'Access to create, update, and delete functions and deployments',
+ 'category' => 'Functions',
+ ],
+ 'executions.read' => [
+ 'description' => 'Access to read function executions',
+ 'category' => 'Functions',
+ ],
+ 'executions.write' => [
+ 'description' => 'Access to create function executions',
+ 'category' => 'Functions',
+ ],
+ 'execution.read' => [
+ 'description' => 'Access to read function executions. This scope is deprecated for consistency purposes, and replaced by `executions.read`.',
+ 'category' => 'Functions',
+ 'deprecated' => true,
+ ],
+ 'execution.write' => [
+ 'description' => 'Access to create function executions. This scope is deprecated for consistency purposes, and replaced by `executions.write`.',
+ 'category' => 'Functions',
+ 'deprecated' => true,
+ ],
+
+ // Sites
+ 'sites.read' => [
+ 'description' => 'Access to read sites and deployments',
+ 'category' => 'Sites',
+ ],
+ 'sites.write' => [
+ 'description' => 'Access to create, update, and delete sites and deployments',
+ 'category' => 'Sites',
+ ],
+ 'log.read' => [
+ 'description' => 'Access to read site logs',
+ 'category' => 'Sites',
+ ],
+ 'log.write' => [
+ 'description' => 'Access to update, and delete site logs',
+ 'category' => 'Sites',
+ ],
+
+ // Messaging
+ 'providers.read' => [
+ 'description' => 'Access to read messaging providers',
+ 'category' => 'Messaging',
+ ],
+ 'providers.write' => [
+ 'description' => 'Access to create, update, and delete messaging providers',
+ 'category' => 'Messaging',
+ ],
+ 'topics.read' => [
+ 'description' => 'Access to read messaging topics',
+ 'category' => 'Messaging',
+ ],
+ 'topics.write' => [
+ 'description' => 'Access to create, update, and delete messaging topics',
+ 'category' => 'Messaging',
+ ],
+ 'subscribers.read' => [
+ 'description' => 'Access to read messaging subscribers',
+ 'category' => 'Messaging',
+ ],
+ 'subscribers.write' => [
+ 'description' => 'Access to create, update, and delete messaging subscribers',
+ 'category' => 'Messaging',
+ ],
+ 'targets.read' => [
+ 'description' => 'Access to read messaging targets',
+ 'category' => 'Messaging',
+ ],
+ 'targets.write' => [
+ 'description' => 'Access to create, update, and delete messaging targets',
+ 'category' => 'Messaging',
+ ],
+ 'messages.read' => [
+ 'description' => 'Access to read messaging messages',
+ 'category' => 'Messaging',
+ ],
+ 'messages.write' => [
+ 'description' => 'Access to create, update, and delete messaging messages',
+ 'category' => 'Messaging',
+ ],
+
+ // Proxy
+ 'rules.read' => [
+ 'description' => 'Access to read proxy rules.',
+ 'category' => 'Proxy',
+ ],
+ 'rules.write' => [
+ 'description' => 'Access to create, update, and delete proxy rules.',
+ 'category' => 'Proxy',
+ ],
+
+ // Other
+ "webhooks.read" => [
+ "description" =>
+ "Access to read webhooks",
+ 'category' => 'Other',
+ ],
+ "webhooks.write" => [
+ "description" =>
+ "Access to create, update, and delete webhooks",
+ 'category' => 'Other',
+ ],
+ 'locale.read' => [
+ 'description' => 'Access to use Locale service',
+ 'category' => 'Other',
+ ],
+ 'avatars.read' => [
+ 'description' => 'Access to use Avatars service',
+ 'category' => 'Other',
+ ],
+ 'health.read' => [
+ 'description' => 'Access to use Health service',
+ 'category' => 'Other',
+ ],
+ 'assistant.read' => [
+ 'description' => 'Access to use Assistant service',
+ 'category' => 'Other',
+ ],
+ 'migrations.read' => [
+ 'description' => 'Access to read migrations',
+ 'category' => 'Other',
+ ],
+ 'migrations.write' => [
+ 'description' => 'Access to create, update, and delete migrations.',
+ 'category' => 'Other',
+ ],
+
+ // TODO: Figure out where to move those
+ 'schedules.read' => [
+ 'description' => 'Access to read schedules.',
+ 'category' => 'Other',
+ ],
+ 'schedules.write' => [
+ 'description' => 'Access to create, update, and delete schedules.',
+ 'category' => 'Other',
+ ],
+ 'vcs.read' => [
+ 'description' => 'Access to read resources under VCS service.',
+ 'category' => 'Other',
+ ],
+ 'vcs.write' => [
+ 'description' => 'Access to create, update, and delete resources under VCS service.',
+ 'category' => 'Other',
+ ],
+
+ // Advisor
+ 'insights.read' => [
+ 'description' => 'Access to read insights under Advisor service.',
+ 'category' => 'Advisor',
+ ],
+ 'insights.write' => [
+ 'description' => 'Reserved for Advisor insight ingestion outside CE.',
+ 'category' => 'Advisor',
+ ],
+ 'reports.read' => [
+ 'description' => 'Access to read reports under Advisor service.',
+ 'category' => 'Advisor',
+ ],
+ 'reports.write' => [
+ 'description' => 'Access to delete reports under Advisor service.',
+ 'category' => 'Advisor',
],
];
diff --git a/app/config/sdks.php b/app/config/sdks.php
index 1a808aa10a..e29b28690f 100644
--- a/app/config/sdks.php
+++ b/app/config/sdks.php
@@ -250,26 +250,16 @@ return [
],
],
],
- [
- 'key' => 'markdown',
- 'name' => 'Markdown',
- 'version' => '0.3.0',
- 'url' => 'https://github.com/appwrite/sdk-for-md.git',
- 'package' => 'https://www.npmjs.com/package/@appwrite.io/docs',
- 'enabled' => true,
- 'beta' => false,
- 'dev' => false,
- 'hidden' => false,
- 'family' => APP_SDK_PLATFORM_CONSOLE,
- 'prism' => 'markdown',
- 'source' => \realpath(__DIR__ . '/../sdks/console-md'),
- 'gitUrl' => 'git@github.com:appwrite/sdk-for-md.git',
- 'gitRepoName' => 'sdk-for-md',
- 'gitUserName' => 'appwrite',
- 'gitBranch' => 'dev',
- 'repoBranch' => 'main',
- 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/md/CHANGELOG.md'),
- ],
+ ],
+ ],
+
+ APP_SDK_PLATFORM_STATIC => [
+ 'key' => APP_SDK_PLATFORM_STATIC,
+ 'name' => 'Static',
+ 'description' => 'SDK artifacts for Appwrite integrations that do not require a generated platform API specification.',
+ 'enabled' => true,
+ 'beta' => false,
+ 'sdks' => [
[
'key' => 'agent-skills',
'name' => 'AgentSkills',
@@ -279,9 +269,10 @@ return [
'beta' => false,
'dev' => false,
'hidden' => false,
- 'family' => APP_SDK_PLATFORM_CONSOLE,
+ 'spec' => 'static',
+ 'family' => APP_SDK_PLATFORM_STATIC,
'prism' => 'agent-skills',
- 'source' => \realpath(__DIR__ . '/../sdks/console-agent-skills'),
+ 'source' => \realpath(__DIR__ . '/../sdks/static-agent-skills'),
'gitUrl' => 'git@github.com:appwrite/agent-skills.git',
'gitRepoName' => 'agent-skills',
'gitUserName' => 'appwrite',
@@ -298,9 +289,10 @@ return [
'beta' => false,
'dev' => false,
'hidden' => false,
- 'family' => APP_SDK_PLATFORM_CONSOLE,
+ 'spec' => 'static',
+ 'family' => APP_SDK_PLATFORM_STATIC,
'prism' => 'cursor-plugin',
- 'source' => \realpath(__DIR__ . '/../sdks/console-cursor-plugin'),
+ 'source' => \realpath(__DIR__ . '/../sdks/static-cursor-plugin'),
'gitUrl' => 'git@github.com:appwrite/cursor-plugin.git',
'gitRepoName' => 'cursor-plugin',
'gitUserName' => 'appwrite',
@@ -308,6 +300,46 @@ return [
'repoBranch' => 'main',
'changelog' => \realpath(__DIR__ . '/../../docs/sdks/cursor-plugin/CHANGELOG.md'),
],
+ [
+ 'key' => 'claude-plugin',
+ 'name' => 'ClaudePlugin',
+ 'version' => '0.1.0',
+ 'url' => 'https://github.com/appwrite/claude-plugin.git',
+ 'enabled' => true,
+ 'beta' => false,
+ 'dev' => false,
+ 'hidden' => false,
+ 'spec' => 'static',
+ 'family' => APP_SDK_PLATFORM_STATIC,
+ 'prism' => 'claude-plugin',
+ 'source' => \realpath(__DIR__ . '/../sdks/static-claude-plugin'),
+ 'gitUrl' => 'git@github.com:appwrite/claude-plugin.git',
+ 'gitRepoName' => 'claude-plugin',
+ 'gitUserName' => 'appwrite',
+ 'gitBranch' => 'dev',
+ 'repoBranch' => 'main',
+ 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/claude-plugin/CHANGELOG.md'),
+ ],
+ [
+ 'key' => 'codex-plugin',
+ 'name' => 'CodexPlugin',
+ 'version' => '0.1.1',
+ 'url' => 'https://github.com/appwrite/codex-plugin.git',
+ 'enabled' => true,
+ 'beta' => false,
+ 'dev' => false,
+ 'hidden' => false,
+ 'spec' => 'static',
+ 'family' => APP_SDK_PLATFORM_STATIC,
+ 'prism' => 'codex-plugin',
+ 'source' => \realpath(__DIR__ . '/../sdks/static-codex-plugin'),
+ 'gitUrl' => 'git@github.com:appwrite/codex-plugin.git',
+ 'gitRepoName' => 'codex-plugin',
+ 'gitUserName' => 'appwrite',
+ 'gitBranch' => 'dev',
+ 'repoBranch' => 'main',
+ 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/codex-plugin/CHANGELOG.md'),
+ ],
],
],
@@ -494,6 +526,25 @@ return [
'gitBranch' => 'dev',
'changelog' => \realpath(__DIR__ . '/../../docs/sdks/swift/CHANGELOG.md'),
],
+ [
+ 'key' => 'rust',
+ 'name' => 'Rust',
+ 'version' => '0.1.0',
+ 'url' => 'https://github.com/appwrite/sdk-for-rust',
+ 'package' => 'https://crates.io/crates/appwrite',
+ 'enabled' => true,
+ 'beta' => true,
+ 'dev' => true,
+ 'hidden' => false,
+ 'family' => APP_SDK_PLATFORM_SERVER,
+ 'prism' => 'rust',
+ 'source' => \realpath(__DIR__ . '/../sdks/server-rust'),
+ 'gitUrl' => 'git@github.com:appwrite/sdk-for-rust.git',
+ 'gitRepoName' => 'sdk-for-rust',
+ 'gitUserName' => 'appwrite',
+ 'gitBranch' => 'dev',
+ 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/rust/CHANGELOG.md'),
+ ],
[
'key' => 'graphql',
'name' => 'GraphQL',
diff --git a/app/config/services.php b/app/config/services.php
index a99501c530..f829937623 100644
--- a/app/config/services.php
+++ b/app/config/services.php
@@ -137,7 +137,7 @@ return [
'docs' => true,
'docsUrl' => '',
'tests' => false,
- 'optional' => false,
+ 'optional' => true,
'icon' => '',
'platforms' => ['client', 'server', 'console'],
],
@@ -193,7 +193,7 @@ return [
'docs' => false,
'docsUrl' => '',
'tests' => false,
- 'optional' => false,
+ 'optional' => true,
'icon' => '',
'platforms' => ['client', 'server', 'console'],
],
@@ -235,7 +235,7 @@ return [
'docs' => true,
'docsUrl' => 'https://appwrite.io/docs/proxy',
'tests' => false,
- 'optional' => false,
+ 'optional' => true,
'icon' => '/images/services/proxy.png',
'platforms' => ['client', 'server', 'console'],
],
@@ -286,12 +286,12 @@ return [
'name' => 'Migrations',
'subtitle' => 'The Migrations service allows you to migrate third-party data to your Appwrite project.',
'description' => '/docs/services/migrations.md',
- 'controller' => 'api/migrations.php',
+ 'controller' => '', // Uses modules
'sdk' => true,
'docs' => true,
'docsUrl' => 'https://appwrite.io/docs/migrations',
'tests' => true,
- 'optional' => false,
+ 'optional' => true,
'icon' => '/images/services/migrations.png',
'platforms' => ['client', 'server', 'console'],
],
@@ -308,5 +308,19 @@ return [
'optional' => true,
'icon' => '/images/services/messaging.png',
'platforms' => ['client', 'server', 'console'],
- ]
+ ],
+ 'advisor' => [
+ 'key' => 'advisor',
+ 'name' => 'Advisor',
+ 'subtitle' => 'The Advisor service surfaces actionable reports about your project resources, with CTA descriptors for one-click remediation in the console.',
+ 'description' => '/docs/services/advisor.md',
+ 'controller' => '', // Uses modules
+ 'sdk' => true,
+ 'docs' => true,
+ 'docsUrl' => 'https://appwrite.io/docs/server/advisor',
+ 'tests' => true,
+ 'optional' => true,
+ 'icon' => '/images/services/insights.png',
+ 'platforms' => ['server', 'console'],
+ ],
];
diff --git a/app/config/templates/function.php b/app/config/templates/function.php
index 6bdfb5ab80..c6ac446509 100644
--- a/app/config/templates/function.php
+++ b/app/config/templates/function.php
@@ -31,9 +31,6 @@ class FunctionUseCases
public const DEV_TOOLS = 'dev-tools';
public const AUTH = 'auth';
- /**
- * @var array
- */
public static function getAll(): array
{
return [
@@ -82,12 +79,13 @@ return [
...getRuntimes($templateRuntimes['DENO'], 'deno cache src/main.ts', 'src/main.ts', 'deno/starter', $allowList),
...getRuntimes($templateRuntimes['BUN'], 'bun install', 'src/main.ts', 'bun/starter', $allowList),
...getRuntimes($templateRuntimes['RUBY'], 'bundle install', 'lib/main.rb', 'ruby/starter', $allowList),
+ ...getRuntimes($templateRuntimes['RUST'], '', 'main.rs', 'rust/starter', $allowList),
],
- 'instructions' => 'For documentation and instructions check out file .',
+ 'instructions' => 'For documentation and instructions check out the templates repository .',
'vcsProvider' => 'github',
'providerRepositoryId' => 'templates',
'providerOwner' => 'appwrite',
- 'providerVersion' => '0.2.*',
+ 'providerVersion' => '0.3.*',
'variables' => [],
'scopes' => ['users.read']
],
diff --git a/app/config/templates/site.php b/app/config/templates/site.php
index 50a9fb8d5d..b26d31f475 100644
--- a/app/config/templates/site.php
+++ b/app/config/templates/site.php
@@ -25,9 +25,6 @@ class SiteUseCases
public const FORMS = 'forms';
public const DASHBOARD = 'dashboard';
- /**
- * @var array
- */
public static function getAll(): array
{
return [
@@ -252,7 +249,7 @@ return [
'frameworks' => [
getFramework('VITE', [
'providerRootDirectory' => './vite/vitepress',
- 'outputDirectory' => '404.html',
+ 'fallbackFile' => '404.html',
'installCommand' => 'npm i vitepress && npm install',
'buildCommand' => 'npm run docs:build',
'outputDirectory' => './.vitepress/dist',
@@ -275,7 +272,7 @@ return [
'frameworks' => [
getFramework('VUE', [
'providerRootDirectory' => './vue/vuepress',
- 'outputDirectory' => '404.html',
+ 'fallbackFile' => '404.html',
'installCommand' => 'npm install',
'buildCommand' => 'npm run build',
'outputDirectory' => './src/.vuepress/dist',
@@ -298,7 +295,7 @@ return [
'frameworks' => [
getFramework('REACT', [
'providerRootDirectory' => './react/docusaurus',
- 'outputDirectory' => '404.html',
+ 'fallbackFile' => '404.html',
'installCommand' => 'npm install',
'buildCommand' => 'npm run build',
'outputDirectory' => './build',
@@ -1490,13 +1487,13 @@ return [
]
],
[
- 'key' => 'crm-dashboard-react-admin',
- 'name' => 'CRM dashboard with React Admin',
- 'tagline' => 'A React-based admin dashboard template with CRM features.',
+ 'key' => 'dashboard-react-admin',
+ 'name' => 'E-commerce dashboard with React Admin',
+ 'tagline' => 'A React-based admin dashboard template with e-commerce features.',
'score' => 4, // 0 to 10 based on looks of screenshot (avoid 1,2,3,8,9,10 if possible)
- 'useCases' => [SiteUseCases::DASHBOARD],
- 'screenshotDark' => $url . '/images/sites/templates/crm-dashboard-react-admin-dark.png',
- 'screenshotLight' => $url . '/images/sites/templates/crm-dashboard-react-admin-light.png',
+ 'useCases' => [SiteUseCases::DASHBOARD, SiteUseCases::ECOMMERCE],
+ 'screenshotDark' => $url . '/images/sites/templates/dashboard-react-admin-dark.png',
+ 'screenshotLight' => $url . '/images/sites/templates/dashboard-react-admin-light.png',
'frameworks' => [
getFramework('REACT', [
'providerRootDirectory' => './react/react-admin',
diff --git a/app/config/variables.php b/app/config/variables.php
index 7a3ed13049..90df9b4518 100644
--- a/app/config/variables.php
+++ b/app/config/variables.php
@@ -872,18 +872,18 @@ return [
],
[
'name' => '_APP_FUNCTIONS_BUILD_TIMEOUT',
- 'description' => 'Deprecated since 1.7.0. The maximum number of seconds allowed as a timeout value when building a new function. The default value is 900 seconds.',
+ 'description' => 'Deprecated since 1.7.0. The maximum number of seconds allowed as a timeout value when building a new function. The default value is 2700 seconds.',
'introduction' => '0.13.0',
- 'default' => '900',
+ 'default' => '2700',
'required' => false,
'question' => '',
'filter' => ''
],
[
'name' => '_APP_COMPUTE_BUILD_TIMEOUT',
- 'description' => 'The maximum number of seconds allowed as a timeout value when building a new function or site. The default value is 900 seconds.',
+ 'description' => 'The maximum number of seconds allowed as a timeout value when building a new function or site. The default value is 2700 seconds.',
'introduction' => '1.7.0',
- 'default' => '900',
+ 'default' => '2700',
'required' => false,
'question' => '',
'filter' => ''
@@ -1336,6 +1336,15 @@ return [
'category' => 'Migrations',
'description' => '',
'variables' => [
+ [
+ 'name' => '_APP_MIGRATION_HOST',
+ 'description' => 'Internal hostname the migrations worker uses to reach this instance\'s API (for migrations and CSV/JSON imports & exports). Defaults to \'appwrite\', the API service name in the standard Docker Compose setup. Only change this for non-standard deployments.',
+ 'introduction' => '1.9.0',
+ 'default' => 'appwrite',
+ 'required' => false,
+ 'question' => '',
+ 'filter' => ''
+ ],
[
'name' => '_APP_MIGRATIONS_FIREBASE_CLIENT_ID',
'description' => 'Google OAuth client ID. You can find it in your GCP application settings.',
diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php
index b58a9b4185..e01c27e45c 100644
--- a/app/controllers/api/account.php
+++ b/app/controllers/api/account.php
@@ -9,15 +9,16 @@ use Appwrite\Auth\Validator\PasswordDictionary;
use Appwrite\Auth\Validator\PasswordHistory;
use Appwrite\Auth\Validator\PersonalData;
use Appwrite\Auth\Validator\Phone;
+use Appwrite\Bus\Events\SessionCreated;
use Appwrite\Detector\Detector;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
-use Appwrite\Event\Mail;
-use Appwrite\Event\Messaging;
-use Appwrite\Event\StatsUsage;
+use Appwrite\Event\Message\Mail as MailMessage;
+use Appwrite\Event\Message\Messaging as MessagingMessage;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
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 +29,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;
@@ -42,6 +44,7 @@ use Utopia\Auth\Proofs\Code as ProofsCode;
use Utopia\Auth\Proofs\Password as ProofsPassword;
use Utopia\Auth\Proofs\Token as ProofsToken;
use Utopia\Auth\Store;
+use Utopia\Bus\Bus;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
@@ -60,6 +63,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;
@@ -75,140 +79,25 @@ use Utopia\Validator\WhiteList;
$oauthDefaultSuccess = '/console/auth/oauth2/success';
$oauthDefaultFailure = '/console/auth/oauth2/failure';
-function sendSessionAlert(Locale $locale, Document $user, Document $project, array $platform, Document $session, Mail $queueForMails)
-{
- $subject = $locale->getText("emails.sessionAlert.subject");
- $preview = $locale->getText("emails.sessionAlert.preview");
- $customTemplate = $project->getAttribute('templates', [])['email.sessionAlert-' . $locale->default] ?? [];
- $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base');
- $validator = new FileName();
- if (!$validator->isValid($smtpBaseTemplate)) {
- throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid template path');
- }
+$createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Bus $bus, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) {
- $bodyTemplate = __DIR__ . '/../../config/locale/templates/' . $smtpBaseTemplate . '.tpl';
+ // Attempt to decode secret as a JWT (used by OAuth2 token flow to carry provider info)
+ $oauthProvider = null;
+ try {
+ $jwtDecoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 60, 0);
+ $payload = $jwtDecoder->decode($secret);
- $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-session-alert.tpl');
- $message
- ->setParam('{{hello}}', $locale->getText("emails.sessionAlert.hello"))
- ->setParam('{{body}}', $locale->getText("emails.sessionAlert.body"))
- ->setParam('{{listDevice}}', $locale->getText("emails.sessionAlert.listDevice"))
- ->setParam('{{listIpAddress}}', $locale->getText("emails.sessionAlert.listIpAddress"))
- ->setParam('{{listCountry}}', $locale->getText("emails.sessionAlert.listCountry"))
- ->setParam('{{footer}}', $locale->getText("emails.sessionAlert.footer"))
- ->setParam('{{thanks}}', $locale->getText("emails.sessionAlert.thanks"))
- ->setParam('{{signature}}', $locale->getText("emails.sessionAlert.signature"));
-
- $body = $message->render();
-
- $smtp = $project->getAttribute('smtp', []);
- $smtpEnabled = $smtp['enabled'] ?? false;
-
- $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
- $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
- $replyTo = "";
-
- if ($smtpEnabled) {
- if (!empty($smtp['senderEmail'])) {
- $senderEmail = $smtp['senderEmail'];
- }
- if (!empty($smtp['senderName'])) {
- $senderName = $smtp['senderName'];
- }
- if (!empty($smtp['replyTo'])) {
- $replyTo = $smtp['replyTo'];
+ if (empty($payload['provider'])) {
+ throw new Exception(Exception::USER_INVALID_TOKEN);
}
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '');
-
- if (!empty($customTemplate)) {
- if (!empty($customTemplate['senderEmail'])) {
- $senderEmail = $customTemplate['senderEmail'];
- }
- if (!empty($customTemplate['senderName'])) {
- $senderName = $customTemplate['senderName'];
- }
- if (!empty($customTemplate['replyTo'])) {
- $replyTo = $customTemplate['replyTo'];
- }
-
- $body = $customTemplate['message'] ?? '';
- $subject = $customTemplate['subject'] ?? $subject;
- }
-
- $queueForMails
- ->setSmtpReplyTo($replyTo)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $oauthProvider = $payload['provider'];
+ $secret = $payload['secret'];
+ } catch (\Ahc\Jwt\JWTException) {
+ // Not a JWT — use secret as-is (non-OAuth flows)
}
- // session alerts should always have a client name!
- $clientName = $session->getAttribute('clientName');
- if (empty($clientName)) {
- // fallback to the user agent and then unknown!
- $userAgent = $session->getAttribute('userAgent');
- $clientName = !empty($userAgent) ? $userAgent : 'UNKNOWN';
-
- $session->setAttribute('clientName', $clientName);
- }
-
- $projectName = $project->getAttribute('name');
- if ($project->getId() === 'console') {
- $projectName = $platform['platformName'];
- }
-
- $emailVariables = [
- 'direction' => $locale->getText('settings.direction'),
- 'date' => (new \DateTime())->format('F j'),
- 'year' => (new \DateTime())->format('YYYY'),
- 'time' => (new \DateTime())->format('H:i:s'),
- 'user' => $user->getAttribute('name'),
- 'project' => $projectName,
- 'device' => $session->getAttribute('clientName'),
- 'ipAddress' => $session->getAttribute('ip'),
- 'country' => $locale->getText('countries.' . $session->getAttribute('countryCode'), $locale->getText('locale.country.unknown')),
- ];
-
- if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) {
- $emailVariables = array_merge($emailVariables, [
- 'accentColor' => $platform['accentColor'],
- 'logoUrl' => $platform['logoUrl'],
- 'twitter' => $platform['twitterUrl'],
- 'discord' => $platform['discordUrl'],
- 'github' => $platform['githubUrl'],
- 'terms' => $platform['termsUrl'],
- 'privacy' => $platform['privacyUrl'],
- 'platform' => $platform['platformName'],
- ]);
- }
-
- $email = $user->getAttribute('email');
-
- $queueForMails
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body)
- ->setBodyTemplate($bodyTemplate)
- ->appendVariables($emailVariables)
- ->setRecipient($email);
-
- // since this is console project, set email sender name!
- if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) {
- $queueForMails->setSenderName($platform['emailSenderName']);
- }
-
- $queueForMails->trigger();
-}
-
-
-$createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, Authorization $authorization) {
-
/** @var Appwrite\Utopia\Database\Documents\User $userFromRequest */
$userFromRequest = $authorization->skip(fn () => $dbForProject->getDocument('users', $userId));
@@ -220,6 +109,12 @@ $createSession = function (string $userId, string $secret, Request $request, Res
?: $userFromRequest->tokenVerify(null, $secret, $proofForCode);
if (!$verifiedToken) {
+ // Could mean invalid/expired JWT, or expired secret
+ throw new Exception(Exception::USER_INVALID_TOKEN);
+ }
+
+ // OAuth2 tokens must have a provider from the JWT
+ if ($verifiedToken->getAttribute('type') === TOKEN_TYPE_OAUTH2 && $oauthProvider === null) {
throw new Exception(Exception::USER_INVALID_TOKEN);
}
@@ -240,12 +135,9 @@ $createSession = function (string $userId, string $secret, Request $request, Res
});
$provider = match ($verifiedToken->getAttribute('type')) {
- TOKEN_TYPE_VERIFICATION,
- TOKEN_TYPE_RECOVERY,
- TOKEN_TYPE_INVITE => SESSION_PROVIDER_EMAIL,
TOKEN_TYPE_MAGIC_URL => SESSION_PROVIDER_MAGIC_URL,
TOKEN_TYPE_PHONE => SESSION_PROVIDER_PHONE,
- TOKEN_TYPE_OAUTH2 => SESSION_PROVIDER_OAUTH2,
+ TOKEN_TYPE_OAUTH2 => $oauthProvider,
default => SESSION_PROVIDER_TOKEN,
};
$session = new Document(array_merge(
@@ -296,23 +188,12 @@ $createSession = function (string $userId, string $secret, Request $request, Res
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed saving user to DB');
}
- $isAllowedTokenType = match ($verifiedToken->getAttribute('type')) {
- TOKEN_TYPE_MAGIC_URL,
- TOKEN_TYPE_EMAIL => false,
- default => true
- };
-
- $hasUserEmail = $user->getAttribute('email', false) !== false;
-
- $isSessionAlertsEnabled = $project->getAttribute('auths', [])['sessionAlerts'] ?? false;
-
- $isNotFirstSession = $dbForProject->count('sessions', [
- Query::equal('userId', [$user->getId()]),
- ]) !== 1;
-
- if ($isAllowedTokenType && $hasUserEmail && $isSessionAlertsEnabled && $isNotFirstSession) {
- sendSessionAlert($locale, $user, $project, $platform, $session, $queueForMails);
- }
+ $bus->dispatch(new SessionCreated(
+ user: $user->getArrayCopy(),
+ project: $project->getArrayCopy(),
+ session: $session->getArrayCopy(),
+ locale: $locale->default,
+ ));
$queueForEvents
->setParam('userId', $user->getId())
@@ -323,7 +204,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res
->setProperty('secret', $sessionSecret)
->encode();
- if (!Config::getParam('domainVerification')) {
+ if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded]));
}
@@ -331,8 +212,8 @@ $createSession = function (string $userId, string $secret, Request $request, Res
$protocol = $request->getProtocol();
$response
- ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
- ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
+ ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, null)
+ ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'))
->setStatusCode(Response::STATUS_CODE_CREATED);
$countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown'));
@@ -381,7 +262,8 @@ Http::post('/v1/account')
->inject('dbForProject')
->inject('authorization')
->inject('hooks')
- ->action(function (string $userId, string $email, string $password, string $name, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Authorization $authorization, Hooks $hooks) {
+ ->inject('plan')
+ ->action(function (string $userId, string $email, string $password, string $name, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Authorization $authorization, Hooks $hooks, array $plan) {
$email = \strtolower($email);
if ('console' === $project->getId()) {
@@ -430,11 +312,38 @@ Http::post('/v1/account')
$passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
$proof = new ProofsPassword();
$hash = $proof->hash($password);
+ $emailMetadata = [
+ 'emailCanonical' => null,
+ 'emailIsCanonical' => null,
+ 'emailIsCorporate' => null,
+ 'emailIsDisposable' => null,
+ 'emailIsFree' => null,
+ ];
try {
- $emailCanonical = new Email($email);
- } catch (Throwable) {
- $emailCanonical = null;
+ $parsedEmail = new Email($email);
+ $canonical = $parsedEmail->getCanonical();
+ $emailMetadata = [
+ 'emailCanonical' => $canonical,
+ 'emailIsCanonical' => $parsedEmail->get() === $canonical,
+ 'emailIsCorporate' => $parsedEmail->isCorporate(),
+ 'emailIsDisposable' => $parsedEmail->isDisposable(),
+ 'emailIsFree' => $parsedEmail->isFree(),
+ ];
+ } catch (\Throwable) {
+ throw new Exception(Exception::GENERAL_INVALID_EMAIL);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
+ throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
+ throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
+ throw new Exception(Exception::USER_EMAIL_FREE);
}
try {
@@ -465,11 +374,11 @@ Http::post('/v1/account')
'authenticators' => null,
'search' => implode(' ', [$userId, $email, $name]),
'accessedAt' => DateTime::now(),
- 'emailCanonical' => $emailCanonical?->getCanonical(),
- 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(),
- 'emailIsCorporate' => $emailCanonical?->isCorporate(),
- 'emailIsDisposable' => $emailCanonical?->isDisposable(),
- 'emailIsFree' => $emailCanonical?->isFree(),
+ 'emailCanonical' => $emailMetadata['emailCanonical'],
+ 'emailIsCanonical' => $emailMetadata['emailIsCanonical'],
+ 'emailIsCorporate' => $emailMetadata['emailIsCorporate'],
+ 'emailIsDisposable' => $emailMetadata['emailIsDisposable'],
+ 'emailIsFree' => $emailMetadata['emailIsFree'],
]);
$user->removeAttribute('$sequence');
@@ -543,7 +452,7 @@ Http::delete('/v1/account')
->groups(['api', 'account'])
->label('scope', 'account')
->label('audits.event', 'user.delete')
- ->label('audits.resource', 'user/{response.$id}')
+ ->label('audits.resource', 'user/{user.$id}')
->label('sdk', new Method(
namespace: 'account',
group: 'account',
@@ -564,19 +473,26 @@ Http::delete('/v1/account')
->inject('dbForProject')
->inject('queueForEvents')
->inject('queueForDeletes')
- ->action(function (Document $user, Document $project, Response $response, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes) {
+ ->inject('authorization')
+ ->action(function (Document $user, Document $project, Response $response, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes, Authorization $authorization) {
if ($user->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
}
if ($project->getId() === 'console') {
- // get all memberships
$memberships = $user->getAttribute('memberships', []);
foreach ($memberships as $membership) {
- // prevent deletion if at least one active membership
- if ($membership->getAttribute('confirm', false)) {
- throw new Exception(Exception::USER_DELETION_PROHIBITED);
+ if (!$membership->getAttribute('confirm', false)) {
+ continue;
}
+
+ $team = $dbForProject->getDocument('teams', $membership->getAttribute('teamId'));
+ if ($team->isEmpty()) {
+ continue;
+ }
+
+ // Team is left as-is — we don't promote non-owner members to owner.
+ // Orphan teams are cleaned up later by Cloud's inactive project cleanup.
}
}
@@ -669,15 +585,18 @@ Http::delete('/v1/account/sessions')
->inject('queueForDeletes')
->inject('store')
->inject('proofForToken')
- ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken) {
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
+ ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain) {
$protocol = $request->getProtocol();
$sessions = $user->getAttribute('sessions', []);
+ $currentSession = null;
foreach ($sessions as $session) {/** @var Document $session */
$dbForProject->deleteDocument('sessions', $session->getId());
- if (!Config::getParam('domainVerification')) {
+ if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([]));
}
@@ -690,10 +609,11 @@ Http::delete('/v1/account/sessions')
// If current session delete the cookies too
$response
- ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
- ->addCookie($store->getKey(), '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'));
+ ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, null)
+ ->addCookie($store->getKey(), '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'));
// Use current session for events.
+ $currentSession = $session;
$queueForEvents
->setPayload($response->output($session, Response::MODEL_SESSION));
@@ -706,9 +626,11 @@ Http::delete('/v1/account/sessions')
$dbForProject->purgeCachedDocument('users', $user->getId());
- $queueForEvents
- ->setParam('userId', $user->getId())
- ->setParam('sessionId', $session->getId());
+ if ($currentSession instanceof Document) {
+ $queueForEvents
+ ->setParam('userId', $user->getId())
+ ->setParam('sessionId', $currentSession->getId());
+ }
$response->noContent();
});
@@ -754,7 +676,8 @@ Http::get('/v1/account/sessions/:sessionId')
->setAttribute('secret', $session->getAttribute('secret', ''))
;
- return $response->dynamic($session, Response::MODEL_SESSION);
+ $response->dynamic($session, Response::MODEL_SESSION);
+ return;
}
}
@@ -794,7 +717,9 @@ Http::delete('/v1/account/sessions/:sessionId')
->inject('queueForDeletes')
->inject('store')
->inject('proofForToken')
- ->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken) {
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
+ ->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Store $store, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain) {
$protocol = $request->getProtocol();
$sessionId = ($sessionId === 'current')
@@ -820,13 +745,13 @@ Http::delete('/v1/account/sessions/:sessionId')
->setAttribute('current', true)
->setAttribute('countryName', $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')));
- if (!Config::getParam('domainVerification')) {
+ if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([]));
}
$response
- ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
- ->addCookie($store->getKey(), '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'));
+ ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, null)
+ ->addCookie($store->getKey(), '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'));
}
$dbForProject->purgeCachedDocument('users', $user->getId());
@@ -907,11 +832,11 @@ Http::patch('/v1/account/sessions/:sessionId')
$refreshToken = $session->getAttribute('providerRefreshToken', '');
$oAuthProviders = Config::getParam('oAuthProviders') ?? [];
$className = $oAuthProviders[$provider]['class'] ?? null;
- if (!empty($provider) && ($className === null || !\class_exists($className))) {
+ if (!empty($refreshToken) && ($className === null || !\class_exists($className))) {
throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED);
}
- if (!empty($provider) && $className !== null && \class_exists($className)) {
+ if ($className !== null && \class_exists($className)) {
$appId = $project->getAttribute('oAuthProviders', [])[$provider . 'Appid'] ?? '';
$appSecret = $project->getAttribute('oAuthProviders', [])[$provider . 'Secret'] ?? '{}';
@@ -934,7 +859,7 @@ Http::patch('/v1/account/sessions/:sessionId')
->setPayload($response->output($session, Response::MODEL_SESSION))
;
- return $response->dynamic($session, Response::MODEL_SESSION);
+ $response->dynamic($session, Response::MODEL_SESSION);
});
Http::post('/v1/account/sessions/email')
@@ -975,13 +900,15 @@ Http::post('/v1/account/sessions/email')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
- ->inject('queueForMails')
+ ->inject('bus')
->inject('hooks')
->inject('store')
->inject('proofForPassword')
->inject('proofForToken')
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
->inject('authorization')
- ->action(function (string $email, string $password, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) {
+ ->action(function (string $email, string $password, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Bus $bus, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) {
$email = \strtolower($email);
$protocol = $request->getProtocol();
@@ -1042,28 +969,28 @@ Http::post('/v1/account/sessions/email')
]));
}
- $dbForProject->purgeCachedDocument('users', $user->getId());
-
$session = $dbForProject->createDocument('sessions', $session->setAttribute('$permissions', [
Permission::read(Role::user($user->getId())),
Permission::update(Role::user($user->getId())),
Permission::delete(Role::user($user->getId())),
]));
+ $dbForProject->purgeCachedDocument('users', $user->getId());
+
$encoded = $store
->setProperty('id', $user->getId())
->setProperty('secret', $secret)
->encode();
- if (!Config::getParam('domainVerification')) {
+ if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded]));
}
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
$response
- ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
- ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
+ ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, null)
+ ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'))
->setStatusCode(Response::STATUS_CODE_CREATED)
;
@@ -1080,15 +1007,12 @@ Http::post('/v1/account/sessions/email')
->setParam('sessionId', $session->getId())
;
- if ($project->getAttribute('auths', [])['sessionAlerts'] ?? false) {
- if (
- $dbForProject->count('sessions', [
- Query::equal('userId', [$user->getId()]),
- ]) !== 1
- ) {
- sendSessionAlert($locale, $user, $project, $platform, $session, $queueForMails);
- }
- }
+ $bus->dispatch(new SessionCreated(
+ user: $user->getArrayCopy(),
+ project: $project->getArrayCopy(),
+ session: $session->getArrayCopy(),
+ locale: $locale->default,
+ ));
$response->dynamic($session, Response::MODEL_SESSION);
});
@@ -1129,8 +1053,10 @@ Http::post('/v1/account/sessions/anonymous')
->inject('store')
->inject('proofForPassword')
->inject('proofForToken')
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
->inject('authorization')
- ->action(function (Request $request, Response $response, Locale $locale, User $user, Document $project, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) {
+ ->action(function (Request $request, Response $response, Locale $locale, User $user, Document $project, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) {
$protocol = $request->getProtocol();
if ('console' === $project->getId()) {
@@ -1221,15 +1147,15 @@ Http::post('/v1/account/sessions/anonymous')
->setProperty('secret', $secret)
->encode();
- if (!Config::getParam('domainVerification')) {
+ if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded]));
}
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
$response
- ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
- ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
+ ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, null)
+ ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'))
->setStatusCode(Response::STATUS_CODE_CREATED)
;
@@ -1280,11 +1206,13 @@ Http::post('/v1/account/sessions/token')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
- ->inject('queueForMails')
+ ->inject('bus')
->inject('store')
->inject('proofForToken')
->inject('proofForCode')
-->inject('authorization')
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
+ ->inject('authorization')
->action($createSession);
Http::get('/v1/account/sessions/oauth2/:provider')
@@ -1482,8 +1410,11 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
->inject('store')
->inject('proofForPassword')
->inject('proofForToken')
+ ->inject('plan')
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
->inject('authorization')
- ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) use ($oauthDefaultSuccess) {
+ ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, array $plan, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) use ($oauthDefaultSuccess) {
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$port = $request->getPort();
$callbackBase = $protocol . '://' . $request->getHostname();
@@ -1672,7 +1603,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
}
}
- if ($user === false || $user->isEmpty()) { // No user logged in or with OAuth2 provider ID, create new one or connect with account with same email
+ if ($user->isEmpty()) { // No user logged in or with OAuth2 provider ID, create new one or connect with account with same email
if (empty($email)) {
$failureRedirect(Exception::USER_UNAUTHORIZED, 'OAuth provider failed to return email.');
}
@@ -1689,7 +1620,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
}
// If user is not found, check if there is a user with the same email
- if ($user === false || $user->isEmpty()) {
+ if ($user->isEmpty()) {
$userWithEmail = $dbForProject->findOne('users', [
Query::equal('email', [$email]),
]);
@@ -1702,7 +1633,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
}
// If user is not found, check if there is an identity with the same email
- if ($user === false || $user->isEmpty()) {
+ if ($user->isEmpty()) {
$identityWithMatchingEmail = $dbForProject->findOne('identities', [
Query::equal('providerEmail', [$email]),
]);
@@ -1714,7 +1645,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
}
}
- if ($user === false || $user->isEmpty()) { // Last option -> create the user
+ if ($user->isEmpty()) { // Last option -> create the user
$limit = $project->getAttribute('auths', [])['limit'] ?? 0;
if ($limit !== 0) {
@@ -1725,10 +1656,38 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
}
}
+ $emailMetadata = [
+ 'emailCanonical' => null,
+ 'emailIsCanonical' => null,
+ 'emailIsCorporate' => null,
+ 'emailIsDisposable' => null,
+ 'emailIsFree' => null,
+ ];
+
try {
- $emailCanonical = new Email($email);
- } catch (Throwable) {
- $emailCanonical = null;
+ $parsedEmail = new Email($email);
+ $canonical = $parsedEmail->getCanonical();
+ $emailMetadata = [
+ 'emailCanonical' => $canonical,
+ 'emailIsCanonical' => $parsedEmail->get() === $canonical,
+ 'emailIsCorporate' => $parsedEmail->isCorporate(),
+ 'emailIsDisposable' => $parsedEmail->isDisposable(),
+ 'emailIsFree' => $parsedEmail->isFree(),
+ ];
+ } catch (\Throwable) {
+ $failureRedirect(Exception::GENERAL_INVALID_EMAIL);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
+ $failureRedirect(Exception::USER_EMAIL_DISPOSABLE);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
+ $failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
+ $failureRedirect(Exception::USER_EMAIL_FREE);
}
try {
@@ -1758,11 +1717,11 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
'authenticators' => null,
'search' => implode(' ', [$userId, $email, $name]),
'accessedAt' => DateTime::now(),
- 'emailCanonical' => $emailCanonical?->getCanonical(),
- 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(),
- 'emailIsCorporate' => $emailCanonical?->isCorporate(),
- 'emailIsDisposable' => $emailCanonical?->isDisposable(),
- 'emailIsFree' => $emailCanonical?->isFree(),
+ 'emailCanonical' => $emailMetadata['emailCanonical'],
+ 'emailIsCanonical' => $emailMetadata['emailIsCanonical'],
+ 'emailIsCorporate' => $emailMetadata['emailIsCorporate'],
+ 'emailIsDisposable' => $emailMetadata['emailIsDisposable'],
+ 'emailIsFree' => $emailMetadata['emailIsFree'],
]);
$user->removeAttribute('$sequence');
@@ -1837,19 +1796,47 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
}
if (empty($user->getAttribute('email'))) {
- $user->setAttribute('email', $oauth2->getUserEmail($accessToken));
+ $email = $oauth2->getUserEmail($accessToken);
+ $emailMetadata = [
+ 'emailCanonical' => null,
+ 'emailIsCanonical' => null,
+ 'emailIsCorporate' => null,
+ 'emailIsDisposable' => null,
+ 'emailIsFree' => null,
+ ];
try {
- $emailCanonical = new Email($user->getAttribute('email'));
- } catch (Throwable) {
- $emailCanonical = null;
+ $parsedEmail = new Email($email);
+ $canonical = $parsedEmail->getCanonical();
+ $emailMetadata = [
+ 'emailCanonical' => $canonical,
+ 'emailIsCanonical' => $parsedEmail->get() === $canonical,
+ 'emailIsCorporate' => $parsedEmail->isCorporate(),
+ 'emailIsDisposable' => $parsedEmail->isDisposable(),
+ 'emailIsFree' => $parsedEmail->isFree(),
+ ];
+ } catch (\Throwable) {
+ $failureRedirect(Exception::GENERAL_INVALID_EMAIL);
}
- $user->setAttribute('emailCanonical', $emailCanonical?->getCanonical());
- $user->setAttribute('emailIsCanonical', $emailCanonical?->isCanonicalSupported());
- $user->setAttribute('emailIsCorporate', $emailCanonical?->isCorporate());
- $user->setAttribute('emailIsDisposable', $emailCanonical?->isDisposable());
- $user->setAttribute('emailIsFree', $emailCanonical?->isFree());
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
+ $failureRedirect(Exception::USER_EMAIL_DISPOSABLE);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
+ $failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
+ $failureRedirect(Exception::USER_EMAIL_FREE);
+ }
+
+ $user->setAttribute('email', $email);
+ $user->setAttribute('emailCanonical', $emailMetadata['emailCanonical']);
+ $user->setAttribute('emailIsCanonical', $emailMetadata['emailIsCanonical']);
+ $user->setAttribute('emailIsCorporate', $emailMetadata['emailIsCorporate']);
+ $user->setAttribute('emailIsDisposable', $emailMetadata['emailIsDisposable']);
+ $user->setAttribute('emailIsFree', $emailMetadata['emailIsFree']);
}
if (empty($user->getAttribute('name'))) {
@@ -1899,7 +1886,12 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
->setParam('tokenId', $token->getId())
;
- $query['secret'] = $secret;
+ // Wrap secret in a JWT that also carries the provider name
+ $jwtEncoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 60, 0);
+ $query['secret'] = $jwtEncoder->encode([
+ 'secret' => $secret,
+ 'provider' => $provider,
+ ]);
$query['userId'] = $user->getId();
// If the `token` param is not set, we persist the session in a cookie
@@ -1938,7 +1930,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
->setProperty('secret', $secret)
->encode();
- if (!Config::getParam('domainVerification')) {
+ if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded]));
}
@@ -1951,17 +1943,17 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
// TODO: Remove this deprecated workaround - support only token
if ($state['success']['path'] == $oauthDefaultSuccess) {
$query['project'] = $project->getId();
- $query['domain'] = Config::getParam('cookieDomain');
+ $query['domain'] = $cookieDomain;
$query['key'] = $store->getKey();
$query['secret'] = $encoded;
}
$response
- ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
- ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'));
+ ->addCookie($store->getKey() . '_legacy', $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, null)
+ ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'));
}
- if (isset($sessionUpgrade) && $sessionUpgrade) {
+ if (isset($sessionUpgrade) && isset($session)) {
foreach ($user->getAttribute('targets', []) as $target) {
if ($target->getAttribute('providerType') !== MESSAGE_TYPE_PUSH) {
continue;
@@ -2056,7 +2048,7 @@ Http::get('/v1/account/tokens/oauth2/:provider')
}
$host = $platform['consoleHostname'] ?? '';
- $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https';
+ $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$port = $request->getPort();
$redirectBase = $protocol . '://' . $host;
if ($protocol === 'https' && $port !== '443') {
@@ -2079,10 +2071,12 @@ Http::get('/v1/account/tokens/oauth2/:provider')
'token' => true,
], $scopes);
+ $loginURL = $oauth2->getLoginURL();
+
$response
->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->addHeader('Pragma', 'no-cache')
- ->redirect($oauth2->getLoginURL());
+ ->redirect($loginURL);
});
Http::post('/v1/account/tokens/magic-url')
@@ -2121,11 +2115,12 @@ Http::post('/v1/account/tokens/magic-url')
->inject('dbForProject')
->inject('locale')
->inject('queueForEvents')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
+ ->inject('plan')
->inject('proofForPassword')
->inject('platform')
->inject('authorization')
- ->action(function (string $userId, string $email, string $url, bool $phrase, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsPassword $proofForPassword, array $platform, Authorization $authorization) {
+ ->action(function (string $userId, string $email, string $url, bool $phrase, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, MailPublisher $publisherForMails, array $plan, ProofsPassword $proofForPassword, array $platform, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled');
}
@@ -2160,10 +2155,38 @@ Http::post('/v1/account/tokens/magic-url')
$userId = $userId === 'unique()' ? ID::unique() : $userId;
+ $emailMetadata = [
+ 'emailCanonical' => null,
+ 'emailIsCanonical' => null,
+ 'emailIsCorporate' => null,
+ 'emailIsDisposable' => null,
+ 'emailIsFree' => null,
+ ];
+
try {
- $emailCanonical = new Email($email);
- } catch (Throwable) {
- $emailCanonical = null;
+ $parsedEmail = new Email($email);
+ $canonical = $parsedEmail->getCanonical();
+ $emailMetadata = [
+ 'emailCanonical' => $canonical,
+ 'emailIsCanonical' => $parsedEmail->get() === $canonical,
+ 'emailIsCorporate' => $parsedEmail->isCorporate(),
+ 'emailIsDisposable' => $parsedEmail->isDisposable(),
+ 'emailIsFree' => $parsedEmail->isFree(),
+ ];
+ } catch (\Throwable) {
+ throw new Exception(Exception::GENERAL_INVALID_EMAIL);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
+ throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
+ throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
+ throw new Exception(Exception::USER_EMAIL_FREE);
}
$user->setAttributes([
@@ -2190,11 +2213,11 @@ Http::post('/v1/account/tokens/magic-url')
'authenticators' => null,
'search' => implode(' ', [$userId, $email]),
'accessedAt' => DateTime::now(),
- 'emailCanonical' => $emailCanonical?->getCanonical(),
- 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(),
- 'emailIsCorporate' => $emailCanonical?->isCorporate(),
- 'emailIsDisposable' => $emailCanonical?->isDisposable(),
- 'emailIsFree' => $emailCanonical?->isFree(),
+ 'emailCanonical' => $emailMetadata['emailCanonical'],
+ 'emailIsCanonical' => $emailMetadata['emailIsCanonical'],
+ 'emailIsCorporate' => $emailMetadata['emailIsCorporate'],
+ 'emailIsDisposable' => $emailMetadata['emailIsDisposable'],
+ 'emailIsFree' => $emailMetadata['emailIsFree'],
]);
$user->removeAttribute('$sequence');
@@ -2248,7 +2271,10 @@ Http::post('/v1/account/tokens/magic-url')
$subject = $locale->getText("emails.magicSession.subject");
$preview = $locale->getText("emails.magicSession.preview");
- $customTemplate = $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? [];
+
+ $customTemplate =
+ $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ??
+ $project->getAttribute('templates', [])['email.magicSession-' . $locale->fallback] ?? [];
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$agentOs = $detector->getOS();
@@ -2278,8 +2304,9 @@ Http::post('/v1/account/tokens/magic-url')
$senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
-
- $replyTo = "";
+ $replyToEmail = '';
+ $replyToName = '';
+ $smtpConfig = [];
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -2288,16 +2315,14 @@ Http::post('/v1/account/tokens/magic-url')
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
- if (!empty($smtp['replyTo'])) {
- $replyTo = $smtp['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
+ if (!empty($smtpReplyToEmail)) {
+ $replyToEmail = $smtpReplyToEmail;
+ }
+ if (!empty($smtp['replyToName'])) {
+ $replyToName = $smtp['replyToName'];
}
-
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '');
if (!empty($customTemplate)) {
if (!empty($customTemplate['senderEmail'])) {
@@ -2306,18 +2331,30 @@ Http::post('/v1/account/tokens/magic-url')
if (!empty($customTemplate['senderName'])) {
$senderName = $customTemplate['senderName'];
}
- if (!empty($customTemplate['replyTo'])) {
- $replyTo = $customTemplate['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? '';
+ if (!empty($customReplyToEmail)) {
+ $replyToEmail = $customReplyToEmail;
+ }
+ if (!empty($customTemplate['replyToName'])) {
+ $replyToName = $customTemplate['replyToName'];
}
$body = $customTemplate['message'] ?? '';
$subject = $customTemplate['subject'] ?? $subject;
}
- $queueForMails
- ->setSmtpReplyTo($replyTo)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ];
}
$projectName = $project->getAttribute('name');
@@ -2339,18 +2376,17 @@ Http::post('/v1/account/tokens/magic-url')
'team' => '',
];
- $queueForMails
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body)
- ->appendVariables($emailVariables)
- ->setRecipient($email);
-
- if ($project->getId() === 'console') {
- $queueForMails->setSenderName($platform['emailSenderName']);
- }
-
- $queueForMails->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $email,
+ subject: $subject,
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ customMailOptions: $project->getId() === 'console' ? ['senderName' => $platform['emailSenderName']] : [],
+ platform: $platform,
+ ));
$token->setAttribute('secret', $tokenSecret);
@@ -2401,11 +2437,12 @@ Http::post('/v1/account/tokens/email')
->inject('dbForProject')
->inject('locale')
->inject('queueForEvents')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
+ ->inject('plan')
->inject('proofForPassword')
->inject('proofForCode')
->inject('authorization')
- ->action(function (string $userId, string $email, bool $phrase, Request $request, Response $response, User $user, Document $project, array $platform, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsPassword $proofForPassword, ProofsCode $proofForCode, Authorization $authorization) {
+ ->action(function (string $userId, string $email, bool $phrase, Request $request, Response $response, User $user, Document $project, array $platform, Database $dbForProject, Locale $locale, Event $queueForEvents, MailPublisher $publisherForMails, array $plan, ProofsPassword $proofForPassword, ProofsCode $proofForCode, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled');
}
@@ -2438,10 +2475,38 @@ Http::post('/v1/account/tokens/email')
$userId = $userId === 'unique()' ? ID::unique() : $userId;
+ $emailMetadata = [
+ 'emailCanonical' => null,
+ 'emailIsCanonical' => null,
+ 'emailIsCorporate' => null,
+ 'emailIsDisposable' => null,
+ 'emailIsFree' => null,
+ ];
+
try {
- $emailCanonical = new Email($email);
- } catch (Throwable) {
- $emailCanonical = null;
+ $parsedEmail = new Email($email);
+ $canonical = $parsedEmail->getCanonical();
+ $emailMetadata = [
+ 'emailCanonical' => $canonical,
+ 'emailIsCanonical' => $parsedEmail->get() === $canonical,
+ 'emailIsCorporate' => $parsedEmail->isCorporate(),
+ 'emailIsDisposable' => $parsedEmail->isDisposable(),
+ 'emailIsFree' => $parsedEmail->isFree(),
+ ];
+ } catch (\Throwable) {
+ throw new Exception(Exception::GENERAL_INVALID_EMAIL);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
+ throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
+ throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
+ throw new Exception(Exception::USER_EMAIL_FREE);
}
$user->setAttributes([
@@ -2466,11 +2531,11 @@ Http::post('/v1/account/tokens/email')
'memberships' => null,
'search' => implode(' ', [$userId, $email]),
'accessedAt' => DateTime::now(),
- 'emailCanonical' => $emailCanonical?->getCanonical(),
- 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(),
- 'emailIsCorporate' => $emailCanonical?->isCorporate(),
- 'emailIsDisposable' => $emailCanonical?->isDisposable(),
- 'emailIsFree' => $emailCanonical?->isFree(),
+ 'emailCanonical' => $emailMetadata['emailCanonical'],
+ 'emailIsCanonical' => $emailMetadata['emailIsCanonical'],
+ 'emailIsCorporate' => $emailMetadata['emailIsCorporate'],
+ 'emailIsDisposable' => $emailMetadata['emailIsDisposable'],
+ 'emailIsFree' => $emailMetadata['emailIsFree'],
]);
$user->removeAttribute('$sequence');
@@ -2529,7 +2594,9 @@ Http::post('/v1/account/tokens/email')
$preview = $locale->getText("emails.otpSession.preview");
$heading = $locale->getText("emails.otpSession.heading");
- $customTemplate = $project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ?? [];
+ $customTemplate =
+ $project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ??
+ $project->getAttribute('templates', [])['email.otpSession-' . $locale->fallback] ?? [];
$smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base');
$validator = new FileName();
@@ -2565,7 +2632,9 @@ Http::post('/v1/account/tokens/email')
$senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
- $replyTo = "";
+ $replyToEmail = '';
+ $replyToName = '';
+ $smtpConfig = [];
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -2574,16 +2643,14 @@ Http::post('/v1/account/tokens/email')
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
- if (!empty($smtp['replyTo'])) {
- $replyTo = $smtp['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
+ if (!empty($smtpReplyToEmail)) {
+ $replyToEmail = $smtpReplyToEmail;
+ }
+ if (!empty($smtp['replyToName'])) {
+ $replyToName = $smtp['replyToName'];
}
-
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '');
if (!empty($customTemplate)) {
if (!empty($customTemplate['senderEmail'])) {
@@ -2592,18 +2659,30 @@ Http::post('/v1/account/tokens/email')
if (!empty($customTemplate['senderName'])) {
$senderName = $customTemplate['senderName'];
}
- if (!empty($customTemplate['replyTo'])) {
- $replyTo = $customTemplate['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? '';
+ if (!empty($customReplyToEmail)) {
+ $replyToEmail = $customReplyToEmail;
+ }
+ if (!empty($customTemplate['replyToName'])) {
+ $replyToName = $customTemplate['replyToName'];
}
$body = $customTemplate['message'] ?? '';
$subject = $customTemplate['subject'] ?? $subject;
}
- $queueForMails
- ->setSmtpReplyTo($replyTo)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ];
}
$projectName = $project->getAttribute('name');
@@ -2639,20 +2718,18 @@ Http::post('/v1/account/tokens/email')
]);
}
- $queueForMails
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body)
- ->setBodyTemplate($bodyTemplate)
- ->appendVariables($emailVariables)
- ->setRecipient($email);
-
- // since this is console project, set email sender name!
- if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) {
- $queueForMails->setSenderName($platform['emailSenderName']);
- }
-
- $queueForMails->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $email,
+ subject: $subject,
+ bodyTemplate: $bodyTemplate,
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ customMailOptions: $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE ? ['senderName' => $platform['emailSenderName']] : [],
+ platform: $platform,
+ ));
$token->setAttribute('secret', $tokenSecret);
@@ -2708,14 +2785,16 @@ Http::put('/v1/account/sessions/magic-url')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
- ->inject('queueForMails')
+ ->inject('bus')
->inject('store')
->inject('proofForCode')
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
->inject('authorization')
- ->action(function ($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForCode, $authorization) use ($createSession) {
+ ->action(function ($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $bus, $store, $proofForCode, $domainVerification, $cookieDomain, $authorization) use ($createSession) {
$proofForToken = new ProofsToken(TOKEN_LENGTH_MAGIC_URL);
$proofForToken->setHash(new Sha());
- $createSession($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForToken, $proofForCode, $authorization);
+ $createSession($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $bus, $store, $proofForToken, $proofForCode, $domainVerification, $cookieDomain, $authorization);
});
Http::put('/v1/account/sessions/phone')
@@ -2757,10 +2836,12 @@ Http::put('/v1/account/sessions/phone')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
- ->inject('queueForMails')
+ ->inject('bus')
->inject('store')
->inject('proofForToken')
->inject('proofForCode')
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
->inject('authorization')
->action($createSession);
@@ -2798,15 +2879,15 @@ Http::post('/v1/account/tokens/phone')
->inject('platform')
->inject('dbForProject')
->inject('queueForEvents')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->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, MessagingPublisher $publisherForMessaging, 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');
}
@@ -2918,11 +2999,6 @@ Http::post('/v1/account/tokens/phone')
if ($sendSMS) {
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl');
- $customTemplate = $project->getAttribute('templates', [])['sms.login-' . $locale->default] ?? [];
- if (!empty($customTemplate)) {
- $message = $customTemplate['message'] ?? $message;
- }
-
$projectName = $project->getAttribute('name');
if ($project->getId() === 'console') {
$projectName = $platform['platformName'];
@@ -2944,27 +3020,25 @@ Http::post('/v1/account/tokens/phone')
],
]);
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_INTERNAL)
- ->setMessage($messageDoc)
- ->setRecipients([$phone])
- ->setProviderType(MESSAGE_TYPE_SMS);
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_INTERNAL,
+ project: $project,
+ message: $messageDoc,
+ recipients: [$phone],
+ providerType: MESSAGE_TYPE_SMS,
+ ));
$helper = PhoneNumberUtil::getInstance();
try {
$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);
@@ -3221,7 +3295,7 @@ Http::patch('/v1/account/password')
}
$history[] = $newPassword;
- $history = array_slice($history, (count($history) - $historyLimit), $historyLimit);
+ $history = array_slice($history, -$historyLimit);
}
if ($project->getAttribute('auths', [])['personalDataCheck'] ?? false) {
@@ -3291,9 +3365,10 @@ Http::patch('/v1/account/email')
->inject('queueForEvents')
->inject('project')
->inject('hooks')
+ ->inject('plan')
->inject('proofForPassword')
- ->inject('authorization')
- ->action(function (string $email, string $password, ?\DateTime $requestTimestamp, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Document $project, Hooks $hooks, ProofsPassword $proofForPassword, Authorization $authorization) {
+ ->inject('authorization')
+ ->action(function (string $email, string $password, ?\DateTime $requestTimestamp, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Document $project, Hooks $hooks, array $plan, ProofsPassword $proofForPassword, Authorization $authorization) {
// passwordUpdate will be empty if the user has never set a password
$passwordUpdate = $user->getAttribute('passwordUpdate');
@@ -3321,20 +3396,48 @@ Http::patch('/v1/account/email')
throw new Exception(Exception::GENERAL_BAD_REQUEST); /** Return a generic bad request to prevent exposing existing accounts */
}
+ $emailMetadata = [
+ 'emailCanonical' => null,
+ 'emailIsCanonical' => null,
+ 'emailIsCorporate' => null,
+ 'emailIsDisposable' => null,
+ 'emailIsFree' => null,
+ ];
+
try {
- $emailCanonical = new Email($email);
- } catch (Throwable) {
- $emailCanonical = null;
+ $parsedEmail = new Email($email);
+ $canonical = $parsedEmail->getCanonical();
+ $emailMetadata = [
+ 'emailCanonical' => $canonical,
+ 'emailIsCanonical' => $parsedEmail->get() === $canonical,
+ 'emailIsCorporate' => $parsedEmail->isCorporate(),
+ 'emailIsDisposable' => $parsedEmail->isDisposable(),
+ 'emailIsFree' => $parsedEmail->isFree(),
+ ];
+ } catch (\Throwable) {
+ throw new Exception(Exception::GENERAL_INVALID_EMAIL);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
+ throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
+ throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
+ throw new Exception(Exception::USER_EMAIL_FREE);
}
$user
->setAttribute('email', $email)
->setAttribute('emailVerification', false) // After this user needs to confirm mail again
- ->setAttribute('emailCanonical', $emailCanonical?->getCanonical())
- ->setAttribute('emailIsCanonical', $emailCanonical?->isCanonicalSupported())
- ->setAttribute('emailIsCorporate', $emailCanonical?->isCorporate())
- ->setAttribute('emailIsDisposable', $emailCanonical?->isDisposable())
- ->setAttribute('emailIsFree', $emailCanonical?->isFree())
+ ->setAttribute('emailCanonical', $emailMetadata['emailCanonical'])
+ ->setAttribute('emailIsCanonical', $emailMetadata['emailIsCanonical'])
+ ->setAttribute('emailIsCorporate', $emailMetadata['emailIsCorporate'])
+ ->setAttribute('emailIsDisposable', $emailMetadata['emailIsDisposable'])
+ ->setAttribute('emailIsFree', $emailMetadata['emailIsFree'])
;
if (empty($passwordUpdate)) {
@@ -3355,9 +3458,6 @@ Http::patch('/v1/account/email')
try {
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
- /**
- * @var Document $oldTarget
- */
$oldTarget = $user->find('identifier', $oldEmail, 'targets');
if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) {
@@ -3444,9 +3544,6 @@ Http::patch('/v1/account/phone')
try {
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
- /**
- * @var Document $oldTarget
- */
$oldTarget = $user->find('identifier', $oldPhone, 'targets');
if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) {
@@ -3527,7 +3624,9 @@ Http::patch('/v1/account/status')
->inject('dbForProject')
->inject('queueForEvents')
->inject('store')
- ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Store $store) {
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
+ ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Store $store, bool $domainVerification, ?string $cookieDomain) {
$user->setAttribute('status', false);
@@ -3537,14 +3636,14 @@ Http::patch('/v1/account/status')
->setParam('userId', $user->getId())
->setPayload($response->output($user, Response::MODEL_ACCOUNT));
- if (!Config::getParam('domainVerification')) {
+ if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([]));
}
$protocol = $request->getProtocol();
$response
- ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
- ->addCookie($store->getKey(), '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
+ ->addCookie($store->getKey() . '_legacy', '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, null)
+ ->addCookie($store->getKey(), '', \time() - 3600, '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite'))
;
$response->dynamic($user, Response::MODEL_ACCOUNT);
@@ -3583,11 +3682,11 @@ Http::post('/v1/account/recovery')
->inject('project')
->inject('platform')
->inject('locale')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('queueForEvents')
->inject('proofForToken')
->inject('authorization')
- ->action(function (string $email, string $url, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Mail $queueForMails, Event $queueForEvents, ProofsToken $proofForToken, Authorization $authorization) {
+ ->action(function (string $email, string $url, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, MailPublisher $publisherForMails, Event $queueForEvents, ProofsToken $proofForToken, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled');
@@ -3649,7 +3748,9 @@ Http::post('/v1/account/recovery')
$body = $locale->getText("emails.recovery.body");
$subject = $locale->getText("emails.recovery.subject");
$preview = $locale->getText("emails.recovery.preview");
- $customTemplate = $project->getAttribute('templates', [])['email.recovery-' . $locale->default] ?? [];
+ $customTemplate =
+ $project->getAttribute('templates', [])['email.recovery-' . $locale->default] ??
+ $project->getAttribute('templates', [])['email.recovery-' . $locale->fallback] ?? [];
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl');
$message
@@ -3666,7 +3767,9 @@ Http::post('/v1/account/recovery')
$senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
- $replyTo = "";
+ $replyToEmail = '';
+ $replyToName = '';
+ $smtpConfig = [];
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -3675,16 +3778,14 @@ Http::post('/v1/account/recovery')
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
- if (!empty($smtp['replyTo'])) {
- $replyTo = $smtp['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
+ if (!empty($smtpReplyToEmail)) {
+ $replyToEmail = $smtpReplyToEmail;
+ }
+ if (!empty($smtp['replyToName'])) {
+ $replyToName = $smtp['replyToName'];
}
-
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '');
if (!empty($customTemplate)) {
if (!empty($customTemplate['senderEmail'])) {
@@ -3693,18 +3794,30 @@ Http::post('/v1/account/recovery')
if (!empty($customTemplate['senderName'])) {
$senderName = $customTemplate['senderName'];
}
- if (!empty($customTemplate['replyTo'])) {
- $replyTo = $customTemplate['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? '';
+ if (!empty($customReplyToEmail)) {
+ $replyToEmail = $customReplyToEmail;
+ }
+ if (!empty($customTemplate['replyToName'])) {
+ $replyToName = $customTemplate['replyToName'];
}
$body = $customTemplate['message'] ?? '';
$subject = $customTemplate['subject'] ?? $subject;
}
- $queueForMails
- ->setSmtpReplyTo($replyTo)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ];
}
$emailVariables = [
@@ -3717,19 +3830,18 @@ Http::post('/v1/account/recovery')
'team' => ''
];
- $queueForMails
- ->setRecipient($profile->getAttribute('email', ''))
- ->setName($profile->getAttribute('name', ''))
- ->setBody($body)
- ->appendVariables($emailVariables)
- ->setSubject($subject)
- ->setPreview($preview);
-
- if ($project->getId() === 'console') {
- $queueForMails->setSenderName($platform['emailSenderName']);
- }
-
- $queueForMails->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $profile->getAttribute('email', ''),
+ name: $profile->getAttribute('name', ''),
+ subject: $subject,
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ customMailOptions: $project->getId() === 'console' ? ['senderName' => $platform['emailSenderName']] : [],
+ platform: $platform,
+ ));
$recovery->setAttribute('secret', $secret);
@@ -3737,7 +3849,7 @@ Http::post('/v1/account/recovery')
->setParam('userId', $profile->getId())
->setParam('tokenId', $recovery->getId())
->setUser($profile)
- ->setPayload(Response::showSensitive(fn () => $response->output($recovery, Response::MODEL_TOKEN)), sensitive: ['secret']);
+ ->setPayload($response->showSensitive(fn () => $response->output($recovery, Response::MODEL_TOKEN)), sensitive: ['secret']);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -3838,7 +3950,7 @@ Http::put('/v1/account/recovery')
$queueForEvents
->setParam('userId', $profile->getId())
->setParam('tokenId', $recoveryDocument->getId())
- ->setPayload(Response::showSensitive(fn () => $response->output($recoveryDocument, Response::MODEL_TOKEN)), sensitive: ['secret']);
+ ->setPayload($response->showSensitive(fn () => $response->output($recoveryDocument, Response::MODEL_TOKEN)), sensitive: ['secret']);
$response->dynamic($recoveryDocument, Response::MODEL_TOKEN);
});
@@ -3897,10 +4009,10 @@ Http::post('/v1/account/verifications/email')
->inject('dbForProject')
->inject('locale')
->inject('queueForEvents')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('proofForToken')
->inject('authorization')
- ->action(function (string $url, Request $request, Response $response, Document $project, array $platform, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsToken $proofForToken, Authorization $authorization) {
+ ->action(function (string $url, Request $request, Response $response, Document $project, array $platform, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, MailPublisher $publisherForMails, ProofsToken $proofForToken, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled');
@@ -3957,7 +4069,9 @@ Http::post('/v1/account/verifications/email')
$subject = $locale->getText("emails.verification.subject");
$heading = $locale->getText("emails.verification.heading");
- $customTemplate = $project->getAttribute('templates', [])['email.verification-' . $locale->default] ?? [];
+ $customTemplate =
+ $project->getAttribute('templates', [])['email.verification-' . $locale->default] ??
+ $project->getAttribute('templates', [])['email.verification-' . $locale->fallback] ?? [];
$smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base');
$validator = new FileName();
@@ -3983,7 +4097,9 @@ Http::post('/v1/account/verifications/email')
$senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
- $replyTo = "";
+ $replyToEmail = '';
+ $replyToName = '';
+ $smtpConfig = [];
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -3992,16 +4108,14 @@ Http::post('/v1/account/verifications/email')
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
- if (!empty($smtp['replyTo'])) {
- $replyTo = $smtp['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
+ if (!empty($smtpReplyToEmail)) {
+ $replyToEmail = $smtpReplyToEmail;
+ }
+ if (!empty($smtp['replyToName'])) {
+ $replyToName = $smtp['replyToName'];
}
-
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '');
if (!empty($customTemplate)) {
if (!empty($customTemplate['senderEmail'])) {
@@ -4010,18 +4124,30 @@ Http::post('/v1/account/verifications/email')
if (!empty($customTemplate['senderName'])) {
$senderName = $customTemplate['senderName'];
}
- if (!empty($customTemplate['replyTo'])) {
- $replyTo = $customTemplate['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? '';
+ if (!empty($customReplyToEmail)) {
+ $replyToEmail = $customReplyToEmail;
+ }
+ if (!empty($customTemplate['replyToName'])) {
+ $replyToName = $customTemplate['replyToName'];
}
$body = $customTemplate['message'] ?? '';
$subject = $customTemplate['subject'] ?? $subject;
}
- $queueForMails
- ->setSmtpReplyTo($replyTo)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ];
}
$emailVariables = [
@@ -4048,27 +4174,26 @@ Http::post('/v1/account/verifications/email')
]);
}
- $queueForMails
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body)
- ->setBodyTemplate($bodyTemplate)
- ->appendVariables($emailVariables)
- ->setRecipient($user->getAttribute('email'))
- ->setName($user->getAttribute('name') ?? '');
-
- if ($project->getId() === 'console') {
- $queueForMails->setSenderName($platform['emailSenderName']);
- }
-
- $queueForMails->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $user->getAttribute('email'),
+ name: $user->getAttribute('name') ?? '',
+ subject: $subject,
+ bodyTemplate: $bodyTemplate,
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ customMailOptions: $project->getId() === 'console' ? ['senderName' => $platform['emailSenderName']] : [],
+ platform: $platform,
+ ));
$verification->setAttribute('secret', $verificationSecret);
$queueForEvents
->setParam('userId', $user->getId())
->setParam('tokenId', $verification->getId())
- ->setPayload(Response::showSensitive(fn () => $response->output($verification, Response::MODEL_TOKEN)), sensitive: ['secret']);
+ ->setPayload($response->showSensitive(fn () => $response->output($verification, Response::MODEL_TOKEN)), sensitive: ['secret']);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -4160,7 +4285,7 @@ Http::put('/v1/account/verifications/email')
$queueForEvents
->setParam('userId', $userId)
->setParam('tokenId', $verification->getId())
- ->setPayload(Response::showSensitive(fn () => $response->output($verification, Response::MODEL_TOKEN)), sensitive: ['secret']);
+ ->setPayload($response->showSensitive(fn () => $response->output($verification, Response::MODEL_TOKEN)), sensitive: ['secret']);
$response->dynamic($verification, Response::MODEL_TOKEN);
});
@@ -4195,15 +4320,15 @@ Http::post('/v1/account/verifications/phone')
->inject('user')
->inject('dbForProject')
->inject('queueForEvents')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->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, MessagingPublisher $publisherForMessaging, 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');
}
@@ -4256,11 +4381,6 @@ Http::post('/v1/account/verifications/phone')
if ($sendSMS) {
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl');
- $customTemplate = $project->getAttribute('templates', [])['sms.verification-' . $locale->default] ?? [];
- if (!empty($customTemplate)) {
- $message = $customTemplate['message'] ?? $message;
- }
-
$messageContent = Template::fromString($locale->getText("sms.verification.body"));
$messageContent
->setParam('{{project}}', $project->getAttribute('name'))
@@ -4277,27 +4397,25 @@ Http::post('/v1/account/verifications/phone')
],
]);
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_INTERNAL)
- ->setMessage($messageDoc)
- ->setRecipients([$user->getAttribute('phone')])
- ->setProviderType(MESSAGE_TYPE_SMS);
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_INTERNAL,
+ project: $project,
+ message: $messageDoc,
+ recipients: [$user->getAttribute('phone')],
+ providerType: MESSAGE_TYPE_SMS,
+ ));
$helper = PhoneNumberUtil::getInstance();
try {
$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);
@@ -4539,7 +4657,7 @@ Http::delete('/v1/account/targets/:targetId/push')
->groups(['api', 'account'])
->label('scope', 'targets.write')
->label('audits.event', 'target.delete')
- ->label('audits.resource', 'target/response.$id')
+ ->label('audits.resource', 'target/{request.targetId}')
->label('event', 'users.[userId].targets.[targetId].delete')
->label('sdk', new Method(
namespace: 'account',
@@ -4696,5 +4814,5 @@ Http::delete('/v1/account/identities/:identityId')
->setParam('identityId', $identity->getId())
->setPayload($response->output($identity, Response::MODEL_IDENTITY));
- return $response->noContent();
+ $response->noContent();
});
diff --git a/app/controllers/api/graphql.php b/app/controllers/api/graphql.php
index 2d0a840bd6..9ec2479749 100644
--- a/app/controllers/api/graphql.php
+++ b/app/controllers/api/graphql.php
@@ -28,12 +28,18 @@ use Utopia\Validator\Text;
Http::init()
->groups(['graphql'])
->inject('project')
+ ->inject('user')
+ ->inject('request')
+ ->inject('response')
->inject('authorization')
- ->action(function (Document $project, Authorization $authorization) {
+ ->action(function (Document $project, User $user, Request $request, Response $response, Authorization $authorization) {
+ $response->setUser($user);
+ $request->setUser($user);
+
if (
array_key_exists('graphql', $project->getAttribute('apis', []))
&& !$project->getAttribute('apis', [])['graphql']
- && !(User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
+ && !($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles()))
) {
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
}
@@ -225,7 +231,7 @@ function execute(
$validations = GraphQL::getStandardValidationRules();
if (System::getEnv('_APP_GRAPHQL_INTROSPECTION', 'enabled') === 'disabled') {
- $validations[] = new DisableIntrospection();
+ $validations[] = new DisableIntrospection(DisableIntrospection::ENABLED);
}
if (System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled') {
diff --git a/app/controllers/api/locale.php b/app/controllers/api/locale.php
index d6b4bb814d..c70102a6f1 100644
--- a/app/controllers/api/locale.php
+++ b/app/controllers/api/locale.php
@@ -231,6 +231,7 @@ Http::get('/v1/locale/continents')
->inject('locale')
->action(function (Response $response, Locale $locale) {
$list = array_keys(Config::getParam('locale-continents'));
+ $output = [];
foreach ($list as $value) {
$output[] = new Document([
diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php
index a52ec70b12..f59f606174 100644
--- a/app/controllers/api/messaging.php
+++ b/app/controllers/api/messaging.php
@@ -5,10 +5,10 @@ use Appwrite\Auth\Validator\Phone;
use Appwrite\Detector\Detector;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Message\Messaging as MessagingMessage;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
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 +43,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;
@@ -482,7 +483,6 @@ Http::post('/v1/messaging/providers/msg91')
$enabled === true
&& \array_key_exists('senderId', $credentials)
&& \array_key_exists('authKey', $credentials)
- && \array_key_exists('from', $options)
) {
$enabled = true;
} else {
@@ -1180,6 +1180,7 @@ Http::get('/v1/messaging/providers/:providerId/logs')
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
+ 'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
@@ -2585,6 +2586,7 @@ Http::get('/v1/messaging/topics/:topicId/logs')
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
+ 'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
@@ -3000,6 +3002,7 @@ Http::get('/v1/messaging/subscribers/:subscriberId/logs')
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
+ 'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
@@ -3185,9 +3188,9 @@ Http::post('/v1/messaging/messages/email')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
- ->action(function (string $messageId, string $subject, string $content, ?array $topics, ?array $users, ?array $targets, ?array $cc, ?array $bcc, ?array $attachments, bool $draft, bool $html, ?string $scheduledAt, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response) {
+ ->action(function (string $messageId, string $subject, string $content, ?array $topics, ?array $users, ?array $targets, ?array $cc, ?array $bcc, ?array $attachments, bool $draft, bool $html, ?string $scheduledAt, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, MessagingPublisher $publisherForMessaging, Response $response) {
$messageId = $messageId == 'unique()'
? ID::unique()
: $messageId;
@@ -3204,10 +3207,6 @@ Http::post('/v1/messaging/messages/email')
throw new Exception(Exception::MESSAGE_MISSING_TARGET);
}
- if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) {
- throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE);
- }
-
$mergedTargets = \array_merge($targets, $cc, $bcc);
if (!empty($mergedTargets)) {
@@ -3276,9 +3275,11 @@ Http::post('/v1/messaging/messages/email')
switch ($status) {
case MessageStatus::PROCESSING:
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($message->getId());
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $project,
+ messageId: $message->getId(),
+ ));
break;
case MessageStatus::SCHEDULED:
$schedule = $dbForPlatform->createDocument('schedules', new Document([
@@ -3364,9 +3365,9 @@ Http::post('/v1/messaging/messages/sms')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
- ->action(function (string $messageId, string $content, ?array $topics, ?array $users, ?array $targets, bool $draft, ?string $scheduledAt, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response) {
+ ->action(function (string $messageId, string $content, ?array $topics, ?array $users, ?array $targets, bool $draft, ?string $scheduledAt, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, MessagingPublisher $publisherForMessaging, Response $response) {
$messageId = $messageId == 'unique()'
? ID::unique()
: $messageId;
@@ -3383,10 +3384,6 @@ Http::post('/v1/messaging/messages/sms')
throw new Exception(Exception::MESSAGE_MISSING_TARGET);
}
- if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) {
- throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE);
- }
-
if (!empty($targets)) {
$foundTargets = $dbForProject->find('targets', [
Query::equal('$id', $targets),
@@ -3424,9 +3421,11 @@ Http::post('/v1/messaging/messages/sms')
switch ($status) {
case MessageStatus::PROCESSING:
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($message->getId());
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $project,
+ messageId: $message->getId(),
+ ));
break;
case MessageStatus::SCHEDULED:
$schedule = $dbForPlatform->createDocument('schedules', new Document([
@@ -3504,10 +3503,10 @@ Http::post('/v1/messaging/messages/push')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
->inject('platform')
- ->action(function (string $messageId, string $title, string $body, ?array $topics, ?array $users, ?array $targets, ?array $data, string $action, string $image, string $icon, string $sound, string $color, string $tag, int $badge, bool $draft, ?string $scheduledAt, bool $contentAvailable, bool $critical, string $priority, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response, array $platform) {
+ ->action(function (string $messageId, string $title, string $body, ?array $topics, ?array $users, ?array $targets, ?array $data, string $action, string $image, string $icon, string $sound, string $color, string $tag, int $badge, bool $draft, ?string $scheduledAt, bool $contentAvailable, bool $critical, string $priority, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, MessagingPublisher $publisherForMessaging, Response $response, array $platform) {
$messageId = $messageId == 'unique()'
? ID::unique()
: $messageId;
@@ -3524,10 +3523,6 @@ Http::post('/v1/messaging/messages/push')
throw new Exception(Exception::MESSAGE_MISSING_TARGET);
}
- if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) {
- throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE);
- }
-
if (!empty($targets)) {
$foundTargets = $dbForProject->find('targets', [
Query::equal('$id', $targets),
@@ -3566,7 +3561,7 @@ Http::post('/v1/messaging/messages/push')
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https';
$endpoint = "$protocol://{$platform['apiHostname']}/v1";
- $scheduleTime = $currentScheduledAt ?? $scheduledAt;
+ $scheduleTime = $scheduledAt;
if (!\is_null($scheduleTime)) {
$expiry = (new \DateTime($scheduleTime))->add(new \DateInterval('P15D'))->format('U');
} else {
@@ -3648,9 +3643,11 @@ Http::post('/v1/messaging/messages/push')
switch ($status) {
case MessageStatus::PROCESSING:
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($message->getId());
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $project,
+ messageId: $message->getId(),
+ ));
break;
case MessageStatus::SCHEDULED:
$schedule = $dbForPlatform->createDocument('schedules', new Document([
@@ -3813,6 +3810,7 @@ Http::get('/v1/messaging/messages/:messageId/logs')
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
+ 'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
@@ -3992,9 +3990,9 @@ Http::patch('/v1/messaging/messages/email/:messageId')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
- ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $subject, ?string $content, ?bool $draft, ?bool $html, ?array $cc, ?array $bcc, ?string $scheduledAt, ?array $attachments, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response) {
+ ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $subject, ?string $content, ?bool $draft, ?bool $html, ?array $cc, ?array $bcc, ?string $scheduledAt, ?array $attachments, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, MessagingPublisher $publisherForMessaging, Response $response) {
$message = $dbForProject->getDocument('messages', $messageId);
if ($message->isEmpty()) {
@@ -4150,9 +4148,11 @@ Http::patch('/v1/messaging/messages/email/:messageId')
$message = $dbForProject->updateDocument('messages', $message->getId(), $message);
if ($status === MessageStatus::PROCESSING) {
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($message->getId());
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $project,
+ messageId: $message->getId(),
+ ));
}
$queueForEvents
@@ -4214,9 +4214,9 @@ Http::patch('/v1/messaging/messages/sms/:messageId')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
- ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $content, ?bool $draft, ?string $scheduledAt, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response) {
+ ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $content, ?bool $draft, ?string $scheduledAt, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, MessagingPublisher $publisherForMessaging, Response $response) {
$message = $dbForProject->getDocument('messages', $messageId);
if ($message->isEmpty()) {
@@ -4332,9 +4332,11 @@ Http::patch('/v1/messaging/messages/sms/:messageId')
$message = $dbForProject->updateDocument('messages', $message->getId(), $message);
if ($status === MessageStatus::PROCESSING) {
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($message->getId());
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $project,
+ messageId: $message->getId(),
+ ));
}
$queueForEvents
@@ -4388,10 +4390,10 @@ Http::patch('/v1/messaging/messages/push/:messageId')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
->inject('platform')
- ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $title, ?string $body, ?array $data, ?string $action, ?string $image, ?string $icon, ?string $sound, ?string $color, ?string $tag, ?int $badge, ?bool $draft, ?string $scheduledAt, ?bool $contentAvailable, ?bool $critical, ?string $priority, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response, array $platform) {
+ ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $title, ?string $body, ?array $data, ?string $action, ?string $image, ?string $icon, ?string $sound, ?string $color, ?string $tag, ?int $badge, ?bool $draft, ?string $scheduledAt, ?bool $contentAvailable, ?bool $critical, ?string $priority, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, MessagingPublisher $publisherForMessaging, Response $response, array $platform) {
$message = $dbForProject->getDocument('messages', $messageId);
if ($message->isEmpty()) {
@@ -4593,9 +4595,11 @@ Http::patch('/v1/messaging/messages/push/:messageId')
$message = $dbForProject->updateDocument('messages', $message->getId(), $message);
if ($status === MessageStatus::PROCESSING) {
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($message->getId());
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $project,
+ messageId: $message->getId(),
+ ));
}
$queueForEvents
@@ -4656,7 +4660,7 @@ Http::delete('/v1/messaging/messages/:messageId')
if (!empty($scheduleId)) {
try {
$dbForPlatform->deleteDocument('schedules', $scheduleId);
- } catch (Exception) {
+ } catch (\Throwable) {
// Ignore
}
}
diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php
deleted file mode 100644
index 5e57eadc4e..0000000000
--- a/app/controllers/api/migrations.php
+++ /dev/null
@@ -1,1040 +0,0 @@
- Transfer::GROUP_DATABASES_TABLES_DB,
- DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB,
- DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB
- };
-}
-
-Http::post('/v1/migrations/appwrite')
- ->groups(['api', 'migrations'])
- ->desc('Create Appwrite migration')
- ->label('scope', 'migrations.write')
- ->label('event', 'migrations.[migrationId].create')
- ->label('audits.event', 'migration.create')
- ->label('sdk', new Method(
- namespace: 'migrations',
- group: null,
- name: 'createAppwriteMigration',
- description: '/docs/references/migrations/migration-appwrite.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_ACCEPTED,
- model: Response::MODEL_MIGRATION,
- )
- ]
- ))
- ->param('resources', [], new ArrayList(new WhiteList(Appwrite::getSupportedResources())), 'List of resources to migrate')
- ->param('endpoint', '', new URL(), 'Source Appwrite endpoint')
- ->param('projectId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Source Project ID', false, ['dbForProject'])
- ->param('apiKey', '', new Text(512), 'Source API Key')
- ->inject('response')
- ->inject('dbForProject')
- ->inject('project')
- ->inject('platform')
- ->inject('user')
- ->inject('queueForEvents')
- ->inject('queueForMigrations')
- ->action(function (array $resources, string $endpoint, string $projectId, string $apiKey, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Event $queueForEvents, Migration $queueForMigrations) {
- $migration = $dbForProject->createDocument('migrations', new Document([
- '$id' => ID::unique(),
- 'status' => 'pending',
- 'stage' => 'init',
- 'source' => Appwrite::getName(),
- 'destination' => Appwrite::getName(),
- 'credentials' => [
- 'endpoint' => $endpoint,
- 'projectId' => $projectId,
- 'apiKey' => $apiKey,
- ],
- 'resources' => $resources,
- 'statusCounters' => '{}',
- 'resourceData' => '{}',
- 'errors' => [],
- ]));
-
- $queueForEvents->setParam('migrationId', $migration->getId());
-
- // Trigger Transfer
- $queueForMigrations
- ->setMigration($migration)
- ->setProject($project)
- ->setPlatform($platform)
- ->setUser($user)
- ->trigger();
-
- $response
- ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
- ->dynamic($migration, Response::MODEL_MIGRATION);
- });
-
-Http::post('/v1/migrations/firebase')
- ->groups(['api', 'migrations'])
- ->desc('Create Firebase migration')
- ->label('scope', 'migrations.write')
- ->label('event', 'migrations.[migrationId].create')
- ->label('audits.event', 'migration.create')
- ->label('sdk', new Method(
- namespace: 'migrations',
- group: null,
- name: 'createFirebaseMigration',
- description: '/docs/references/migrations/migration-firebase.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_ACCEPTED,
- model: Response::MODEL_MIGRATION,
- )
- ]
- ))
- ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate')
- ->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials')
- ->inject('response')
- ->inject('dbForProject')
- ->inject('project')
- ->inject('platform')
- ->inject('user')
- ->inject('queueForEvents')
- ->inject('queueForMigrations')
- ->action(function (array $resources, string $serviceAccount, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Event $queueForEvents, Migration $queueForMigrations) {
- $serviceAccountData = json_decode($serviceAccount, true);
-
- if (empty($serviceAccountData)) {
- throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON');
- }
-
- if (!isset($serviceAccountData['project_id']) || !isset($serviceAccountData['client_email']) || !isset($serviceAccountData['private_key'])) {
- throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON');
- }
-
- $migration = $dbForProject->createDocument('migrations', new Document([
- '$id' => ID::unique(),
- 'status' => 'pending',
- 'stage' => 'init',
- 'source' => Firebase::getName(),
- 'destination' => Appwrite::getName(),
- 'credentials' => [
- 'serviceAccount' => $serviceAccount,
- ],
- 'resources' => $resources,
- 'statusCounters' => '{}',
- 'resourceData' => '{}',
- 'errors' => [],
- ]));
-
- $queueForEvents->setParam('migrationId', $migration->getId());
-
- // Trigger Transfer
- $queueForMigrations
- ->setMigration($migration)
- ->setProject($project)
- ->setPlatform($platform)
- ->setUser($user)
- ->trigger();
-
- $response
- ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
- ->dynamic($migration, Response::MODEL_MIGRATION);
- });
-
-Http::post('/v1/migrations/supabase')
- ->groups(['api', 'migrations'])
- ->desc('Create Supabase migration')
- ->label('scope', 'migrations.write')
- ->label('event', 'migrations.[migrationId].create')
- ->label('audits.event', 'migration.create')
- ->label('sdk', new Method(
- namespace: 'migrations',
- group: null,
- name: 'createSupabaseMigration',
- description: '/docs/references/migrations/migration-supabase.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_ACCEPTED,
- model: Response::MODEL_MIGRATION,
- )
- ]
- ))
- ->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate')
- ->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint')
- ->param('apiKey', '', new Text(512), 'Source\'s API Key')
- ->param('databaseHost', '', new Text(512), 'Source\'s Database Host')
- ->param('username', '', new Text(512), 'Source\'s Database Username')
- ->param('password', '', new Text(512), 'Source\'s Database Password')
- ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true)
- ->inject('response')
- ->inject('dbForProject')
- ->inject('project')
- ->inject('platform')
- ->inject('user')
- ->inject('queueForEvents')
- ->inject('queueForMigrations')
- ->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Event $queueForEvents, Migration $queueForMigrations) {
- $migration = $dbForProject->createDocument('migrations', new Document([
- '$id' => ID::unique(),
- 'status' => 'pending',
- 'stage' => 'init',
- 'source' => Supabase::getName(),
- 'destination' => Appwrite::getName(),
- 'credentials' => [
- 'endpoint' => $endpoint,
- 'apiKey' => $apiKey,
- 'databaseHost' => $databaseHost,
- 'username' => $username,
- 'password' => $password,
- 'port' => $port,
- ],
- 'resources' => $resources,
- 'statusCounters' => '{}',
- 'resourceData' => '{}',
- 'errors' => [],
- ]));
-
- $queueForEvents->setParam('migrationId', $migration->getId());
-
- // Trigger Transfer
- $queueForMigrations
- ->setMigration($migration)
- ->setProject($project)
- ->setPlatform($platform)
- ->setUser($user)
- ->trigger();
-
- $response
- ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
- ->dynamic($migration, Response::MODEL_MIGRATION);
- });
-
-Http::post('/v1/migrations/nhost')
- ->groups(['api', 'migrations'])
- ->desc('Create NHost migration')
- ->label('scope', 'migrations.write')
- ->label('event', 'migrations.[migrationId].create')
- ->label('audits.event', 'migration.create')
- ->label('sdk', new Method(
- namespace: 'migrations',
- group: null,
- name: 'createNHostMigration',
- description: '/docs/references/migrations/migration-nhost.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_ACCEPTED,
- model: Response::MODEL_MIGRATION,
- )
- ]
- ))
- ->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate')
- ->param('subdomain', '', new Text(512), 'Source\'s Subdomain')
- ->param('region', '', new Text(512), 'Source\'s Region')
- ->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret')
- ->param('database', '', new Text(512), 'Source\'s Database Name')
- ->param('username', '', new Text(512), 'Source\'s Database Username')
- ->param('password', '', new Text(512), 'Source\'s Database Password')
- ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true)
- ->inject('response')
- ->inject('dbForProject')
- ->inject('project')
- ->inject('platform')
- ->inject('user')
- ->inject('queueForEvents')
- ->inject('queueForMigrations')
- ->action(function (array $resources, string $subdomain, string $region, string $adminSecret, string $database, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Event $queueForEvents, Migration $queueForMigrations) {
- $migration = $dbForProject->createDocument('migrations', new Document([
- '$id' => ID::unique(),
- 'status' => 'pending',
- 'stage' => 'init',
- 'source' => NHost::getName(),
- 'destination' => Appwrite::getName(),
- 'credentials' => [
- 'subdomain' => $subdomain,
- 'region' => $region,
- 'adminSecret' => $adminSecret,
- 'database' => $database,
- 'username' => $username,
- 'password' => $password,
- 'port' => $port,
- ],
- 'resources' => $resources,
- 'statusCounters' => '{}',
- 'resourceData' => '{}',
- 'errors' => [],
- ]));
-
- $queueForEvents->setParam('migrationId', $migration->getId());
-
- // Trigger Transfer
- $queueForMigrations
- ->setMigration($migration)
- ->setProject($project)
- ->setPlatform($platform)
- ->setUser($user)
- ->trigger();
-
- $response
- ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
- ->dynamic($migration, Response::MODEL_MIGRATION);
- });
-
-Http::post('/v1/migrations/csv/imports')
- ->alias('/v1/migrations/csv')
- ->groups(['api', 'migrations'])
- ->desc('Import documents from a CSV')
- ->label('scope', 'migrations.write')
- ->label('event', 'migrations.[migrationId].create')
- ->label('audits.event', 'migration.create')
- ->label('sdk', new Method(
- namespace: 'migrations',
- group: null,
- name: 'createCSVImport',
- description: '/docs/references/migrations/migration-csv-import.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_ACCEPTED,
- model: Response::MODEL_MIGRATION,
- )
- ]
- ))
- ->param('bucketId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).', false, ['dbForProject'])
- ->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File ID.', false, ['dbForProject'])
- ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.')
- ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true)
- ->inject('response')
- ->inject('dbForProject')
- ->inject('dbForPlatform')
- ->inject('authorization')
- ->inject('project')
- ->inject('platform')
- ->inject('deviceForFiles')
- ->inject('deviceForMigrations')
- ->inject('queueForEvents')
- ->inject('queueForMigrations')
- ->action(function (
- string $bucketId,
- string $fileId,
- string $resourceId,
- bool $internalFile,
- Response $response,
- Database $dbForProject,
- Database $dbForPlatform,
- Authorization $authorization,
- Document $project,
- array $platform,
- Device $deviceForFiles,
- Device $deviceForMigrations,
- Event $queueForEvents,
- Migration $queueForMigrations
- ) {
- $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) {
- if ($internalFile) {
- return $dbForPlatform->getDocument('buckets', 'default');
- }
- return $dbForProject->getDocument('buckets', $bucketId);
- });
-
- if ($bucket->isEmpty()) {
- throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
- }
-
- $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId));
- if ($file->isEmpty()) {
- throw new Exception(Exception::STORAGE_FILE_NOT_FOUND);
- }
-
- $path = $file->getAttribute('path', '');
- if (!$deviceForFiles->exists($path)) {
- throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path);
- }
-
- // No encryption or compression on files above 20MB.
- $hasEncryption = !empty($file->getAttribute('openSSLCipher'));
- $compression = $file->getAttribute('algorithm', Compression::NONE);
- $hasCompression = $compression !== Compression::NONE;
-
- $migrationId = ID::unique();
- $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.csv');
-
- if ($hasEncryption || $hasCompression) {
- $source = $deviceForFiles->read($path);
-
- if ($hasEncryption) {
- $source = OpenSSL::decrypt(
- $source,
- $file->getAttribute('openSSLCipher'),
- System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')),
- 0,
- hex2bin($file->getAttribute('openSSLIV')),
- hex2bin($file->getAttribute('openSSLTag'))
- );
- }
-
- if ($hasCompression) {
- switch ($compression) {
- case Compression::ZSTD:
- $source = (new Zstd())->decompress($source);
- break;
- case Compression::GZIP:
- $source = (new GZIP())->decompress($source);
- break;
- }
- }
-
- // Manual write after decryption and/or decompression
- if (!$deviceForMigrations->write($newPath, $source, 'text/csv')) {
- throw new \Exception('Unable to copy file');
- }
- } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) {
- throw new \Exception('Unable to copy file');
- }
-
- // getting databasetype
- $resources = explode(':', $resourceId);
- $databaseId = $resources[0];
- $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
- $databaseType = $database->getAttribute('type');
- if (!in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) {
- throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv');
- }
- $fileSize = $deviceForMigrations->getFileSize($newPath);
- $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]);
-
- $migration = $dbForProject->createDocument('migrations', new Document([
- '$id' => $migrationId,
- 'status' => 'pending',
- 'stage' => 'init',
- 'source' => CSV::getName(),
- 'destination' => Appwrite::getName(),
- 'resources' => $resources,
- 'resourceId' => $resourceId,
- 'resourceType' => Resource::TYPE_DATABASE,
- 'statusCounters' => '{}',
- 'resourceData' => '{}',
- 'errors' => [],
- 'options' => [
- 'path' => $newPath,
- 'size' => $fileSize,
- ],
- ]));
-
- $queueForEvents->setParam('migrationId', $migration->getId());
-
- $queueForMigrations
- ->setMigration($migration)
- ->setProject($project)
- ->setProject($project)
- ->trigger();
-
- $response
- ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
- ->dynamic($migration, Response::MODEL_MIGRATION);
- });
-
-Http::post('/v1/migrations/csv/exports')
- ->groups(['api', 'migrations'])
- ->desc('Export documents to CSV')
- ->label('scope', 'migrations.write')
- ->label('event', 'migrations.[migrationId].create')
- ->label('audits.event', 'migration.create')
- ->label('sdk', new Method(
- namespace: 'migrations',
- group: null,
- name: 'createCSVExport',
- description: '/docs/references/migrations/migration-csv-export.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_ACCEPTED,
- model: Response::MODEL_MIGRATION,
- )
- ]
- ))
- ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.')
- ->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .csv extension.')
- ->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true)
- ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true)
- ->param('delimiter', ',', new Text(1), 'The character that separates each column value. Default is comma.', true)
- ->param('enclosure', '"', new Text(1), 'The character that encloses each column value. Default is double quotes.', true)
- ->param('escape', '"', new Text(1), 'The escape character for the enclosure character. Default is double quotes.', true)
- ->param('header', true, new Boolean(), 'Whether to include the header row with column names. Default is true.', true)
- ->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true)
- ->inject('user')
- ->inject('response')
- ->inject('dbForProject')
- ->inject('dbForPlatform')
- ->inject('authorization')
- ->inject('project')
- ->inject('platform')
- ->inject('queueForEvents')
- ->inject('queueForMigrations')
- ->action(function (
- string $resourceId,
- string $filename,
- array $columns,
- array $queries,
- string $delimiter,
- string $enclosure,
- string $escape,
- bool $header,
- bool $notify,
- Document $user,
- Response $response,
- Database $dbForProject,
- Database $dbForPlatform,
- Authorization $authorization,
- Document $project,
- array $platform,
- Event $queueForEvents,
- Migration $queueForMigrations
- ) {
- try {
- $parsedQueries = Query::parseQueries($queries);
- } catch (QueryException $e) {
- throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
- }
-
- $bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default'));
- if ($bucket->isEmpty()) {
- throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
- }
-
- [$databaseId, $collectionId] = \explode(':', $resourceId, 2);
- if (empty($databaseId)) {
- throw new Exception(Exception::DATABASE_NOT_FOUND);
- }
- if (empty($collectionId)) {
- throw new Exception(Exception::COLLECTION_NOT_FOUND);
- }
-
- $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
- if ($database->isEmpty()) {
- throw new Exception(Exception::DATABASE_NOT_FOUND);
- }
-
- $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId));
- if ($collection->isEmpty()) {
- throw new Exception(Exception::COLLECTION_NOT_FOUND);
- }
-
- $validator = new Documents(
- attributes: $collection->getAttribute('attributes', []),
- indexes: $collection->getAttribute('indexes', []),
- idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(),
- );
-
- if (!$validator->isValid($parsedQueries)) {
- throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
- }
-
- // getting databasetype
- $resources = explode(':', $resourceId);
- $databaseId = $resources[0];
- $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
- $databaseType = $database->getAttribute('type');
- if (!in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) {
- throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv');
- }
- $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]);
-
- $migration = $dbForProject->createDocument('migrations', new Document([
- '$id' => ID::unique(),
- 'status' => 'pending',
- 'stage' => 'init',
- 'source' => Appwrite::getName(),
- 'destination' => CSV::getName(),
- 'resources' => $resources,
- 'resourceId' => $resourceId,
- 'resourceType' => Resource::TYPE_DATABASE,
- 'statusCounters' => '{}',
- 'resourceData' => '{}',
- 'errors' => [],
- 'options' => [
- 'bucketId' => 'default', // Always use internal bucket
- 'filename' => $filename,
- 'columns' => $columns,
- 'queries' => $queries,
- 'delimiter' => $delimiter,
- 'enclosure' => $enclosure,
- 'escape' => $escape,
- 'header' => $header,
- 'notify' => $notify,
- 'userInternalId' => $user->getSequence(),
- ],
- ]));
-
- $queueForEvents->setParam('migrationId', $migration->getId());
-
- $queueForMigrations
- ->setMigration($migration)
- ->setProject($project)
- ->setPlatform($platform)
- ->trigger();
-
- $response
- ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
- ->dynamic($migration, Response::MODEL_MIGRATION);
- });
-
-Http::get('/v1/migrations')
- ->groups(['api', 'migrations'])
- ->desc('List migrations')
- ->label('scope', 'migrations.read')
- ->label('sdk', new Method(
- namespace: 'migrations',
- group: null,
- name: 'list',
- description: '/docs/references/migrations/list-migrations.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_MIGRATION_LIST,
- )
- ]
- ))
- ->param('queries', [], new Migrations(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Migrations::ALLOWED_ATTRIBUTES), true)
- ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
- ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
- ->inject('response')
- ->inject('dbForProject')
- ->action(function (array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject) {
- try {
- $queries = Query::parseQueries($queries);
- } catch (QueryException $e) {
- throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
- }
-
- if (!empty($search)) {
- $queries[] = Query::search('search', $search);
- }
-
- $cursor = Query::getCursorQueries($queries, false);
- $cursor = \reset($cursor);
-
- if ($cursor !== false) {
- $validator = new Cursor();
- if (!$validator->isValid($cursor)) {
- throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
- }
-
- $migrationId = $cursor->getValue();
- $cursorDocument = $dbForProject->getDocument('migrations', $migrationId);
-
- if ($cursorDocument->isEmpty()) {
- throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Migration '{$migrationId}' for the 'cursor' value not found.");
- }
-
- $cursor->setValue($cursorDocument);
- }
-
- $filterQueries = Query::groupByType($queries)['filters'];
- try {
- $migrations = $dbForProject->find('migrations', $queries);
- $total = $includeTotal ? $dbForProject->count('migrations', $filterQueries, APP_LIMIT_COUNT) : 0;
- } catch (OrderException $e) {
- throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
- }
- $response->dynamic(new Document([
- 'migrations' => $migrations,
- 'total' => $total,
- ]), Response::MODEL_MIGRATION_LIST);
- });
-
-Http::get('/v1/migrations/:migrationId')
- ->groups(['api', 'migrations'])
- ->desc('Get migration')
- ->label('scope', 'migrations.read')
- ->label('sdk', new Method(
- namespace: 'migrations',
- group: null,
- name: 'get',
- description: '/docs/references/migrations/get-migration.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_MIGRATION,
- )
- ]
- ))
- ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration unique ID.', false, ['dbForProject'])
- ->inject('response')
- ->inject('dbForProject')
- ->action(function (string $migrationId, Response $response, Database $dbForProject) {
- $migration = $dbForProject->getDocument('migrations', $migrationId);
-
- if ($migration->isEmpty()) {
- throw new Exception(Exception::MIGRATION_NOT_FOUND);
- }
-
- $response->dynamic($migration, Response::MODEL_MIGRATION);
- });
-
-Http::post('/v1/migrations/appwrite/console-key')
- ->groups(['api', 'migrations'])
- ->desc('Create console API key for migration')
- ->label('scope', 'migrations.write')
- ->label('sdk', new Method(
- namespace: 'migrations',
- group: null,
- name: 'createAppwriteConsoleKey',
- description: '/docs/references/migrations/migration-appwrite-console-key.md',
- auth: [AuthType::KEY],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_MIGRATION_KEY,
- )
- ]
- ))
- ->param('resources', [], new ArrayList(new WhiteList(\array_keys(Config::getParam('consoleProjectScopes')))), 'List of resource types to request access for.', true)
- ->inject('response')
- ->inject('project')
- ->action(function (array $resources, Response $response, Document $project) {
- $consoleProjectScopes = Config::getParam('consoleProjectScopes');
-
- $scopes = empty($resources)
- ? \array_values($consoleProjectScopes)
- : \array_values(\array_intersect_key($consoleProjectScopes, \array_flip($resources)));
-
- if (empty($scopes)) {
- throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE);
- }
-
- $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', APP_CONSOLE_KEY_TTL, 0);
- $consoleKey = $jwt->encode([
- 'projectId' => 'console',
- 'name' => 'Migration Settings Key',
- 'source' => KEY_SOURCE_MIGRATION,
- 'scopes' => $scopes,
- 'disabledMetrics' => [
- METRIC_DATABASES_OPERATIONS_READS,
- METRIC_DATABASES_OPERATIONS_WRITES,
- METRIC_NETWORK_REQUESTS,
- METRIC_NETWORK_INBOUND,
- METRIC_NETWORK_OUTBOUND,
- ],
- 'scopedProjectId' => $project->getId(),
- ]);
-
- $response->dynamic(new Document([
- 'key' => API_KEY_DYNAMIC . '_' . $consoleKey,
- 'expire' => DateTime::addSeconds(new \DateTime(), APP_CONSOLE_KEY_TTL),
- ]), Response::MODEL_MIGRATION_KEY);
- });
-
-Http::get('/v1/migrations/appwrite/report')
- ->groups(['api', 'migrations'])
- ->desc('Get Appwrite migration report')
- ->label('scope', 'migrations.write')
- ->label('sdk', new Method(
- namespace: 'migrations',
- group: null,
- name: 'getAppwriteReport',
- description: '/docs/references/migrations/migration-appwrite-report.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_MIGRATION_REPORT,
- )
- ]
- ))
- ->param('resources', [], new ArrayList(new WhiteList(Appwrite::getSupportedResources())), 'List of resources to migrate')
- ->param('endpoint', '', new URL(), "Source's Appwrite Endpoint")
- ->param('projectID', '', new Text(512), "Source's Project ID")
- ->param('key', '', new Text(512), "Source's API Key")
- ->inject('response')
- ->inject('getDatabasesDB')
- ->action(function (array $resources, string $endpoint, string $projectID, string $key, Response $response, callable $getDatabasesDB) {
-
- try {
- $appwrite = new Appwrite($projectID, $endpoint, $key, $getDatabasesDB);
- $report = $appwrite->report($resources);
- } catch (\Throwable $e) {
- throw new Exception(
- Exception::MIGRATION_PROVIDER_ERROR,
- 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
- );
- }
-
- $response
- ->setStatusCode(Response::STATUS_CODE_OK)
- ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT);
- });
-
-Http::get('/v1/migrations/firebase/report')
- ->groups(['api', 'migrations'])
- ->desc('Get Firebase migration report')
- ->label('scope', 'migrations.write')
- ->label('sdk', new Method(
- namespace: 'migrations',
- group: null,
- name: 'getFirebaseReport',
- description: '/docs/references/migrations/migration-firebase-report.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_MIGRATION_REPORT,
- )
- ]
- ))
- ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate')
- ->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials')
- ->inject('response')
- ->action(function (array $resources, string $serviceAccount, Response $response) {
- $serviceAccount = json_decode($serviceAccount, true);
-
- if (empty($serviceAccount)) {
- throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON');
- }
-
- if (!isset($serviceAccount['project_id']) || !isset($serviceAccount['client_email']) || !isset($serviceAccount['private_key'])) {
- throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON');
- }
-
- try {
- $firebase = new Firebase($serviceAccount);
- $report = $firebase->report($resources);
- } catch (\Throwable $e) {
- throw new Exception(
- Exception::MIGRATION_PROVIDER_ERROR,
- 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
- );
- }
-
- $response
- ->setStatusCode(Response::STATUS_CODE_OK)
- ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT);
- });
-
-Http::get('/v1/migrations/supabase/report')
- ->groups(['api', 'migrations'])
- ->desc('Get Supabase migration report')
- ->label('scope', 'migrations.write')
- ->label('sdk', new Method(
- namespace: 'migrations',
- group: null,
- name: 'getSupabaseReport',
- description: '/docs/references/migrations/migration-supabase-report.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_MIGRATION_REPORT,
- )
- ]
- ))
- ->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate')
- ->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint.')
- ->param('apiKey', '', new Text(512), 'Source\'s API Key.')
- ->param('databaseHost', '', new Text(512), 'Source\'s Database Host.')
- ->param('username', '', new Text(512), 'Source\'s Database Username.')
- ->param('password', '', new Text(512), 'Source\'s Database Password.')
- ->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true)
- ->inject('response')
- ->inject('dbForProject')
- ->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response) {
- try {
- $supabase = new Supabase($endpoint, $apiKey, $databaseHost, 'postgres', $username, $password, $port);
- $report = $supabase->report($resources);
- } catch (\Throwable $e) {
- throw new Exception(
- Exception::MIGRATION_PROVIDER_ERROR,
- 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
- );
- }
-
- $response
- ->setStatusCode(Response::STATUS_CODE_OK)
- ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT);
- });
-
-Http::get('/v1/migrations/nhost/report')
- ->groups(['api', 'migrations'])
- ->desc('Get NHost migration report')
- ->label('scope', 'migrations.write')
- ->label('sdk', new Method(
- namespace: 'migrations',
- group: null,
- name: 'getNHostReport',
- description: '/docs/references/migrations/migration-nhost-report.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_MIGRATION_REPORT,
- )
- ]
- ))
- ->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate.')
- ->param('subdomain', '', new Text(512), 'Source\'s Subdomain.')
- ->param('region', '', new Text(512), 'Source\'s Region.')
- ->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret.')
- ->param('database', '', new Text(512), 'Source\'s Database Name.')
- ->param('username', '', new Text(512), 'Source\'s Database Username.')
- ->param('password', '', new Text(512), 'Source\'s Database Password.')
- ->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true)
- ->inject('response')
- ->action(function (array $resources, string $subdomain, string $region, string $adminSecret, string $database, string $username, string $password, int $port, Response $response) {
- try {
- $nhost = new NHost($subdomain, $region, $adminSecret, $database, $username, $password, $port);
- $report = $nhost->report($resources);
- } catch (\Throwable $e) {
- throw new Exception(
- Exception::MIGRATION_PROVIDER_ERROR,
- 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
- );
- }
-
- $response
- ->setStatusCode(Response::STATUS_CODE_OK)
- ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT);
- });
-
-Http::patch('/v1/migrations/:migrationId')
- ->groups(['api', 'migrations'])
- ->desc('Update retry migration')
- ->label('scope', 'migrations.write')
- ->label('event', 'migrations.[migrationId].retry')
- ->label('audits.event', 'migration.retry')
- ->label('audits.resource', 'migrations/{request.migrationId}')
- ->label('sdk', new Method(
- namespace: 'migrations',
- group: null,
- name: 'retry',
- description: '/docs/references/migrations/retry-migration.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_ACCEPTED,
- model: Response::MODEL_MIGRATION,
- )
- ]
- ))
- ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration unique ID.', false, ['dbForProject'])
- ->inject('response')
- ->inject('dbForProject')
- ->inject('project')
- ->inject('platform')
- ->inject('user')
- ->inject('queueForMigrations')
- ->action(function (string $migrationId, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Migration $queueForMigrations) {
- $migration = $dbForProject->getDocument('migrations', $migrationId);
-
- if ($migration->isEmpty()) {
- throw new Exception(Exception::MIGRATION_NOT_FOUND);
- }
-
- if ($migration->getAttribute('status') !== 'failed') {
- throw new Exception(Exception::MIGRATION_IN_PROGRESS, 'Migration not failed yet');
- }
-
- $migration
- ->setAttribute('status', 'pending')
- ->setAttribute('dateUpdated', \time());
-
- // Trigger Migration
- $queueForMigrations
- ->setMigration($migration)
- ->setProject($project)
- ->setPlatform($platform)
- ->setUser($user)
- ->trigger();
-
- $response->noContent();
- });
-
-Http::delete('/v1/migrations/:migrationId')
- ->groups(['api', 'migrations'])
- ->desc('Delete migration')
- ->label('scope', 'migrations.write')
- ->label('event', 'migrations.[migrationId].delete')
- ->label('audits.event', 'migrationId.delete')
- ->label('audits.resource', 'migrations/{request.migrationId}')
- ->label('sdk', new Method(
- namespace: 'migrations',
- group: null,
- name: 'delete',
- description: '/docs/references/migrations/delete-migration.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_NOCONTENT,
- model: Response::MODEL_NONE,
- )
- ],
- contentType: ContentType::NONE
- ))
- ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration ID.', false, ['dbForProject'])
- ->inject('response')
- ->inject('dbForProject')
- ->inject('queueForEvents')
- ->action(function (string $migrationId, Response $response, Database $dbForProject, Event $queueForEvents) {
- $migration = $dbForProject->getDocument('migrations', $migrationId);
-
- if ($migration->isEmpty()) {
- throw new Exception(Exception::MIGRATION_NOT_FOUND);
- }
-
- if (!$dbForProject->deleteDocument('migrations', $migration->getId())) {
- throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove migration from DB');
- }
-
- $queueForEvents->setParam('migrationId', $migration->getId());
-
- $response->noContent();
- });
diff --git a/app/controllers/api/project.php b/app/controllers/api/project.php
index b79180c91c..544beade77 100644
--- a/app/controllers/api/project.php
+++ b/app/controllers/api/project.php
@@ -1,25 +1,15 @@
3600,
'1d' => 86400,
+ default => throw new \LogicException('Unsupported period: ' . $period),
};
$limit = match ($period) {
'1h' => (new DateTime($startDate))->diff(new DateTime($endDate))->days * 24,
- '1d' => (new DateTime($startDate))->diff(new DateTime($endDate))->days
+ '1d' => (new DateTime($startDate))->diff(new DateTime($endDate))->days,
};
$format = match ($period) {
@@ -442,228 +433,3 @@ Http::get('/v1/project/usage')
'embeddingsTextErrorsTotal' => $total[METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR] ?? 0,
]), Response::MODEL_USAGE_PROJECT);
});
-
-
-// Variables
-Http::post('/v1/project/variables')
- ->desc('Create variable')
- ->groups(['api'])
- ->label('scope', 'projects.write')
- ->label('audits.event', 'variable.create')
- ->label('sdk', new Method(
- namespace: 'project',
- group: null,
- name: 'createVariable',
- description: '/docs/references/project/create-variable.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_CREATED,
- model: Response::MODEL_VARIABLE,
- )
- ]
- ))
- ->param('key', null, new Text(Database::LENGTH_KEY), 'Variable key. Max length: ' . Database::LENGTH_KEY . ' chars.', false)
- ->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.', false)
- ->param('secret', true, new Boolean(), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true)
- ->inject('project')
- ->inject('response')
- ->inject('dbForProject')
- ->inject('dbForPlatform')
- ->action(function (string $key, string $value, bool $secret, Document $project, Response $response, Database $dbForProject, Database $dbForPlatform) {
- $variableId = ID::unique();
-
- $variable = new Document([
- '$id' => $variableId,
- '$permissions' => [
- Permission::read(Role::any()),
- Permission::update(Role::any()),
- Permission::delete(Role::any()),
- ],
- 'resourceInternalId' => '',
- 'resourceId' => '',
- 'resourceType' => 'project',
- 'key' => $key,
- 'value' => $value,
- 'secret' => $secret,
- 'search' => implode(' ', [$variableId, $key, 'project']),
- ]);
-
- try {
- $variable = $dbForProject->createDocument('variables', $variable);
- } catch (DuplicateException $th) {
- throw new Exception(Exception::VARIABLE_ALREADY_EXISTS);
- }
-
- $functions = $dbForProject->find('functions', [
- Query::limit(APP_LIMIT_SUBQUERY)
- ]);
-
- foreach ($functions as $function) {
- $dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false));
- }
-
- $response
- ->setStatusCode(Response::STATUS_CODE_CREATED)
- ->dynamic($variable, Response::MODEL_VARIABLE);
- });
-
-Http::get('/v1/project/variables')
- ->desc('List variables')
- ->groups(['api'])
- ->label('scope', 'projects.read')
- ->label('sdk', new Method(
- namespace: 'project',
- group: null,
- name: 'listVariables',
- description: '/docs/references/project/list-variables.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_VARIABLE_LIST,
- )
- ]
- ))
- ->inject('response')
- ->inject('dbForProject')
- ->action(function (Response $response, Database $dbForProject) {
- $variables = $dbForProject->find('variables', [
- Query::equal('resourceType', ['project']),
- Query::limit(APP_LIMIT_SUBQUERY)
- ]);
-
- $response->dynamic(new Document([
- 'variables' => $variables,
- 'total' => \count($variables),
- ]), Response::MODEL_VARIABLE_LIST);
- });
-
-Http::get('/v1/project/variables/:variableId')
- ->desc('Get variable')
- ->groups(['api'])
- ->label('scope', 'projects.read')
- ->label('sdk', new Method(
- namespace: 'project',
- group: null,
- name: 'getVariable',
- description: '/docs/references/project/get-variable.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_VARIABLE,
- )
- ]
- ))
- ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
- ->inject('response')
- ->inject('project')
- ->inject('dbForProject')
- ->action(function (string $variableId, Response $response, Document $project, Database $dbForProject) {
- $variable = $dbForProject->getDocument('variables', $variableId);
- if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceType') !== 'project') {
- throw new Exception(Exception::VARIABLE_NOT_FOUND);
- }
-
- $response->dynamic($variable, Response::MODEL_VARIABLE);
- });
-
-Http::put('/v1/project/variables/:variableId')
- ->desc('Update variable')
- ->groups(['api'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'project',
- group: null,
- name: 'updateVariable',
- description: '/docs/references/project/update-variable.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_VARIABLE,
- )
- ]
- ))
- ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
- ->param('key', null, new Text(255), 'Variable key. Max length: 255 chars.', false)
- ->param('value', null, new Nullable(new Text(8192, 0)), 'Variable value. Max length: 8192 chars.', true)
- ->param('secret', null, new Nullable(new Boolean()), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true)
- ->inject('project')
- ->inject('response')
- ->inject('dbForProject')
- ->inject('dbForPlatform')
- ->action(function (string $variableId, string $key, ?string $value, ?bool $secret, Document $project, Response $response, Database $dbForProject, Database $dbForPlatform) {
- $variable = $dbForProject->getDocument('variables', $variableId);
- if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceType') !== 'project') {
- throw new Exception(Exception::VARIABLE_NOT_FOUND);
- }
-
- if ($variable->getAttribute('secret') === true && $secret === false) {
- throw new Exception(Exception::VARIABLE_CANNOT_UNSET_SECRET);
- }
-
- $variable
- ->setAttribute('key', $key)
- ->setAttribute('value', $value ?? $variable->getAttribute('value'))
- ->setAttribute('secret', $secret ?? $variable->getAttribute('secret'))
- ->setAttribute('search', implode(' ', [$variableId, $key, 'project']));
-
- try {
- $dbForProject->updateDocument('variables', $variable->getId(), $variable);
- } catch (DuplicateException $th) {
- throw new Exception(Exception::VARIABLE_ALREADY_EXISTS);
- }
-
- $functions = $dbForProject->find('functions', [
- Query::limit(APP_LIMIT_SUBQUERY)
- ]);
-
- foreach ($functions as $function) {
- $dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false));
- }
-
- $response->dynamic($variable, Response::MODEL_VARIABLE);
- });
-
-Http::delete('/v1/project/variables/:variableId')
- ->desc('Delete variable')
- ->groups(['api'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'project',
- group: null,
- name: 'deleteVariable',
- description: '/docs/references/project/delete-variable.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_NOCONTENT,
- model: Response::MODEL_NONE,
- )
- ],
- contentType: ContentType::NONE
- ))
- ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
- ->inject('project')
- ->inject('response')
- ->inject('dbForProject')
- ->action(function (string $variableId, Document $project, Response $response, Database $dbForProject) {
- $variable = $dbForProject->getDocument('variables', $variableId);
- if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceType') !== 'project') {
- throw new Exception(Exception::VARIABLE_NOT_FOUND);
- }
-
- $dbForProject->deleteDocument('variables', $variable->getId());
-
- $functions = $dbForProject->find('functions', [
- Query::limit(APP_LIMIT_SUBQUERY)
- ]);
-
- foreach ($functions as $function) {
- $dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false));
- }
-
- $response->noContent();
- });
diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php
index 24a1b28cdd..8ab30fac99 100644
--- a/app/controllers/api/projects.php
+++ b/app/controllers/api/projects.php
@@ -1,48 +1,18 @@
desc('Get project')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.read')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'projects',
- name: 'get',
- description: '/docs/references/projects/get.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/service')
- ->desc('Update service status')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'projects',
- name: 'updateServiceStatus',
- description: '/docs/references/projects/update-service-status.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('service', '', new WhiteList(array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional'])), true), 'Service name.')
- ->param('status', null, new Boolean(), 'Service status.')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $service, bool $status, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $services = $project->getAttribute('services', []);
- $services[$service] = $status;
-
- $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('services', $services));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/service/all')
- ->desc('Update all service status')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'projects',
- name: 'updateServiceStatusAll',
- description: '/docs/references/projects/update-service-status-all.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('status', null, new Boolean(), 'Service status.')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, bool $status, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $allServices = array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional']));
-
- $services = [];
- foreach ($allServices as $service) {
- $services[$service] = $status;
- }
-
- $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('services', $services));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/api')
- ->desc('Update API status')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', [
- new Method(
- namespace: 'projects',
- group: 'projects',
- name: 'updateApiStatus',
- description: '/docs/references/projects/update-api-status.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ],
- deprecated: new Deprecated(
- since: '1.8.0',
- replaceWith: 'projects.updateAPIStatus',
- ),
- public: false,
- ),
- new Method(
- namespace: 'projects',
- group: 'projects',
- name: 'updateAPIStatus',
- description: '/docs/references/projects/update-api-status.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- )
- ])
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('api', '', new WhiteList(array_keys(Config::getParam('apis')), true), 'API name.')
- ->param('status', null, new Boolean(), 'API status.')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $api, bool $status, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $apis = $project->getAttribute('apis', []);
- $apis[$api] = $status;
-
- $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('apis', $apis));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/api/all')
- ->desc('Update all API status')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', [
- new Method(
- namespace: 'projects',
- group: 'projects',
- name: 'updateApiStatusAll',
- description: '/docs/references/projects/update-api-status-all.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ],
- deprecated: new Deprecated(
- since: '1.8.0',
- replaceWith: 'projects.updateAPIStatusAll',
- ),
- public: false,
- ),
- new Method(
- namespace: 'projects',
- group: 'projects',
- name: 'updateAPIStatusAll',
- description: '/docs/references/projects/update-api-status-all.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- )
- ])
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('status', null, new Boolean(), 'API status.')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, bool $status, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $allApis = array_keys(Config::getParam('apis'));
-
- $apis = [];
- foreach ($allApis as $api) {
- $apis[$api] = $status;
- }
-
- $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('apis', $apis));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
+// Backwards compatibility
Http::patch('/v1/projects/:projectId/oauth2')
->desc('Update project OAuth2')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateOAuth2',
- description: '/docs/references/projects/update-oauth2.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
->param('provider', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders')), true), 'Provider Name')
->param('appId', null, new Nullable(new Text(256)), 'Provider app ID. Max length: 256 chars.', true)
@@ -334,372 +63,11 @@ Http::patch('/v1/projects/:projectId/oauth2')
$response->dynamic($project, Response::MODEL_PROJECT);
});
-Http::patch('/v1/projects/:projectId/auth/session-alerts')
- ->desc('Update project sessions emails')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateSessionAlerts',
- description: '/docs/references/projects/update-session-alerts.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('alerts', false, new Boolean(true), 'Set to true to enable session emails.')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, bool $alerts, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $auths = $project->getAttribute('auths', []);
- $auths['sessionAlerts'] = $alerts;
-
- $dbForPlatform->updateDocument('projects', $project->getId(), $project
- ->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/auth/memberships-privacy')
- ->desc('Update project memberships privacy attributes')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateMembershipsPrivacy',
- description: '/docs/references/projects/update-memberships-privacy.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('userName', true, new Boolean(true), 'Set to true to show userName to members of a team.')
- ->param('userEmail', true, new Boolean(true), 'Set to true to show email to members of a team.')
- ->param('mfa', true, new Boolean(true), 'Set to true to show mfa to members of a team.')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, bool $userName, bool $userEmail, bool $mfa, Response $response, Database $dbForPlatform) {
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $auths = $project->getAttribute('auths', []);
-
- $auths['membershipsUserName'] = $userName;
- $auths['membershipsUserEmail'] = $userEmail;
- $auths['membershipsMfa'] = $mfa;
-
- $dbForPlatform->updateDocument('projects', $project->getId(), $project
- ->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/auth/limit')
- ->desc('Update project users limit')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateAuthLimit',
- description: '/docs/references/projects/update-auth-limit.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('limit', false, new Range(0, APP_LIMIT_USERS), 'Set the max number of users allowed in this project. Use 0 for unlimited.')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $auths = $project->getAttribute('auths', []);
- $auths['limit'] = $limit;
-
- $dbForPlatform->updateDocument('projects', $project->getId(), $project
- ->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/auth/duration')
- ->desc('Update project authentication duration')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateAuthDuration',
- description: '/docs/references/projects/update-auth-duration.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('duration', 31536000, new Range(0, 31536000), 'Project session length in seconds. Max length: 31536000 seconds.')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, int $duration, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $auths = $project->getAttribute('auths', []);
- $auths['duration'] = $duration;
-
- $dbForPlatform->updateDocument('projects', $project->getId(), $project
- ->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/auth/:method')
- ->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateAuthStatus',
- description: '/docs/references/projects/update-auth-status.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('method', '', new WhiteList(\array_keys(Config::getParam('auth')), true), 'Auth Method. Possible values: ' . implode(',', \array_keys(Config::getParam('auth'))), false)
- ->param('status', false, new Boolean(true), 'Set the status of this auth method.')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $method, bool $status, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
- $auth = Config::getParam('auth')[$method] ?? [];
- $authKey = $auth['key'] ?? '';
- $status = ($status === '1' || $status === 'true' || $status === 1 || $status === true);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $auths = $project->getAttribute('auths', []);
- $auths[$authKey] = $status;
-
- $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/auth/password-history')
- ->desc('Update authentication password history. Use this endpoint to set the number of password history to save and 0 to disable password history.')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateAuthPasswordHistory',
- description: '/docs/references/projects/update-auth-password-history.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('limit', 0, new Range(0, APP_LIMIT_USER_PASSWORD_HISTORY), 'Set the max number of passwords to store in user history. User can\'t choose a new password that is already stored in the password history list. Max number of passwords allowed in history is' . APP_LIMIT_USER_PASSWORD_HISTORY . '. Default value is 0')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $auths = $project->getAttribute('auths', []);
- $auths['passwordHistory'] = $limit;
-
- $dbForPlatform->updateDocument('projects', $project->getId(), $project
- ->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/auth/password-dictionary')
- ->desc('Update authentication password dictionary status. Use this endpoint to enable or disable the dicitonary check for user password')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateAuthPasswordDictionary',
- description: '/docs/references/projects/update-auth-password-dictionary.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('enabled', false, new Boolean(false), 'Set whether or not to enable checking user\'s password against most commonly used passwords. Default is false.')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $auths = $project->getAttribute('auths', []);
- $auths['passwordDictionary'] = $enabled;
-
- $dbForPlatform->updateDocument('projects', $project->getId(), $project
- ->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/auth/personal-data')
- ->desc('Update personal data check')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updatePersonalDataCheck',
- description: '/docs/references/projects/update-personal-data-check.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('enabled', false, new Boolean(false), 'Set whether or not to check a password for similarity with personal data. Default is false.')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $auths = $project->getAttribute('auths', []);
- $auths['personalDataCheck'] = $enabled;
-
- $dbForPlatform->updateDocument('projects', $project->getId(), $project
- ->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::patch('/v1/projects/:projectId/auth/max-sessions')
- ->desc('Update project user sessions limit')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateAuthSessionsLimit',
- description: '/docs/references/projects/update-auth-sessions-limit.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('limit', false, new Range(1, APP_LIMIT_USER_SESSIONS_MAX), 'Set the max number of users allowed in this project. Value allowed is between 1-' . APP_LIMIT_USER_SESSIONS_MAX . '. Default is ' . APP_LIMIT_USER_SESSIONS_DEFAULT)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $auths = $project->getAttribute('auths', []);
- $auths['maxSessions'] = $limit;
-
- $dbForPlatform->updateDocument('projects', $project->getId(), $project
- ->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
+// Backwards compatibility
Http::patch('/v1/projects/:projectId/auth/mock-numbers')
->desc('Update the mock numbers for the project')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateMockNumbers',
- description: '/docs/references/projects/update-mock-numbers.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
->param('numbers', '', new ArrayList(new MockNumber(), 10), 'An array of mock numbers and their corresponding verification codes (OTPs). Each number should be a valid E.164 formatted phone number. Maximum of 10 numbers are allowed.')
->inject('response')
@@ -729,1609 +97,29 @@ Http::patch('/v1/projects/:projectId/auth/mock-numbers')
$response->dynamic($project, Response::MODEL_PROJECT);
});
-Http::delete('/v1/projects/:projectId')
- ->desc('Delete project')
- ->groups(['api', 'projects'])
- ->label('audits.event', 'projects.delete')
- ->label('audits.resource', 'project/{request.projectId}')
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'projects',
- name: 'delete',
- description: '/docs/references/projects/delete.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'])
- ->inject('response')
- ->inject('user')
- ->inject('dbForPlatform')
- ->inject('queueForDeletes')
- ->action(function (string $projectId, Response $response, Document $user, Database $dbForPlatform, Delete $queueForDeletes) {
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $queueForDeletes
- ->setProject($project)
- ->setType(DELETE_TYPE_DOCUMENT)
- ->setDocument($project);
-
- if (!$dbForPlatform->deleteDocument('projects', $projectId)) {
- throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove project from DB');
- }
-
- $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')
- ->desc('Create key')
- ->groups(['api', 'projects'])
- ->label('scope', 'keys.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'keys',
- name: 'createKey',
- description: '/docs/references/projects/create-key.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_CREATED,
- model: Response::MODEL_KEY,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- // TODO: When migrating to Platform API, mark keyId required for consistency
- ->param('keyId', 'unique()', fn (Database $dbForPlatform) => new CustomId($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key 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.', true, ['dbForPlatform'])->param('name', null, new Text(128), 'Key name. Max length: 128 chars.')
- ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.')
- ->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $keyId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) {
- $keyId = $keyId == 'unique()' ? ID::unique() : $keyId;
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $key = new Document([
- '$id' => $keyId,
- '$permissions' => [
- Permission::read(Role::any()),
- Permission::update(Role::any()),
- Permission::delete(Role::any()),
- ],
- 'resourceInternalId' => $project->getSequence(),
- 'resourceId' => $project->getId(),
- 'resourceType' => 'projects',
- 'name' => $name,
- 'scopes' => $scopes,
- 'expire' => $expire,
- 'sdks' => [],
- 'accessedAt' => null,
- 'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)),
- ]);
-
- try {
- $key = $dbForPlatform->createDocument('keys', $key);
- } catch (Duplicate) {
- throw new Exception(Exception::KEY_ALREADY_EXISTS);
- }
-
- $dbForPlatform->purgeCachedDocument('projects', $project->getId());
-
- $response
- ->setStatusCode(Response::STATUS_CODE_CREATED)
- ->dynamic($key, Response::MODEL_KEY);
- });
-
-Http::get('/v1/projects/:projectId/keys')
- ->desc('List keys')
- ->groups(['api', 'projects'])
- ->label('scope', 'keys.read')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'keys',
- name: 'listKeys',
- description: '/docs/references/projects/list-keys.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_KEY_LIST,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('queries', [], new Keys(), '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(', ', Keys::ALLOWED_ATTRIBUTES), true)
- ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, array $queries, bool $includeTotal, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- try {
- $queries = Query::parseQueries($queries);
- } catch (QueryException $e) {
- throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
- }
-
- // Backwards compatibility
- if (\count(Query::getByType($queries, [Query::TYPE_LIMIT])) === 0) {
- $queries[] = Query::limit(5000);
- }
-
- $queries[] = Query::equal('resourceType', ['projects']);
- $queries[] = Query::equal('resourceInternalId', [$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());
- }
-
- $keyId = $cursor->getValue();
- $cursorDocument = $dbForPlatform->getDocument('keys', $keyId);
-
- if ($cursorDocument->isEmpty()) {
- throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Key '{$keyId}' for the 'cursor' value not found.");
- }
-
- $cursor->setValue($cursorDocument);
- }
-
- $filterQueries = Query::groupByType($queries)['filters'];
-
- $keys = $dbForPlatform->find('keys', $queries);
-
- $response->dynamic(new Document([
- 'keys' => $keys,
- 'total' => $includeTotal ? $dbForPlatform->count('keys', $filterQueries, APP_LIMIT_COUNT) : 0,
- ]), Response::MODEL_KEY_LIST);
- });
-
-Http::get('/v1/projects/:projectId/keys/:keyId')
- ->desc('Get key')
- ->groups(['api', 'projects'])
- ->label('scope', 'keys.read')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'keys',
- name: 'getKey',
- description: '/docs/references/projects/get-key.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_KEY,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key unique ID.', false, ['dbForPlatform'])
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $keyId, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $key = $dbForPlatform->findOne('keys', [
- Query::equal('$id', [$keyId]),
- Query::equal('resourceType', ['projects']),
- Query::equal('resourceInternalId', [$project->getSequence()]),
- ]);
-
- if ($key->isEmpty()) {
- throw new Exception(Exception::KEY_NOT_FOUND);
- }
-
- $response->dynamic($key, Response::MODEL_KEY);
- });
-
-Http::put('/v1/projects/:projectId/keys/:keyId')
- ->desc('Update key')
- ->groups(['api', 'projects'])
- ->label('scope', 'keys.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'keys',
- name: 'updateKey',
- description: '/docs/references/projects/update-key.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_KEY,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key unique ID.', false, ['dbForPlatform'])
- ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.')
- ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.')
- ->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $keyId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $key = $dbForPlatform->findOne('keys', [
- Query::equal('$id', [$keyId]),
- Query::equal('resourceType', ['projects']),
- Query::equal('resourceInternalId', [$project->getSequence()]),
- ]);
-
- if ($key->isEmpty()) {
- throw new Exception(Exception::KEY_NOT_FOUND);
- }
-
- $key
- ->setAttribute('name', $name)
- ->setAttribute('scopes', $scopes)
- ->setAttribute('expire', $expire);
-
- $dbForPlatform->updateDocument('keys', $key->getId(), $key);
-
- $dbForPlatform->purgeCachedDocument('projects', $project->getId());
-
- $response->dynamic($key, Response::MODEL_KEY);
- });
-
-Http::delete('/v1/projects/:projectId/keys/:keyId')
- ->desc('Delete key')
- ->groups(['api', 'projects'])
- ->label('scope', 'keys.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'keys',
- name: 'deleteKey',
- description: '/docs/references/projects/delete-key.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('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key unique ID.', false, ['dbForPlatform'])
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $keyId, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $key = $dbForPlatform->findOne('keys', [
- Query::equal('$id', [$keyId]),
- Query::equal('resourceType', ['projects']),
- Query::equal('resourceInternalId', [$project->getSequence()]),
- ]);
-
- if ($key->isEmpty()) {
- throw new Exception(Exception::KEY_NOT_FOUND);
- }
-
- $dbForPlatform->deleteDocument('keys', $key->getId());
-
- $dbForPlatform->purgeCachedDocument('projects', $project->getId());
-
- $response->noContent();
- });
-
-// JWT Keys
-
-Http::post('/v1/projects/:projectId/jwts')
- ->groups(['api', 'projects'])
- ->desc('Create JWT')
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'createJWT',
- description: '/docs/references/projects/create-jwt.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_CREATED,
- model: Response::MODEL_JWT,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for JWT key. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.')
- ->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, array $scopes, int $duration, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0);
-
- $response
- ->setStatusCode(Response::STATUS_CODE_CREATED)
- ->dynamic(new Document(['jwt' => API_KEY_DYNAMIC . '_' . $jwt->encode([
- 'projectId' => $project->getId(),
- 'scopes' => $scopes
- ])]), Response::MODEL_JWT);
- });
-
-// Platforms
-
-Http::post('/v1/projects/:projectId/platforms')
- ->desc('Create platform')
- ->groups(['api', 'projects'])
- ->label('audits.event', 'platforms.create')
- ->label('audits.resource', 'project/{request.projectId}')
- ->label('scope', 'platforms.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'platforms',
- name: 'createPlatform',
- description: '/docs/references/projects/create-platform.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_CREATED,
- model: Response::MODEL_PLATFORM,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param(
- 'type',
- null,
- new WhiteList([
- Platform::TYPE_WEB,
- Platform::TYPE_FLUTTER_WEB,
- Platform::TYPE_FLUTTER_IOS,
- Platform::TYPE_FLUTTER_ANDROID,
- Platform::TYPE_FLUTTER_LINUX,
- Platform::TYPE_FLUTTER_MACOS,
- Platform::TYPE_FLUTTER_WINDOWS,
- Platform::TYPE_APPLE_IOS,
- Platform::TYPE_APPLE_MACOS,
- Platform::TYPE_APPLE_WATCHOS,
- Platform::TYPE_APPLE_TVOS,
- Platform::TYPE_ANDROID,
- Platform::TYPE_UNITY,
- Platform::TYPE_REACT_NATIVE_IOS,
- Platform::TYPE_REACT_NATIVE_ANDROID,
- ], true),
- 'Platform type. Possible values are: web, flutter-web, flutter-ios, flutter-android, flutter-linux, flutter-macos, flutter-windows, apple-ios, apple-macos, apple-watchos, apple-tvos, android, unity, react-native-ios, react-native-android.'
- )
- ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.')
- ->param('key', '', new Text(256), 'Package name for Android or bundle ID for iOS or macOS. Max length: 256 chars.', true)
- ->param('store', '', new Text(256), 'App store or Google Play store ID. Max length: 256 chars.', true)
- ->param('hostname', '', new Hostname(), 'Platform client hostname. Max length: 256 chars.', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $type, string $name, string $key, string $store, string $hostname, Response $response, Database $dbForPlatform) {
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $platform = new Document([
- '$id' => ID::unique(),
- '$permissions' => [
- Permission::read(Role::any()),
- Permission::update(Role::any()),
- Permission::delete(Role::any()),
- ],
- 'projectInternalId' => $project->getSequence(),
- 'projectId' => $project->getId(),
- 'type' => $type,
- 'name' => $name,
- 'key' => $key,
- 'store' => $store,
- 'hostname' => $hostname
- ]);
-
- $platform = $dbForPlatform->createDocument('platforms', $platform);
-
- $dbForPlatform->purgeCachedDocument('projects', $project->getId());
-
- $response
- ->setStatusCode(Response::STATUS_CODE_CREATED)
- ->dynamic($platform, Response::MODEL_PLATFORM);
- });
-
-Http::get('/v1/projects/:projectId/platforms')
- ->desc('List platforms')
- ->groups(['api', 'projects'])
- ->label('scope', 'platforms.read')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'platforms',
- name: 'listPlatforms',
- description: '/docs/references/projects/list-platforms.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PLATFORM_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);
- }
-
- $platforms = $dbForPlatform->find('platforms', [
- Query::equal('projectInternalId', [$project->getSequence()]),
- Query::limit(5000),
- ]);
-
- $response->dynamic(new Document([
- 'platforms' => $platforms,
- 'total' => $includeTotal ? count($platforms) : 0,
- ]), Response::MODEL_PLATFORM_LIST);
- });
-
-Http::get('/v1/projects/:projectId/platforms/:platformId')
- ->desc('Get platform')
- ->groups(['api', 'projects'])
- ->label('scope', 'platforms.read')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'platforms',
- name: 'getPlatform',
- description: '/docs/references/projects/get-platform.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PLATFORM,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform unique ID.', false, ['dbForPlatform'])
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $platformId, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $platform = $dbForPlatform->findOne('platforms', [
- Query::equal('$id', [$platformId]),
- Query::equal('projectInternalId', [$project->getSequence()]),
- ]);
-
- if ($platform->isEmpty()) {
- throw new Exception(Exception::PLATFORM_NOT_FOUND);
- }
-
- $response->dynamic($platform, Response::MODEL_PLATFORM);
- });
-
-Http::put('/v1/projects/:projectId/platforms/:platformId')
- ->desc('Update platform')
- ->groups(['api', 'projects'])
- ->label('scope', 'platforms.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'platforms',
- name: 'updatePlatform',
- description: '/docs/references/projects/update-platform.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PLATFORM,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform unique ID.', false, ['dbForPlatform'])
- ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.')
- ->param('key', '', new Text(256), 'Package name for android or bundle ID for iOS. Max length: 256 chars.', true)
- ->param('store', '', new Text(256), 'App store or Google Play store ID. Max length: 256 chars.', true)
- ->param('hostname', '', new Hostname(), 'Platform client URL. Max length: 256 chars.', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $platformId, string $name, string $key, string $store, string $hostname, Response $response, Database $dbForPlatform) {
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $platform = $dbForPlatform->findOne('platforms', [
- Query::equal('$id', [$platformId]),
- Query::equal('projectInternalId', [$project->getSequence()]),
- ]);
-
- if ($platform->isEmpty()) {
- throw new Exception(Exception::PLATFORM_NOT_FOUND);
- }
-
- $platform
- ->setAttribute('name', $name)
- ->setAttribute('key', $key)
- ->setAttribute('store', $store)
- ->setAttribute('hostname', $hostname);
-
- $dbForPlatform->updateDocument('platforms', $platform->getId(), $platform);
-
- $dbForPlatform->purgeCachedDocument('projects', $project->getId());
-
- $response->dynamic($platform, Response::MODEL_PLATFORM);
- });
-
-Http::delete('/v1/projects/:projectId/platforms/:platformId')
- ->desc('Delete platform')
- ->groups(['api', 'projects'])
- ->label('audits.event', 'platforms.delete')
- ->label('audits.resource', 'project/{request.projectId}/platform/${request.platformId}')
- ->label('scope', 'platforms.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'platforms',
- name: 'deletePlatform',
- description: '/docs/references/projects/delete-platform.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('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform unique ID.', false, ['dbForPlatform'])
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $platformId, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $platform = $dbForPlatform->findOne('platforms', [
- Query::equal('$id', [$platformId]),
- Query::equal('projectInternalId', [$project->getSequence()]),
- ]);
-
- if ($platform->isEmpty()) {
- throw new Exception(Exception::PLATFORM_NOT_FOUND);
- }
-
- $dbForPlatform->deleteDocument('platforms', $platformId);
-
- $dbForPlatform->purgeCachedDocument('projects', $project->getId());
-
- $response->noContent();
- });
-
-
-// CUSTOM SMTP and Templates
-Http::patch('/v1/projects/:projectId/smtp')
- ->desc('Update SMTP')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', [
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'updateSmtp',
- description: '/docs/references/projects/update-smtp.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ],
- deprecated: new Deprecated(
- since: '1.8.0',
- replaceWith: 'projects.updateSMTP',
- ),
- public: false,
- ),
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'updateSMTP',
- description: '/docs/references/projects/update-smtp.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- )
- ])
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('enabled', false, new Boolean(), 'Enable custom SMTP service')
- ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true)
- ->param('senderEmail', '', new Email(), 'Email of the sender', true)
- ->param('replyTo', '', new Email(), 'Reply to email', true)
- ->param('host', '', new HostName(), 'SMTP server host name', true)
- ->param('port', 587, new Integer(), 'SMTP server port', true)
- ->param('username', '', new Text(0, 0), 'SMTP server username', true)
- ->param('password', '', new Text(0, 0), 'SMTP server password', true)
- ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, bool $enabled, string $senderName, string $senderEmail, string $replyTo, string $host, int $port, string $username, string $password, string $secure, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- // Ensure required params for when enabling SMTP
- if ($enabled) {
- if (empty($senderName)) {
- throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Sender name is required when enabling SMTP.');
- } elseif (empty($senderEmail)) {
- throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Sender email is required when enabling SMTP.');
- } elseif (empty($host)) {
- throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Host is required when enabling SMTP.');
- } elseif (empty($port)) {
- throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Port is required when enabling SMTP.');
- }
- }
-
- // validate SMTP settings
- if ($enabled) {
- $mail = new PHPMailer(true);
- $mail->isSMTP();
- $mail->SMTPAuth = (!empty($username) && !empty($password));
- $mail->Username = $username;
- $mail->Password = $password;
- $mail->Host = $host;
- $mail->Port = $port;
- $mail->SMTPSecure = $secure;
- $mail->SMTPAutoTLS = false;
- $mail->Timeout = 5;
-
- try {
- $valid = $mail->SmtpConnect();
-
- if (!$valid) {
- throw new Exception('Connection is not valid.');
- }
- } catch (Throwable $error) {
- throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage());
- }
- }
-
- // Save SMTP settings
- if ($enabled) {
- $smtp = [
- 'enabled' => $enabled,
- 'senderName' => $senderName,
- 'senderEmail' => $senderEmail,
- 'replyTo' => $replyTo,
- 'host' => $host,
- 'port' => $port,
- 'username' => $username,
- 'password' => $password,
- 'secure' => $secure,
- ];
- } else {
- $smtp = [
- 'enabled' => false
- ];
- }
-
- $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
-Http::post('/v1/projects/:projectId/smtp/tests')
- ->desc('Create SMTP test')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', [
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'createSmtpTest',
- description: '/docs/references/projects/create-smtp-test.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_NOCONTENT,
- model: Response::MODEL_NONE,
- )
- ],
- deprecated: new Deprecated(
- since: '1.8.0',
- replaceWith: 'projects.createSMTPTest',
- ),
- public: false,
- ),
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'createSMTPTest',
- description: '/docs/references/projects/create-smtp-test.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_NOCONTENT,
- model: Response::MODEL_NONE,
- )
- ]
- )
- ])
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('emails', [], new ArrayList(new Email(), 10), 'Array of emails to send test email to. Maximum of 10 emails are allowed.')
- ->param('senderName', System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'), new Text(255, 0), 'Name of the email sender')
- ->param('senderEmail', System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM), new Email(), 'Email of the sender')
- ->param('replyTo', '', new Email(), 'Reply to email', true)
- ->param('host', '', new HostName(), 'SMTP server host name')
- ->param('port', 587, new Integer(), 'SMTP server port', true)
- ->param('username', '', new Text(0, 0), 'SMTP server username', true)
- ->param('password', '', new Text(0, 0), 'SMTP server password', true)
- ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->inject('queueForMails')
- ->inject('plan')
- ->action(function (string $projectId, array $emails, string $senderName, string $senderEmail, string $replyTo, string $host, int $port, string $username, string $password, string $secure, Response $response, Database $dbForPlatform, Mail $queueForMails, array $plan) {
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $replyToEmail = !empty($replyTo) ? $replyTo : $senderEmail;
-
- $subject = 'Custom SMTP email sample';
- $template = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-smtp-test.tpl');
- $template
- ->setParam('{{from}}', "{$senderName} ({$senderEmail})")
- ->setParam('{{replyTo}}', "{$senderName} ({$replyToEmail})")
- ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL)
- ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR)
- ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER)
- ->setParam('{{discordUrl}}', $plan['discordUrl'] ?? APP_SOCIAL_DISCORD)
- ->setParam('{{githubUrl}}', $plan['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE)
- ->setParam('{{termsUrl}}', $plan['termsUrl'] ?? APP_EMAIL_TERMS_URL)
- ->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL);
-
- foreach ($emails as $email) {
- $queueForMails
- ->setSmtpHost($host)
- ->setSmtpPort($port)
- ->setSmtpUsername($username)
- ->setSmtpPassword($password)
- ->setSmtpSecure($secure)
- ->setSmtpReplyTo($replyTo)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName)
- ->setRecipient($email)
- ->setName('')
- ->setBodyTemplate(__DIR__ . '/../../config/locale/templates/email-base-styled.tpl')
- ->setBody($template->render())
- ->setVariables([])
- ->setSubject($subject)
- ->trigger();
- }
-
- return $response->noContent();
- });
-
-Http::get('/v1/projects/:projectId/templates/sms/:type/:locale')
- ->desc('Get custom SMS template')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', [
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'getSmsTemplate',
- description: '/docs/references/projects/get-sms-template.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_SMS_TEMPLATE,
- )
- ],
- deprecated: new Deprecated(
- since: '1.8.0',
- replaceWith: 'projects.getSMSTemplate',
- ),
- public: false,
- ),
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'getSMSTemplate',
- description: '/docs/references/projects/get-sms-template.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_SMS_TEMPLATE,
- )
- ]
- )
- ])
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('type', '', new WhiteList(Config::getParam('locale-templates')['sms'] ?? [], true), 'Template type')
- ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes'])
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) {
-
- throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED);
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $templates = $project->getAttribute('templates', []);
- $template = $templates['sms.' . $type . '-' . $locale] ?? null;
-
- if (is_null($template)) {
- $template = [
- 'message' => Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl')->render(),
- ];
- }
-
- $template['type'] = $type;
- $template['locale'] = $locale;
-
- $response->dynamic(new Document($template), Response::MODEL_SMS_TEMPLATE);
- });
-
-
-Http::get('/v1/projects/:projectId/templates/email/:type/:locale')
- ->desc('Get custom email template')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'getEmailTemplate',
- description: '/docs/references/projects/get-email-template.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_EMAIL_TEMPLATE,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type')
- ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes'])
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $templates = $project->getAttribute('templates', []);
- $template = $templates['email.' . $type . '-' . $locale] ?? null;
-
- $localeObj = new Locale($locale);
- $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en'));
-
- if (is_null($template)) {
- /**
- * different templates, different placeholders.
- */
- $templateConfigs = [
- 'magicSession' => [
- 'file' => 'email-magic-url.tpl',
- 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase']
- ],
- 'mfaChallenge' => [
- 'file' => 'email-mfa-challenge.tpl',
- 'placeholders' => ['description', 'clientInfo']
- ],
- 'otpSession' => [
- 'file' => 'email-otp.tpl',
- 'placeholders' => ['description', 'clientInfo', 'securityPhrase']
- ],
- 'sessionAlert' => [
- 'file' => 'email-session-alert.tpl',
- 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer']
- ],
- ];
-
- // fallback to the base template.
- $config = $templateConfigs[$type] ?? [
- 'file' => 'email-inner-base.tpl',
- 'placeholders' => ['buttonText', 'body', 'footer']
- ];
-
- $templateString = file_get_contents(__DIR__ . '/../../config/locale/templates/' . $config['file']);
-
- // We use `fromString` due to the replace above
- $message = Template::fromString($templateString);
-
- // Set type-specific parameters
- foreach ($config['placeholders'] as $param) {
- $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']);
- $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml);
- }
-
- $message
- // common placeholders on all the templates
- ->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello"))
- ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks"))
- ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature"));
-
- // `useContent: false` will strip new lines!
- $message = $message->render(useContent: true);
-
- $template = [
- 'message' => $message,
- 'subject' => $localeObj->getText('emails.' . $type . '.subject'),
- 'senderEmail' => '',
- 'senderName' => ''
- ];
- }
-
- $template['type'] = $type;
- $template['locale'] = $locale;
-
- $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE);
- });
-
-Http::patch('/v1/projects/:projectId/templates/sms/:type/:locale')
- ->desc('Update custom SMS template')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', [
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'updateSmsTemplate',
- description: '/docs/references/projects/update-sms-template.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_SMS_TEMPLATE,
- )
- ],
- deprecated: new Deprecated(
- since: '1.8.0',
- replaceWith: 'projects.updateSMSTemplate',
- ),
- public: false,
- ),
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'updateSMSTemplate',
- description: '/docs/references/projects/update-sms-template.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_SMS_TEMPLATE,
- )
- ]
- )
- ])
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('type', '', new WhiteList(Config::getParam('locale-templates')['sms'] ?? [], true), 'Template type')
- ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes'])
- ->param('message', '', new Text(0), 'Template message')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $type, string $locale, string $message, Response $response, Database $dbForPlatform) {
-
- throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED);
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $templates = $project->getAttribute('templates', []);
- $templates['sms.' . $type . '-' . $locale] = [
- 'message' => $message
- ];
-
- $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates));
-
- $response->dynamic(new Document([
- 'message' => $message,
- 'type' => $type,
- 'locale' => $locale,
- ]), Response::MODEL_SMS_TEMPLATE);
- });
-
-Http::patch('/v1/projects/:projectId/templates/email/:type/:locale')
- ->desc('Update custom email templates')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'updateEmailTemplate',
- description: '/docs/references/projects/update-email-template.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_EMAIL_TEMPLATE,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type')
- ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes'])
- ->param('subject', '', new Text(255), 'Email Subject')
- ->param('message', '', new Text(0), 'Template message')
- ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true)
- ->param('senderEmail', '', new Email(), 'Email of the sender', true)
- ->param('replyTo', '', new Email(), 'Reply to email', true)
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $templates = $project->getAttribute('templates', []);
- $templates['email.' . $type . '-' . $locale] = [
- 'senderName' => $senderName,
- 'senderEmail' => $senderEmail,
- 'subject' => $subject,
- 'replyTo' => $replyTo,
- 'message' => $message
- ];
-
- $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates));
-
- $response->dynamic(new Document([
- 'type' => $type,
- 'locale' => $locale,
- 'senderName' => $senderName,
- 'senderEmail' => $senderEmail,
- 'subject' => $subject,
- 'replyTo' => $replyTo,
- 'message' => $message
- ]), Response::MODEL_EMAIL_TEMPLATE);
- });
-
-Http::delete('/v1/projects/:projectId/templates/sms/:type/:locale')
- ->desc('Reset custom SMS template')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', [
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'deleteSmsTemplate',
- description: '/docs/references/projects/delete-sms-template.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_SMS_TEMPLATE,
- )
- ],
- contentType: ContentType::JSON,
- deprecated: new Deprecated(
- since: '1.8.0',
- replaceWith: 'projects.deleteSMSTemplate',
- ),
- public: false,
- ),
- new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'deleteSMSTemplate',
- description: '/docs/references/projects/delete-sms-template.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_SMS_TEMPLATE,
- )
- ],
- contentType: ContentType::JSON
- )
- ])
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('type', '', new WhiteList(Config::getParam('locale-templates')['sms'] ?? [], true), 'Template type')
- ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes'])
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) {
-
- throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED);
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $templates = $project->getAttribute('templates', []);
- $template = $templates['sms.' . $type . '-' . $locale] ?? null;
-
- if (is_null($template)) {
- throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION);
- }
-
- unset($template['sms.' . $type . '-' . $locale]);
-
- $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates));
-
- $response->dynamic(new Document([
- 'type' => $type,
- 'locale' => $locale,
- 'message' => $template['message']
- ]), Response::MODEL_SMS_TEMPLATE);
- });
-
-Http::delete('/v1/projects/:projectId/templates/email/:type/:locale')
+// Backwards compatibility
+Http::delete('/v1/projects/:projectId/templates/email')
+ ->alias('/v1/projects/:projectId/templates/email/:type/:locale')
->desc('Delete custom email template')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'templates',
- name: 'deleteEmailTemplate',
- description: '/docs/references/projects/delete-email-template.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_EMAIL_TEMPLATE,
- )
- ],
- contentType: ContentType::JSON
- ))
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type')
- ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes'])
+ ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes'])
->inject('response')
->inject('dbForPlatform')
->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) {
+ $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en');
$project = $dbForPlatform->getDocument('projects', $projectId);
-
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$templates = $project->getAttribute('templates', []);
- $template = $templates['email.' . $type . '-' . $locale] ?? null;
-
- if (is_null($template)) {
- throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION);
- }
-
unset($templates['email.' . $type . '-' . $locale]);
$project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates));
- $response->dynamic(new Document([
- 'type' => $type,
- 'locale' => $locale,
- 'senderName' => $template['senderName'],
- 'senderEmail' => $template['senderEmail'],
- 'subject' => $template['subject'],
- 'replyTo' => $template['replyTo'],
- 'message' => $template['message']
- ]), Response::MODEL_EMAIL_TEMPLATE);
- });
-
-Http::patch('/v1/projects/:projectId/auth/session-invalidation')
- ->desc('Update invalidate session option of the project')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'auth',
- name: 'updateSessionInvalidation',
- description: '/docs/references/projects/update-session-invalidation.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->param('enabled', false, new Boolean(), 'Update authentication session invalidation status. Use this endpoint to enable or disable session invalidation on password change')
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $auths = $project->getAttribute('auths', []);
- $auths['invalidateSessions'] = $enabled;
- $dbForPlatform->updateDocument('projects', $project->getId(), $project
- ->setAttribute('auths', $auths));
-
- $response->dynamic($project, Response::MODEL_PROJECT);
+ $response->noContent();
});
diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php
index 9d04018b10..3f52069609 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;
@@ -73,7 +73,7 @@ use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
/** TODO: Remove function when we move to using utopia/platform */
-function createUser(Hash $hash, string $userId, ?string $email, ?string $password, ?string $phone, ?string $name, Document $project, Database $dbForProject, Hooks $hooks): Document
+function createUser(Hash $hash, string $userId, ?string $email, ?string $password, ?string $phone, ?string $name, Document $project, Database $dbForProject, Hooks $hooks, array $plan): Document
{
$name = $name ?? '';
$plaintextPassword = $password;
@@ -110,11 +110,39 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor
}
}
+ $emailMetadata = [
+ 'emailCanonical' => null,
+ 'emailIsCanonical' => null,
+ 'emailIsCorporate' => null,
+ 'emailIsDisposable' => null,
+ 'emailIsFree' => null,
+ ];
+
try {
- $emailCanonical = new Email($email);
- } catch (Throwable) {
- $emailCanonical = null;
+ $parsedEmail = new Email($email ?? '');
+ $canonical = $parsedEmail->getCanonical();
+ $emailMetadata = [
+ 'emailCanonical' => $canonical,
+ 'emailIsCanonical' => $parsedEmail->get() === $canonical,
+ 'emailIsCorporate' => $parsedEmail->isCorporate(),
+ 'emailIsDisposable' => $parsedEmail->isDisposable(),
+ 'emailIsFree' => $parsedEmail->isFree(),
+ ];
+ } catch (\Throwable) {
}
+
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ throw new Exception(Exception::USER_EMAIL_FREE);
+ }
+
$hashedPassword = null;
$isHashed = !$hash instanceof Plaintext;
@@ -159,11 +187,11 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor
'tokens' => null,
'memberships' => null,
'search' => implode(' ', [$userId, $email, $phone, $name]),
- 'emailCanonical' => $emailCanonical?->getCanonical(),
- 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(),
- 'emailIsCorporate' => $emailCanonical?->isCorporate(),
- 'emailIsDisposable' => $emailCanonical?->isDisposable(),
- 'emailIsFree' => $emailCanonical?->isFree(),
+ 'emailCanonical' => $emailMetadata['emailCanonical'],
+ 'emailIsCanonical' => $emailMetadata['emailIsCanonical'],
+ 'emailIsCorporate' => $emailMetadata['emailIsCorporate'],
+ 'emailIsDisposable' => $emailMetadata['emailIsDisposable'],
+ 'emailIsFree' => $emailMetadata['emailIsFree'],
]);
if (!$isHashed && !empty($password)) {
@@ -256,10 +284,11 @@ Http::post('/v1/users')
->inject('project')
->inject('dbForProject')
->inject('hooks')
- ->action(function (string $userId, ?string $email, ?string $phone, ?string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
+ ->inject('plan')
+ ->action(function (string $userId, ?string $email, ?string $phone, ?string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks, array $plan) {
$plaintext = new Plaintext();
- $user = createUser($plaintext, $userId, $email, $password, $phone, $name, $project, $dbForProject, $hooks);
+ $user = createUser($plaintext, $userId, $email, $password, $phone, $name, $project, $dbForProject, $hooks, $plan);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($user, Response::MODEL_USER);
@@ -292,11 +321,12 @@ Http::post('/v1/users/bcrypt')
->inject('project')
->inject('dbForProject')
->inject('hooks')
- ->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
+ ->inject('plan')
+ ->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks, array $plan) {
$bcrypt = new Bcrypt();
$bcrypt->setCost(8); // Default cost
- $user = createUser($bcrypt, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
+ $user = createUser($bcrypt, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks, $plan);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -330,10 +360,11 @@ Http::post('/v1/users/md5')
->inject('project')
->inject('dbForProject')
->inject('hooks')
- ->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
+ ->inject('plan')
+ ->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks, array $plan) {
$md5 = new MD5();
- $user = createUser($md5, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
+ $user = createUser($md5, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks, $plan);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -367,10 +398,11 @@ Http::post('/v1/users/argon2')
->inject('project')
->inject('dbForProject')
->inject('hooks')
- ->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
+ ->inject('plan')
+ ->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks, array $plan) {
$argon2 = new Argon2();
- $user = createUser($argon2, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
+ $user = createUser($argon2, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks, $plan);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -405,13 +437,14 @@ Http::post('/v1/users/sha')
->inject('project')
->inject('dbForProject')
->inject('hooks')
- ->action(function (string $userId, string $email, string $password, string $passwordVersion, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
+ ->inject('plan')
+ ->action(function (string $userId, string $email, string $password, string $passwordVersion, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks, array $plan) {
$sha = new Sha();
if (!empty($passwordVersion)) {
$sha->setVersion($passwordVersion);
}
- $user = createUser($sha, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
+ $user = createUser($sha, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks, $plan);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -445,10 +478,11 @@ Http::post('/v1/users/phpass')
->inject('project')
->inject('dbForProject')
->inject('hooks')
- ->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
+ ->inject('plan')
+ ->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks, array $plan) {
$phpass = new PHPass();
- $user = createUser($phpass, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
+ $user = createUser($phpass, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks, $plan);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -487,7 +521,8 @@ Http::post('/v1/users/scrypt')
->inject('project')
->inject('dbForProject')
->inject('hooks')
- ->action(function (string $userId, string $email, string $password, string $passwordSalt, int $passwordCpu, int $passwordMemory, int $passwordParallel, int $passwordLength, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
+ ->inject('plan')
+ ->action(function (string $userId, string $email, string $password, string $passwordSalt, int $passwordCpu, int $passwordMemory, int $passwordParallel, int $passwordLength, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks, array $plan) {
$scrypt = new Scrypt();
$scrypt
->setSalt($passwordSalt)
@@ -496,7 +531,7 @@ Http::post('/v1/users/scrypt')
->setParallelCost($passwordParallel)
->setLength($passwordLength);
- $user = createUser($scrypt, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
+ $user = createUser($scrypt, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks, $plan);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -533,14 +568,15 @@ Http::post('/v1/users/scrypt-modified')
->inject('project')
->inject('dbForProject')
->inject('hooks')
- ->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
+ ->inject('plan')
+ ->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks, array $plan) {
$scryptModified = new ScryptModified();
$scryptModified
->setSalt($passwordSalt)
->setSaltSeparator($passwordSaltSeparator)
->setSignerKey($passwordSignerKey);
- $user = createUser($scryptModified, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
+ $user = createUser($scryptModified, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks, $plan);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@@ -820,7 +856,7 @@ Http::get('/v1/users/:userId/targets/:targetId')
Http::get('/v1/users/:userId/sessions')
->desc('List user sessions')
->groups(['api', 'users'])
- ->label('scope', 'users.read')
+ ->label('scope', ['users.read', 'sessions.read'])
->label('sdk', new Method(
namespace: 'users',
group: 'sessions',
@@ -972,6 +1008,8 @@ Http::get('/v1/users/:userId/logs')
'userId' => ID::custom($log['data']['userId']),
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
+ 'mode' => $log['data']['mode'] ?? null,
+ 'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
@@ -1212,6 +1250,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'])
@@ -1429,8 +1508,10 @@ Http::patch('/v1/users/:userId/email')
->param('email', '', new EmailValidator(allowEmpty: true), 'User email.')
->inject('response')
->inject('dbForProject')
+ ->inject('project')
+ ->inject('plan')
->inject('queueForEvents')
- ->action(function (string $userId, string $email, Response $response, Database $dbForProject, Event $queueForEvents) {
+ ->action(function (string $userId, string $email, Response $response, Database $dbForProject, Document $project, array $plan, Event $queueForEvents) {
$user = $dbForProject->getDocument('users', $userId);
@@ -1454,27 +1535,54 @@ Http::patch('/v1/users/:userId/email')
Query::equal('identifier', [$email]),
]);
- if ($target instanceof Document && !$target->isEmpty()) {
+ if (!$target->isEmpty()) {
throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS);
}
}
$oldEmail = $user->getAttribute('email');
+ $emailMetadata = [
+ 'emailCanonical' => null,
+ 'emailIsCanonical' => null,
+ 'emailIsCorporate' => null,
+ 'emailIsDisposable' => null,
+ 'emailIsFree' => null,
+ ];
+
try {
- $emailCanonical = new Email($email);
- } catch (Throwable) {
- $emailCanonical = null;
+ $parsedEmail = new Email($email);
+ $canonical = $parsedEmail->getCanonical();
+ $emailMetadata = [
+ 'emailCanonical' => $canonical,
+ 'emailIsCanonical' => $parsedEmail->get() === $canonical,
+ 'emailIsCorporate' => $parsedEmail->isCorporate(),
+ 'emailIsDisposable' => $parsedEmail->isDisposable(),
+ 'emailIsFree' => $parsedEmail->isFree(),
+ ];
+ } catch (\Throwable) {
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ throw new Exception(Exception::USER_EMAIL_FREE);
}
$user
->setAttribute('email', $email)
->setAttribute('emailVerification', false)
- ->setAttribute('emailCanonical', $emailCanonical?->getCanonical())
- ->setAttribute('emailIsCanonical', $emailCanonical?->isCanonicalSupported())
- ->setAttribute('emailIsCorporate', $emailCanonical?->isCorporate())
- ->setAttribute('emailIsDisposable', $emailCanonical?->isDisposable())
- ->setAttribute('emailIsFree', $emailCanonical?->isFree())
+ ->setAttribute('emailCanonical', $emailMetadata['emailCanonical'])
+ ->setAttribute('emailIsCanonical', $emailMetadata['emailIsCanonical'])
+ ->setAttribute('emailIsCorporate', $emailMetadata['emailIsCorporate'])
+ ->setAttribute('emailIsDisposable', $emailMetadata['emailIsDisposable'])
+ ->setAttribute('emailIsFree', $emailMetadata['emailIsFree'])
;
try {
@@ -1487,9 +1595,6 @@ Http::patch('/v1/users/:userId/email')
'emailIsDisposable' => $user->getAttribute('emailIsDisposable'),
'emailIsFree' => $user->getAttribute('emailIsFree'),
]));
- /**
- * @var Document $oldTarget
- */
$oldTarget = $user->find('identifier', $oldEmail, 'targets');
if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) {
@@ -1573,7 +1678,7 @@ Http::patch('/v1/users/:userId/phone')
Query::equal('identifier', [$number]),
]);
- if ($target instanceof Document && !$target->isEmpty()) {
+ if (!$target->isEmpty()) {
throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS);
}
}
@@ -1583,9 +1688,6 @@ Http::patch('/v1/users/:userId/phone')
'phone' => $phoneValue,
'phoneVerification' => $user->getAttribute('phoneVerification'),
]));
- /**
- * @var Document $oldTarget
- */
$oldTarget = $user->find('identifier', $oldPhone, 'targets');
if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) {
@@ -2144,8 +2246,8 @@ Http::delete('/v1/users/:userId/mfa/authenticators/:type')
->label('event', 'users.[userId].delete.mfa')
->label('scope', 'users.write')
->label('audits.event', 'user.update')
- ->label('audits.resource', 'user/{response.$id}')
- ->label('audits.userId', '{response.$id}')
+ ->label('audits.resource', 'user/{request.userId}')
+ ->label('audits.userId', '{request.userId}')
->label('usage.metric', 'users.{scope}.requests.update')
->label('sdk', [
new Method(
@@ -2212,7 +2314,7 @@ Http::post('/v1/users/:userId/sessions')
->desc('Create session')
->groups(['api', 'users'])
->label('event', 'users.[userId].sessions.[sessionId].create')
- ->label('scope', 'users.write')
+ ->label('scope', ['users.write', 'sessions.write'])
->label('audits.event', 'session.create')
->label('audits.resource', 'user/{request.userId}')
->label('usage.metric', 'sessions.{scope}.requests.create')
@@ -2296,9 +2398,8 @@ Http::post('/v1/users/:userId/sessions')
->setParam('sessionId', $session->getId())
->setPayload($response->output($session, Response::MODEL_SESSION));
- return $response
- ->setStatusCode(Response::STATUS_CODE_CREATED)
- ->dynamic($session, Response::MODEL_SESSION);
+ $response->setStatusCode(Response::STATUS_CODE_CREATED);
+ $response->dynamic($session, Response::MODEL_SESSION);
});
Http::post('/v1/users/:userId/tokens')
@@ -2361,16 +2462,15 @@ Http::post('/v1/users/:userId/tokens')
->setParam('tokenId', $token->getId())
->setPayload($response->output($token, Response::MODEL_TOKEN));
- return $response
- ->setStatusCode(Response::STATUS_CODE_CREATED)
- ->dynamic($token, Response::MODEL_TOKEN);
+ $response->setStatusCode(Response::STATUS_CODE_CREATED);
+ $response->dynamic($token, Response::MODEL_TOKEN);
});
Http::delete('/v1/users/:userId/sessions/:sessionId')
->desc('Delete user session')
->groups(['api', 'users'])
->label('event', 'users.[userId].sessions.[sessionId].delete')
- ->label('scope', 'users.write')
+ ->label('scope', ['users.write', 'sessions.write'])
->label('audits.event', 'session.delete')
->label('audits.resource', 'user/{request.userId}')
->label('sdk', new Method(
@@ -2421,7 +2521,7 @@ Http::delete('/v1/users/:userId/sessions')
->desc('Delete user sessions')
->groups(['api', 'users'])
->label('event', 'users.[userId].sessions.delete')
- ->label('scope', 'users.write')
+ ->label('scope', ['users.write', 'sessions.write'])
->label('audits.event', 'session.delete')
->label('audits.resource', 'user/{user.$id}')
->label('sdk', new Method(
@@ -2617,7 +2717,7 @@ Http::delete('/v1/users/identities/:identityId')
->setParam('identityId', $identity->getId())
->setPayload($response->output($identity, Response::MODEL_IDENTITY));
- return $response->noContent();
+ $response->noContent();
});
Http::post('/v1/users/:userId/jwts')
@@ -2736,6 +2836,7 @@ Http::get('/v1/users/usage')
$format = match ($days['period']) {
'1h' => 'Y-m-d\TH:00:00.000P',
'1d' => 'Y-m-d\T00:00:00.000P',
+ default => throw new \LogicException('Unsupported period: ' . $days['period']),
};
foreach ($metrics as $metric) {
diff --git a/app/controllers/general.php b/app/controllers/general.php
index f77aa3ec52..dbcfa7f754 100644
--- a/app/controllers/general.php
+++ b/app/controllers/general.php
@@ -7,9 +7,9 @@ use Ahc\Jwt\JWTException;
use Appwrite\Auth\Key;
use Appwrite\Bus\Events\ExecutionCompleted;
use Appwrite\Bus\Events\RequestCompleted;
-use Appwrite\Event\Certificate;
use Appwrite\Event\Delete as DeleteEvent;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Certificate;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Network\Cors;
use Appwrite\Platform\Appwrite;
@@ -25,6 +25,11 @@ use Appwrite\Utopia\Request\Filters\V18 as RequestV18;
use Appwrite\Utopia\Request\Filters\V19 as RequestV19;
use Appwrite\Utopia\Request\Filters\V20 as RequestV20;
use Appwrite\Utopia\Request\Filters\V21 as RequestV21;
+use Appwrite\Utopia\Request\Filters\V22 as RequestV22;
+use Appwrite\Utopia\Request\Filters\V23 as RequestV23;
+use Appwrite\Utopia\Request\Filters\V24 as RequestV24;
+use Appwrite\Utopia\Request\Filters\V25 as RequestV25;
+use Appwrite\Utopia\Request\Filters\V26 as RequestV26;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Filters\V16 as ResponseV16;
use Appwrite\Utopia\Response\Filters\V17 as ResponseV17;
@@ -32,7 +37,13 @@ use Appwrite\Utopia\Response\Filters\V18 as ResponseV18;
use Appwrite\Utopia\Response\Filters\V19 as ResponseV19;
use Appwrite\Utopia\Response\Filters\V20 as ResponseV20;
use Appwrite\Utopia\Response\Filters\V21 as ResponseV21;
+use Appwrite\Utopia\Response\Filters\V22 as ResponseV22;
+use Appwrite\Utopia\Response\Filters\V23 as ResponseV23;
+use Appwrite\Utopia\Response\Filters\V24 as ResponseV24;
+use Appwrite\Utopia\Response\Filters\V25 as ResponseV25;
+use Appwrite\Utopia\Response\Filters\V26 as ResponseV26;
use Appwrite\Utopia\View;
+use Executor\Exception\Timeout as ExecutorTimeout;
use Executor\Executor;
use MaxMind\Db\Reader;
use Swoole\Http\Request as SwooleRequest;
@@ -61,13 +72,11 @@ use Utopia\System\System;
use Utopia\Validator;
use Utopia\Validator\Text;
-Config::setParam('domainVerification', false);
-Config::setParam('cookieDomain', 'localhost');
Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE);
function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount)
{
- $host = $request->getHostname() ?? '';
+ $host = $request->getHostname();
if (!empty($previewHostname)) {
$host = $previewHostname;
}
@@ -118,7 +127,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
}
}
- if (!in_array($host, $platformHostnames)) {
+ if (!in_array($host, $platformHostnames) && System::getEnv('_APP_OPTIONS_ROUTER_PROTECTION', 'enabled') === 'enabled') {
throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Router protection does not allow accessing Appwrite over this domain. Please add it as custom domain to your project or disable _APP_OPTIONS_ROUTER_PROTECTION environment variable.', view: $errorView);
}
@@ -166,14 +175,14 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
if ($request->getMethod() !== Request::METHOD_GET) {
throw new AppwriteException(AppwriteException::GENERAL_PROTOCOL_UNSUPPORTED, 'Method unsupported over HTTP. Please use HTTPS instead.', view: $errorView);
}
- return $response->redirect('https://' . $request->getHostname() . $request->getURI());
+ $response->redirect('https://' . $request->getHostname() . $request->getURI());
+ return false;
}
}
/** @var Database $dbForProject */
$dbForProject = $getProjectDB($project);
- /** @var Document $deployment */
if (!empty($rule->getAttribute('deploymentId', ''))) {
$deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $rule->getAttribute('deploymentId')));
} else {
@@ -200,12 +209,6 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
$deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $activeDeploymentId));
}
- if ($deployment->getAttribute('resourceType', '') === 'functions') {
- $type = 'function';
- } elseif ($deployment->getAttribute('resourceType', '') === 'sites') {
- $type = 'site';
- }
-
if ($deployment->isEmpty()) {
$resourceType = $rule->getAttribute('deploymentResourceType', '');
$resourceId = $rule->getAttribute('deploymentResourceId', '');
@@ -215,6 +218,14 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
throw $exception;
}
+ if ($deployment->getAttribute('resourceType', '') === 'functions') {
+ $type = 'function';
+ } elseif ($deployment->getAttribute('resourceType', '') === 'sites') {
+ $type = 'site';
+ } else {
+ throw new AppwriteException(AppwriteException::GENERAL_SERVER_ERROR, 'Unknown deployment resource type', view: $errorView);
+ }
+
$resource = $type === 'function' ?
$authorization->skip(fn () => $dbForProject->getDocument('functions', $deployment->getAttribute('resourceId', ''))) :
$authorization->skip(fn () => $dbForProject->getDocument('sites', $deployment->getAttribute('resourceId', '')));
@@ -244,6 +255,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
if ($isPreview && $requirePreview) {
$cookie = $request->getCookie(COOKIE_NAME_PREVIEW, '');
$authorized = false;
+ $user = new Document();
// Security checks to mark authorized true
if (!empty($cookie)) {
@@ -273,7 +285,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
$membershipExists = false;
$project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
- if (!$project->isEmpty() && isset($user)) {
+ if (!$project->isEmpty() && !$user->isEmpty()) {
$teamId = $project->getAttribute('teamId', '');
$membership = $user->find('teamId', $teamId, 'memberships');
if (!empty($membership)) {
@@ -301,13 +313,13 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
}
}
- $body = $swooleRequest->getContent() ?? '';
+ $body = $swooleRequest->getContent() ?: '';
$method = $swooleRequest->server['request_method'];
$requestHeaders = $request->getHeaders();
if ($resource->isEmpty() || !$resource->getAttribute('enabled')) {
- if ($type === 'functions') {
+ if ($type === 'function') {
throw new AppwriteException(AppwriteException::FUNCTION_NOT_FOUND, view: $errorView);
} else {
throw new AppwriteException(AppwriteException::SITE_NOT_FOUND, view: $errorView);
@@ -329,7 +341,6 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
$runtime = match ($type) {
'function' => $runtimes[$resource->getAttribute('runtime')] ?? null,
'site' => $runtimes[$resource->getAttribute('buildRuntime')] ?? null,
- default => null
};
// Static site enforced runtime
@@ -379,7 +390,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
$executionId = ID::unique();
$headers = \array_merge([], $requestHeaders);
- $headers['x-appwrite-execution-id'] = $executionId ?? '';
+ $headers['x-appwrite-execution-id'] = $executionId;
$headers['x-appwrite-user-id'] = '';
$headers['x-appwrite-country-code'] = '';
$headers['x-appwrite-continent-code'] = '';
@@ -393,7 +404,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
'projectId' => $project->getId(),
'scopes' => $resource->getAttribute('scopes', [])
]);
- $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $jwtKey;
+ $headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $jwtKey;
$headers['x-appwrite-trigger'] = 'http';
$headers['x-appwrite-user-jwt'] = '';
@@ -458,10 +469,10 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
// V2 vars
if ($version === 'v2') {
$vars = \array_merge($vars, [
- 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '',
- 'APPWRITE_FUNCTION_DATA' => $body ?? '',
- 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '',
- 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? ''
+ 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'],
+ 'APPWRITE_FUNCTION_DATA' => $body,
+ 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'],
+ 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt']
]);
}
@@ -529,6 +540,11 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
}
/** Execute function */
+ $executionResponse = [
+ 'headers' => [],
+ 'body' => '',
+ ];
+
try {
$version = match ($type) {
'function' => $resource->getAttribute('version', 'v2'),
@@ -555,7 +571,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) {
@@ -568,26 +584,30 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
'site' => '',
};
- $executionResponse = $executor->createExecution(
- projectId: $project->getId(),
- deploymentId: $deployment->getId(),
- body: \strlen($body) > 0 ? $body : null,
- variables: $vars,
- timeout: $resource->getAttribute('timeout', 30),
- image: $runtime['image'],
- source: $source,
- entrypoint: $entrypoint,
- version: $version,
- path: $path,
- method: $method,
- headers: $headers,
- runtimeEntrypoint: $runtimeEntrypoint,
- cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT,
- memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT,
- logging: $resource->getAttribute('logging', true),
- requestTimeout: 30,
- responseFormat: Executor::RESPONSE_FORMAT_ARRAY_HEADERS
- );
+ try {
+ $executionResponse = $executor->createExecution(
+ projectId: $project->getId(),
+ deploymentId: $deployment->getId(),
+ body: \strlen($body) > 0 ? $body : null,
+ variables: $vars,
+ timeout: $resource->getAttribute('timeout', 30),
+ image: $runtime['image'],
+ source: $source,
+ entrypoint: $entrypoint,
+ version: $version,
+ path: $path,
+ method: $method,
+ headers: $headers,
+ runtimeEntrypoint: $runtimeEntrypoint,
+ cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT,
+ memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT,
+ logging: $resource->getAttribute('logging', true),
+ requestTimeout: 30,
+ responseFormat: Executor::RESPONSE_FORMAT_ARRAY_HEADERS
+ );
+ } catch (ExecutorTimeout $th) {
+ throw new AppwriteException(AppwriteException::FUNCTION_SYNCHRONOUS_TIMEOUT, previous: $th);
+ }
$headerOverrides = [];
@@ -672,9 +692,8 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
if (\is_string($logs) && \strlen($logs) > $maxLogLength) {
$warningMessage = "[WARNING] Logs truncated. The output exceeded {$maxLogLength} characters.\n";
- $warningLength = \strlen($warningMessage);
- $maxContentLength = max(0, $maxLogLength - $warningLength);
- $logs = $warningMessage . ($maxContentLength > 0 ? \substr($logs, -$maxContentLength) : '');
+ $maxContentLength = $maxLogLength - \strlen($warningMessage);
+ $logs = $warningMessage . \substr($logs, -$maxContentLength);
}
// Truncate errors if they exceed the limit
@@ -683,9 +702,8 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
if (\is_string($errors) && \strlen($errors) > $maxErrorLength) {
$warningMessage = "[WARNING] Errors truncated. The output exceeded {$maxErrorLength} characters.\n";
- $warningLength = \strlen($warningMessage);
- $maxContentLength = max(0, $maxErrorLength - $warningLength);
- $errors = $warningMessage . ($maxContentLength > 0 ? \substr($errors, -$maxContentLength) : '');
+ $maxContentLength = $maxErrorLength - \strlen($warningMessage);
+ $errors = $warningMessage . \substr($errors, -$maxContentLength);
}
/** Update execution status */
$status = $executionResponse['statusCode'] >= 500 ? 'failed' : 'completed';
@@ -713,14 +731,12 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
throw $th;
}
} finally {
- if ($type === 'function' || $type === 'site') {
- $bus->dispatch(new ExecutionCompleted(
- execution: $execution->getArrayCopy(),
- project: $project->getArrayCopy(),
- spec: $spec,
- resource: $resource->getArrayCopy(),
- ));
- }
+ $bus->dispatch(new ExecutionCompleted(
+ execution: $execution->getArrayCopy(),
+ project: $project->getArrayCopy(),
+ spec: $spec,
+ resource: $resource->getArrayCopy(),
+ ));
}
$execution->setAttribute('logs', '');
@@ -734,7 +750,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
$execution->setAttribute('responseBody', $executionResponse['body'] ?? '');
$execution->setAttribute('responseHeaders', $headers);
- $body = $execution['responseBody'] ?? '';
+ $body = $execution['responseBody'];
$contentType = 'text/plain';
foreach ($executionResponse['headers'] as $name => $values) {
@@ -748,11 +764,8 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
}
if (\is_array($values)) {
- $count = 0;
foreach ($values as $value) {
- $override = $count === 0;
- $response->addHeader($name, $value, override: $override);
- $count++;
+ $response->addHeader($name, $value);
}
} else {
$response->addHeader($name, $values);
@@ -849,7 +862,7 @@ Http::init()
/*
* Appwrite Router
*/
- $hostname = $request->getHostname() ?? '';
+ $hostname = $request->getHostname();
$platformHostnames = $platform['hostnames'] ?? [];
// Only run Router when external domain
if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) {
@@ -862,12 +875,12 @@ Http::init()
* Request format
*/
$route = $utopia->getRoute();
- Request::setRoute($route);
+ $request->setRoute($route);
if ($route === null) {
- return $response
- ->setStatusCode(404)
- ->send('Not Found');
+ $response->setStatusCode(404);
+ $response->send('Not Found');
+ return;
}
$requestFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', ''));
@@ -891,6 +904,21 @@ Http::init()
if (version_compare($requestFormat, '1.9.0', '<')) {
$request->addFilter(new RequestV21());
}
+ if (version_compare($requestFormat, '1.9.1', '<')) {
+ $request->addFilter(new RequestV22());
+ }
+ if (version_compare($requestFormat, '1.9.2', '<')) {
+ $request->addFilter(new RequestV23());
+ }
+ if (version_compare($requestFormat, '1.9.3', '<')) {
+ $request->addFilter(new RequestV24());
+ }
+ if (version_compare($requestFormat, '1.9.4', '<')) {
+ $request->addFilter(new RequestV25());
+ }
+ if (version_compare($requestFormat, '1.9.5', '<')) {
+ $request->addFilter(new RequestV26());
+ }
}
$localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', ''));
@@ -898,40 +926,16 @@ Http::init()
$locale->setDefault($localeParam);
}
- $origin = \parse_url($request->getOrigin($request->getReferer('')), PHP_URL_HOST);
- $selfDomain = new Domain($request->getHostname());
- $endDomain = new Domain((string)$origin);
- Config::setParam(
- 'domainVerification',
- ($selfDomain->getRegisterable() === $endDomain->getRegisterable()) &&
- $endDomain->getRegisterable() !== ''
- );
-
$localHosts = ['localhost','localhost:'.$request->getPort()];
$migrationHost = System::getEnv('_APP_MIGRATION_HOST');
if (!empty($migrationHost)) {
+ // Treat the migration host like localhost because internal migration and
+ // CI traffic may use it before a public domain is configured.
$localHosts[] = $migrationHost;
$localHosts[] = $migrationHost.':'.$request->getPort();
}
- $isLocalHost = in_array($request->getHostname(), $localHosts);
- $isIpAddress = filter_var($request->getHostname(), FILTER_VALIDATE_IP) !== false;
-
- $isConsoleProject = $project->getAttribute('$id', '') === 'console';
- $isConsoleRootSession = System::getEnv('_APP_CONSOLE_ROOT_SESSION', 'disabled') === 'enabled';
-
- Config::setParam(
- 'cookieDomain',
- $isLocalHost || $isIpAddress
- ? null
- : (
- $isConsoleProject && $isConsoleRootSession
- ? '.' . $selfDomain->getRegisterable()
- : '.' . $request->getHostname()
- )
- );
-
$warnings = [];
/*
@@ -939,6 +943,21 @@ Http::init()
*/
$responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', ''));
if ($responseFormat) {
+ if (version_compare($responseFormat, '1.9.5', '<')) {
+ $response->addFilter(new ResponseV26());
+ }
+ if (version_compare($responseFormat, '1.9.4', '<')) {
+ $response->addFilter(new ResponseV25());
+ }
+ if (version_compare($responseFormat, '1.9.3', '<')) {
+ $response->addFilter(new ResponseV24());
+ }
+ if (version_compare($responseFormat, '1.9.2', '<')) {
+ $response->addFilter(new ResponseV23());
+ }
+ if (version_compare($responseFormat, '1.9.1', '<')) {
+ $response->addFilter(new ResponseV22());
+ }
if (version_compare($responseFormat, '1.9.0', '<')) {
$response->addFilter(new ResponseV21());
}
@@ -973,7 +992,8 @@ Http::init()
throw new AppwriteException(AppwriteException::GENERAL_PROTOCOL_UNSUPPORTED, 'Method unsupported over HTTP. Please use HTTPS instead.');
}
- return $response->redirect('https://' . $request->getHostname() . $request->getURI());
+ $response->redirect('https://' . $request->getHostname() . $request->getURI());
+ return;
}
}
});
@@ -1012,7 +1032,7 @@ Http::init()
return;
}
$route = $request->getRoute();
- if ($route->getLabel('origin', false) === '*') {
+ if ($route?->getLabel('origin', false) === '*') {
return;
}
if (!$originValidator->isValid($origin)) {
@@ -1028,11 +1048,11 @@ Http::init()
->inject('request')
->inject('console')
->inject('dbForPlatform')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->inject('platform')
->inject('authorization')
->inject('certifiedDomains')
- ->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $queueForCertificates, array $platform, Authorization $authorization, Table $certifiedDomains) {
+ ->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $publisherForCertificates, array $platform, Authorization $authorization, Table $certifiedDomains) {
$hostname = $request->getHostname();
$platformHostnames = $platform['hostnames'] ?? [];
@@ -1058,7 +1078,7 @@ Http::init()
}
// 4. Check/create rule (requires DB access)
- $authorization->skip(function () use ($dbForPlatform, $domain, $console, $queueForCertificates, $certifiedDomains) {
+ $authorization->skip(function () use ($dbForPlatform, $domain, $console, $publisherForCertificates, $certifiedDomains) {
try {
// TODO: (@Meldiron) Remove after 1.7.x migration
$isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5';
@@ -1114,10 +1134,11 @@ Http::init()
$dbForPlatform->createDocument('rules', $document);
Console::info('Issuing a TLS certificate for the main domain (' . $domain->get() . ') in a few seconds...');
- $queueForCertificates
- ->setDomain($document)
- ->setSkipRenewCheck(true)
- ->trigger();
+ $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: $console,
+ domain: $document,
+ skipRenewCheck: true,
+ ));
} catch (Duplicate $e) {
Console::info('Certificate already exists');
} finally {
@@ -1199,9 +1220,7 @@ Http::error()
$line = $error->getLine();
$trace = $error->getTrace();
- if (php_sapi_name() === 'cli') {
- Span::error($error);
- }
+ Span::error($error);
switch ($class) {
case Utopia\Http\Exception::class:
@@ -1261,7 +1280,16 @@ Http::error()
* If not a publishable error, track usage stats. Publishable errors are >= 500 or those explicitly marked as publish=true in errors.php
*/
if (!$publish && $project->getId() !== 'console') {
- if (!DBUser::isPrivileged($authorization->getRoles())) {
+ $errorUser = new DBUser();
+ try {
+ $resolvedUser = $utopia->context()->get('user');
+ if ($resolvedUser instanceof DBUser) {
+ $errorUser = $resolvedUser;
+ }
+ } catch (\Throwable) {
+ // User resource may not be available in error context
+ }
+ if (!$errorUser->isPrivileged($authorization->getRoles())) {
$bus->dispatch(new RequestCompleted(
project: $project->getArrayCopy(),
request: $request,
@@ -1273,7 +1301,7 @@ Http::error()
if ($logger && $publish) {
try {
/** @var Utopia\Database\Document $user */
- $user = $utopia->getResource('user');
+ $user = $utopia->context()->get('user');
} catch (\Throwable) {
// All good, user is optional information for logger
}
@@ -1434,6 +1462,7 @@ Http::error()
case 402: // Error allowed publicly
case 403: // Error allowed publicly
case 404: // Error allowed publicly
+ case 405: // Error allowed publicly
case 408: // Error allowed publicly
case 409: // Error allowed publicly
case 412: // Error allowed publicly
@@ -1468,6 +1497,21 @@ Http::error()
'type' => $type,
];
+ // Add CORS headers to error responses so browsers can read the error.
+ // Wrapped in try-catch: if the error itself is a DB failure, resolving
+ // the cors resource (which depends on rule -> DB) would cascade.
+ // Uses override:true to avoid duplicate headers if init() already set them.
+ try {
+ $cors = $utopia->context()->get('cors');
+ foreach ($cors->headers($request->getOrigin()) as $name => $value) {
+ $response
+ ->removeHeader($name)
+ ->addHeader($name, $value);
+ }
+ } catch (Throwable) {
+ // Degrade gracefully - error response without CORS is no worse than before.
+ }
+
$response
->addHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
->addHeader('Expires', '0')
@@ -1489,9 +1533,9 @@ Http::error()
->setParam('development', Http::isDevelopment())
->setParam('projectName', $project->getAttribute('name'))
->setParam('projectURL', $project->getAttribute('url'))
- ->setParam('message', $output['message'] ?? '')
- ->setParam('type', $output['type'] ?? '')
- ->setParam('code', $output['code'] ?? '')
+ ->setParam('message', $output['message'])
+ ->setParam('type', $output['type'])
+ ->setParam('code', $output['code'])
->setParam('trace', $output['trace'] ?? [])
->setParam('exception', $error);
@@ -1606,7 +1650,7 @@ Http::get('/.well-known/acme-challenge/*')
throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND, 'Unknown path');
}
- if (!\substr($absolute, 0, \strlen($base)) === $base) {
+ if (\substr($absolute, 0, \strlen($base)) !== $base) {
throw new AppwriteException(AppwriteException::GENERAL_UNAUTHORIZED_SCOPE, 'Invalid path');
}
@@ -1685,7 +1729,7 @@ Http::get('/_appwrite/authorize')
->inject('previewHostname')
->action(function (Request $request, Response $response, string $previewHostname) {
- $host = $request->getHostname() ?? '';
+ $host = $request->getHostname();
if (!empty($previewHostname)) {
$host = $previewHostname;
}
diff --git a/app/controllers/mock.php b/app/controllers/mock.php
index 712d4b7742..4e92b3482d 100644
--- a/app/controllers/mock.php
+++ b/app/controllers/mock.php
@@ -244,36 +244,38 @@ Http::get('/v1/mock/github/callback')
throw new Exception(Exception::PROJECT_NOT_FOUND, $error);
}
- if (!empty($providerInstallationId)) {
- $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
- $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
- $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
- $owner = $github->getOwnerName($providerInstallationId) ?? '';
-
- $projectInternalId = $project->getSequence();
-
- $teamId = $project->getAttribute('teamId', '');
-
- $installation = new Document([
- '$id' => ID::unique(),
- '$permissions' => [
- Permission::read(Role::team(ID::custom($teamId))),
- Permission::update(Role::team(ID::custom($teamId), 'owner')),
- Permission::update(Role::team(ID::custom($teamId), 'developer')),
- Permission::delete(Role::team(ID::custom($teamId), 'owner')),
- Permission::delete(Role::team(ID::custom($teamId), 'developer')),
- ],
- 'providerInstallationId' => $providerInstallationId,
- 'projectId' => $projectId,
- 'projectInternalId' => $projectInternalId,
- 'provider' => 'github',
- 'organization' => $owner,
- 'personal' => false
- ]);
-
- $installation = $dbForPlatform->createDocument('installations', $installation);
+ if (empty($providerInstallationId)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Missing provider installation ID');
}
+ $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
+ $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
+ $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
+ $owner = $github->getOwnerName($providerInstallationId);
+
+ $projectInternalId = $project->getSequence();
+
+ $teamId = $project->getAttribute('teamId', '');
+
+ $installation = new Document([
+ '$id' => ID::unique(),
+ '$permissions' => [
+ Permission::read(Role::team(ID::custom($teamId))),
+ Permission::update(Role::team(ID::custom($teamId), 'owner')),
+ Permission::update(Role::team(ID::custom($teamId), 'developer')),
+ Permission::delete(Role::team(ID::custom($teamId), 'owner')),
+ Permission::delete(Role::team(ID::custom($teamId), 'developer')),
+ ],
+ 'providerInstallationId' => $providerInstallationId,
+ 'projectId' => $projectId,
+ 'projectInternalId' => $projectInternalId,
+ 'provider' => 'github',
+ 'organization' => $owner,
+ 'personal' => false
+ ]);
+
+ $installation = $dbForPlatform->createDocument('installations', $installation);
+
$response->json([
'installationId' => $installation->getId(),
]);
diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php
index 87cb30cb0f..6f808296b0 100644
--- a/app/controllers/shared/api.php
+++ b/app/controllers/shared/api.php
@@ -3,21 +3,25 @@
use Appwrite\Auth\Key;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\Bus\Events\RequestCompleted;
-use Appwrite\Event\Audit;
-use Appwrite\Event\Build;
+use Appwrite\Event\Context\Audit as AuditContext;
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
-use Appwrite\Event\Func;
-use Appwrite\Event\Mail;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Message\Audit as AuditMessage;
+use Appwrite\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Message\Usage as UsageMessage;
+use Appwrite\Event\Publisher\Audit;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
+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\Platform\Modules\Storage\Config\CacheControl;
+use Appwrite\Platform\Modules\Storage\Config\StorageCacheControl;
use Appwrite\SDK\Method;
+use Appwrite\Usage\Context;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
@@ -35,13 +39,14 @@ use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Authorization\Input;
use Utopia\Database\Validator\Roles;
use Utopia\Http\Http;
+use Utopia\Span\Span;
use Utopia\System\System;
use Utopia\Telemetry\Adapter as Telemetry;
use Utopia\Validator\WhiteList;
-$parseLabel = function (string $label, array $responsePayload, array $requestParams, User $user) {
+$parseLabel = function (string $label, array $responsePayload, array $requestParams, User $user, Document $project) {
preg_match_all('/{(.*?)}/', $label, $matches);
- foreach ($matches[1] ?? [] as $pos => $match) {
+ foreach ($matches[1] as $pos => $match) {
$find = $matches[0][$pos];
$parts = explode('.', $match);
@@ -49,11 +54,12 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar
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");
}
- $namespace = $parts[0] ?? '';
- $replace = $parts[1] ?? '';
+ $namespace = $parts[0];
+ $replace = $parts[1];
$params = match ($namespace) {
- 'user' => (array)$user,
+ 'user' => (array) $user,
+ 'project' => $project->getArrayCopy(),
'request' => $requestParams,
default => $responsePayload,
};
@@ -61,13 +67,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 +81,7 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar
$label = \str_replace($find, $replacement, $label);
}
}
+
return $label;
};
@@ -84,7 +91,7 @@ Http::init()
->inject('request')
->inject('dbForPlatform')
->inject('dbForProject')
- ->inject('queueForAudits')
+ ->inject('auditContext')
->inject('project')
->inject('user')
->inject('session')
@@ -93,8 +100,11 @@ Http::init()
->inject('team')
->inject('apiKey')
->inject('authorization')
- ->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, Audit $queueForAudits, Document $project, Document $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) {
+ ->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, AuditContext $auditContext, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) {
$route = $utopia->getRoute();
+ if ($route === null) {
+ throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND);
+ }
/**
* Handle user authentication and session validation.
@@ -160,7 +170,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,42 +180,42 @@ 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
- if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_DYNAMIC) && $apiKey->getProjectId() === $project->getId()) {
+ // Dynamic supported for backwards compatibility
+ if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_EPHEMERAL || $apiKey->getType() === 'dynamic') && $apiKey->getProjectId() === $project->getId()) {
$authorization->setDefaultStatus(false);
}
$user = new User([
'$id' => '',
'status' => true,
- 'type' => ACTIVITY_TYPE_APP,
+ 'type' => ACTIVITY_TYPE_KEY_PROJECT,
'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(),
'password' => '',
'name' => $apiKey->getName(),
]);
- $queueForAudits->setUser($user);
+ $auditContext->user = $user;
}
// 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 +224,6 @@ Http::init()
}
if (!$dbKey) {
- \var_dump($apiKey);
- \var_dump($request->getHeader('x-appwrite-key', ''));
throw new Exception(Exception::USER_UNAUTHORIZED);
}
@@ -233,7 +241,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,19 +249,25 @@ 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()));
}
}
- $queueForAudits->setUser($user);
+ $userClone = clone $user;
+ $userClone->setAttribute('type', match ($apiKey->getType()) {
+ API_KEY_STANDARD => ACTIVITY_TYPE_KEY_PROJECT,
+ API_KEY_ACCOUNT => ACTIVITY_TYPE_KEY_ACCOUNT,
+ default => ACTIVITY_TYPE_KEY_ORGANIZATION,
+ });
+ $auditContext->user = $userClone;
}
// Apply permission
@@ -285,7 +299,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 +324,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,21 +352,17 @@ Http::init()
$scopes = \array_unique($scopes);
- // Migration-sourced keys are scoped to a single project's console
- // endpoints (e.g. /v1/projects/:projectId/platforms). Verify the
- // URL :projectId matches the token's scopedProjectId.
- if (!empty($apiKey) && $apiKey->getSource() === KEY_SOURCE_MIGRATION) {
- $scopedProjectId = $apiKey->getScopedProjectId();
-
- if (empty($scopedProjectId)) {
- throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE);
- }
-
- $pathValues = $route->getPathValues($request);
-
- if (($pathValues['projectId'] ?? '') !== $scopedProjectId) {
- throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE);
- }
+ // 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);
@@ -365,18 +375,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->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([
@@ -385,12 +395,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')
]));
@@ -403,9 +416,6 @@ Http::init()
}
// Steps 7-9: Access Control - Method, Namespace and Scope Validation
- /**
- * @var ?Method $method
- */
$method = $route->getLabel('sdk', false);
// Take the first method if there's more than one,
@@ -414,26 +424,35 @@ Http::init()
$method = $method[0];
}
- if (!empty($method)) {
- $namespace = $method->getNamespace();
+ if (! empty($method)) {
+ $namespace = \strtolower($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 8b: Check REST protocol status
+ if (
+ array_key_exists('rest', $project->getAttribute('apis', []))
+ && ! $project->getAttribute('apis', [])['rest']
+ && ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles()))
+ ) {
+ throw new AppwriteException(AppwriteException::GENERAL_API_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);
}
@@ -451,13 +470,92 @@ 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);
}
}
});
+Http::init()
+ ->groups(['api'])
+ ->inject('utopia')
+ ->inject('request')
+ ->inject('response')
+ ->inject('project')
+ ->inject('user')
+ ->inject('timelimit')
+ ->inject('devKey')
+ ->inject('authorization')
+ ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, callable $timelimit, Document $devKey, Authorization $authorization) {
+ $response->setUser($user);
+ $request->setUser($user);
+
+ $roles = $authorization->getRoles();
+ $shouldCheckAbuse = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled'
+ && ! $user->isApp($roles)
+ && ! $user->isPrivileged($roles)
+ && $devKey->isEmpty();
+
+ $route = $utopia->getRoute();
+ if ($route === null) {
+ throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND);
+ }
+
+ $abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}');
+ $abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel;
+ $closestLimit = null;
+
+ foreach ($abuseKeyLabel as $abuseKey) {
+ $isRateLimited = false;
+
+ try {
+ $start = $request->getContentRangeStart();
+ $end = $request->getContentRangeEnd();
+ $timeLimit = $timelimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600));
+ $timeLimit
+ ->setParam('{projectId}', $project->getId())
+ ->setParam('{userId}', $user->getId())
+ ->setParam('{userAgent}', $request->getUserAgent(''))
+ ->setParam('{ip}', $request->getIP())
+ ->setParam('{url}', $request->getHostname() . $route->getPath())
+ ->setParam('{method}', $request->getMethod())
+ ->setParam('{chunkId}', (int) ($start / ($end + 1 - $start)));
+
+ foreach ($request->getParams() as $key => $value) {
+ if (! empty($value)) {
+ $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value);
+ }
+ }
+
+ $abuse = new Abuse($timeLimit);
+ $remaining = $timeLimit->remaining();
+ $limit = $timeLimit->limit();
+ $time = $timeLimit->time() + $route->getLabel('abuse-time', 3600);
+
+ if ($limit && ($remaining < $closestLimit || is_null($closestLimit))) {
+ $closestLimit = $remaining;
+ $response
+ ->addHeader('X-RateLimit-Limit', $limit)
+ ->addHeader('X-RateLimit-Remaining', $remaining)
+ ->addHeader('X-RateLimit-Reset', $time);
+ }
+
+ if ($shouldCheckAbuse) {
+ $isRateLimited = $abuse->check();
+ }
+ } catch (\Throwable $th) {
+ \error_log((string) $th);
+
+ continue;
+ }
+
+ if ($isRateLimited) {
+ throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED);
+ }
+ }
+ });
+
Http::init()
->groups(['api'])
->inject('utopia')
@@ -466,27 +564,30 @@ Http::init()
->inject('project')
->inject('user')
->inject('queueForEvents')
- ->inject('queueForMessaging')
- ->inject('queueForAudits')
+ ->inject('auditContext')
->inject('queueForDeletes')
->inject('queueForDatabase')
- ->inject('queueForBuilds')
- ->inject('queueForStatsUsage')
- ->inject('queueForFunctions')
- ->inject('queueForMails')
+ ->inject('usage')
+ ->inject('publisherForFunctions')
->inject('dbForProject')
- ->inject('timelimit')
->inject('resourceToken')
->inject('mode')
->inject('apiKey')
->inject('plan')
- ->inject('devKey')
->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) {
+ ->inject('cacheControlForStorage')
+ ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Context $usage, FunctionPublisher $publisherForFunctions, Database $dbForProject, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Telemetry $telemetry, array $platform, Authorization $authorization, callable $cacheControlForStorage) {
+
+ $response->setUser($user);
+ $request->setUser($user);
$route = $utopia->getRoute();
+ if ($route === null) {
+ throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND);
+ }
+
$path = $route->getMatchedPath();
$databaseType = match (true) {
str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB,
@@ -494,78 +595,6 @@ Http::init()
default => '',
};
- if (
- array_key_exists('rest', $project->getAttribute('apis', []))
- && !$project->getAttribute('apis', [])['rest']
- && !(User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
- ) {
- throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
- }
-
- /*
- * Abuse Check
- */
-
- $abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}');
- $timeLimitArray = [];
-
- $abuseKeyLabel = (!is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel;
-
- foreach ($abuseKeyLabel as $abuseKey) {
- $start = $request->getContentRangeStart();
- $end = $request->getContentRangeEnd();
- $timeLimit = $timelimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600));
- $timeLimit
- ->setParam('{projectId}', $project->getId())
- ->setParam('{userId}', $user->getId())
- ->setParam('{userAgent}', $request->getUserAgent(''))
- ->setParam('{ip}', $request->getIP())
- ->setParam('{url}', $request->getHostname() . $route->getPath())
- ->setParam('{method}', $request->getMethod())
- ->setParam('{chunkId}', (int)($start / ($end + 1 - $start)));
- $timeLimitArray[] = $timeLimit;
- }
-
- $closestLimit = null;
-
- $roles = $authorization->getRoles();
- $isPrivilegedUser = User::isPrivileged($roles);
- $isAppUser = User::isApp($roles);
-
- foreach ($timeLimitArray as $timeLimit) {
- foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys
- if (!empty($value)) {
- $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value);
- }
- }
-
- $abuse = new Abuse($timeLimit);
- $remaining = $timeLimit->remaining();
-
- $limit = $timeLimit->limit();
- $time = $timeLimit->time() + $route->getLabel('abuse-time', 3600);
-
- if ($limit && ($remaining < $closestLimit || is_null($closestLimit))) {
- $closestLimit = $remaining;
- $response
- ->addHeader('X-RateLimit-Limit', $limit)
- ->addHeader('X-RateLimit-Remaining', $remaining)
- ->addHeader('X-RateLimit-Reset', $time);
- }
-
- $enabled = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled';
-
- if (
- $enabled // Abuse is enabled
- && !$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
- ) {
- throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED);
- }
- }
-
/**
* TODO: (@loks0n)
* Avoid mutating the message across file boundaries - it's difficult to reason about at scale.
@@ -578,104 +607,91 @@ Http::init()
->setProject($project)
->setUser($user);
- $queueForAudits
- ->setMode($mode)
- ->setUserAgent($request->getUserAgent(''))
- ->setIP($request->getIP())
- ->setHostname($request->getHostname())
- ->setEvent($route->getLabel('audits.event', ''))
- ->setProject($project);
+ $auditContext->mode = $mode;
+ $auditContext->userAgent = $request->getUserAgent('');
+ $auditContext->ip = $request->getIP();
+ $auditContext->hostname = $request->getHostname();
+ $auditContext->event = $route->getLabel('audits.event', '');
+ $auditContext->project = $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);
+ if (empty($user->getAttribute('type'))) {
+ $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER);
}
+ $auditContext->user = $userClone;
}
/* Auto-set projects */
$queueForDeletes->setProject($project);
$queueForDatabase->setProject($project);
- $queueForMessaging->setProject($project);
- $queueForFunctions->setProject($project);
- $queueForBuilds->setProject($project);
- $queueForMails->setProject($project);
-
- /* Auto-set platforms */
- $queueForFunctions->setPlatform($platform);
- $queueForBuilds->setPlatform($platform);
- $queueForMails->setPlatform($platform);
-
$useCache = $route->getLabel('cache', false);
$storageCacheOperationsCounter = $telemetry->createCounter('storage.cache.operations.load');
if ($useCache) {
$route = $utopia->match($request);
+ $roles = $authorization->getRoles();
+ $isAppUser = $user->isApp($roles);
$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($roles);
$key = $request->cacheIdentifier();
- $cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key));
+ Span::add('storage.cache.key', $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()) {
+ $cacheControl = \sprintf('private, max-age=%d', $timestamp);
$parts = explode('/', $cacheLog->getAttribute('resourceType', ''));
- $type = $parts[0] ?? null;
+ $type = $parts[0];
- 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();
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isToken = ! $resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence();
+ $isPrivilegedUser = $user->isPrivileged($roles);
- 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())) {
+ Span::add('storage.bucket.id', $bucketId);
+ Span::add('storage.file.id', $fileId);
+ // 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());
@@ -684,18 +700,44 @@ Http::init()
])));
}
}
+
+ if ($isImageTransformation) {
+ $cacheControl = $cacheControlForStorage(new StorageCacheControl(
+ source: CacheControl::SOURCE_CACHE,
+ user: $user,
+ maxAge: $timestamp,
+ project: $project,
+ bucket: $bucket,
+ file: $file,
+ resourceToken: $resourceToken,
+ fileSecurity: $fileSecurity,
+ cacheLog: $cacheLog,
+ route: $route,
+ ));
+ }
+ }
+
+ $accessedAt = $cacheLog->getAttribute('accessedAt', '');
+ if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) {
+ $authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), new Document([
+ 'accessedAt' => DateTime::now(),
+ ])));
+ // Refresh the filesystem file's mtime so TTL-based expiry in cache->load() stays valid
+ $cache->save($key, $data);
}
$response
- ->addHeader('Cache-Control', sprintf('private, max-age=%d', $timestamp))
+ ->addHeader('Cache-Control', $cacheControl)
->addHeader('X-Appwrite-Cache', 'hit')
->setContentType($cacheLog->getAttribute('mimeType'));
$storageCacheOperationsCounter->add(1, ['result' => 'hit']);
- if (!$isImageTransformation || !$isDisabled) {
+ if (! $isImageTransformation || ! $isDisabled) {
+ Span::add('storage.cache.hit', true);
$response->send($data);
}
} else {
$storageCacheOperationsCounter->add(1, ['result' => 'miss']);
+ Span::add('storage.cache.hit', false);
$response
->addHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
->addHeader('Pragma', 'no-cache')
@@ -709,12 +751,12 @@ Http::init()
->groups(['session'])
->inject('user')
->inject('request')
- ->action(function (Document $user, Request $request) {
+ ->action(function (User $user, Request $request) {
if (\str_contains($request->getURI(), 'oauth2')) {
return;
}
- if (!$user->isEmpty()) {
+ if (! $user->isEmpty()) {
throw new Exception(Exception::USER_SESSION_ALREADY_EXISTS);
}
});
@@ -733,7 +775,12 @@ Http::shutdown()
->inject('project')
->inject('dbForProject')
->action(function (Http $utopia, Request $request, Response $response, Document $project, Database $dbForProject) {
- $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT;
+ $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? 0;
+
+ if ($sessionLimit === 0) {
+ return;
+ }
+
$session = $response->getPayload();
$userId = $session['userId'] ?? '';
if (empty($userId)) {
@@ -767,13 +814,13 @@ Http::shutdown()
->inject('project')
->inject('user')
->inject('queueForEvents')
- ->inject('queueForAudits')
- ->inject('queueForStatsUsage')
+ ->inject('auditContext')
+ ->inject('publisherForAudits')
+ ->inject('usage')
+ ->inject('publisherForUsage')
->inject('queueForDeletes')
->inject('queueForDatabase')
- ->inject('queueForBuilds')
- ->inject('queueForMessaging')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('queueForRealtime')
->inject('dbForProject')
@@ -781,11 +828,13 @@ 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')
+ ->inject('mode')
+ ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) {
$responsePayload = $response->getPayload();
- if (!empty($queueForEvents->getEvent())) {
+ if (! empty($queueForEvents->getEvent())) {
if (empty($queueForEvents->getPayload())) {
$queueForEvents->setPayload($responsePayload);
}
@@ -807,19 +856,25 @@ 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
- ->from($queueForEvents)
- ->trigger();
+ $publisherForFunctions->enqueue(FunctionMessage::fromEvent(
+ event: $queueForEvents->getEvent(),
+ params: $queueForEvents->getParams(),
+ project: $queueForEvents->getProject(),
+ user: $queueForEvents->getUser(),
+ userId: $queueForEvents->getUserId(),
+ payload: $queueForEvents->getPayload(),
+ platform: $queueForEvents->getPlatform(),
+ ));
break;
}
}
}
// Only trigger webhooks if there are matching webhook events
- if (!empty($webhooksEvents)) {
+ if (! empty($webhooksEvents)) {
foreach ($generatedEvents as $event) {
if (isset($webhooksEvents[$event])) {
$queueForWebhooks
@@ -843,7 +898,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();
@@ -856,10 +911,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);
}
}
@@ -873,19 +928,21 @@ Http::shutdown()
* Audit labels
*/
$pattern = $route->getLabel('audits.resource', null);
- if (!empty($pattern)) {
- $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user);
- if (!empty($resource) && $resource !== $pattern) {
- $queueForAudits->setResource($resource);
+ if (! empty($pattern)) {
+ $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project);
+ if (! empty($resource) && $resource !== $pattern) {
+ $auditContext->resource = $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);
- $queueForAudits->setUser($userClone);
- } elseif ($queueForAudits->getUser() === null || $queueForAudits->getUser()->isEmpty()) {
+ if (empty($user->getAttribute('type'))) {
+ $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER);
+ }
+ $auditContext->user = $userClone;
+ } elseif ($auditContext->user === null || $auditContext->user->isEmpty()) {
/**
* User in the request is empty, and no user was set for auditing previously.
* This indicates:
@@ -903,56 +960,46 @@ Http::shutdown()
'name' => 'Guest',
]);
- $queueForAudits->setUser($user);
+ $auditContext->user = $user;
}
- if (!empty($queueForAudits->getResource()) && !$queueForAudits->getUser()->isEmpty()) {
+ $auditUser = $auditContext->user;
+ if (! empty($auditContext->resource) && ! $auditUser->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)) {
- $queueForAudits->setPayload($responsePayload);
+ if (! empty($pattern)) {
+ $auditContext->payload = $responsePayload;
}
- foreach ($queueForEvents->getParams() as $key => $value) {
- $queueForAudits->setParam($key, $value);
- }
-
- $queueForAudits->trigger();
+ $publisherForAudits->enqueue(AuditMessage::fromContext($auditContext));
}
- if (!empty($queueForDeletes->getType())) {
+ if (! empty($queueForDeletes->getType())) {
$queueForDeletes->trigger();
}
- if (!empty($queueForDatabase->getType())) {
+ if (! empty($queueForDatabase->getType())) {
$queueForDatabase->trigger();
}
- if (!empty($queueForBuilds->getType())) {
- $queueForBuilds->trigger();
- }
-
- if (!empty($queueForMessaging->getType())) {
- $queueForMessaging->trigger();
- }
-
// Cache label
$useCache = $route->getLabel('cache', false);
if ($useCache) {
$resource = $resourceType = null;
$data = $response->getPayload();
- if (!empty($data['payload'])) {
+ $statusCode = $response->getStatusCode();
+ if (! empty($data['payload']) && $statusCode >= 200 && $statusCode < 300) {
$pattern = $route->getLabel('cache.resource', null);
- if (!empty($pattern)) {
- $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user);
+ if (! empty($pattern)) {
+ $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project);
}
$pattern = $route->getLabel('cache.resourceType', null);
- if (!empty($pattern)) {
- $resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user);
+ if (! empty($pattern)) {
+ $resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project);
}
$cache = new Cache(
@@ -961,7 +1008,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()) {
@@ -994,7 +1041,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,
@@ -1002,9 +1049,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/controllers/shared/api/auth.php b/app/controllers/shared/api/auth.php
index 6e1f9f389f..db98d97bf5 100644
--- a/app/controllers/shared/api/auth.php
+++ b/app/controllers/shared/api/auth.php
@@ -36,8 +36,9 @@ Http::init()
->inject('request')
->inject('project')
->inject('geodb')
+ ->inject('user')
->inject('authorization')
- ->action(function (Http $utopia, Request $request, Document $project, Reader $geodb, Authorization $authorization) {
+ ->action(function (Http $utopia, Request $request, Document $project, Reader $geodb, User $user, Authorization $authorization) {
$denylist = System::getEnv('_APP_CONSOLE_COUNTRIES_DENYLIST', '');
if (!empty($denylist && $project->getId() === 'console')) {
$countries = explode(',', $denylist);
@@ -50,8 +51,8 @@ Http::init()
$route = $utopia->match($request);
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
- $isAppUser = User::isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
+ $isAppUser = $user->isApp($authorization->getRoles());
if ($isAppUser || $isPrivilegedUser) { // Skip limits for app and console devs
return;
diff --git a/app/http.php b/app/http.php
index 517a1aa544..6dc415f000 100644
--- a/app/http.php
+++ b/app/http.php
@@ -1,14 +1,13 @@
column('value', Table::TYPE_INT, 1);
$certifiedDomains->create();
-Http::setResource('riskyDomains', fn () => $riskyDomains);
-Http::setResource('certifiedDomains', fn () => $certifiedDomains);
-
-$http = new Server(
- host: "0.0.0.0",
- port: System::getEnv('PORT', 80),
- mode: SWOOLE_PROCESS,
-);
+global $container;
+$container->set('riskyDomains', fn () => $riskyDomains);
+$container->set('certifiedDomains', fn () => $certifiedDomains);
+$container->set('pools', function ($register) {
+ return $register->get('pools');
+}, ['register']);
$payloadSize = 12 * (1024 * 1024); // 12MB - adding slight buffer for headers and other data that might be sent with the payload - update later with valid testing
$totalWorkers = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6));
+$swoole = new Server(
+ host: "0.0.0.0",
+ port: System::getEnv('PORT', 80),
+ settings: [
+ Constant::OPTION_WORKER_NUM => $totalWorkers,
+ Constant::OPTION_DISPATCH_FUNC => dispatch(...),
+ Constant::OPTION_DISPATCH_MODE => SWOOLE_DISPATCH_UIDMOD,
+ Constant::OPTION_HTTP_COMPRESSION => false,
+ Constant::OPTION_PACKAGE_MAX_LENGTH => $payloadSize,
+ Constant::OPTION_OUTPUT_BUFFER_SIZE => $payloadSize,
+ Constant::OPTION_TASK_WORKER_NUM => 1, // required for the task to fetch domains background
+ ],
+ resources: $container,
+);
+
+$http = $swoole->getServer();
+
/**
* Assigns HTTP requests to worker threads by analyzing its payload/content.
*
@@ -68,16 +83,16 @@ $totalWorkers = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intva
* riskier tasks to a dedicated worker subset. Prefers idle workers, with fallback to random selection if necessary.
* doc: https://openswoole.com/docs/modules/swoole-server/configuration#dispatch_func
*
- * @param Server $server Swoole server instance.
+ * @param \Swoole\Http\Server $server Swoole server instance.
* @param int $fd client ID
* @param int $type the type of data and its current state
* @param string|null $data Request content for categorization.
* @global int $totalThreads Total number of workers.
* @return int Chosen worker ID for the request.
*/
-function dispatch(Server $server, int $fd, int $type, $data = null): int
+function dispatch(\Swoole\Http\Server $server, int $fd, int $type, $data = null): int
{
- $resolveWorkerId = function (Server $server, $data = null) {
+ $resolveWorkerId = function (\Swoole\Http\Server $server, $data = null) {
global $totalWorkers, $riskyDomains;
// If data is not set we can send request to any worker
@@ -103,7 +118,7 @@ function dispatch(Server $server, int $fd, int $type, $data = null): int
$lines = explode("\n", $data, 3);
$request = $lines[0];
if (count($lines) > 1) {
- $domain = trim(explode('Host: ', $lines[1])[1]);
+ $domain = trim(explode('Host: ', $lines[1])[1] ?? '');
}
// Sync executions are considered risky
@@ -160,18 +175,6 @@ function dispatch(Server $server, int $fd, int $type, $data = null): int
return $workerId;
}
-
-$http
- ->set([
- Constant::OPTION_WORKER_NUM => $totalWorkers,
- Constant::OPTION_DISPATCH_FUNC => dispatch(...),
- Constant::OPTION_DISPATCH_MODE => SWOOLE_DISPATCH_UIDMOD,
- Constant::OPTION_HTTP_COMPRESSION => false,
- Constant::OPTION_PACKAGE_MAX_LENGTH => $payloadSize,
- Constant::OPTION_OUTPUT_BUFFER_SIZE => $payloadSize,
- Constant::OPTION_TASK_WORKER_NUM => 1, // required for the task to fetch domains background
- ]);
-
$http->on(Constant::EVENT_WORKER_START, function ($server, $workerId) {
});
@@ -188,16 +191,12 @@ $http->on(Constant::EVENT_AFTER_RELOAD, function ($server) {
Console::success('Reload completed...');
});
-Http::setResource('bus', function ($register, $utopia) {
- return $register->get('bus')->setResolver(fn (string $name) => $utopia->getResource($name));
-}, ['register', 'utopia']);
+$container->set('bus', fn ($register) => $register->get('bus')->setResolver(fn (string $name) => $swoole->context()->get($name)), ['register']);
include __DIR__ . '/controllers/general.php';
-function createDatabase(Http $app, string $resourceKey, string $dbName, array $collections, mixed $pools, ?callable $extraSetup = null): void
+function createDatabase(Container $resources, string $resourceKey, string $dbName, array $collections, mixed $pools, ?callable $extraSetup = null): void
{
- $max = 15;
- $sleep = 2;
$max = 15;
$sleep = 2;
$attempts = 0;
@@ -205,7 +204,7 @@ function createDatabase(Http $app, string $resourceKey, string $dbName, array $c
while (true) {
try {
$attempts++;
- $resource = $app->getResource($resourceKey);
+ $resource = $resources->get($resourceKey);
/* @var $database Database */
$database = is_callable($resource) ? $resource() : $resource;
break; // exit loop on success
@@ -288,23 +287,21 @@ function createDatabase(Http $app, string $resourceKey, string $dbName, array $c
Span::current()?->finish();
}
-$http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $totalWorkers, $register) {
- $app = new Http('UTC');
+$http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorkers, $container) {
+ /** @var \Utopia\Pools\Group $pools */
+ $pools = $container->get('pools');
- go(function () use ($register, $app) {
- $pools = $register->get('pools');
- /** @var Group $pools */
- Http::setResource('pools', fn () => $pools);
+ go(function () use ($container, $pools) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
// create logs database first, `getLogsDB` is a callable.
- createDatabase($app, 'getLogsDB', 'logs', $collections['logs'], $pools);
+ createDatabase($container, 'getLogsDB', 'logs', $collections['logs'], $pools);
// create appwrite database, `dbForPlatform` is a direct access call.
- createDatabase($app, 'dbForPlatform', 'appwrite', $collections['console'], $pools, function (Database $dbForPlatform) use ($collections, $app) {
- $authorization = $app->getResource('authorization');
+ createDatabase($container, 'dbForPlatform', 'appwrite', $collections['console'], $pools, function (Database $dbForPlatform) use ($collections, $container) {
+ $authorization = $container->get('authorization');
if ($dbForPlatform->getCollection(AuditAdapterSQL::COLLECTION)->isEmpty()) {
$adapter = new AdapterDatabase($dbForPlatform);
@@ -413,27 +410,19 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $tot
$projectCollections = $collections['projects'];
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
- $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', ''));
- $sharedTablesV2 = \array_diff($sharedTables, $sharedTablesV1);
-
$documentsSharedTables = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', ''));
- $documentsSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', ''));
- $documentsSharedTablesV2 = \array_diff($documentsSharedTables, $documentsSharedTablesV1);
-
$vectorSharedTables = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''));
- $vectorSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', ''));
- $vectorSharedTablesV2 = \array_diff($vectorSharedTables, $vectorSharedTablesV1);
- $cache = $app->getResource('cache');
+ $cache = $container->get('cache');
- // All shared tables V2 pools that need project metadata collections
- $sharedTablesV2All = \array_values(\array_unique(\array_filter([
- ...$sharedTablesV2,
- ...$documentsSharedTablesV2,
- ...$vectorSharedTablesV2,
+ // All shared tables pools that need project metadata collections
+ $allSharedTables = \array_values(\array_unique(\array_filter([
+ ...$sharedTables,
+ ...$documentsSharedTables,
+ ...$vectorSharedTables,
])));
- foreach ($sharedTablesV2All as $hostname) {
+ foreach ($allSharedTables as $hostname) {
Span::init('database.setup');
Span::add('database.hostname', $hostname);
@@ -510,14 +499,11 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $tot
});
});
-$http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, SwooleResponse $swooleResponse) use ($register, $files) {
+$swoole->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files, $swoole, $setRequestContext) {
Span::init('http.request');
- Http::setResource('swooleRequest', fn () => $swooleRequest);
- Http::setResource('swooleResponse', fn () => $swooleResponse);
-
- $request = new Request($swooleRequest);
- $response = new Response($swooleResponse);
+ $request = new Request($utopiaRequest->getSwooleRequest());
+ $response = new Response($utopiaResponse->getSwooleResponse());
Span::add('http.method', $request->getMethod());
@@ -533,15 +519,18 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool
return;
}
- $app = new Http('UTC');
+ $app = new Http($swoole, 'UTC');
+ $app->context()->set('request', fn () => $request);
+ $app->context()->set('response', fn () => $response);
+ $app->context()->set('utopia', fn () => $app);
+
+ $setRequestContext($app->context());
+
$app->setCompression(System::getEnv('_APP_COMPRESSION_ENABLED', 'enabled') === 'enabled');
$app->setCompressionMinSize(intval(System::getEnv('_APP_COMPRESSION_MIN_SIZE_BYTES', '1024'))); // 1KB
- $pools = $register->get('pools');
- Http::setResource('pools', fn () => $pools);
-
try {
- $authorization = $app->getResource('authorization');
+ $authorization = $app->context()->get('authorization');
$request->setAuthorization($authorization);
$response->setAuthorization($authorization);
@@ -557,18 +546,18 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
- $logger = $app->getResource("logger");
+ $logger = $app->context()->get("logger");
if ($logger) {
try {
/** @var Utopia\Database\Document $user */
- $user = $app->getResource('user');
+ $user = $app->context()->get('user');
} catch (\Throwable $_th) {
// All good, user is optional information for logger
}
$route = $app->getRoute();
- $log = $app->getResource("log");
+ $log = $app->context()->get("log");
if (isset($user) && !$user->isEmpty()) {
$log->setUser(new User($user->getId()));
@@ -623,6 +612,7 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool
}
}
+ $swooleResponse = $utopiaResponse->getSwooleResponse();
$swooleResponse->setStatusCode(500);
$output = ((Http::isDevelopment())) ? [
@@ -646,19 +636,16 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool
});
// Fetch domains every `DOMAIN_SYNC_TIMER` seconds and update in the memory
-$http->on(Constant::EVENT_TASK, function () use ($register) {
+$http->on(Constant::EVENT_TASK, function () use ($container) {
$lastSyncUpdate = null;
- $pools = $register->get('pools');
- Http::setResource('pools', fn () => $pools);
- $app = new Http('UTC');
/** @var Utopia\Database\Database $dbForPlatform */
- $dbForPlatform = $app->getResource('dbForPlatform');
+ $dbForPlatform = $container->get('dbForPlatform');
- /** @var Table $riskyDomains */
- $riskyDomains = $app->getResource('riskyDomains');
+ /** @var \Swoole\Table $riskyDomains */
+ $riskyDomains = $container->get('riskyDomains');
- Timer::tick(DOMAIN_SYNC_TIMER * 1000, function () use ($dbForPlatform, $riskyDomains, &$lastSyncUpdate, $app) {
+ Timer::tick(DOMAIN_SYNC_TIMER * 1000, function () use ($dbForPlatform, $riskyDomains, &$lastSyncUpdate, $container) {
try {
$time = DateTime::now();
$limit = 1000;
@@ -675,7 +662,7 @@ $http->on(Constant::EVENT_TASK, function () use ($register) {
}
$results = [];
try {
- $authorization = $app->getResource('authorization');
+ $authorization = $container->get('authorization');
$results = $authorization->skip(fn () => $dbForPlatform->find('rules', $queries));
} catch (Throwable $th) {
Console::error('rules ' . $th->getMessage());
@@ -725,4 +712,4 @@ $http->on(Constant::EVENT_TASK, function () use ($register) {
});
});
-$http->start();
+$swoole->start();
diff --git a/app/init/configs.php b/app/init/configs.php
index cdca9a7632..360a7abc34 100644
--- a/app/init/configs.php
+++ b/app/init/configs.php
@@ -12,7 +12,7 @@ Config::load('runtimes-v2', __DIR__ . '/../config/runtimes-v2.php', $configAdapt
Config::load('template-runtimes', __DIR__ . '/../config/template-runtimes.php', $configAdapter);
Config::load('events', __DIR__ . '/../config/events.php', $configAdapter);
Config::load('auth', __DIR__ . '/../config/auth.php', $configAdapter);
-Config::load('apis', __DIR__ . '/../config/apis.php', $configAdapter); // List of APIs
+Config::load('protocols', __DIR__ . '/../config/protocols.php', $configAdapter);
Config::load('errors', __DIR__ . '/../config/errors.php', $configAdapter);
Config::load('oAuthProviders', __DIR__ . '/../config/oAuthProviders.php', $configAdapter);
Config::load('sdks', __DIR__ . '/../config/sdks.php', $configAdapter);
@@ -25,7 +25,6 @@ Config::load('roles', __DIR__ . '/../config/roles.php', $configAdapter); // Use
Config::load('projectScopes', __DIR__ . '/../config/scopes/project.php', $configAdapter);
Config::load('organizationScopes', __DIR__ . '/../config/scopes/organization.php', $configAdapter);
Config::load('accountScopes', __DIR__ . '/../config/scopes/account.php', $configAdapter);
-Config::load('consoleProjectScopes', __DIR__ . '/../config/scopes/consoleProject.php', $configAdapter);
Config::load('services', __DIR__ . '/../config/services.php', $configAdapter); // List of services
Config::load('variables', __DIR__ . '/../config/variables.php', $configAdapter); // List of env variables
Config::load('regions', __DIR__ . '/../config/regions.php', $configAdapter); // List of available regions
diff --git a/app/init/constants.php b/app/init/constants.php
index 23f3959f5d..abbe8a535e 100644
--- a/app/init/constants.php
+++ b/app/init/constants.php
@@ -1,6 +1,13 @@
value, // legacy databases.createIndex
+ InsightType::TABLES_DB_INDEX->value, // tablesDB.createIndex
+ InsightType::DOCUMENTS_DB_INDEX->value, // documentsDB.createIndex
+ InsightType::VECTORS_DB_INDEX->value, // vectorsDB.createIndex
+ InsightType::DATABASE_PERFORMANCE->value,
+ InsightType::SITE_PERFORMANCE->value,
+ InsightType::SITE_ACCESSIBILITY->value,
+ InsightType::SITE_SEO->value,
+ InsightType::FUNCTION_PERFORMANCE->value,
+];
+
+// Public API services (SDK namespaces) that an insight CTA's `service` can reference.
+// Analyzers must pick the one matching the engine the resource lives in.
+const ADVISOR_CTA_SERVICES = [
+ InsightCTAService::DATABASES->value, // legacy
+ InsightCTAService::TABLES_DB->value,
+ InsightCTAService::DOCUMENTS_DB->value,
+ InsightCTAService::VECTORS_DB->value,
+];
+
+// Public API method names that an insight CTA's `method` can reference for index suggestions.
+const ADVISOR_CTA_METHODS = [
+ InsightCTAMethod::CREATE_INDEX->value,
+];
+
+// Insight severities
+const ADVISOR_SEVERITIES = [
+ InsightSeverity::INFO->value,
+ InsightSeverity::WARNING->value,
+ InsightSeverity::CRITICAL->value,
+];
+
+// Insight statuses
+const ADVISOR_STATUSES = [
+ InsightStatus::ACTIVE->value,
+ InsightStatus::DISMISSED->value,
+];
+
+// Report types
+const ADVISOR_REPORT_TYPES = [
+ ReportType::LIGHTHOUSE->value,
+ ReportType::AUDIT->value,
+ ReportType::DATABASE_ANALYZER->value,
+];
// Resource types for Tokens
const TOKENS_RESOURCE_TYPE_FILES = 'files';
diff --git a/app/init/database/filters.php b/app/init/database/filters.php
index c2ba091529..e171805c47 100644
--- a/app/init/database/filters.php
+++ b/app/init/database/filters.php
@@ -1,5 +1,6 @@
getAuthorization()->skip(fn () => $database
+ $platforms = $database->getAuthorization()->skip(fn () => $database
->find('platforms', [
Query::equal('projectInternalId', [$document->getSequence()]),
Query::limit(APP_LIMIT_SUBQUERY),
]));
+
+ foreach ($platforms as $platform) {
+ $platform->setAttribute('type', Platform::mapDeprecatedType($platform->getAttribute('type')));
+ }
+
+ return $platforms;
}
);
@@ -468,3 +475,17 @@ Database::addFilter(
]));
}
);
+
+Database::addFilter(
+ 'subQueryReportInsights',
+ function (mixed $value) {
+ return;
+ },
+ function (mixed $value, Document $document, Database $database) {
+ return $database->getAuthorization()->skip(fn () => $database->find('insights', [
+ Query::equal('projectInternalId', [$document->getAttribute('projectInternalId')]),
+ Query::equal('reportInternalId', [$document->getSequence()]),
+ Query::limit(APP_LIMIT_SUBQUERY),
+ ]));
+ }
+);
diff --git a/app/init/database/formats.php b/app/init/database/formats.php
index 6c73877576..9ecf07716a 100644
--- a/app/init/database/formats.php
+++ b/app/init/database/formats.php
@@ -1,9 +1,9 @@
getHeader('x-appwrite-project', '');
+
+ if (!empty($projectId)) {
+ return $projectId;
+ }
+
+ $projectId = $request->getParam('project', '');
+
+ return \is_string($projectId) ? $projectId : '';
+ };
+
+ $getMode = static function (Request $request, Document $project) use ($getProjectId): string {
+ $mode = $request->getParam('mode', $request->getHeader('x-appwrite-mode', APP_MODE_DEFAULT));
+ $projectId = $getProjectId($request);
+
+ if (!empty($projectId) && $project->getId() !== $projectId) {
+ $mode = APP_MODE_ADMIN;
+ }
+
+ return $mode;
+ };
+
+ $getDbForPlatform = static function (Authorization $authorization) {
+ $database = getConsoleDB();
+ $database->setAuthorization($authorization);
+
+ return $database;
+ };
+
+ $getDbForProject = static function (Document $project, Authorization $authorization) use ($getDbForPlatform) {
+ if ($project->isEmpty() || $project->getId() === 'console') {
+ return $getDbForPlatform($authorization);
+ }
+
+ $database = getProjectDB($project);
+ $database->setAuthorization($authorization);
+
+ return $database;
+ };
+
+ $findRule = static function (Request $request, Document $project, Authorization $authorization) use ($getDbForPlatform): Document {
+ $domain = \parse_url($request->getOrigin(), PHP_URL_HOST);
+
+ if (empty($domain)) {
+ $domain = \parse_url($request->getReferer(), PHP_URL_HOST);
+ }
+
+ if (empty($domain)) {
+ return new Document();
+ }
+
+ $dbForPlatform = $getDbForPlatform($authorization);
+ $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5';
+
+ $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) {
+ if ($isMd5) {
+ return $dbForPlatform->getDocument('rules', md5($domain));
+ }
+
+ return $dbForPlatform->findOne('rules', [
+ Query::equal('domain', [$domain]),
+ ]);
+ });
+
+ $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence();
+
+ if (!$permitsCurrentProject && !$rule->isEmpty() && !empty($rule->getAttribute('projectId', ''))) {
+ $trustedProjects = [];
+ foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) {
+ if (empty($trustedProject)) {
+ continue;
+ }
+
+ $trustedProjects[] = $trustedProject;
+ }
+
+ if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects, true)) {
+ $permitsCurrentProject = true;
+ }
+ }
+
+ if (!$permitsCurrentProject) {
+ return new Document();
+ }
+
+ return $rule;
+ };
+
+ $findDevKey = static function (Request $request, Document $project, array $servers, Authorization $authorization) use ($getDbForPlatform): Document {
+ $devKey = $request->getHeader('x-appwrite-dev-key', $request->getParam('devKey', ''));
+ $key = $project->find('secret', $devKey, 'devKeys');
+
+ if (!$key) {
+ return new Document([]);
+ }
+
+ $expire = $key->getAttribute('expire');
+ if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
+ return new Document([]);
+ }
+
+ $dbForPlatform = $getDbForPlatform($authorization);
+ $accessedAt = $key->getAttribute('accessedAt', 0);
+
+ if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_KEY_ACCESS)) > $accessedAt) {
+ $key->setAttribute('accessedAt', DatabaseDateTime::now());
+ $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([
+ 'accessedAt' => $key->getAttribute('accessedAt'),
+ ])));
+ $dbForPlatform->purgeCachedDocument('projects', $project->getId());
+ }
+
+ $sdkValidator = new WhiteList($servers, true);
+ $sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN'));
+
+ if ($sdk !== 'unknown' && $sdkValidator->isValid($sdk)) {
+ $sdks = $key->getAttribute('sdks', []);
+
+ if (!\in_array($sdk, $sdks, true)) {
+ $sdks[] = $sdk;
+ $key->setAttribute('sdks', $sdks);
+ $key->setAttribute('accessedAt', DatabaseDateTime::now());
+
+ $key = $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([
+ 'sdks' => $key->getAttribute('sdks'),
+ 'accessedAt' => $key->getAttribute('accessedAt'),
+ ])));
+ $dbForPlatform->purgeCachedDocument('projects', $project->getId());
+ }
+ }
+
+ return $key;
+ };
+
+ $container->set('authorization', function () {
+ return new Authorization();
+ }, []);
+
+ $container->set('project', function (Request $request, Document $console, Authorization $authorization) use ($getProjectId, $getDbForPlatform) {
+ $projectId = $getProjectId($request);
+
+ if (empty($projectId) || $projectId === 'console') {
+ return $console;
+ }
+
+ $dbForPlatform = $getDbForPlatform($authorization);
+
+ return $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
+ }, ['request', 'console', 'authorization']);
+
+ $container->set('originValidator', function (array $platform, Request $request, Document $project, array $servers, Authorization $authorization) use ($findDevKey, $findRule) {
+ $devKey = $findDevKey($request, $project, $servers, $authorization);
+
+ if (!$devKey->isEmpty()) {
+ return new URL();
+ }
+
+ $allowedHostnames = [...($platform['hostnames'] ?? [])];
+ if (!$project->isEmpty() && $project->getId() !== 'console') {
+ $allowedHostnames = [...$allowedHostnames, ...Platform::getHostnames($project->getAttribute('platforms', []))];
+ }
+
+ $rule = $findRule($request, $project, $authorization);
+ if (!$rule->isEmpty() && !empty($rule->getAttribute('domain', ''))) {
+ $allowedHostnames[] = $rule->getAttribute('domain', '');
+ }
+
+ $originHostname = \parse_url($request->getOrigin(), PHP_URL_HOST);
+ $refererHostname = \parse_url($request->getReferer(), PHP_URL_HOST);
+ $hostname = $originHostname ?: $refererHostname;
+
+ if ($request->getMethod() === 'OPTIONS' && !empty($hostname)) {
+ $allowedHostnames[] = $hostname;
+ }
+
+ $allowedSchemes = [...($platform['schemas'] ?? [])];
+ if (!$project->isEmpty() && $project->getId() !== 'console') {
+ $allowedSchemes[] = 'exp';
+ $allowedSchemes[] = 'appwrite-callback-' . $project->getId();
+ $allowedSchemes = [...$allowedSchemes, ...Platform::getSchemes($project->getAttribute('platforms', []))];
+ }
+
+ return new Origin(\array_unique($allowedHostnames), \array_unique($allowedSchemes));
+ }, ['platform', 'request', 'project', 'servers', 'authorization']);
+
+ $container->set('user', function (Request $request, Document $project, Document $console, Authorization $authorization) use ($getMode, $getDbForPlatform, $getDbForProject) {
+ $mode = $getMode($request, $project);
+ $store = new Store();
+ $proofForToken = new Token();
+ $proofForToken->setHash(new Sha());
+
+ $authorization->setDefaultStatus(true);
+
+ $dbForPlatform = $getDbForPlatform($authorization);
+ $dbForProject = $getDbForProject($project, $authorization);
+
+ $store->setKey('a_session_' . $project->getId());
+ if ($mode === APP_MODE_ADMIN) {
+ $store->setKey('a_session_' . $console->getId());
+ }
+
+ $store->decode(
+ $request->getCookie(
+ $store->getKey(),
+ $request->getCookie($store->getKey() . '_legacy', '')
+ )
+ );
+
+ if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
+ $sessionHeader = $request->getHeader('x-appwrite-session', '');
+
+ if (!empty($sessionHeader)) {
+ $store->decode($sessionHeader);
+ }
+ }
+
+ if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
+ $fallback = \json_decode($request->getHeader('x-fallback-cookies', ''), true);
+ $store->decode((\is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : '');
+ }
+
+ $user = null;
+ if ($mode === APP_MODE_ADMIN) {
+ /** @var User $user */
+ $user = $dbForPlatform->getDocument('users', $store->getProperty('id', ''));
+ } else {
+ if ($project->isEmpty()) {
+ $user = new User([]);
+ } elseif (!empty($store->getProperty('id', ''))) {
+ if ($project->getId() === 'console') {
+ /** @var User $user */
+ $user = $dbForPlatform->getDocument('users', $store->getProperty('id', ''));
+ } else {
+ /** @var User $user */
+ $user = $dbForProject->getDocument('users', $store->getProperty('id', ''));
+ }
+ }
+ }
+
+ if (
+ !$user
+ || $user->isEmpty()
+ || !$user->sessionVerify($store->getProperty('secret', ''), $proofForToken)
+ ) {
+ $user = new User([]);
+ }
+
+ $authJWT = $request->getHeader('x-appwrite-jwt', '');
+ if (!empty($authJWT) && !$project->isEmpty()) {
+ if (!$user->isEmpty()) {
+ throw new Exception(Exception::USER_JWT_AND_COOKIE_SET);
+ }
+
+ $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0);
+
+ try {
+ $payload = $jwt->decode($authJWT);
+ } catch (JWTException $error) {
+ throw new Exception(Exception::USER_JWT_INVALID, 'Failed to verify JWT. ' . $error->getMessage());
+ }
+
+ $jwtUserId = $payload['userId'] ?? '';
+ if (!empty($jwtUserId)) {
+ if ($mode === APP_MODE_ADMIN) {
+ $user = $dbForPlatform->getDocument('users', $jwtUserId);
+ } else {
+ $user = $dbForProject->getDocument('users', $jwtUserId);
+ }
+ }
+
+ $jwtSessionId = $payload['sessionId'] ?? '';
+ if (!empty($jwtSessionId) && empty($user->find('$id', $jwtSessionId, 'sessions'))) {
+ $user = new User([]);
+ }
+ }
+
+ $accountKey = $request->getHeader('x-appwrite-key', '');
+ $accountKeyUserId = $request->getHeader('x-appwrite-user', '');
+
+ if (!empty($accountKeyUserId) && !empty($accountKey)) {
+ if (!$user->isEmpty()) {
+ throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET);
+ }
+
+ $accountKeyUser = $authorization->skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId));
+ if (!$accountKeyUser->isEmpty()) {
+ $key = $accountKeyUser->find(
+ key: 'secret',
+ find: $accountKey,
+ subject: 'keys'
+ );
+
+ if (!empty($key)) {
+ $expire = $key->getAttribute('expire');
+ if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
+ throw new Exception(Exception::ACCOUNT_KEY_EXPIRED);
+ }
+
+ $user = $accountKeyUser;
+ }
+ }
+ }
+
+ // Query params mirror the header fallback pattern used by ?project= and ?devKey=,
+ // allowing Console to embed impersonation in direct file/image URLs where headers cannot be set.
+ $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', ''));
+ $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', (string)$request->getParam('impersonateEmail', ''));
+ $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', (string)$request->getParam('impersonatePhone', ''));
+
+ if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) {
+ $userDb = ($mode === APP_MODE_ADMIN || $project->getId() === 'console') ? $dbForPlatform : $dbForProject;
+ $targetUser = null;
+
+ if (!empty($impersonateUserId)) {
+ $targetUser = $authorization->skip(fn () => $userDb->getDocument('users', $impersonateUserId));
+ } elseif (!empty($impersonateEmail)) {
+ $targetUser = $authorization->skip(fn () => $userDb->findOne('users', [
+ Query::equal('email', [\strtolower($impersonateEmail)]),
+ ]));
+ } elseif (!empty($impersonatePhone)) {
+ $targetUser = $authorization->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));
+ }
+ }
+
+ $dbForPlatform->setMetadata('user', $user->getId());
+ $dbForProject->setMetadata('user', $user->getId());
+
+ return $user;
+ }, ['request', 'project', 'console', 'authorization']);
+};
diff --git a/app/init/registers.php b/app/init/registers.php
index 7c2f822fdd..54c0053a33 100644
--- a/app/init/registers.php
+++ b/app/init/registers.php
@@ -6,7 +6,6 @@ use Appwrite\Hooks\Hooks;
use Appwrite\PubSub\Adapter\Redis as PubSub;
use Appwrite\URL\URL as AppwriteURL;
use MaxMind\Db\Reader;
-use PHPMailer\PHPMailer\PHPMailer;
use Swoole\Database\PDOProxy;
use Utopia\Cache\Adapter\Redis as RedisCache;
use Utopia\Config\Config;
@@ -25,6 +24,7 @@ use Utopia\Logger\Adapter\LogOwl;
use Utopia\Logger\Adapter\Raygun;
use Utopia\Logger\Adapter\Sentry;
use Utopia\Logger\Logger;
+use Utopia\Messaging\Adapter\Email\SMTP;
use Utopia\Mongo\Client as MongoClient;
use Utopia\Pools\Adapter\Stack as StackPool;
use Utopia\Pools\Adapter\Swoole as SwoolePool;
@@ -56,7 +56,7 @@ $register->set('logger', function () {
}
try {
- $loggingProvider = new DSN($providerConfig ?? '');
+ $loggingProvider = new DSN($providerConfig);
$providerName = $loggingProvider->getScheme();
$providerConfig = match ($providerName) {
@@ -71,12 +71,12 @@ $register->set('logger', function () {
$providerConfig = match ($providerName) {
'sentry' => [ 'key' => $configChunks[0], 'projectId' => $configChunks[1] ?? '', 'host' => '',],
- 'logowl' => ['ticket' => $configChunks[0] ?? '', 'host' => ''],
+ 'logowl' => ['ticket' => $configChunks[0], 'host' => ''],
default => ['key' => $providerConfig],
};
}
- if (empty($providerName) || empty($providerConfig)) {
+ if (empty($providerName)) {
return;
}
@@ -121,7 +121,7 @@ $register->set('realtimeLogger', function () {
default => ['key' => $loggingProvider->getHost()],
};
- if (empty($providerName) || empty($providerConfig)) {
+ if (empty($providerName)) {
return;
}
@@ -242,29 +242,18 @@ $register->set('pools', function () {
],
];
- $maxConnections = System::getEnv('_APP_CONNECTIONS_MAX', 151);
- $instanceConnections = $maxConnections / System::getEnv('_APP_POOL_CLIENTS', 14);
+ $maxConnections = (int) System::getEnv('_APP_CONNECTIONS_MAX', 151);
+ $instanceConnections = $maxConnections / (int) System::getEnv('_APP_POOL_CLIENTS', 14);
- $multiprocessing = System::getEnv('_APP_SERVER_MULTIPROCESS', 'disabled') === 'enabled';
-
- if ($multiprocessing) {
- $workerCount = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6));
- } else {
- $workerCount = 1;
- }
-
- if ($workerCount > $instanceConnections) {
- throw new \Exception('Pool size is too small. Increase the number of allowed database connections or decrease the number of workers.', 500);
- }
-
- $poolSize = (int)($instanceConnections / $workerCount);
+ $workerCount = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6));
+ $poolSize = max(1, (int)($instanceConnections / $workerCount));
foreach ($connections as $key => $connection) {
- $type = $connection['type'] ?? '';
- $multiple = $connection['multiple'] ?? false;
- $schemes = $connection['schemes'] ?? [];
+ $type = $connection['type'];
+ $multiple = $connection['multiple'];
+ $schemes = $connection['schemes'];
$config = [];
- $dsns = explode(',', $connection['dsns'] ?? '');
+ $dsns = explode(',', $connection['dsns']);
foreach ($dsns as &$dsn) {
$dsn = explode('=', $dsn);
$name = ($multiple) ? $key . '_' . $dsn[0] : $key;
@@ -308,7 +297,7 @@ $register->set('pools', function () {
]);
});
},
- 'mongodb' => function () use ($dsnHost, $dsnPort, $dsnUser, $dsnPass, $dsnDatabase, $dsn) {
+ 'mongodb' => function () use ($dsnHost, $dsnPort, $dsnUser, $dsnPass, $dsnDatabase) {
try {
$mongo = new MongoClient($dsnDatabase, $dsnHost, (int)$dsnPort, $dsnUser, $dsnPass, false);
@$mongo->connect();
@@ -329,7 +318,7 @@ $register->set('pools', function () {
));
});
},
- 'redis' => function () use ($dsnHost, $dsnPort, $dsnPass) {
+ default => function () use ($dsnHost, $dsnPort, $dsnPass) {
$redis = new \Redis();
@$redis->pconnect($dsnHost, (int)$dsnPort);
if ($dsnPass) {
@@ -339,7 +328,6 @@ $register->set('pools', function () {
return $redis;
},
- default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Invalid scheme'),
};
$poolAdapter = System::getEnv('_APP_POOL_ADAPTER', default: 'stack') === 'swoole' ? new SwoolePool() : new StackPool();
@@ -433,35 +421,20 @@ $register->set('db', function () {
});
$register->set('smtp', function () {
- $mail = new PHPMailer(true);
-
- $mail->isSMTP();
-
- $username = System::getEnv('_APP_SMTP_USERNAME');
- $password = System::getEnv('_APP_SMTP_PASSWORD');
-
- $mail->XMailer = 'Appwrite Mailer';
- $mail->Host = System::getEnv('_APP_SMTP_HOST', 'smtp');
- $mail->Port = System::getEnv('_APP_SMTP_PORT', 25);
- $mail->SMTPAuth = !empty($username) && !empty($password);
- $mail->Username = $username;
- $mail->Password = $password;
- $mail->SMTPSecure = System::getEnv('_APP_SMTP_SECURE', '');
- $mail->SMTPAutoTLS = false;
- $mail->SMTPKeepAlive = true;
- $mail->CharSet = 'UTF-8';
- $mail->Timeout = 10; /* Connection timeout */
- $mail->getSMTPInstance()->Timelimit = 30; /* Timeout for each individual SMTP command (e.g. HELO, EHLO, etc.) */
-
- $from = \urldecode(System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'));
- $email = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
-
- $mail->setFrom($email, $from);
- $mail->addReplyTo($email, $from);
-
- $mail->isHTML(true);
-
- return $mail;
+ $username = System::getEnv('_APP_SMTP_USERNAME', '');
+ $password = System::getEnv('_APP_SMTP_PASSWORD', '');
+ return new SMTP(
+ host: System::getEnv('_APP_SMTP_HOST', 'smtp'),
+ port: (int) System::getEnv('_APP_SMTP_PORT', 25),
+ username: $username,
+ password: $password,
+ smtpSecure: System::getEnv('_APP_SMTP_SECURE', ''),
+ smtpAutoTLS: false,
+ xMailer: 'Appwrite Mailer',
+ timeout: 10,
+ keepAlive: true,
+ timelimit: 30,
+ );
});
$register->set('geodb', function () {
return new Reader(__DIR__ . '/../assets/dbip/dbip-country-lite-2025-12.mmdb');
diff --git a/app/init/resources.php b/app/init/resources.php
index 5b989f781b..dbaa89b21d 100644
--- a/app/init/resources.php
+++ b/app/init/resources.php
@@ -1,46 +1,21 @@
new Log());
-Http::setResource('logger', function ($register) {
+global $register;
+global $container;
+$container = new Container();
+
+$container->set('logger', function ($register) {
return $register->get('logger');
}, ['register']);
-Http::setResource('hooks', function ($register) {
+$container->set('hooks', function ($register) {
return $register->get('hooks');
}, ['register']);
-global $register;
-Http::setResource('register', fn () => $register);
-Http::setResource('locale', function () {
- $locale = new Locale(System::getEnv('_APP_LOCALE', 'en'));
- $locale->setFallback(System::getEnv('_APP_LOCALE', 'en'));
- return $locale;
-});
+$container->set('register', fn () => $register);
-Http::setResource('localeCodes', function () {
+$container->set('localeCodes', function () {
return array_map(fn ($locale) => $locale['code'], Config::getParam('locale-codes', []));
});
-// Queues
-Http::setResource('publisher', function (Group $pools) {
+// Queues - shared infrastructure (stateless pool wrappers)
+$container->set('publisher', function (Group $pools) {
return new BrokerPool(publisher: $pools->get('publisher'));
}, ['pools']);
-Http::setResource('publisherDatabases', function (Publisher $publisher) {
+$container->set('publisherDatabases', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
-Http::setResource('publisherFunctions', function (Publisher $publisher) {
+$container->set('publisherFunctions', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
-Http::setResource('publisherMigrations', function (Publisher $publisher) {
+$container->set('publisherMigrations', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
-Http::setResource('publisherStatsUsage', function (Publisher $publisher) {
+$container->set('publisherMails', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
-Http::setResource('publisherMails', function (Publisher $publisher) {
+$container->set('publisherDeletes', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
-Http::setResource('publisherDeletes', function (Publisher $publisher) {
+$container->set('publisherMessaging', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
-Http::setResource('publisherMessaging', function (Publisher $publisher) {
+$container->set('publisherWebhooks', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
-Http::setResource('publisherWebhooks', function (Publisher $publisher) {
- return $publisher;
-}, ['publisher']);
-Http::setResource('queueForMessaging', function (Publisher $publisher) {
- return new Messaging($publisher);
-}, ['publisher']);
-Http::setResource('queueForMails', function (Publisher $publisher) {
- return new Mail($publisher);
-}, ['publisher']);
-Http::setResource('queueForBuilds', function (Publisher $publisher) {
- return new Build($publisher);
-}, ['publisher']);
-Http::setResource('queueForScreenshots', function (Publisher $publisher) {
- return new Screenshot($publisher);
-}, ['publisher']);
-Http::setResource('queueForDatabase', function (Publisher $publisher) {
- return new EventDatabase($publisher);
-}, ['publisher']);
-Http::setResource('queueForDeletes', function (Publisher $publisher) {
- return new Delete($publisher);
-}, ['publisher']);
-Http::setResource('queueForEvents', function (Publisher $publisher) {
- return new Event($publisher);
-}, ['publisher']);
-Http::setResource('queueForWebhooks', function (Publisher $publisher) {
- return new Webhook($publisher);
-}, ['publisher']);
-Http::setResource('queueForRealtime', function () {
- return new Realtime();
-}, []);
-Http::setResource('queueForStatsUsage', function (Publisher $publisher) {
- return new StatsUsage($publisher);
-}, ['publisher']);
-Http::setResource('queueForAudits', function (Publisher $publisher) {
- return new AuditEvent($publisher);
-}, ['publisher']);
-Http::setResource('queueForFunctions', function (Publisher $publisher) {
- return new Func($publisher);
-}, ['publisher']);
-Http::setResource('eventProcessor', function () {
- return new EventProcessor();
-}, []);
-Http::setResource('queueForCertificates', function (Publisher $publisher) {
- return new Certificate($publisher);
-}, ['publisher']);
-Http::setResource('queueForMigrations', function (Publisher $publisher) {
- return new Migration($publisher);
-}, ['publisher']);
-Http::setResource('queueForStatsResources', function (Publisher $publisher) {
- return new StatsResources($publisher);
-}, ['publisher']);
+$container->set('publisherForAudits', fn (Publisher $publisher) => new AuditPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME))
+), ['publisher']);
+$container->set('publisherForCertificates', fn (Publisher $publisher) => new CertificatePublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME))
+), ['publisher']);
+$container->set('publisherForScreenshots', fn (Publisher $publisher) => new ScreenshotPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME))
+), ['publisher']);
+$container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
+), ['publisher']);
+$container->set('publisherForExecutions', fn (Publisher $publisher) => new ExecutionPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_EXECUTIONS_QUEUE_NAME', Event::EXECUTIONS_QUEUE_NAME))
+), ['publisher']);
+$container->set('publisherForFunctions', fn (Publisher $publisher) => new FunctionPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', Event::FUNCTIONS_QUEUE_TTL)
+), ['publisher']);
+$container->set('publisherForMigrations', fn (Publisher $publisher) => new MigrationPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME))
+), ['publisher']);
+$container->set('publisherForStatsResources', fn (Publisher $publisher) => new StatsResourcesPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME))
+), ['publisher']);
+$container->set('publisherForBuilds', fn (Publisher $publisher) => new BuildPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME))
+), ['publisher']);
+$container->set('publisherForMails', fn (Publisher $publisher) => new MailPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME))
+), ['publisher']);
+$container->set('publisherForMessaging', fn (Publisher $publisher) => new MessagingPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME))
+), ['publisher']);
/**
* Platform configuration
*/
-Http::setResource('platform', function () {
+$container->set('platform', function () {
return Config::getParam('platform', []);
}, []);
-/**
- * List of allowed request hostnames for the request.
- */
-Http::setResource('allowedHostnames', function (array $platform, Document $project, Document $rule, Document $devKey, Request $request) {
- $allowed = [...($platform['hostnames'] ?? [])];
-
- /* Add platform configured hostnames */
- 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()) {
- $allowed[] = $request->getHostname();
- }
-
- $originHostname = parse_url($request->getOrigin(), PHP_URL_HOST);
- $refererHostname = parse_url($request->getReferer(), PHP_URL_HOST);
-
- $hostname = $originHostname;
- if (empty($hostname)) {
- $hostname = $refererHostname;
- }
-
- /* Add request hostname for preflight requests */
- if ($request->getMethod() === 'OPTIONS') {
- $allowed[] = $hostname;
- }
-
- /* Allow the request origin of rule */
- 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)) {
- $allowed[] = $hostname;
- }
-
- return array_unique($allowed);
-}, ['platform', 'project', 'rule', 'devKey', 'request']);
-
-/**
- * List of allowed request schemes for the request.
- */
-Http::setResource('allowedSchemes', function (array $platform, Document $project) {
- $allowed = [...($platform['schemas'] ?? [])];
-
- if (!$project->isEmpty() && $project->getId() !== 'console') {
- /* Add hardcoded schemes */
- $allowed[] = 'exp';
- $allowed[] = 'appwrite-callback-' . $project->getId();
-
- /* Add platform configured schemes */
- $platforms = $project->getAttribute('platforms', []);
- $schemes = Platform::getSchemes($platforms);
- $allowed = [...$allowed, ...$schemes];
- }
-
- return array_unique($allowed);
-}, ['platform', 'project']);
-
-/**
- * Rule associated with a request origin.
- */
-Http::setResource('rule', function (Request $request, Database $dbForPlatform, Document $project, Authorization $authorization) {
- $domain = \parse_url($request->getOrigin(), PHP_URL_HOST);
-
- if (empty($domain)) {
- $domain = \parse_url($request->getReferer(), PHP_URL_HOST);
- }
-
- if (empty($domain)) {
- return new Document();
- }
-
- // TODO: (@Meldiron) Remove after 1.7.x migration
- $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5';
- $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) {
- if ($isMd5) {
- return $dbForPlatform->getDocument('rules', md5($domain));
- }
-
- return $dbForPlatform->findOne('rules', [
- Query::equal('domain', [$domain]),
- ]) ?? new Document();
- });
-
- $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence();
-
- // 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', ''))) {
- $trustedProjects = [];
- foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) {
- if (empty($trustedProject)) {
- continue;
- }
- $trustedProjects[] = $trustedProject;
- }
- if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects)) {
- $permitsCurrentProject = true;
- }
- }
-
- if (!$permitsCurrentProject) {
- return new Document();
- }
-
- return $rule;
-}, ['request', 'dbForPlatform', 'project', 'authorization']);
-
-/**
- * CORS service
- */
-Http::setResource('cors', function (array $allowedHostnames) {
- $corsConfig = Config::getParam('cors');
-
- return new Cors(
- $allowedHostnames,
- allowedMethods: $corsConfig['allowedMethods'],
- allowedHeaders: $corsConfig['allowedHeaders'],
- allowCredentials: true,
- exposedHeaders: $corsConfig['exposedHeaders'],
- );
-}, ['allowedHostnames']);
-
-Http::setResource('originValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) {
- 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()) {
- return new URL();
- }
- return new Redirect($allowedHostnames, $allowedSchemes);
-}, ['devKey', 'allowedHostnames', 'allowedSchemes']);
-
-Http::setResource('user', function (string $mode, Document $project, Document $console, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Store $store, Token $proofForToken, $authorization) {
- /**
- * Handles user authentication and session validation.
- *
- * This function follows a series of steps to determine the appropriate user session
- * based on cookies, headers, and JWT tokens.
- *
- * Process:
- * 1. Checks the cookie based on mode:
- * - If in admin mode, uses console project id for key.
- * - Otherwise, sets the key using the project ID
- * 2. If no cookie is found, attempts to retrieve the fallback header `x-fallback-cookies`.
- * - If this method is used, returns the header: `X-Debug-Fallback: true`.
- * 3. Fetches the user document from the appropriate database based on the mode.
- * 4. If the user document is empty or the session key cannot be verified, sets an empty user document.
- * 5. Regardless of the results from steps 1-4, attempts to fetch the JWT token.
- * 6. If the JWT user has a valid session ID, updates the user variable with the user from `projectDB`,
- * 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) {
- $store->setKey('a_session_' . $console->getId());
- }
-
- $store->decode(
- $request->getCookie(
- $store->getKey(), // Get sessions
- $request->getCookie($store->getKey() . '_legacy', '')
- )
- );
-
- // Get session from header for SSR clients
- if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
- $sessionHeader = $request->getHeader('x-appwrite-session', '');
-
- if (!empty($sessionHeader)) {
- $store->decode($sessionHeader);
- }
- }
-
- // Get fallback session from old clients (no SameSite support) or clients who block 3rd-party cookies
- if ($response) { // if in http context - add debug header
- $response->addHeader('X-Debug-Fallback', 'false');
- }
-
- if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
- if ($response) {
- $response->addHeader('X-Debug-Fallback', 'true');
- }
- $fallback = $request->getHeader('x-fallback-cookies', '');
- $fallback = \json_decode($fallback, true);
- $store->decode(((is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : ''));
- }
-
- $user = null;
- if (APP_MODE_ADMIN === $mode) {
- /** @var User $user */
- $user = $dbForPlatform->getDocument('users', $store->getProperty('id', ''));
- } else {
- if ($project->isEmpty()) {
- $user = new User([]);
- } else {
- if (!empty($store->getProperty('id', ''))) {
- if ($project->getId() === 'console') {
- /** @var User $user */
- $user = $dbForPlatform->getDocument('users', $store->getProperty('id', ''));
- } else {
- /** @var User $user */
- $user = $dbForProject->getDocument('users', $store->getProperty('id', ''));
- }
- }
- }
- }
-
- if (
- !$user ||
- $user->isEmpty() // Check a document has been found in the DB
- || !$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()) {
- throw new Exception(Exception::USER_JWT_AND_COOKIE_SET);
- }
-
- $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0);
- try {
- $payload = $jwt->decode($authJWT);
- } catch (JWTException $error) {
- throw new Exception(Exception::USER_JWT_INVALID, 'Failed to verify JWT. ' . $error->getMessage());
- }
-
- $jwtUserId = $payload['userId'] ?? '';
- if (!empty($jwtUserId)) {
- if ($mode === APP_MODE_ADMIN) {
- $user = $dbForPlatform->getDocument('users', $jwtUserId);
- } else {
- $user = $dbForProject->getDocument('users', $jwtUserId);
- }
- }
- $jwtSessionId = $payload['sessionId'] ?? '';
- if (!empty($jwtSessionId)) {
- if (empty($user->find('$id', $jwtSessionId, 'sessions'))) { // Match JWT to active token
- $user = new User([]);
- }
- }
- }
-
- // 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()) {
- throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET);
- }
-
- $accountKeyUser = $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId));
- if (!$accountKeyUser->isEmpty()) {
- $key = $accountKeyUser->find(
- key: 'secret',
- find: $accountKey,
- subject: 'keys'
- );
-
- if (!empty($key)) {
- $expire = $key->getAttribute('expire');
- if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
- throw new Exception(Exception::ACCOUNT_KEY_EXPIRED);
- }
-
- $user = $accountKeyUser;
- }
- }
- }
-
- $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) {
- /** @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)) {
- $projectId = $request->getHeader('x-appwrite-project', '');
- }
-
- if (empty($projectId) || $projectId === 'console') {
- return $console;
- }
-
- $project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
-
- return $project;
-}, ['dbForPlatform', 'request', 'console', 'authorization']);
-
-Http::setResource('session', function (User $user, Store $store, Token $proofForToken) {
- if ($user->isEmpty()) {
- return;
- }
-
- $sessions = $user->getAttribute('sessions', []);
- $sessionId = $user->sessionVerify($store->getProperty('secret', ''), $proofForToken);
-
- if (!$sessionId) {
- return;
- }
- foreach ($sessions as $session) {
- /** @var Document $session */
- if ($sessionId === $session->getId()) {
- return $session;
- }
- }
-
- return;
-}, ['user', 'store', 'proofForToken']);
-
-Http::setResource('store', function (): Store {
- return new Store();
-});
-
-Http::setResource('proofForPassword', function (): Password {
- $hash = new Argon2();
- $hash
- ->setMemoryCost(7168)
- ->setTimeCost(5)
- ->setThreads(1);
-
- $password = new Password();
- $password
- ->setHash($hash);
-
- return $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;
-});
-
-Http::setResource('console', function () {
+$container->set('console', function () {
return new Document(Config::getParam('console'));
}, []);
-Http::setResource('authorization', function () {
+$container->set('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, Request $request) {
- if ($project->isEmpty() || $project->getId() === 'console') {
- return $dbForPlatform;
- }
-
- $database = $project->getAttribute('database', '');
- if (empty($database)) {
- throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Project database is not configured');
- }
-
- try {
- $dsn = new DSN($database);
- } catch (\InvalidArgumentException) {
- // TODO: Temporary until all projects are using shared tables
- $dsn = new DSN('mysql://' . $database);
- }
-
- $adapter = new DatabasePool($pools->get($dsn->getHost()));
- $database = new Database($adapter, $cache);
-
- $database
- ->setDatabase(APP_DATABASE)
- ->setAuthorization($authorization)
- ->setMetadata('host', \gethostname())
- ->setMetadata('project', $project->getId())
- ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
- ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
- $database->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())
- ->setNamespace($dsn->getParam('namespace'));
- } else {
- $database
- ->setSharedTables(false)
- ->setTenant(null)
- ->setNamespace('_' . $project->getSequence());
- }
-
- /**
- * This isolated event handling for `users.*.create` which is based on a `Database::EVENT_DOCUMENT_CREATE` listener may look odd, but it is **intentional**.
- *
- * Accounts can be created in many ways beyond `createAccount`
- * (anonymous, OAuth, phone, etc.), and those flows are probably not covered in event tests; so we handle this here.
- */
- $eventDatabaseListener = function (Document $project, Document $document, Response $response, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime) {
- // Only trigger events for user creation with the database listener.
- if ($document->getCollection() !== 'users') {
- return;
- }
-
- $queueForEvents
- ->setEvent('users.[userId].create')
- ->setParam('userId', $document->getId())
- ->setPayload($response->output($document, Response::MODEL_USER));
-
- // Trigger functions, webhooks, and realtime events
- $queueForFunctions
- ->from($queueForEvents)
- ->trigger();
-
-
- /** Trigger webhooks events only if a project has them enabled */
- if (!empty($project->getAttribute('webhooks'))) {
- $queueForWebhooks
- ->from($queueForEvents)
- ->trigger();
- }
-
- /** Trigger realtime events only for non console events */
- if ($queueForEvents->getProject()->getId() !== 'console') {
- $queueForRealtime
- ->from($queueForEvents)
- ->trigger();
- }
- };
-
- /**
- * Purge function events cache when functions are created, updated or deleted.
- */
- $functionsEventsCacheListener = function (string $event, Document $document, Document $project, Database $dbForProject) {
-
-
- if ($document->getCollection() !== 'functions') {
- return;
- }
-
- if ($project->isEmpty() || $project->getId() === 'console') {
- return;
- }
-
- $hostname = $dbForProject->getAdapter()->getHostname();
- $cacheKey = \sprintf(
- '%s-cache-%s:%s:%s:project:%s:functions:events',
- $dbForProject->getCacheName(),
- $hostname ?? '',
- $dbForProject->getNamespace(),
- $dbForProject->getTenant(),
- $project->getId()
- );
-
- $dbForProject->getCache()->purge($cacheKey);
- };
-
- /**
- * Prefix metrics with database type when applicable.
- * Avoids prefixing for legacy and tablesdb types to preserve historical metrics.
- */
- $getDatabaseTypePrefixedMetric = function (string $databaseType, string $metric): string {
- if (
- $databaseType === '' ||
- $databaseType === DATABASE_TYPE_LEGACY ||
- $databaseType === DATABASE_TYPE_TABLESDB
- ) {
- return $metric;
- }
-
- return $databaseType . '.' . $metric;
- };
-
- // Determine database type from request path, similar to api.php
- $path = $request->getURI();
- $databaseType = match (true) {
- str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB,
- str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB,
- default => '',
- };
-
- $usageDatabaseListener = function (string $event, Document $document, StatsUsage $queueForStatsUsage) use ($getDatabaseTypePrefixedMetric, $databaseType) {
- $value = 1;
-
- switch ($event) {
- case Database::EVENT_DOCUMENT_DELETE:
- $value = -1;
- break;
- case Database::EVENT_DOCUMENTS_DELETE:
- $value = -1 * $document->getAttribute('modified', 0);
- break;
- case Database::EVENT_DOCUMENTS_CREATE:
- $value = $document->getAttribute('modified', 0);
- break;
- case Database::EVENT_DOCUMENTS_UPSERT:
- $value = $document->getAttribute('created', 0);
- break;
- }
-
- switch (true) {
- case $document->getCollection() === 'teams':
- $queueForStatsUsage->addMetric(METRIC_TEAMS, $value); // per project
- break;
- case $document->getCollection() === 'users':
- $queueForStatsUsage->addMetric(METRIC_USERS, $value); // per project
- if ($event === Database::EVENT_DOCUMENT_DELETE) {
- $queueForStatsUsage->addReduce($document);
- }
- break;
- case $document->getCollection() === 'sessions': // sessions
- $queueForStatsUsage->addMetric(METRIC_SESSIONS, $value); //per project
- break;
- case $document->getCollection() === 'databases': // databases
- $metric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASES);
- $queueForStatsUsage->addMetric($metric, $value); // per project
-
- if ($event === Database::EVENT_DOCUMENT_DELETE) {
- $queueForStatsUsage->addReduce($document);
- }
- break;
- case str_starts_with($document->getCollection(), 'database_') && !str_contains($document->getCollection(), 'collection'): //collections
- $parts = explode('_', $document->getCollection());
- $databaseInternalId = $parts[1] ?? 0;
- $collectionMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_COLLECTIONS);
- $databaseIdCollectionMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_COLLECTIONS);
- $queueForStatsUsage
- ->addMetric($collectionMetric, $value) // per project
- ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdCollectionMetric), $value);
-
- if ($event === Database::EVENT_DOCUMENT_DELETE) {
- $queueForStatsUsage->addReduce($document);
- }
- break;
- case str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_'): //documents
- $parts = explode('_', $document->getCollection());
- $databaseInternalId = $parts[1] ?? 0;
- $collectionInternalId = $parts[3] ?? 0;
- $documentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DOCUMENTS);
- $databaseIdDocumentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_DOCUMENTS);
- $databaseIdCollectionIdDocumentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS);
- $queueForStatsUsage
- ->addMetric($documentsMetric, $value) // per project
- ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
- ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
- break;
- case $document->getCollection() === 'buckets': //buckets
- $queueForStatsUsage
- ->addMetric(METRIC_BUCKETS, $value); // per project
- if ($event === Database::EVENT_DOCUMENT_DELETE) {
- $queueForStatsUsage
- ->addReduce($document);
- }
- break;
- case str_starts_with($document->getCollection(), 'bucket_'): // files
- $parts = explode('_', $document->getCollection());
- $bucketInternalId = $parts[1];
- $queueForStatsUsage
- ->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
-
- if ($event === Database::EVENT_DOCUMENT_DELETE) {
- $queueForStatsUsage
- ->addReduce($document);
- }
- break;
- case $document->getCollection() === 'sites':
- $queueForStatsUsage
- ->addMetric(METRIC_SITES, $value); // per project
-
- if ($event === Database::EVENT_DOCUMENT_DELETE) {
- $queueForStatsUsage
- ->addReduce($document);
- }
- break;
- case $document->getCollection() === 'deployments':
- $queueForStatsUsage
- ->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
- ->addMetric(str_replace(['{resourceType}'], [$document->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_DEPLOYMENTS_STORAGE), $document->getAttribute('size') * $value)
- ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$document->getAttribute('resourceType'), $document->getAttribute('resourceInternalId')], METRIC_RESOURCE_TYPE_ID_DEPLOYMENTS), $value) // per function
- ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$document->getAttribute('resourceType'), $document->getAttribute('resourceInternalId')], METRIC_RESOURCE_TYPE_ID_DEPLOYMENTS_STORAGE), $document->getAttribute('size') * $value);
- break;
- default:
- break;
- }
- };
-
- // Clone the queues, to prevent events triggered by the database listener
- // from overwriting the events that are supposed to be triggered in the shutdown hook.
- $queueForEventsClone = new Event($publisher);
- $queueForFunctions = new Func($publisherFunctions);
- $queueForWebhooks = new Webhook($publisherWebhooks);
- $queueForRealtime = new Realtime();
-
- // to get consumed in the stats worker without calling to database
- $queueForStatsUsage->setContext('database', new Document(['type' => $databaseType]));
-
-
- $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))
- ;
-
-
- return $database;
-}, ['pools', 'dbForPlatform', 'cache', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'queueForFunctions', 'queueForWebhooks', 'queueForRealtime', 'queueForStatsUsage', 'authorization', 'request']);
-
-Http::setResource('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) {
+$container->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) {
$adapter = new DatabasePool($pools->get('console'));
$database = new Database($adapter, $cache);
@@ -851,236 +168,44 @@ Http::setResource('dbForPlatform', function (Group $pools, Cache $cache, Authori
return $database;
}, ['pools', 'cache', 'authorization']);
-Http::setResource('getDatabasesDB', function (Group $pools, Cache $cache, Document $project, Request $request, StatsUsage $queueForStatsUsage, Authorization $authorization) {
-
- return function (Document $database) use ($pools, $cache, $project, $request, $queueForStatsUsage, $authorization): Database {
- $databaseDSN = $database->getAttribute('database', '');
- $databaseType = $database->getAttribute('type', '');
-
- try {
- $databaseDSN = new DSN($databaseDSN);
- } catch (\InvalidArgumentException) {
- // for old databases migrated through patch script
- // databaseDSN determines the adapter
- $databaseDSN = new DSN('mysql://'.$databaseDSN);
- }
- try {
- $dsn = new DSN($project->getAttribute('database'));
- } catch (\InvalidArgumentException) {
- // TODO: Temporary until all projects are using shared tables
- $dsn = new DSN('mysql://' . $project->getAttribute('database'));
- }
-
- $pool = $pools->get($databaseDSN->getHost());
-
- $adapter = new DatabasePool($pool);
- $database = new Database($adapter, $cache);
- $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
-
- $database
- ->setDatabase(APP_DATABASE)
- ->setAuthorization($authorization)
- ->setMetadata('host', \gethostname())
- ->setMetadata('project', $project->getId())
- ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
- ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
- // inside pools authorization needs to be set first
- $database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB);
- if (\in_array($dsn->getHost(), $sharedTables)) {
- $database
- ->setSharedTables(true)
- ->setTenant((int)$project->getSequence())
- ->setNamespace($dsn->getParam('namespace'));
- } else {
- $database
- ->setSharedTables(false)
- ->setTenant(null)
- ->setNamespace('_' . $project->getSequence());
- }
- $timeout = \intval($request->getHeader('x-appwrite-timeout'));
- if (!empty($timeout) && Http::isDevelopment()) {
- $database->setTimeout($timeout);
- }
-
- // Register database event listeners for usage stats collection
- $documentsMetric = METRIC_DOCUMENTS;
- $databaseIdDocumentsMetric = METRIC_DATABASE_ID_DOCUMENTS;
- $databaseIdCollectionIdDocumentsMetric = METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS;
- if ($databaseType !== DATABASE_TYPE_LEGACY && $databaseType !== DATABASE_TYPE_TABLESDB) {
- $documentsMetric = $databaseType. '.' .$documentsMetric;
- $databaseIdDocumentsMetric = $databaseType. '.' .$databaseIdDocumentsMetric;
- $databaseIdCollectionIdDocumentsMetric = $databaseType . '.' .$databaseIdCollectionIdDocumentsMetric;
- }
- $database
- ->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', function ($event, $document) use ($queueForStatsUsage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
- $value = 1;
-
- if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
- $parts = explode('_', $document->getCollection());
- $databaseInternalId = $parts[1] ?? 0;
- $collectionInternalId = $parts[3] ?? 0;
- $queueForStatsUsage
- ->addMetric($documentsMetric, $value) // per project
- ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
- ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
- }
- })
- ->on(Database::EVENT_DOCUMENT_DELETE, 'calculate-usage', function ($event, $document) use ($queueForStatsUsage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
- $value = -1;
-
- if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
- $parts = explode('_', $document->getCollection());
- $databaseInternalId = $parts[1] ?? 0;
- $collectionInternalId = $parts[3] ?? 0;
- $queueForStatsUsage
- ->addMetric($documentsMetric, $value) // per project
- ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
- ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
- }
- })
- ->on(Database::EVENT_DOCUMENTS_CREATE, 'calculate-usage', function ($event, $document) use ($queueForStatsUsage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
- $value = $document->getAttribute('modified', 0);
-
- if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
- $parts = explode('_', $document->getCollection());
- $databaseInternalId = $parts[1] ?? 0;
- $collectionInternalId = $parts[3] ?? 0;
- $queueForStatsUsage
- ->addMetric($documentsMetric, $value) // per project
- ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
- ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
- }
- })
- ->on(Database::EVENT_DOCUMENTS_DELETE, 'calculate-usage', function ($event, $document) use ($queueForStatsUsage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
- $value = -1 * $document->getAttribute('modified', 0);
-
- if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
- $parts = explode('_', $document->getCollection());
- $databaseInternalId = $parts[1] ?? 0;
- $collectionInternalId = $parts[3] ?? 0;
- $queueForStatsUsage
- ->addMetric($documentsMetric, $value) // per project
- ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
- ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
- }
- })
- ->on(Database::EVENT_DOCUMENTS_UPSERT, 'calculate-usage', function ($event, $document) use ($queueForStatsUsage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
- $value = $document->getAttribute('created', 0);
-
- if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
- $parts = explode('_', $document->getCollection());
- $databaseInternalId = $parts[1] ?? 0;
- $collectionInternalId = $parts[3] ?? 0;
- $queueForStatsUsage
- ->addMetric($documentsMetric, $value) // per project
- ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
- ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
- }
- });
-
- return $database;
- };
-
-}, ['pools','cache','project','request','queueForStatsUsage','authorization']);
-
-Http::setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache, Authorization $authorization) {
- $databases = [];
-
- return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$databases) {
- if ($project->isEmpty() || $project->getId() === 'console') {
- return $dbForPlatform;
- }
-
- $database = $project->getAttribute('database', '');
- if (empty($database)) {
- throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Project database is not configured');
- }
-
- try {
- $dsn = new DSN($database);
- } catch (\InvalidArgumentException) {
- // TODO: Temporary until all projects are using shared tables
- $dsn = new DSN('mysql://' . $database);
- }
-
- $configure = (function (Database $database) use ($project, $dsn, $authorization) {
- $database
- ->setDatabase(APP_DATABASE)
- ->setAuthorization($authorization)
- ->setMetadata('host', \gethostname())
- ->setMetadata('project', $project->getId())
- ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
- ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES)
- ->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())
- ->setNamespace($dsn->getParam('namespace'));
- } else {
- $database
- ->setSharedTables(false)
- ->setTenant(null)
- ->setNamespace('_' . $project->getSequence());
- }
- });
-
- if (isset($databases[$dsn->getHost()])) {
- $database = $databases[$dsn->getHost()];
- $configure($database);
- return $database;
- }
-
- $adapter = new DatabasePool($pools->get($dsn->getHost()));
- $database = new Database($adapter, $cache);
- $databases[$dsn->getHost()] = $database;
- $configure($database);
-
- return $database;
- };
-}, ['pools', 'dbForPlatform', 'cache', 'authorization']);
-
-Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
+$container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
$database = null;
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;
}
$adapter = new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $logsCollections = $collections['logs'] ?? [];
+ $logsCollections = array_keys($logsCollections);
+
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setSharedTables(true)
+ ->setGlobalCollections($logsCollections)
->setNamespace('logsV1')
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
// set tenant
if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
- $database->setTenant((int) $project->getSequence());
+ $database->setTenant($project->getSequence());
}
return $database;
};
}, ['pools', 'cache', 'authorization']);
-Http::setResource('audit', function ($dbForProject) {
- $adapter = new AdapterDatabase($dbForProject);
- return new Audit($adapter);
-}, ['dbForProject']);
+$container->set('telemetry', fn () => new NoTelemetry());
-Http::setResource('telemetry', fn () => new NoTelemetry());
-
-Http::setResource('cache', function (Group $pools, Telemetry $telemetry) {
+$container->set('cache', function (Group $pools, Telemetry $telemetry) {
$list = Config::getParam('pools-cache', []);
$adapters = [];
@@ -1090,10 +215,15 @@ Http::setResource('cache', function (Group $pools, Telemetry $telemetry) {
$cache = new Cache(new Sharding($adapters));
$cache->setTelemetry($telemetry);
+
return $cache;
}, ['pools', 'telemetry']);
-Http::setResource('redis', function () {
+$container->set('cacheControlForStorage', fn () => function (StorageCacheControl $config): string {
+ return \sprintf('private, max-age=%d', $config->maxAge);
+});
+
+$container->set('redis', function () {
$host = System::getEnv('_APP_REDIS_HOST', 'localhost');
$port = System::getEnv('_APP_REDIS_PORT', 6379);
$pass = System::getEnv('_APP_REDIS_PASS', '');
@@ -1108,36 +238,20 @@ Http::setResource('redis', function () {
return $redis;
});
-Http::setResource('timelimit', function (\Redis $redis) {
+$container->set('timelimit', function (\Redis $redis) {
return function (string $key, int $limit, int $time) use ($redis) {
return new TimeLimitRedis($key, $limit, $time, $redis);
};
}, ['redis']);
-Http::setResource('deviceForLocal', function (Telemetry $telemetry) {
+$container->set('deviceForLocal', function (Telemetry $telemetry) {
return new Device\Telemetry($telemetry, new Local());
}, ['telemetry']);
-Http::setResource('deviceForFiles', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
-Http::setResource('deviceForSites', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
-Http::setResource('deviceForMigrations', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
-Http::setResource('deviceForFunctions', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
-Http::setResource('deviceForBuilds', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId()));
-}, ['project', '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 = '';
@@ -1159,8 +273,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);
@@ -1169,6 +284,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);
@@ -1181,7 +297,7 @@ function getDevice(string $root, string $connection = ''): Device
return new Local($root);
}
} else {
- switch (strtolower(System::getEnv('_APP_STORAGE_DEVICE', Storage::DEVICE_LOCAL) ?? '')) {
+ switch (strtolower(System::getEnv('_APP_STORAGE_DEVICE', Storage::DEVICE_LOCAL))) {
case Storage::DEVICE_LOCAL:
default:
return new Local($root);
@@ -1192,8 +308,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);
@@ -1207,6 +324,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', '');
@@ -1214,6 +332,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', '');
@@ -1221,6 +340,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', '');
@@ -1228,34 +348,23 @@ 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 */
-
- /**
- * 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']);
-
-Http::setResource('geodb', function ($register) {
+$container->set('geodb', function ($register) {
/** @var Utopia\Registry\Registry $register */
return $register->get('geodb');
}, ['register']);
-Http::setResource('passwordsDictionary', function ($register) {
+$container->set('passwordsDictionary', function ($register) {
/** @var Utopia\Registry\Registry $register */
return $register->get('passwordsDictionary');
}, ['register']);
-
-Http::setResource('servers', function () {
+$container->set('servers', function () {
$platforms = Config::getParam('sdks');
$server = $platforms[APP_SDK_PLATFORM_SERVER];
@@ -1266,355 +375,25 @@ Http::setResource('servers', function () {
return $languages;
});
-Http::setResource('promiseAdapter', function ($register) {
+$container->set('promiseAdapter', function ($register) {
return $register->get('promiseAdapter');
}, ['register']);
-Http::setResource('schema', function ($utopia, $dbForProject, $authorization) {
-
- $complexity = function (int $complexity, array $args) {
- $queries = Query::parseQueries($args['queries'] ?? []);
- $query = Query::getByType($queries, [Query::TYPE_LIMIT])[0] ?? null;
- $limit = $query ? $query->getValue() : APP_LIMIT_LIST_DEFAULT;
-
- return $complexity * $limit;
- };
-
- $attributes = function (int $limit, int $offset) use ($dbForProject, $authorization) {
- $attrs = $authorization->skip(fn () => $dbForProject->find('attributes', [
- Query::limit($limit),
- Query::offset($offset),
- ]));
-
- return \array_map(function ($attr) {
- return $attr->getArrayCopy();
- }, $attrs);
- };
-
- $urls = [
- 'list' => function (string $databaseId, string $collectionId, array $args) {
- return "/v1/databases/$databaseId/collections/$collectionId/documents";
- },
- 'create' => function (string $databaseId, string $collectionId, array $args) {
- return "/v1/databases/$databaseId/collections/$collectionId/documents";
- },
- 'read' => function (string $databaseId, string $collectionId, array $args) {
- return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}";
- },
- 'update' => function (string $databaseId, string $collectionId, array $args) {
- return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}";
- },
- 'delete' => function (string $databaseId, string $collectionId, array $args) {
- return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}";
- },
- ];
-
- // NOTE: `params` and `urls` are not used internally in the `Schema::build` function below!
- $params = [
- 'list' => function (string $databaseId, string $collectionId, array $args) {
- return ['queries' => $args['queries']];
- },
- 'create' => function (string $databaseId, string $collectionId, array $args) {
- $id = $args['id'] ?? 'unique()';
- $permissions = $args['permissions'] ?? null;
-
- unset($args['id']);
- unset($args['permissions']);
-
- // Order must be the same as the route params
- return [
- 'databaseId' => $databaseId,
- 'documentId' => $id,
- 'collectionId' => $collectionId,
- 'data' => $args,
- 'permissions' => $permissions,
- ];
- },
- 'update' => function (string $databaseId, string $collectionId, array $args) {
- $documentId = $args['id'];
- $permissions = $args['permissions'] ?? null;
-
- unset($args['id']);
- unset($args['permissions']);
-
- // Order must be the same as the route params
- return [
- 'databaseId' => $databaseId,
- 'collectionId' => $collectionId,
- 'documentId' => $documentId,
- 'data' => $args,
- 'permissions' => $permissions,
- ];
- },
- ];
-
- return Schema::build(
- $utopia,
- $complexity,
- $attributes,
- $urls,
- $params,
- );
-}, ['utopia', 'dbForProject', 'authorization']);
-
-Http::setResource('gitHub', function (Cache $cache) {
+$container->set('gitHub', function (Cache $cache) {
return new VcsGitHub($cache);
}, ['cache']);
-Http::setResource('requestTimestamp', function ($request) {
- //TODO: Move this to the Request class itself
- $timestampHeader = $request->getHeader('x-appwrite-timestamp');
- $requestTimestamp = null;
- 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']);
-
-Http::setResource('plan', function (array $plan = []) {
+$container->set('plan', function () {
return [];
});
-Http::setResource('smsRates', function () {
+$container->set('smsRates', function () {
return [];
});
-Http::setResource('devKey', function (Request $request, Document $project, array $servers, Database $dbForPlatform, Authorization $authorization) {
- $devKey = $request->getHeader('x-appwrite-dev-key', $request->getParam('devKey', ''));
-
- // Check if given key match project's development keys
- $key = $project->find('secret', $devKey, 'devKeys');
- if (!$key) {
- return new Document([]);
- }
-
- // check expiration
- $expire = $key->getAttribute('expire');
- if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
- return new Document([]);
- }
-
- // update access time
- $accessedAt = $key->getAttribute('accessedAt', 0);
- if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_KEY_ACCESS)) > $accessedAt) {
- $key->setAttribute('accessedAt', DatabaseDateTime::now());
- $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([
- 'accessedAt' => $key->getAttribute('accessedAt')
- ])));
- $dbForPlatform->purgeCachedDocument('projects', $project->getId());
- }
-
- // add sdk to key
- $sdkValidator = new WhiteList($servers, true);
- $sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN'));
-
- if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) {
- $sdks = $key->getAttribute('sdks', []);
-
- if (!in_array($sdk, $sdks)) {
- $sdks[] = $sdk;
- $key->setAttribute('sdks', $sdks);
-
- /** Update access time as well */
- $key->setAttribute('accessedAt', DatabaseDateTime::now());
- $key = $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([
- 'sdks' => $key->getAttribute('sdks'),
- 'accessedAt' => $key->getAttribute('accessedAt')
- ])));
- $dbForPlatform->purgeCachedDocument('projects', $project->getId());
- }
- }
-
- return $key;
-}, ['request', 'project', 'servers', 'dbForPlatform', 'authorization']);
-
-Http::setResource('team', function (Document $project, Database $dbForPlatform, Http $utopia, Request $request, Authorization $authorization) {
- $teamInternalId = '';
- if ($project->getId() !== 'console') {
- $teamInternalId = $project->getAttribute('teamInternalId', '');
- } else {
- $route = $utopia->match($request);
- $path = !empty($route) ? $route->getPath() : $request->getURI();
- $orgHeader = $request->getHeader('x-appwrite-organization', '');
- if (str_starts_with($path, '/v1/projects/:projectId')) {
- $uri = $request->getURI();
- $pid = explode('/', $uri)[3];
- $p = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $pid));
- $teamInternalId = $p->getAttribute('teamInternalId', '');
- } elseif ($path === '/v1/projects') {
- $teamId = $request->getParam('teamId', '');
-
- if (empty($teamId)) {
- return new Document([]);
- }
-
- $team = $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $teamId));
- return $team;
- } elseif (!empty($orgHeader)) {
- return $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $orgHeader));
- }
- }
-
- // if teamInternalId is empty, return an empty document
-
- if (empty($teamInternalId)) {
- return new Document([]);
- }
-
- $team = $authorization->skip(function () use ($dbForPlatform, $teamInternalId) {
- return $dbForPlatform->findOne('teams', [
- Query::equal('$sequence', [$teamInternalId]),
- ]);
- });
-
- return $team;
-}, ['project', 'dbForPlatform', 'utopia', 'request', 'authorization']);
-
-Http::setResource(
+$container->set(
'isResourceBlocked',
fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false
);
-Http::setResource('previewHostname', function (Request $request, ?Key $apiKey) {
- $allowed = false;
-
- if (Http::isDevelopment()) {
- $allowed = 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)) {
- return $host;
- }
- }
-
- return '';
-}, ['request', 'apiKey']);
-
-Http::setResource('apiKey', function (Request $request, Document $project, Document $team, Document $user): ?Key {
- $key = $request->getHeader('x-appwrite-key');
-
- if (empty($key)) {
- return null;
- }
-
- $key = Key::decode($project, $team, $user, $key);
-
- $userHeader = $request->getHeader('x-appwrite-user');
- $organizationHeader = $request->getHeader('x-appwrite-organization');
- $projectHeader = $request->getHeader('x-appwrite-project');
-
- if (!empty($key->getProjectId())) {
- if (empty($projectHeader) || $projectHeader !== $key->getProjectId()) {
- throw new Exception(Exception::PROJECT_ID_MISSING);
- }
- }
-
- if (!empty($key->getUserId())) {
- if (empty($userHeader) || $userHeader !== $key->getUserId()) {
- throw new Exception(Exception::USER_ID_MISSING);
- }
- }
-
- if (!empty($key->getTeamId())) {
- if (empty($organizationHeader) || $organizationHeader !== $key->getTeamId()) {
- throw new Exception(Exception::ORGANIZATION_ID_MISSING);
- }
- }
-
- return $key;
-}, ['request', 'project', 'team', 'user']);
-
-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
- // 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.
-
- try {
- $payload = $jwt->decode($tokenJWT);
- } catch (JWTException $error) {
- return new Document([]);
- }
-
- $tokenId = $payload['tokenId'] ?? '';
- if (empty($tokenId)) {
- return new Document([]);
- }
-
- $token = $authorization->skip(fn () => $dbForProject->getDocument('resourceTokens', $tokenId));
-
- if ($token->isEmpty()) {
- return new Document([]);
- }
-
- $expiry = $token->getAttribute('expire');
-
- if ($expiry !== null) {
- $now = new \DateTime();
- $expiryDate = new \DateTime($expiry);
-
- if ($expiryDate < $now) {
- return new Document([]);
- }
- }
-
- return match ($token->getAttribute('resourceType')) {
- TOKENS_RESOURCE_TYPE_FILES => (function () use ($token, $dbForProject, $authorization) {
- $sequences = explode(':', $token->getAttribute('resourceInternalId'));
- $ids = explode(':', $token->getAttribute('resourceId'));
-
- if (count($sequences) !== 2 || count($ids) !== 2) {
- return new Document([]);
- }
-
- $accessedAt = $token->getAttribute('accessedAt', 0);
- if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_RESOURCE_TOKEN_ACCESS)) > $accessedAt) {
- $token->setAttribute('accessedAt', DatabaseDateTime::now());
- $authorization->skip(fn () => $dbForProject->updateDocument('resourceTokens', $token->getId(), new Document([
- 'accessedAt' => $token->getAttribute('accessedAt')
- ])));
- }
-
- return new Document([
- 'bucketId' => $ids[0],
- 'fileId' => $ids[1],
- 'bucketInternalId' => $sequences[0],
- 'fileInternalId' => $sequences[1],
- ]);
- })(),
-
- default => throw new Exception(Exception::TOKEN_RESOURCE_TYPE_INVALID),
- };
- }
- return new Document([]);
-}, ['project', 'dbForProject', 'request', 'authorization']);
-
-Http::setResource('transactionState', function (Database $dbForProject, Authorization $authorization, callable $getDatabasesDB) {
- return new TransactionState($dbForProject, $authorization, $getDatabasesDB);
-}, ['dbForProject', 'authorization', 'getDatabasesDB']);
-
-Http::setResource('executionsRetentionCount', function (Document $project, array $plan) {
- if ($project->getId() === 'console' || empty($plan)) {
- return 0;
- }
-
- return (int) ($plan['executionsRetentionCount'] ?? 100);
-}, ['project', 'plan']);
-
-Http::setResource('embeddingAgent', function ($register) {
- $adapter = new Ollama();
- $adapter->setEndpoint(System::getEnv('_APP_EMBEDDING_ENDPOINT', 'http://ollama:11434/api/embed'));
- $adapter->setTimeout((int) System::getEnv('_APP_EMBEDDING_TIMEOUT', '30000'));
- return new Agent($adapter);
-}, ['register']);
+$container->set('executor', fn () => new Executor());
diff --git a/app/init/resources/request.php b/app/init/resources/request.php
new file mode 100644
index 0000000000..68f5968519
--- /dev/null
+++ b/app/init/resources/request.php
@@ -0,0 +1,1446 @@
+set('utopia:graphql', fn ($utopia) => $utopia, ['utopia']);
+
+ $context->set('log', fn () => new Log(), []);
+
+ $context->set('logger', fn ($register) => $register->get('logger'), ['register']);
+
+ $context->set('authorization', fn () => new Authorization(), []);
+
+ $context->set('store', fn (): Store => new Store(), []);
+
+ $context->set('proofForPassword', function (): Password {
+ $hash = new Argon2();
+ $hash
+ ->setMemoryCost(7168)
+ ->setTimeCost(5)
+ ->setThreads(1);
+
+ $password = new Password();
+ $password
+ ->setHash($hash);
+
+ return $password;
+ });
+
+ $context->set('proofForToken', function (): Token {
+ $token = new Token();
+ $token->setHash(new Sha());
+
+ return $token;
+ });
+
+ $context->set('proofForCode', function (): Code {
+ $code = new Code();
+ $code->setHash(new Sha());
+
+ return $code;
+ });
+
+ $context->set('locale', function () {
+ $locale = new Locale(System::getEnv('_APP_LOCALE', 'en'));
+ $locale->setFallback(System::getEnv('_APP_LOCALE', 'en'));
+
+ return $locale;
+ });
+
+ // Per-request queue resources (stateful, accumulate event data during request)
+ $context->set('queueForDatabase', fn (Publisher $publisher) => new EventDatabase($publisher), ['publisher']);
+ $context->set('queueForDeletes', fn (Publisher $publisher) => new Delete($publisher), ['publisher']);
+ $context->set('queueForEvents', fn (Publisher $publisher) => new Event($publisher), ['publisher']);
+ $context->set('queueForWebhooks', fn (Publisher $publisher) => new Webhook($publisher), ['publisher']);
+ $context->set('queueForRealtime', fn () => new Realtime(), []);
+ $context->set('usage', fn () => new UsageContext(), []);
+ $context->set('auditContext', fn () => new AuditContext(), []);
+ $context->set('publisherForFunctions', fn (Publisher $publisher) => new FunctionPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', Event::FUNCTIONS_QUEUE_TTL)
+ ), ['publisher']);
+ $context->set('eventProcessor', fn () => new EventProcessor(), []);
+ $context->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) {
+ $adapter = new DatabasePool($pools->get('console'));
+ $database = new Database($adapter, $cache);
+
+ $database
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization)
+ ->setNamespace('_console')
+ ->setMetadata('host', \gethostname())
+ ->setMetadata('project', 'console')
+ ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
+ ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
+
+ $database->setDocumentType('users', User::class);
+
+ return $database;
+ }, ['pools', 'cache', 'authorization']);
+
+ $context->set('getProjectDB', function (Group $pools, Database $dbForPlatform, Cache $cache, Authorization $authorization) {
+ $adapters = [];
+
+ return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$adapters) {
+ if ($project->isEmpty() || $project->getId() === 'console') {
+ return $dbForPlatform;
+ }
+
+ $database = $project->getAttribute('database', '');
+ if (empty($database)) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Project database is not configured');
+ }
+
+ try {
+ $dsn = new DSN($database);
+ } catch (\InvalidArgumentException) {
+ // TODO: Temporary until all projects are using shared tables
+ $dsn = new DSN('mysql://' . $database);
+ }
+
+ $adapter = $adapters[$dsn->getHost()] ??= new DatabasePool($pools->get($dsn->getHost()));
+ $database = new Database($adapter, $cache);
+
+ $database
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization)
+ ->setMetadata('host', \gethostname())
+ ->setMetadata('project', $project->getId())
+ ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
+ ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES)
+ ->setDocumentType('users', User::class);
+
+ $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
+
+ if (\in_array($dsn->getHost(), $sharedTables)) {
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
+ $database
+ ->setSharedTables(true)
+ ->setTenant($project->getSequence())
+ ->setGlobalCollections($projectsGlobalCollections)
+ ->setNamespace($dsn->getParam('namespace'));
+ } else {
+ $database
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $project->getSequence());
+ }
+
+ return $database;
+ };
+ }, ['pools', 'dbForPlatform', 'cache', 'authorization']);
+
+ $context->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
+ $adapter = null;
+
+ return function (?Document $project = null) use ($pools, $cache, $authorization, &$adapter) {
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $logsCollections = $collections['logs'] ?? [];
+ $logsCollections = array_keys($logsCollections);
+
+ $adapter ??= new DatabasePool($pools->get('logs'));
+ $database = new Database($adapter, $cache);
+
+ $database
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization)
+ ->setSharedTables(true)
+ ->setGlobalCollections($logsCollections)
+ ->setNamespace('logsV1')
+ ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
+ ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
+
+ if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
+ $database->setTenant($project->getSequence());
+ }
+
+ return $database;
+ };
+ }, ['pools', 'cache', 'authorization']);
+
+ /**
+ * List of allowed request hostnames for the request.
+ */
+ $context->set('allowedHostnames', function (array $platform, Document $project, Document $rule, Document $devKey, Request $request) {
+ $allowed = [...($platform['hostnames'] ?? [])];
+
+ /* Add platform configured hostnames */
+ 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()) {
+ $allowed[] = $request->getHostname();
+ }
+
+ $originHostname = parse_url($request->getOrigin(), PHP_URL_HOST);
+ $refererHostname = parse_url($request->getReferer(), PHP_URL_HOST);
+
+ $hostname = $originHostname;
+ if (empty($hostname)) {
+ $hostname = $refererHostname;
+ }
+
+ /* Add request hostname for preflight requests */
+ if ($request->getMethod() === 'OPTIONS') {
+ $allowed[] = $hostname;
+ }
+
+ /* Allow the request origin of rule */
+ 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)) {
+ $allowed[] = $hostname;
+ }
+
+ return array_unique($allowed);
+ }, ['platform', 'project', 'rule', 'devKey', 'request']);
+
+ /**
+ * List of allowed request schemes for the request.
+ */
+ $context->set('allowedSchemes', function (array $platform, Document $project) {
+ $allowed = [...($platform['schemas'] ?? [])];
+
+ if (! $project->isEmpty() && $project->getId() !== 'console') {
+ /* Add hardcoded schemes */
+ $allowed[] = 'exp';
+ $allowed[] = 'appwrite-callback-' . $project->getId();
+
+ /* Add platform configured schemes */
+ $platforms = $project->getAttribute('platforms', []);
+ $schemes = Platform::getSchemes($platforms);
+ $allowed = [...$allowed, ...$schemes];
+ }
+
+ return array_unique($allowed);
+ }, ['platform', 'project']);
+
+ /**
+ * Whether the request origin is verified against the request hostname.
+ */
+ $context->set('domainVerification', function (Request $request) {
+ $origin = \parse_url($request->getOrigin($request->getReferer('')), PHP_URL_HOST);
+ $selfDomain = new Domain($request->getHostname());
+ $endDomain = new Domain((string) $origin);
+
+ return ($selfDomain->getRegisterable() === $endDomain->getRegisterable())
+ && $endDomain->getRegisterable() !== '';
+ }, ['request']);
+
+ /**
+ * Cookie domain for the current request.
+ */
+ $context->set('cookieDomain', function (Request $request, Document $project) {
+ $localHosts = ['localhost', 'localhost:' . $request->getPort()];
+
+ $migrationHost = System::getEnv('_APP_MIGRATION_HOST');
+ if (!empty($migrationHost)) {
+ // Treat the migration host like localhost because internal migration and CI
+ // traffic may use it before a public domain is configured.
+ $localHosts[] = $migrationHost;
+ $localHosts[] = $migrationHost . ':' . $request->getPort();
+ }
+
+ $hostname = $request->getHostname();
+ $isLocalHost = \in_array($hostname, $localHosts, true);
+ $isIpAddress = \filter_var($hostname, FILTER_VALIDATE_IP) !== false;
+
+ if ($isLocalHost || $isIpAddress) {
+ return;
+ }
+
+ $isConsoleProject = $project->getAttribute('$id', '') === 'console';
+ $isConsoleRootSession = System::getEnv('_APP_CONSOLE_ROOT_SESSION', 'disabled') === 'enabled';
+
+ if ($isConsoleProject && $isConsoleRootSession) {
+ $domain = new Domain($hostname);
+
+ return '.' . $domain->getRegisterable();
+ }
+
+ return '.' . $hostname;
+ }, ['request', 'project']);
+
+ /**
+ * Rule associated with a request origin.
+ */
+ $context->set('rule', function (Request $request, Database $dbForPlatform, Document $project, Authorization $authorization) {
+ $domain = \parse_url($request->getOrigin(), PHP_URL_HOST);
+
+ if (empty($domain)) {
+ $domain = \parse_url($request->getReferer(), PHP_URL_HOST);
+ }
+
+ if (empty($domain)) {
+ return new Document();
+ }
+
+ // TODO: (@Meldiron) Remove after 1.7.x migration
+ $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5';
+ $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) {
+ if ($isMd5) {
+ return $dbForPlatform->getDocument('rules', md5($domain));
+ }
+
+ return $dbForPlatform->findOne('rules', [
+ Query::equal('domain', [$domain]),
+ ]);
+ });
+
+ $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence();
+
+ // 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', ''))) {
+ $trustedProjects = [];
+ foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) {
+ if (empty($trustedProject)) {
+ continue;
+ }
+ $trustedProjects[] = $trustedProject;
+ }
+ if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects)) {
+ $permitsCurrentProject = true;
+ }
+ }
+
+ if (! $permitsCurrentProject) {
+ return new Document();
+ }
+
+ return $rule;
+ }, ['request', 'dbForPlatform', 'project', 'authorization']);
+
+ /**
+ * CORS service
+ */
+ $context->set('cors', function (array $allowedHostnames) {
+ $corsConfig = Config::getParam('cors');
+
+ return new Cors(
+ $allowedHostnames,
+ allowedMethods: $corsConfig['allowedMethods'],
+ allowedHeaders: $corsConfig['allowedHeaders'],
+ allowCredentials: true,
+ exposedHeaders: $corsConfig['exposedHeaders'],
+ );
+ }, ['allowedHostnames']);
+
+ $context->set(
+ 'originValidator',
+ fn (Document $devKey, array $allowedHostnames, array $allowedSchemes) => $devKey->isEmpty()
+ ? new Origin($allowedHostnames, $allowedSchemes)
+ : new URL(),
+ ['devKey', 'allowedHostnames', 'allowedSchemes']
+ );
+
+ $context->set(
+ 'redirectValidator',
+ fn (Document $devKey, array $allowedHostnames, array $allowedSchemes) => $devKey->isEmpty()
+ ? new Redirect($allowedHostnames, $allowedSchemes)
+ : new URL(),
+ ['devKey', 'allowedHostnames', 'allowedSchemes']
+ );
+
+ $context->set('user', function (string $mode, Document $project, Document $console, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Store $store, Token $proofForToken, $authorization) {
+ /**
+ * Handles user authentication and session validation.
+ *
+ * This function follows a series of steps to determine the appropriate user session
+ * based on cookies, headers, and JWT tokens.
+ *
+ * Process:
+ * 1. Checks the cookie based on mode:
+ * - If in admin mode, uses console project id for key.
+ * - Otherwise, sets the key using the project ID
+ * 2. If no cookie is found, attempts to retrieve the fallback header `x-fallback-cookies`.
+ * - If this method is used, returns the header: `X-Debug-Fallback: true`.
+ * 3. Fetches the user document from the appropriate database based on the mode.
+ * 4. If the user document is empty or the session key cannot be verified, sets an empty user document.
+ * 5. Regardless of the results from steps 1-4, attempts to fetch the JWT token.
+ * 6. If the JWT user has a valid session ID, updates the user variable with the user from `projectDB`,
+ * 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 ($mode === APP_MODE_ADMIN) {
+ $store->setKey('a_session_' . $console->getId());
+ }
+
+ $store->decode(
+ $request->getCookie(
+ $store->getKey(), // Get sessions
+ $request->getCookie($store->getKey() . '_legacy', '')
+ )
+ );
+
+ // Get session from header for SSR clients
+ if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
+ $sessionHeader = $request->getHeader('x-appwrite-session', '');
+
+ if (! empty($sessionHeader)) {
+ $store->decode($sessionHeader);
+ }
+ }
+
+ // Get fallback session from old clients (no SameSite support) or clients who block 3rd-party cookies
+ $response->addHeader('X-Debug-Fallback', 'false');
+
+ if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
+ $response->addHeader('X-Debug-Fallback', 'true');
+ $fallback = $request->getHeader('x-fallback-cookies', '');
+ $fallback = \json_decode($fallback, true);
+ $store->decode(((is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : ''));
+ }
+
+ $user = null;
+ 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 ($project->getId() === 'console') {
+ /** @var User $user */
+ $user = $dbForPlatform->getDocument('users', $store->getProperty('id', ''));
+ } else {
+ /** @var User $user */
+ $user = $dbForProject->getDocument('users', $store->getProperty('id', ''));
+ }
+ }
+ }
+ }
+
+ if (
+ ! $user ||
+ $user->isEmpty() // Check a document has been found in the DB
+ || ! $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()) {
+ throw new Exception(Exception::USER_JWT_AND_COOKIE_SET);
+ }
+
+ $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0);
+ try {
+ $payload = $jwt->decode($authJWT);
+ } catch (JWTException $error) {
+ throw new Exception(Exception::USER_JWT_INVALID, 'Failed to verify JWT. ' . $error->getMessage());
+ }
+
+ $jwtUserId = $payload['userId'] ?? '';
+ if (! empty($jwtUserId)) {
+ if ($mode === APP_MODE_ADMIN) {
+ $user = $dbForPlatform->getDocument('users', $jwtUserId);
+ } else {
+ $user = $dbForProject->getDocument('users', $jwtUserId);
+ }
+ }
+ $jwtSessionId = $payload['sessionId'] ?? '';
+ if (! empty($jwtSessionId)) {
+ if (empty($user->find('$id', $jwtSessionId, 'sessions'))) { // Match JWT to active token
+ $user = new User([]);
+ }
+ }
+ }
+
+ // 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()) {
+ throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET);
+ }
+
+ $accountKeyUser = $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId));
+ if (! $accountKeyUser->isEmpty()) {
+ $key = $accountKeyUser->find(
+ key: 'secret',
+ find: $accountKey,
+ subject: 'keys'
+ );
+
+ if (! empty($key)) {
+ $expire = $key->getAttribute('expire');
+ if (! empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
+ throw new Exception(Exception::ACCOUNT_KEY_EXPIRED);
+ }
+
+ $user = $accountKeyUser;
+ }
+ }
+ }
+
+ // Impersonation: if current user has impersonator capability and headers/params are set, act as another user
+ // Query params mirror the header fallback pattern used by ?project= and ?devKey=,
+ // allowing Console to embed impersonation in direct file/image URLs where headers cannot be set.
+ $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', ''));
+ $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', (string)$request->getParam('impersonateEmail', ''));
+ $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', (string)$request->getParam('impersonatePhone', ''));
+ if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) {
+ $userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject;
+ $targetUser = null;
+ 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']);
+
+ $context->set('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)) {
+ $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;
+ }
+
+ $project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
+
+ return $project;
+ }, ['dbForPlatform', 'request', 'console', 'authorization', 'utopia']);
+
+ $context->set('session', function (User $user, Store $store, Token $proofForToken) {
+ if ($user->isEmpty()) {
+ return;
+ }
+
+ $sessions = $user->getAttribute('sessions', []);
+ $sessionId = $user->sessionVerify($store->getProperty('secret', ''), $proofForToken);
+
+ if (! $sessionId) {
+ return;
+ }
+ foreach ($sessions as $session) {
+ /** @var Document $session */
+ if ($sessionId === $session->getId()) {
+ return $session;
+ }
+ }
+
+ return;
+ }, ['user', 'store', 'proofForToken']);
+
+ $context->set('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, FunctionPublisher $publisherForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, UsageContext $usage, Authorization $authorization, Request $request) {
+ if ($project->isEmpty() || $project->getId() === 'console') {
+ return $dbForPlatform;
+ }
+
+ $database = $project->getAttribute('database', '');
+ if (empty($database)) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Project database is not configured');
+ }
+
+ try {
+ $dsn = new DSN($database);
+ } catch (\InvalidArgumentException) {
+ // TODO: Temporary until all projects are using shared tables
+ $dsn = new DSN('mysql://' . $database);
+ }
+
+ $adapter = new DatabasePool($pools->get($dsn->getHost()));
+ $database = new Database($adapter, $cache);
+
+ $database
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization)
+ ->setMetadata('host', \gethostname())
+ ->setMetadata('project', $project->getId())
+ ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
+ ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
+ $database->setDocumentType('users', User::class);
+
+ $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
+
+ if (\in_array($dsn->getHost(), $sharedTables)) {
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
+ $database
+ ->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
+ ->setTenant($project->getSequence())
+ ->setNamespace($dsn->getParam('namespace'));
+ } else {
+ $database
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $project->getSequence());
+ }
+
+ /**
+ * This isolated event handling for `users.*.create` which is based on a `Database::EVENT_DOCUMENT_CREATE` listener may look odd, but it is **intentional**.
+ *
+ * Accounts can be created in many ways beyond `createAccount`
+ * (anonymous, OAuth, phone, etc.), and those flows are probably not covered in event tests; so we handle this here.
+ */
+ $eventDatabaseListener = function (Document $project, Document $document, Response $response, Event $queueForEvents, FunctionPublisher $publisherForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime) {
+ // Only trigger events for user creation with the database listener.
+ if ($document->getCollection() !== 'users') {
+ return;
+ }
+
+ $queueForEvents
+ ->setEvent('users.[userId].create')
+ ->setParam('userId', $document->getId())
+ ->setPayload($response->output($document, Response::MODEL_USER));
+
+ // Trigger functions, webhooks, and realtime events
+ $publisherForFunctions->enqueue(FunctionMessage::fromEvent(
+ event: $queueForEvents->getEvent(),
+ params: $queueForEvents->getParams(),
+ project: $queueForEvents->getProject(),
+ user: $queueForEvents->getUser(),
+ userId: $queueForEvents->getUserId(),
+ payload: $queueForEvents->getPayload(),
+ platform: $queueForEvents->getPlatform(),
+ ));
+
+ /** Trigger webhooks events only if a project has them enabled */
+ if (! empty($project->getAttribute('webhooks'))) {
+ $queueForWebhooks
+ ->from($queueForEvents)
+ ->trigger();
+ }
+
+ /** Trigger realtime events only for non console events */
+ if ($queueForEvents->getProject()->getId() !== 'console') {
+ $queueForRealtime
+ ->from($queueForEvents)
+ ->trigger();
+ }
+ };
+
+ /**
+ * Purge function events cache when functions are created, updated or deleted.
+ */
+ $functionsEventsCacheListener = function (string $event, Document $document, Document $project, Database $dbForProject) {
+
+ if ($document->getCollection() !== 'functions') {
+ return;
+ }
+
+ if ($project->isEmpty() || $project->getId() === 'console') {
+ return;
+ }
+
+ $hostname = $dbForProject->getAdapter()->getHostname();
+ $cacheKey = \sprintf(
+ '%s-cache-%s:%s:%s:project:%s:functions:events',
+ $dbForProject->getCacheName(),
+ $hostname,
+ $dbForProject->getNamespace(),
+ $dbForProject->getTenant(),
+ $project->getId()
+ );
+
+ $dbForProject->getCache()->purge($cacheKey);
+ };
+
+ /**
+ * Prefix metrics with database type when applicable.
+ * Avoids prefixing for legacy and tablesdb types to preserve historical metrics.
+ */
+ $getDatabaseTypePrefixedMetric = function (string $databaseType, string $metric): string {
+ if (
+ $databaseType === '' ||
+ $databaseType === DATABASE_TYPE_LEGACY ||
+ $databaseType === DATABASE_TYPE_TABLESDB
+ ) {
+ return $metric;
+ }
+
+ return $databaseType . '.' . $metric;
+ };
+
+ // Determine database type from request path, similar to api.php
+ $path = $request->getURI();
+ $databaseType = match (true) {
+ str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB,
+ str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB,
+ default => '',
+ };
+
+ $usageDatabaseListener = function (string $event, Document $document, UsageContext $usage) use ($getDatabaseTypePrefixedMetric, $databaseType) {
+ $value = 1;
+
+ switch ($event) {
+ case Database::EVENT_DOCUMENT_DELETE:
+ $value = -1;
+ break;
+ case Database::EVENT_DOCUMENTS_DELETE:
+ $value = -1 * $document->getAttribute('modified', 0);
+ break;
+ case Database::EVENT_DOCUMENTS_CREATE:
+ $value = $document->getAttribute('modified', 0);
+ break;
+ case Database::EVENT_DOCUMENTS_UPSERT:
+ $value = $document->getAttribute('created', 0);
+ break;
+ }
+
+ switch (true) {
+ case $document->getCollection() === 'teams':
+ $usage->addMetric(METRIC_TEAMS, $value); // per project
+ break;
+ case $document->getCollection() === 'users':
+ $usage->addMetric(METRIC_USERS, $value); // per project
+ if ($event === Database::EVENT_DOCUMENT_DELETE) {
+ $usage->addReduce($document);
+ }
+ break;
+ case $document->getCollection() === 'sessions': // sessions
+ $usage->addMetric(METRIC_SESSIONS, $value); // per project
+ break;
+ case $document->getCollection() === 'databases': // databases
+ $metric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASES);
+ $usage->addMetric($metric, $value); // per project
+
+ if ($event === Database::EVENT_DOCUMENT_DELETE) {
+ $usage->addReduce($document);
+ }
+ break;
+ case str_starts_with($document->getCollection(), 'database_') && ! str_contains($document->getCollection(), 'collection'): // collections
+ $parts = explode('_', $document->getCollection());
+ $databaseInternalId = $parts[1] ?? 0;
+ $collectionMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_COLLECTIONS);
+ $databaseIdCollectionMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_COLLECTIONS);
+ $usage
+ ->addMetric($collectionMetric, $value) // per project
+ ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdCollectionMetric), $value);
+
+ if ($event === Database::EVENT_DOCUMENT_DELETE) {
+ $usage->addReduce($document);
+ }
+ break;
+ case str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_'): // documents
+ $parts = explode('_', $document->getCollection());
+ $databaseInternalId = $parts[1] ?? 0;
+ $collectionInternalId = $parts[3] ?? 0;
+ $documentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DOCUMENTS);
+ $databaseIdDocumentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_DOCUMENTS);
+ $databaseIdCollectionIdDocumentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS);
+ $usage
+ ->addMetric($documentsMetric, $value) // per project
+ ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
+ ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
+ break;
+ case $document->getCollection() === 'buckets': // buckets
+ $usage->addMetric(METRIC_BUCKETS, $value); // per project
+ if ($event === Database::EVENT_DOCUMENT_DELETE) {
+ $usage
+ ->addReduce($document);
+ }
+ break;
+ case str_starts_with($document->getCollection(), 'bucket_'): // files
+ $parts = explode('_', $document->getCollection());
+ $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':
+ $usage->addMetric(METRIC_FUNCTIONS, $value); // per project
+
+ if ($event === Database::EVENT_DOCUMENT_DELETE) {
+ $usage
+ ->addReduce($document);
+ }
+ break;
+ case $document->getCollection() === 'sites':
+ $usage->addMetric(METRIC_SITES, $value); // per project
+
+ if ($event === Database::EVENT_DOCUMENT_DELETE) {
+ $usage
+ ->addReduce($document);
+ }
+ break;
+ case $document->getCollection() === 'deployments':
+ $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
+ ->addMetric(str_replace(['{resourceType}'], [$document->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_DEPLOYMENTS_STORAGE), $document->getAttribute('size') * $value)
+ ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$document->getAttribute('resourceType'), $document->getAttribute('resourceInternalId')], METRIC_RESOURCE_TYPE_ID_DEPLOYMENTS), $value) // per function
+ ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$document->getAttribute('resourceType'), $document->getAttribute('resourceInternalId')], METRIC_RESOURCE_TYPE_ID_DEPLOYMENTS_STORAGE), $document->getAttribute('size') * $value);
+ break;
+ default:
+ break;
+ }
+ };
+
+ // Clone the queues, to prevent events triggered by the database listener
+ // from overwriting the events that are supposed to be triggered in the shutdown hook.
+ $queueForEventsClone = new Event($publisher);
+ $queueForWebhooks = new Webhook($publisherWebhooks);
+ $queueForRealtime = new Realtime();
+
+ $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),
+ $publisherForFunctions,
+ $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', 'publisherForFunctions', 'queueForWebhooks', 'queueForRealtime', 'usage', 'authorization', 'request']);
+
+ $context->set('schema', function ($utopia, $dbForProject, $authorization) {
+
+ $complexity = function (int $complexity, array $args) {
+ $queries = Query::parseQueries($args['queries'] ?? []);
+ $query = Query::getByType($queries, [Query::TYPE_LIMIT])[0] ?? null;
+ $limit = $query ? $query->getValue() : APP_LIMIT_LIST_DEFAULT;
+
+ return $complexity * $limit;
+ };
+
+ $attributes = function (int $limit, int $offset) use ($dbForProject, $authorization) {
+ $attrs = $authorization->skip(fn () => $dbForProject->find('attributes', [
+ Query::limit($limit),
+ Query::offset($offset),
+ ]));
+
+ return \array_map(function ($attr) {
+ return $attr->getArrayCopy();
+ }, $attrs);
+ };
+
+ $urls = [
+ 'list' => function (string $databaseId, string $collectionId, array $args) {
+ return "/v1/databases/$databaseId/collections/$collectionId/documents";
+ },
+ 'create' => function (string $databaseId, string $collectionId, array $args) {
+ return "/v1/databases/$databaseId/collections/$collectionId/documents";
+ },
+ 'read' => function (string $databaseId, string $collectionId, array $args) {
+ return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}";
+ },
+ 'update' => function (string $databaseId, string $collectionId, array $args) {
+ return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}";
+ },
+ 'delete' => function (string $databaseId, string $collectionId, array $args) {
+ return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}";
+ },
+ ];
+
+ // NOTE: `params` and `urls` are not used internally in the `Schema::build` function below!
+ $params = [
+ 'list' => function (string $databaseId, string $collectionId, array $args) {
+ return ['queries' => $args['queries']];
+ },
+ 'create' => function (string $databaseId, string $collectionId, array $args) {
+ $id = $args['id'] ?? 'unique()';
+ $permissions = $args['permissions'] ?? null;
+
+ unset($args['id']);
+ unset($args['permissions']);
+
+ // Order must be the same as the route params
+ return [
+ 'databaseId' => $databaseId,
+ 'documentId' => $id,
+ 'collectionId' => $collectionId,
+ 'data' => $args,
+ 'permissions' => $permissions,
+ ];
+ },
+ 'update' => function (string $databaseId, string $collectionId, array $args) {
+ $documentId = $args['id'];
+ $permissions = $args['permissions'] ?? null;
+
+ unset($args['id']);
+ unset($args['permissions']);
+
+ // Order must be the same as the route params
+ return [
+ 'databaseId' => $databaseId,
+ 'collectionId' => $collectionId,
+ 'documentId' => $documentId,
+ 'data' => $args,
+ 'permissions' => $permissions,
+ ];
+ },
+ ];
+
+ return Schema::build(
+ $utopia,
+ $complexity,
+ $attributes,
+ $urls,
+ $params,
+ );
+ }, ['utopia', 'dbForProject', 'authorization']);
+
+ $context->set('audit', fn ($dbForProject) => new Audit(new AdapterDatabase($dbForProject)), ['dbForProject']);
+
+ $context->set('mode', function ($request, Document $project) {
+ /** @var Appwrite\Utopia\Request $request */
+
+ /**
+ * Defines the mode for the request:
+ * - 'default' => Requests for Client and Server Side
+ * - 'admin' => Request from the Console on non-console projects
+ */
+ $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']);
+
+ $context->set('requestTimestamp', function ($request) {
+ // TODO: Move this to the Request class itself
+ $timestampHeader = $request->getHeader('x-appwrite-timestamp');
+ $requestTimestamp = null;
+ 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']);
+
+ $context->set('devKey', function (Request $request, Document $project, array $servers, Database $dbForPlatform, Authorization $authorization) {
+ $devKey = $request->getHeader('x-appwrite-dev-key', $request->getParam('devKey', ''));
+
+ // Check if given key match project's development keys
+ $key = $project->find('secret', $devKey, 'devKeys');
+ if (! $key) {
+ return new Document([]);
+ }
+
+ // check expiration
+ $expire = $key->getAttribute('expire');
+ if (! empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
+ return new Document([]);
+ }
+
+ // update access time
+ $accessedAt = $key->getAttribute('accessedAt', 0);
+ if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_KEY_ACCESS)) > $accessedAt) {
+ $key->setAttribute('accessedAt', DatabaseDateTime::now());
+ $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([
+ 'accessedAt' => $key->getAttribute('accessedAt')
+ ])));
+ $dbForPlatform->purgeCachedDocument('projects', $project->getId());
+ }
+
+ // add sdk to key
+ $sdkValidator = new WhiteList($servers, true);
+ $sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN'));
+
+ if ($sdk !== 'unknown' && $sdkValidator->isValid($sdk)) {
+ $sdks = $key->getAttribute('sdks', []);
+
+ if (! in_array($sdk, $sdks)) {
+ $sdks[] = $sdk;
+ $key->setAttribute('sdks', $sdks);
+
+ /** Update access time as well */
+ $key->setAttribute('accessedAt', DatabaseDateTime::now());
+ $key = $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([
+ 'sdks' => $key->getAttribute('sdks'),
+ 'accessedAt' => $key->getAttribute('accessedAt')
+ ])));
+ $dbForPlatform->purgeCachedDocument('projects', $project->getId());
+ }
+ }
+
+ return $key;
+ }, ['request', 'project', 'servers', 'dbForPlatform', 'authorization']);
+
+ $context->set('team', function (Document $project, Database $dbForPlatform, Http $utopia, Request $request, Authorization $authorization) {
+ $teamInternalId = '';
+ if ($project->getId() !== 'console') {
+ $teamInternalId = $project->getAttribute('teamInternalId', '');
+ } else {
+ $route = $utopia->match($request);
+ $path = ! empty($route) ? $route->getPath() : $request->getURI();
+ $orgHeader = $request->getHeader('x-appwrite-organization', '');
+ if (str_starts_with($path, '/v1/projects/:projectId')) {
+ $uri = $request->getURI();
+ $pid = explode('/', $uri)[3];
+ $p = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $pid));
+ $teamInternalId = $p->getAttribute('teamInternalId', '');
+ } elseif ($path === '/v1/projects') {
+ $teamId = $request->getParam('teamId', '');
+
+ if (empty($teamId)) {
+ return new Document([]);
+ }
+
+ $team = $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $teamId));
+
+ return $team;
+ } elseif (! empty($orgHeader)) {
+ return $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $orgHeader));
+ }
+ }
+
+ // if teamInternalId is empty, return an empty document
+
+ if (empty($teamInternalId)) {
+ return new Document([]);
+ }
+
+ $team = $authorization->skip(function () use ($dbForPlatform, $teamInternalId) {
+ return $dbForPlatform->findOne('teams', [
+ Query::equal('$sequence', [$teamInternalId]),
+ ]);
+ });
+
+ return $team;
+ }, ['project', 'dbForPlatform', 'utopia', 'request', 'authorization']);
+
+ $context->set('previewHostname', function (Request $request, ?Key $apiKey) {
+ $allowed = false;
+
+ if (Http::isDevelopment()) {
+ $allowed = 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)) {
+ return $host;
+ }
+ }
+
+ return '';
+ }, ['request', 'apiKey']);
+
+ $context->set('apiKey', function (Request $request, Document $project, Document $team, Document $user): ?Key {
+ $key = $request->getHeader('x-appwrite-key');
+
+ if (empty($key)) {
+ return null;
+ }
+
+ $key = Key::decode($project, $team, $user, $key);
+
+ $userHeader = $request->getHeader('x-appwrite-user');
+ $organizationHeader = $request->getHeader('x-appwrite-organization');
+ $projectHeader = $request->getHeader('x-appwrite-project');
+
+ if (! empty($key->getProjectId())) {
+ if (empty($projectHeader) || $projectHeader !== $key->getProjectId()) {
+ throw new Exception(Exception::PROJECT_ID_MISSING);
+ }
+ }
+
+ if (! empty($key->getUserId())) {
+ if (empty($userHeader) || $userHeader !== $key->getUserId()) {
+ throw new Exception(Exception::USER_ID_MISSING);
+ }
+ }
+
+ if (! empty($key->getTeamId())) {
+ if (empty($organizationHeader) || $organizationHeader !== $key->getTeamId()) {
+ throw new Exception(Exception::ORGANIZATION_ID_MISSING);
+ }
+ }
+
+ return $key;
+ }, ['request', 'project', 'team', 'user']);
+
+ $context->set('resourceToken', function ($project, $dbForProject, $request, Authorization $authorization) {
+ $tokenJWT = $request->getParam('token');
+
+ 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.
+
+ try {
+ $payload = $jwt->decode($tokenJWT);
+ } catch (JWTException $error) {
+ return new Document([]);
+ }
+
+ $tokenId = $payload['tokenId'] ?? '';
+ if (empty($tokenId)) {
+ return new Document([]);
+ }
+
+ $token = $authorization->skip(fn () => $dbForProject->getDocument('resourceTokens', $tokenId));
+
+ if ($token->isEmpty()) {
+ return new Document([]);
+ }
+
+ $expiry = $token->getAttribute('expire');
+
+ if ($expiry !== null) {
+ $now = new \DateTime();
+ $expiryDate = new \DateTime($expiry);
+
+ if ($expiryDate < $now) {
+ return new Document([]);
+ }
+ }
+
+ return match ($token->getAttribute('resourceType')) {
+ TOKENS_RESOURCE_TYPE_FILES => (function () use ($token, $dbForProject, $authorization) {
+ $sequences = explode(':', $token->getAttribute('resourceInternalId'));
+ $ids = explode(':', $token->getAttribute('resourceId'));
+
+ if (count($sequences) !== 2 || count($ids) !== 2) {
+ return new Document([]);
+ }
+
+ $accessedAt = $token->getAttribute('accessedAt', 0);
+ if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_RESOURCE_TOKEN_ACCESS)) > $accessedAt) {
+ $token->setAttribute('accessedAt', DatabaseDateTime::now());
+ $authorization->skip(fn () => $dbForProject->updateDocument('resourceTokens', $token->getId(), new Document([
+ 'accessedAt' => $token->getAttribute('accessedAt')
+ ])));
+ }
+
+ return new Document([
+ 'bucketId' => $ids[0],
+ 'fileId' => $ids[1],
+ 'bucketInternalId' => $sequences[0],
+ 'fileInternalId' => $sequences[1],
+ ]);
+ })(),
+
+ default => throw new Exception(Exception::TOKEN_RESOURCE_TYPE_INVALID),
+ };
+ }
+
+ return new Document([]);
+ }, ['project', 'dbForProject', 'request', 'authorization']);
+
+ $context->set('getDatabasesDB', function (Group $pools, Cache $cache, Document $project, Request $request, UsageContext $usage, Authorization $authorization) {
+
+ return function (Document $database) use ($pools, $cache, $project, $request, $usage, $authorization): Database {
+ $databaseDSN = $database->getAttribute('database') ?: $project->getAttribute('database', '');
+ $databaseType = $database->getAttribute('type', '');
+
+ try {
+ $databaseDSN = new DSN($databaseDSN);
+ } catch (\InvalidArgumentException) {
+ // for old databases migrated through patch script
+ // databaseDSN determines the adapter
+ $databaseDSN = new DSN('mysql://' . $databaseDSN);
+ }
+ try {
+ $dsn = new DSN($project->getAttribute('database'));
+ } catch (\InvalidArgumentException) {
+ // TODO: Temporary until all projects are using shared tables
+ $dsn = new DSN('mysql://' . $project->getAttribute('database'));
+ }
+
+ $databaseHost = $databaseDSN->getHost();
+ $pool = $pools->get($databaseHost);
+
+ $adapter = new DatabasePool($pool);
+ $database = new Database($adapter, $cache);
+ $sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')));
+
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
+ $database
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization)
+ ->setMetadata('host', \gethostname())
+ ->setMetadata('project', $project->getId())
+ ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
+ ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
+ // inside pools authorization needs to be set first
+ $database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB);
+
+ // For separate pools (documentsdb/vectorsdb), check their own shared tables config.
+ // If not configured, use dedicated mode to avoid cross-engine tenant type mismatches.
+ if ($databaseHost !== $dsn->getHost()) {
+ $dbTypeSharedTables = match ($databaseType) {
+ DOCUMENTSDB => \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', ''))),
+ VECTORSDB => \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''))),
+ default => [],
+ };
+
+ if (\in_array($databaseHost, $dbTypeSharedTables)) {
+ $database
+ ->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
+ ->setTenant($project->getSequence())
+ ->setNamespace($databaseDSN->getParam('namespace'));
+ } else {
+ $database
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $project->getSequence());
+ }
+ } elseif (\in_array($dsn->getHost(), $sharedTables)) {
+ $database
+ ->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
+ ->setTenant($project->getSequence())
+ ->setNamespace($dsn->getParam('namespace'));
+ } else {
+ $database
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $project->getSequence());
+ }
+ $timeout = \intval($request->getHeader('x-appwrite-timeout'));
+ if (!empty($timeout) && Http::isDevelopment()) {
+ $database->setTimeout($timeout);
+ }
+
+ // Register database event listeners for usage stats collection
+ $documentsMetric = METRIC_DOCUMENTS;
+ $databaseIdDocumentsMetric = METRIC_DATABASE_ID_DOCUMENTS;
+ $databaseIdCollectionIdDocumentsMetric = METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS;
+ if ($databaseType !== DATABASE_TYPE_LEGACY && $databaseType !== DATABASE_TYPE_TABLESDB) {
+ $documentsMetric = $databaseType . '.' . $documentsMetric;
+ $databaseIdDocumentsMetric = $databaseType . '.' . $databaseIdDocumentsMetric;
+ $databaseIdCollectionIdDocumentsMetric = $databaseType . '.' . $databaseIdCollectionIdDocumentsMetric;
+ }
+ $database
+ ->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
+ $value = 1;
+
+ if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
+ $parts = explode('_', $document->getCollection());
+ $databaseInternalId = $parts[1] ?? 0;
+ $collectionInternalId = $parts[3] ?? 0;
+ $usage
+ ->addMetric($documentsMetric, $value) // per project
+ ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
+ ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
+ }
+ })
+ ->on(Database::EVENT_DOCUMENT_DELETE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
+ $value = -1;
+
+ if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
+ $parts = explode('_', $document->getCollection());
+ $databaseInternalId = $parts[1] ?? 0;
+ $collectionInternalId = $parts[3] ?? 0;
+ $usage
+ ->addMetric($documentsMetric, $value) // per project
+ ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
+ ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
+ }
+ })
+ ->on(Database::EVENT_DOCUMENTS_CREATE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
+ $value = $document->getAttribute('modified', 0);
+
+ if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
+ $parts = explode('_', $document->getCollection());
+ $databaseInternalId = $parts[1] ?? 0;
+ $collectionInternalId = $parts[3] ?? 0;
+ $usage
+ ->addMetric($documentsMetric, $value) // per project
+ ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
+ ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
+ }
+ })
+ ->on(Database::EVENT_DOCUMENTS_DELETE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
+ $value = -1 * $document->getAttribute('modified', 0);
+
+ if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
+ $parts = explode('_', $document->getCollection());
+ $databaseInternalId = $parts[1] ?? 0;
+ $collectionInternalId = $parts[3] ?? 0;
+ $usage
+ ->addMetric($documentsMetric, $value) // per project
+ ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
+ ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
+ }
+ })
+ ->on(Database::EVENT_DOCUMENTS_UPSERT, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) {
+ $value = $document->getAttribute('created', 0);
+
+ if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) {
+ $parts = explode('_', $document->getCollection());
+ $databaseInternalId = $parts[1] ?? 0;
+ $collectionInternalId = $parts[3] ?? 0;
+ $usage
+ ->addMetric($documentsMetric, $value) // per project
+ ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database
+ ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection
+ }
+ });
+
+ return $database;
+ };
+
+ }, ['pools', 'cache', 'project', 'request', 'usage', 'authorization']);
+
+ $context->set(
+ 'transactionState',
+ fn (Database $dbForProject, Authorization $authorization, callable $getDatabasesDB) => new TransactionState($dbForProject, $authorization, $getDatabasesDB),
+ ['dbForProject', 'authorization', 'getDatabasesDB']
+ );
+
+ $context->set(
+ 'executionsRetentionCount',
+ fn (Document $project, array $plan) => ($project->getId() === 'console' || empty($plan))
+ ? 0
+ : (int) ($plan['executionsRetentionCount'] ?? 100),
+ ['project', 'plan']
+ );
+
+ $context->set('deviceForFiles', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId())), ['project', 'telemetry']);
+ $context->set('deviceForSites', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId())), ['project', 'telemetry']);
+ $context->set('deviceForMigrations', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId())), ['project', 'telemetry']);
+ $context->set('deviceForFunctions', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId())), ['project', 'telemetry']);
+ $context->set('deviceForBuilds', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId())), ['project', 'telemetry']);
+
+ $context->set('embeddingAgent', function ($register) {
+ $adapter = new Ollama();
+ $adapter->setEndpoint(System::getEnv('_APP_EMBEDDING_ENDPOINT', 'http://ollama:11434/api/embed'));
+ $adapter->setTimeout((int) System::getEnv('_APP_EMBEDDING_TIMEOUT', '30000'));
+ return new Agent($adapter);
+ }, ['register']);
+};
diff --git a/app/init/span.php b/app/init/span.php
index 76f37f5300..f6871badfa 100644
--- a/app/init/span.php
+++ b/app/init/span.php
@@ -3,6 +3,30 @@
use Utopia\Span\Exporter;
use Utopia\Span\Span;
use Utopia\Span\Storage;
+use Utopia\System\System;
Span::setStorage(new Storage\Coroutine());
-Span::addExporter(new Exporter\Pretty());
+
+// Resolve trace filters once at boot to avoid repeated env lookups per span.
+$traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', '');
+$traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', '');
+$traceEnabled = $traceProjectId !== '' || $traceFunctionId !== '';
+
+Span::addExporter(new Exporter\Pretty(), function (Span $span) use ($traceEnabled, $traceProjectId, $traceFunctionId): bool {
+ if (\str_starts_with($span->getAction(), 'listener.')) {
+ return $span->getError() !== null;
+ }
+
+ // Selective tracing: when _APP_TRACE_PROJECT_ID / _APP_TRACE_FUNCTION_ID are set,
+ // only export spans tagged with matching project.id / function.id.
+ if ($traceEnabled) {
+ if ($traceProjectId !== '' && $span->get('project.id') !== $traceProjectId) {
+ return false;
+ }
+ if ($traceFunctionId !== '' && $span->get('function.id') !== $traceFunctionId) {
+ return false;
+ }
+ }
+
+ return true;
+});
diff --git a/app/init/worker/message.php b/app/init/worker/message.php
new file mode 100644
index 0000000000..d4aea0c51e
--- /dev/null
+++ b/app/init/worker/message.php
@@ -0,0 +1,461 @@
+set('log', fn () => new Log(), []);
+
+ $container->set('usage', fn () => new Context(), []);
+
+ $container->set('authorization', function () {
+ $authorization = new Authorization();
+ $authorization->disable();
+
+ return $authorization;
+ }, []);
+
+ $container->set('dbForPlatform', function (Cache $cache, Group $pools, Authorization $authorization) {
+ $adapter = new DatabasePool($pools->get('console'));
+ $dbForPlatform = new Database($adapter, $cache);
+
+ $dbForPlatform
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization)
+ ->setNamespace('_console')
+ ->setDocumentType('users', User::class);
+
+ return $dbForPlatform;
+ }, ['cache', 'pools', 'authorization']);
+
+ $container->set('project', function ($message, Database $dbForPlatform) {
+ $payload = $message->getPayload() ?? [];
+ $project = new Document($payload['project'] ?? []);
+
+ if ($project->isEmpty() || $project->getId() === 'console') {
+ return $project;
+ }
+
+ return $dbForPlatform->getDocument('projects', $project->getId());
+ }, ['message', 'dbForPlatform']);
+
+ $container->set('dbForProject', function (Cache $cache, Group $pools, Document $project, Database $dbForPlatform, Authorization $authorization) {
+ if ($project->isEmpty() || $project->getId() === 'console') {
+ return $dbForPlatform;
+ }
+
+ try {
+ $dsn = new DSN($project->getAttribute('database'));
+ } catch (\InvalidArgumentException) {
+ // TODO: Temporary until all projects are using shared tables
+ $dsn = new DSN('mysql://' . $project->getAttribute('database'));
+ }
+
+ $adapter = new DatabasePool($pools->get($dsn->getHost()));
+ $database = new Database($adapter, $cache);
+ $database->setDocumentType('users', User::class);
+
+ $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
+
+ if (\in_array($dsn->getHost(), $sharedTables)) {
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
+ $database
+ ->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
+ ->setTenant($project->getSequence())
+ ->setNamespace($dsn->getParam('namespace'));
+ } else {
+ $database
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $project->getSequence());
+ }
+
+ $database
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization)
+ ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER);
+
+ return $database;
+ }, ['cache', 'pools', 'project', 'dbForPlatform', 'authorization']);
+
+ $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform, Cache $cache, Authorization $authorization) {
+ $databases = []; // TODO: @Meldiron This should probably be responsibility of utopia-php/pools
+
+ return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$databases): Database {
+ if ($project->isEmpty() || $project->getId() === 'console') {
+ return $dbForPlatform;
+ }
+
+ try {
+ $dsn = new DSN($project->getAttribute('database'));
+ } catch (\InvalidArgumentException) {
+ // TODO: Temporary until all projects are using shared tables
+ $dsn = new DSN('mysql://' . $project->getAttribute('database'));
+ }
+
+ if (isset($databases[$dsn->getHost()])) {
+ $database = $databases[$dsn->getHost()];
+ $database->setAuthorization($authorization);
+ $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
+
+ if (\in_array($dsn->getHost(), $sharedTables)) {
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
+ $database
+ ->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
+ ->setTenant($project->getSequence())
+ ->setNamespace($dsn->getParam('namespace'));
+ } else {
+ $database
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $project->getSequence());
+ }
+
+ return $database;
+ }
+
+ $adapter = new DatabasePool($pools->get($dsn->getHost()));
+ $database = new Database($adapter, $cache);
+
+ $databases[$dsn->getHost()] = $database;
+
+ $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
+
+ if (\in_array($dsn->getHost(), $sharedTables)) {
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
+ $database
+ ->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
+ ->setTenant($project->getSequence())
+ ->setNamespace($dsn->getParam('namespace'));
+ } else {
+ $database
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $project->getSequence());
+ }
+
+ $database
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization)
+ ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER);
+
+ return $database;
+ };
+ }, ['pools', 'dbForPlatform', 'cache', 'authorization']);
+
+ $container->set('getDatabasesDB', function (Cache $cache, Registry $register, Document $project, Authorization $authorization) {
+ return function (Document $database, ?Document $projectDocument = null) use ($cache, $register, $project, $authorization): Database {
+ $projectDocument ??= $project;
+ $databaseDSN = $database->getAttribute('database', $project->getAttribute('database', ''));
+ $databaseType = $database->getAttribute('type', '');
+
+ // Backwards-compatibility: older or seeded legacy databases may not have a DSN stored
+ // in the "database" attribute. In that case, fall back to the project's database DSN.
+ if ($databaseDSN === '') {
+ $databaseDSN = $projectDocument->getAttribute('database', '');
+ }
+
+ try {
+ $databaseDSN = new DSN($databaseDSN);
+ } catch (\InvalidArgumentException) {
+ $databaseDSN = new DSN('mysql://' . $databaseDSN);
+ }
+
+ try {
+ $dsn = new DSN($projectDocument->getAttribute('database'));
+ } catch (\InvalidArgumentException) {
+ // Temporary fallback until all projects use shared tables
+ $dsn = new DSN('mysql://' . $projectDocument->getAttribute('database'));
+ }
+
+ $pools = $register->get('pools');
+ $databaseHost = $databaseDSN->getHost();
+ $pool = $pools->get($databaseHost);
+
+ $adapter = new DatabasePool($pool);
+ $database = new Database($adapter, $cache);
+ $database
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization);
+ $database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB);
+
+ $sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')));
+
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
+ $database->setGlobalCollections($projectsGlobalCollections);
+
+ // For separate pools (documentsdb/vectorsdb), check their own shared tables config.
+ // If not configured, use dedicated mode to avoid cross-engine tenant type mismatches.
+ if ($databaseHost !== $dsn->getHost()) {
+ $dbTypeSharedTables = match ($databaseType) {
+ DOCUMENTSDB => \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', ''))),
+ VECTORSDB => \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''))),
+ default => [],
+ };
+
+ if (\in_array($databaseHost, $dbTypeSharedTables)) {
+ $database
+ ->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
+ ->setTenant($projectDocument->getSequence())
+ ->setNamespace($databaseDSN->getParam('namespace'));
+ } else {
+ $database
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $projectDocument->getSequence());
+ }
+ } elseif (\in_array($dsn->getHost(), $sharedTables, true)) {
+ $database
+ ->setSharedTables(true)
+ ->setGlobalCollections($projectsGlobalCollections)
+ ->setTenant($projectDocument->getSequence())
+ ->setNamespace($dsn->getParam('namespace'));
+ } else {
+ $database
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $projectDocument->getSequence());
+ }
+
+ $database->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER);
+ return $database;
+ };
+ }, ['cache', 'register', 'project', 'authorization']);
+
+ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
+ $database = null;
+
+ return function (?Document $project = null) use ($pools, $cache, $authorization, &$database) {
+ if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
+ $database->setTenant($project->getSequence());
+
+ return $database;
+ }
+
+ /** @var array $collections */
+ $collections = Config::getParam('collections', []);
+ $logsCollections = $collections['logs'] ?? [];
+ $logsCollections = array_keys($logsCollections);
+
+ $adapter = new DatabasePool($pools->get('logs'));
+ $database = new Database($adapter, $cache);
+
+ $database
+ ->setDatabase(APP_DATABASE)
+ ->setAuthorization($authorization)
+ ->setSharedTables(true)
+ ->setGlobalCollections($logsCollections)
+ ->setNamespace('logsV1')
+ ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER)
+ ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES_WORKER);
+
+ if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
+ $database->setTenant($project->getSequence());
+ }
+
+ return $database;
+ };
+ }, ['pools', 'cache', 'authorization']);
+
+ $container->set('abuseRetention', function () {
+ return \time() - (int) System::getEnv('_APP_MAINTENANCE_RETENTION_ABUSE', 86400); // 1 day
+ }, []);
+
+ $container->set('auditRetention', function (Document $project) {
+ if ($project->getId() === 'console') {
+ return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE', 15778800)); // 6 months
+ }
+
+ return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT', 1209600)); // 14 days
+ }, ['project']);
+
+ $container->set('executionRetention', function () {
+ return DateTime::addSeconds(new \DateTime(), -1 * (int) System::getEnv('_APP_MAINTENANCE_RETENTION_EXECUTION', 1209600)); // 14 days
+ }, []);
+
+ $container->set('queueForDatabase', function (Publisher $publisher) {
+ return new EventDatabase($publisher);
+ }, ['publisher']);
+
+ $container->set('queueForDeletes', function (Publisher $publisher) {
+ return new Delete($publisher);
+ }, ['publisher']);
+
+ $container->set('queueForEvents', function (Publisher $publisher) {
+ return new Event($publisher);
+ }, ['publisher']);
+
+ $container->set('queueForWebhooks', function (Publisher $publisher) {
+ return new Webhook($publisher);
+ }, ['publisher']);
+
+ $container->set('publisherForFunctions', fn (Publisher $publisher) => new FunctionPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', Event::FUNCTIONS_QUEUE_TTL)
+ ), ['publisher']);
+
+ $container->set('queueForRealtime', function () {
+ return new Realtime();
+ }, []);
+
+ $container->set('deviceForSites', function (Document $project, Telemetry $telemetry) {
+ return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId()));
+ }, ['project', 'telemetry']);
+
+ $container->set('deviceForMigrations', function (Document $project, Telemetry $telemetry) {
+ return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId()));
+ }, ['project', 'telemetry']);
+
+ $container->set('deviceForFunctions', function (Document $project, Telemetry $telemetry) {
+ return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId()));
+ }, ['project', 'telemetry']);
+
+ $container->set('deviceForFiles', function (Document $project, Telemetry $telemetry) {
+ return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId()));
+ }, ['project', 'telemetry']);
+
+ $container->set('deviceForBuilds', function (Document $project, Telemetry $telemetry) {
+ return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId()));
+ }, ['project', 'telemetry']);
+
+ $container->set('deviceForCache', function (Document $project, Telemetry $telemetry) {
+ return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_CACHE . '/app-' . $project->getId()));
+ }, ['project', 'telemetry']);
+
+ $container->set('logError', function (Registry $register, Document $project) {
+ return function (Throwable $error, string $namespace, string $action, ?array $extras = null) use ($register, $project) {
+ $logger = $register->get('logger');
+
+ if ($logger) {
+ $version = System::getEnv('_APP_VERSION', 'UNKNOWN');
+
+ $log = new Log();
+ $log->setNamespace($namespace);
+ $log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
+ $log->setVersion($version);
+ $log->setType(Log::TYPE_ERROR);
+ $log->setMessage($error->getMessage());
+
+ $log->addTag('code', $error->getCode());
+ $log->addTag('verboseType', \get_class($error));
+ $log->addTag('projectId', $project->getId());
+
+ $log->addExtra('file', $error->getFile());
+ $log->addExtra('line', $error->getLine());
+ $log->addExtra('trace', $error->getTraceAsString());
+
+ if ($error->getPrevious() !== null) {
+ if ($error->getPrevious()->getMessage() != $error->getMessage()) {
+ $log->addExtra('previousMessage', $error->getPrevious()->getMessage());
+ }
+ $log->addExtra('previousFile', $error->getPrevious()->getFile());
+ $log->addExtra('previousLine', $error->getPrevious()->getLine());
+ }
+
+ foreach (($extras ?? []) as $key => $value) {
+ $log->addExtra($key, $value);
+ }
+
+ $log->setAction($action);
+
+ $isProduction = System::getEnv('_APP_ENV', 'development') === 'production';
+ $log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING);
+
+ try {
+ $responseCode = $logger->addLog($log);
+ Console::info('Error log pushed with status code: ' . $responseCode);
+ } catch (Throwable $th) {
+ Console::error('Error pushing log: ' . $th->getMessage());
+ }
+ }
+
+ Console::warning("Failed: {$error->getMessage()}");
+ Console::warning($error->getTraceAsString());
+
+ if ($error->getPrevious() !== null) {
+ if ($error->getPrevious()->getMessage() != $error->getMessage()) {
+ Console::warning("Previous Failed: {$error->getPrevious()->getMessage()}");
+ }
+ Console::warning("Previous File: {$error->getPrevious()->getFile()} Line: {$error->getPrevious()->getLine()}");
+ }
+ };
+ }, ['register', 'project']);
+
+ $container->set('getAudit', function (Database $dbForPlatform, callable $getProjectDB) {
+ 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']);
+
+ $container->set('executionsRetentionCount', function (Document $project, array $plan) {
+ if ($project->getId() === 'console' || empty($plan)) {
+ return 0;
+ }
+
+ return (int) ($plan['executionsRetentionCount'] ?? 100);
+ }, ['project', 'plan']);
+};
diff --git a/app/listeners.php b/app/listeners.php
index 714c255974..240225dc45 100644
--- a/app/listeners.php
+++ b/app/listeners.php
@@ -1,9 +1,11 @@
getMessage(),
+ $e->getFile(),
+ $e->getLine()
+ ));
+});
+
// Allows overriding
if (!function_exists('getConsoleDB')) {
function getConsoleDB(): Database
@@ -113,9 +130,15 @@ if (!function_exists('getProjectDB')) {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
+ $collections = Config::getParam('collections', []);
+ $projectCollections = $collections['projects'] ?? [];
+ $projectsGlobalCollections = array_keys($projectCollections);
+ $projectsGlobalCollections[] = 'audit';
+
$database
->setSharedTables(true)
- ->setTenant((int)$project->getSequence())
+ ->setGlobalCollections($projectsGlobalCollections)
+ ->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -227,10 +250,14 @@ if (!function_exists('getTelemetry')) {
if (!function_exists('triggerStats')) {
function triggerStats(array $event, string $projectId): void
{
- return;
}
}
+global $container;
+$container->set('pools', function ($register) {
+ return $register->get('pools');
+}, ['register']);
+
$realtime = getRealtime();
/**
@@ -246,7 +273,9 @@ $stats->create();
$containerId = uniqid();
$statsDocument = null;
-$workerNumber = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6));
+
+$workerNumber = intval(System::getEnv('_APP_WORKERS_NUM', 0))
+ ?: intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6));
$adapter = new Adapter\Swoole(port: System::getEnv('PORT', 80));
$adapter
@@ -310,14 +339,14 @@ if (!function_exists('logError')) {
$server->error(logError(...));
-$server->onStart(function () use ($stats, $register, $containerId, &$statsDocument) {
+$server->onStart(function () use ($stats, $containerId, &$statsDocument) {
sleep(5); // wait for the initial database schema to be ready
Console::success('Server started successfully');
/**
* Create document for this worker to share stats across Containers.
*/
- go(function () use ($register, $containerId, &$statsDocument) {
+ go(function () use ($containerId, &$statsDocument) {
$attempts = 0;
$database = getConsoleDB();
@@ -347,7 +376,7 @@ $server->onStart(function () use ($stats, $register, $containerId, &$statsDocume
*/
// TODO: Remove this if check once it doesn't cause issues for cloud
if (System::getEnv('_APP_EDITION', 'self-hosted') === 'self-hosted') {
- Timer::tick(5000, function () use ($register, $stats, &$statsDocument) {
+ Timer::tick(5000, function () use ($stats, &$statsDocument) {
$payload = [];
foreach ($stats as $projectId => $value) {
$payload[$projectId] = $stats->get($projectId, 'connectionsTotal');
@@ -378,15 +407,32 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
Console::success('Worker ' . $workerId . ' started successfully');
$telemetry = getTelemetry($workerId);
+ $realtimeDelayBuckets = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000];
+ $workerTelemetryAttributes = ['workerId' => (string) $workerId];
$register->set('telemetry', fn () => $telemetry);
+ $register->set('telemetry.workerAttributes', fn () => $workerTelemetryAttributes);
+ $register->set('telemetry.workerCounter', fn () => $telemetry->createUpDownCounter('realtime.server.active_workers'));
+ $register->set('telemetry.workerClientCounter', fn () => $telemetry->createUpDownCounter('realtime.server.worker_clients'));
+ $register->set('telemetry.workerSubscriptionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.worker_subscriptions'));
$register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections'));
$register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created'));
$register->set('telemetry.messageSentCounter', fn () => $telemetry->createCounter('realtime.server.message.sent'));
+ $register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram(
+ name: 'realtime.server.delivery_delay',
+ unit: 'ms',
+ advisory: ['ExplicitBucketBoundaries' => $realtimeDelayBuckets],
+ ));
+ $register->set('telemetry.arrivalDelayHistogram', fn () => $telemetry->createHistogram(
+ name: 'realtime.server.arrival_delay',
+ unit: 'ms',
+ advisory: ['ExplicitBucketBoundaries' => $realtimeDelayBuckets],
+ ));
+ $register->get('telemetry.workerCounter')->add(1);
$attempts = 0;
$start = time();
- Timer::tick(5000, function () use ($server, $register, $realtime, $stats) {
+ Timer::tick(5000, function () use ($server, $realtime, $stats) {
/**
* Sending current connections to project channels on the console project every 5 seconds.
*/
@@ -432,7 +478,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
]
];
- $server->send($realtime->getSubscribers($event), json_encode([
+ $server->send(array_keys($realtime->getSubscribers($event)), json_encode([
'type' => 'event',
'data' => $event['data']
]));
@@ -498,21 +544,38 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
$pubsub->subscribe(['realtime'], function (mixed $redis, string $channel, string $payload) use ($server, $workerId, $stats, $register, $realtime) {
$event = json_decode($payload, true);
+ $eventTimestamp = $event['data']['timestamp'] ?? null;
+ if (\is_string($eventTimestamp)) {
+ try {
+ $eventDate = new \DateTimeImmutable($eventTimestamp, new \DateTimeZone('UTC'));
+ $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
+ $eventTimestampMs = (float) $eventDate->format('U.u') * 1000;
+ $nowTimestampMs = (float) $now->format('U.u') * 1000;
+ $arrivalDelayMs = (int) \max(0, $nowTimestampMs - $eventTimestampMs);
+
+ $register->get('telemetry.arrivalDelayHistogram')->record($arrivalDelayMs);
+ } catch (\Throwable) {
+ // Ignore invalid timestamp payloads.
+ }
+ }
+
if ($event['permissionsChanged'] && isset($event['userId'])) {
$projectId = $event['project'];
$userId = $event['userId'];
if ($realtime->hasSubscriber($projectId, 'user:' . $userId)) {
$connection = array_key_first(reset($realtime->subscriptions[$projectId]['user:' . $userId]));
+ $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection));
$consoleDatabase = getConsoleDB();
$project = $consoleDatabase->getAuthorization()->skip(fn () => $consoleDatabase->getDocument('projects', $projectId));
$database = getProjectDB($project);
- /** @var Appwrite\Utopia\Database\Documents\User $user */
+ /** @var User $user */
$user = $database->getDocument('users', $userId);
$roles = $user->getRoles($database->getAuthorization());
$authorization = $realtime->connections[$connection]['authorization'] ?? null;
+ $previousUserId = $realtime->connections[$connection]['userId'] ?? '';
$meta = $realtime->getSubscriptionMetadata($connection);
@@ -520,13 +583,19 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
foreach ($meta as $subscriptionId => $subscription) {
$queries = Query::parseQueries($subscription['queries'] ?? []);
+ $channels = Realtime::rebindAccountChannels(
+ $subscription['channels'] ?? [],
+ $previousUserId,
+ $userId
+ );
$realtime->subscribe(
$projectId,
$connection,
$subscriptionId,
$roles,
- $subscription['channels'] ?? [],
- $queries
+ $channels,
+ $queries,
+ $userId
);
}
@@ -534,12 +603,18 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
if ($authorization !== null) {
$realtime->connections[$connection]['authorization'] = $authorization;
}
+
+ $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection));
+ $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
+ if ($subscriptionDelta !== 0) {
+ $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
+ }
}
}
$receivers = $realtime->getSubscribers($event);
- if (Http::isDevelopment() && !empty($receivers)) {
+ if (System::getEnv('_APP_ENV', 'production') === 'development' && !empty($receivers)) {
Console::log("[Debug][Worker {$workerId}] Receivers: " . count($receivers));
Console::log("[Debug][Worker {$workerId}] Connection IDs: " . json_encode(array_keys($receivers)));
Console::log("[Debug][Worker {$workerId}] Matched: " . json_encode(array_values($receivers)));
@@ -576,6 +651,20 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
if ($total > 0) {
$register->get('telemetry.messageSentCounter')->add($total);
$stats->incr($event['project'], 'messages', $total);
+ $updatedAt = $event['data']['payload']['$updatedAt'] ?? null;
+ if (\is_string($updatedAt)) {
+ try {
+ $updatedAtDate = new \DateTimeImmutable($updatedAt, new \DateTimeZone('UTC'));
+ $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
+ $updatedAtTimestampMs = (float) $updatedAtDate->format('U.u') * 1000;
+ $nowTimestampMs = (float) $now->format('U.u') * 1000;
+ $delayMs = (int) \max(0, $nowTimestampMs - $updatedAtTimestampMs);
+
+ $register->get('telemetry.deliveryDelayHistogram')->record($delayMs);
+ } catch (\Throwable) {
+ // Ignore invalid timestamp payloads.
+ }
+ }
$projectId = $event['project'] ?? null;
@@ -605,25 +694,50 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
Console::error('Failed to restart pub/sub...');
});
-$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime) {
- $app = new Http('UTC');
+$server->onWorkerStop(function (int $workerId) use ($register) {
+ Console::warning('Worker ' . $workerId . ' stopping');
+
+ try {
+ $register->get('telemetry.workerCounter')->add(-1);
+ } catch (\Throwable $th) {
+ Console::error('Realtime onWorkerStop telemetry error: ' . $th->getMessage());
+ }
+});
+
+$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $registerConnectionResources) {
+ global $container;
$request = new Request($request);
$response = new Response(new SwooleResponse());
Console::info("Connection open (user: {$connection})");
- Http::setResource('pools', fn () => $register->get('pools'));
- Http::setResource('request', fn () => $request);
- Http::setResource('response', fn () => $response);
+ $connectionContainer = new Container($container);
+ $connectionContainer->set('request', fn () => $request);
+
+ $registerConnectionResources($connectionContainer);
$project = null;
$logUser = null;
$authorization = null;
+ $rawSize = $request->getSize();
+ $channelCount = 0;
+ $subscriptionCount = 0;
+ $outboundBytes = 0;
+ $responseCode = 200;
+ $subscriptionMode = 'message';
+ $success = false;
+
+ Span::init('realtime.open');
+ Span::add('realtime.connection.id', $connection);
+ Span::add('realtime.inbound_bytes', $rawSize);
+ if (!empty($request->getOrigin())) {
+ Span::add('realtime.origin', $request->getOrigin());
+ }
try {
/** @var Document $project */
- $project = $app->getResource('project');
- $authorization = $app->getResource('authorization');
+ $project = $connectionContainer->get('project');
+ $authorization = $connectionContainer->get('authorization');
/*
* Project Check
@@ -632,10 +746,16 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing or unknown project ID');
}
+ $timelimit = $connectionContainer->get('timelimit');
+ $user = $connectionContainer->get('user'); /** @var User $user */
+ $logUser = $user;
+
+ $apis = $project->getAttribute('apis', []);
+ // Websocket is what to check, but realtime is checked too for backwards compatibility
+ $websocketEnabled = $apis['websocket'] ?? $apis['realtime'] ?? true;
if (
- array_key_exists('realtime', $project->getAttribute('apis', []))
- && !$project->getAttribute('apis', [])['realtime']
- && !(User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
+ !$websocketEnabled
+ && !($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles()))
) {
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
}
@@ -646,10 +766,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Project is not accessible in this region. Please make sure you are using the correct endpoint');
}
- $timelimit = $app->getResource('timelimit');
- $user = $app->getResource('user'); /** @var User $user */
- $logUser = $user;
-
/*
* Abuse Check
*
@@ -666,8 +782,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
throw new Exception(Exception::REALTIME_TOO_MANY_MESSAGES, 'Too many requests');
}
- $rawSize = $request->getSize();
-
triggerStats([
METRIC_REALTIME_INBOUND => $rawSize,
], $project->getId());
@@ -678,7 +792,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
* Skip this check for non-web platforms which are not required to send an origin header.
*/
$origin = $request->getOrigin();
- $originValidator = $app->getResource('originValidator');
+ $originValidator = $connectionContainer->get('originValidator');
if (!empty($origin) && !$originValidator->isValid($origin) && $project->getId() !== 'console') {
throw new Exception(Exception::REALTIME_POLICY_VIOLATION, $originValidator->getDescription());
@@ -687,15 +801,53 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$roles = $user->getRoles($authorization);
$channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId());
+ $channelCount = \count($channels);
+
+ $updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void {
+ $register->get('telemetry.connectionCounter')->add(1);
+ $register->get('telemetry.workerClientCounter')->add(1, $register->get('telemetry.workerAttributes'));
+ $register->get('telemetry.connectionCreatedCounter')->add(1);
+
+ $stats->set($projectId, [
+ 'projectId' => $projectId,
+ 'teamId' => $teamId
+ ]);
+ $stats->incr($projectId, 'connections');
+ $stats->incr($projectId, 'connectionsTotal');
+
+ triggerStats([
+ METRIC_REALTIME_CONNECTIONS => 1,
+ METRIC_REALTIME_OUTBOUND => \strlen($payloadJson),
+ ], $projectId);
+ };
/**
* Channels Check
*/
if (empty($channels)) {
- throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing channels');
+ // in case of message based 'subscribe' channels will be empty at first and only projectId and roles will be available
+ $sanitizedUser = empty($user->getId()) ? null : $response->output($user, Response::MODEL_ACCOUNT);
+ $connectedPayloadJson = json_encode([
+ 'type' => 'connected',
+ 'data' => [
+ 'channels' => [],
+ 'subscriptions' => [],
+ 'user' => $sanitizedUser
+ ]
+ ]);
+
+ $realtime->subscribe($project->getId(), $connection, '', $roles, [], [], $user->getId());
+ $realtime->connections[$connection]['authorization'] = $authorization;
+ $server->send([$connection], $connectedPayloadJson);
+ $outboundBytes += \strlen($connectedPayloadJson);
+ $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson);
+ $subscriptionMode = 'message';
+ $success = true;
+ return;
}
$names = array_keys($channels);
+ $subscriptionMode = 'url';
try {
$subscriptions = Realtime::constructSubscriptions(
@@ -716,11 +868,16 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$subscriptionId,
$roles,
$subscription['channels'],
- $subscription['queries']
+ $subscription['queries'],
+ $user->getId()
);
$mapping[$index] = $subscriptionId;
}
+ $subscriptionCount = \count($subscriptions);
+ if (!empty($subscriptions)) {
+ $register->get('telemetry.workerSubscriptionCounter')->add(\count($subscriptions), $register->get('telemetry.workerAttributes'));
+ }
$realtime->connections[$connection]['authorization'] = $authorization;
@@ -736,21 +893,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
]);
$server->send([$connection], $connectedPayloadJson);
-
- $register->get('telemetry.connectionCounter')->add(1);
- $register->get('telemetry.connectionCreatedCounter')->add(1);
-
- $stats->set($project->getId(), [
- 'projectId' => $project->getId(),
- 'teamId' => $project->getAttribute('teamId')
- ]);
- $stats->incr($project->getId(), 'connections');
- $stats->incr($project->getId(), 'connectionsTotal');
-
- $connectedOutboundBytes = \strlen($connectedPayloadJson);
-
- triggerStats([METRIC_REALTIME_CONNECTIONS => 1, METRIC_REALTIME_OUTBOUND => $connectedOutboundBytes], $project->getId());
-
+ $outboundBytes += \strlen($connectedPayloadJson);
+ $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson);
+ $success = true;
} catch (Throwable $th) {
logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization);
@@ -760,12 +905,13 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
if (!\is_int($code)) {
$code = 500;
}
+ $responseCode = $code;
$message = $th->getMessage();
// sanitize 0 && 5xx errors
$realtimeViolation = $th instanceof AppwriteException && $th->getType() === AppwriteException::REALTIME_POLICY_VIOLATION;
- if (($code === 0 || $code >= 500) && !$realtimeViolation && !Http::isDevelopment()) {
+ if (($code === 0 || $code >= 500) && !$realtimeViolation && System::getEnv('_APP_ENV', 'production') !== 'development') {
$message = 'Error: Server Error';
}
@@ -777,30 +923,59 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
]
];
- $server->send([$connection], json_encode($response));
+ $responsePayloadJson = json_encode($response);
+ $server->send([$connection], $responsePayloadJson);
+ $outboundBytes += \strlen($responsePayloadJson);
$server->close($connection, $code);
- if (Http::isDevelopment()) {
+ if (System::getEnv('_APP_ENV', 'production') === 'development') {
Console::error('[Error] Connection Error');
Console::error('[Error] Code: ' . $response['data']['code']);
Console::error('[Error] Message: ' . $response['data']['message']);
}
+ Span::error($th);
+ } finally {
+ Span::add('realtime.success', $success);
+ Span::add('realtime.response_code', $responseCode);
+ Span::add('realtime.subscription_mode', $subscriptionMode);
+ Span::add('realtime.channel_count', $channelCount);
+ Span::add('realtime.subscription_count', $subscriptionCount);
+ Span::add('realtime.outbound_bytes', $outboundBytes);
+ if (!empty($project?->getId())) {
+ Span::add('project.id', $project->getId());
+ }
+ if (!empty($logUser?->getId())) {
+ Span::add('user.id', $logUser->getId());
+ }
+ Span::current()?->finish();
}
});
-$server->onMessage(function (int $connection, string $message) use ($server, $register, $realtime, $containerId) {
+$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId, $register) {
$project = null;
$authorization = null;
+ $projectId = $realtime->connections[$connection]['projectId'] ?? null;
+ $rawSize = \strlen($message);
+ $messageType = 'invalid';
+ $subscriptionDelta = 0;
+ $subscriptionsRequested = 0;
+ $subscriptionsRemoved = 0;
+ $outboundBytes = 0;
+ $responseCode = 200;
+ $success = false;
+
+ Span::init('realtime.message');
+ Span::add('realtime.connection.id', $connection);
+ Span::add('realtime.inbound_bytes', $rawSize);
+ Span::add('realtime.container.id', $containerId);
try {
- $rawSize = \strlen($message);
$response = new Response(new SwooleResponse());
- $projectId = $realtime->connections[$connection]['projectId'] ?? null;
// Get authorization from connection (stored during onOpen)
$authorization = $realtime->connections[$connection]['authorization'] ?? null;
if ($authorization === null) {
- $authorization = new Authorization('');
+ $authorization = new Authorization();
}
$database = getConsoleDB();
@@ -845,6 +1020,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format is not valid.');
}
+ $messageType = $message['type'] ?? 'invalid';
+
+ if (!\is_scalar($messageType)) {
+ throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.');
+ }
+
// Ping does not require project context; other messages do (e.g. after unsubscribe during auth)
if (empty($projectId) && ($message['type'] ?? '') !== 'ping') {
throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing project context. Reconnect to the project first.');
@@ -857,6 +1038,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
]);
$server->send([$connection], $pongPayloadJson);
+ $outboundBytes += \strlen($pongPayloadJson);
if ($project !== null && !$project->isEmpty()) {
$pongOutboundBytes = \strlen($pongPayloadJson);
@@ -902,7 +1084,13 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
$authorization = $realtime->connections[$connection]['authorization'] ?? null;
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
+ // Capture the pre-auth userId so we can rebind any account channels
+ // that were stored under it (e.g. guest who subscribed to `account`
+ // and now authenticates). unsubscribe() below clears the connection
+ // entry, so we must read it first.
+ $previousUserId = $realtime->connections[$connection]['userId'] ?? '';
+ $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection));
$meta = $realtime->getSubscriptionMetadata($connection);
$realtime->unsubscribe($connection);
@@ -910,14 +1098,20 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
if (!empty($projectId)) {
foreach ($meta as $subscriptionId => $subscription) {
$queries = Query::parseQueries($subscription['queries'] ?? []);
+ $channels = Realtime::rebindAccountChannels(
+ $subscription['channels'] ?? [],
+ $previousUserId,
+ $user->getId()
+ );
$realtime->subscribe(
$projectId,
$connection,
$subscriptionId,
$roles,
- $subscription['channels'] ?? [],
- $queries
+ $channels,
+ $queries,
+ $user->getId()
);
}
}
@@ -926,6 +1120,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
$realtime->connections[$connection]['authorization'] = $authorization;
}
+ $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection));
+ $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
+ if ($subscriptionDelta !== 0) {
+ $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
+ }
+
$user = $response->output($user, Response::MODEL_ACCOUNT);
$authResponsePayloadJson = json_encode([
@@ -938,6 +1138,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
]);
$server->send([$connection], $authResponsePayloadJson);
+ $outboundBytes += \strlen($authResponsePayloadJson);
if ($project !== null && !$project->isEmpty()) {
$authOutboundBytes = \strlen($authResponsePayloadJson);
@@ -951,20 +1152,185 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
break;
+ case 'subscribe':
+ /**
+ * Message based upsertion of a subscription
+ * If subscriptionId is given then it will match subId of the connection and update the subscription with channels and queries
+ * If non-existing subid is given or not given a new subid will be generated
+ * Similar to what we have now -> two subscribe() block with same channels and queries still two different subscriptions
+ *
+ * structure of the payload -> array of maps
+ * 'data' : [subscriptionId:"" , channels:[] , queries:[]]
+ */
+ if (!is_array($message['data']) || !array_is_list($message['data'])) {
+ throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.');
+ }
+
+ $roles = $realtime->connections[$connection]['roles'] ?? [Role::guests()->toString()];
+ $userId = $realtime->connections[$connection]['userId'] ?? '';
+
+ // bulk validation + parsing before subscribing
+ $parsedPayloads = [];
+ $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection));
+ foreach ($message['data'] as $payload) {
+ if (!\is_array($payload)) {
+ throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each subscribe payload must be an object.');
+ }
+ if (!array_key_exists('channels', $payload)) {
+ throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not present in payload.');
+ }
+ if (!is_array($payload['channels']) || !array_is_list($payload['channels'])) {
+ throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'channels is not a valid array.');
+ }
+ // registering the queries if not present and check in the same payload later on
+ if (!array_key_exists('queries', $payload)) {
+ $payload['queries'] = [];
+ }
+ if (!is_array($payload['queries']) || !array_is_list($payload['queries'])) {
+ throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'queries is not a valid array.');
+ }
+
+ $subscriptionId = \array_key_exists('subscriptionId', $payload)
+ ? $payload['subscriptionId']
+ : ID::unique();
+
+ try {
+ $convertedQueries = Realtime::convertQueries($payload['queries']);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Invalid query: ' . $e->getMessage());
+ }
+
+ $convertedChannels = \array_keys(Realtime::convertChannels($payload['channels'], $userId));
+
+ $parsedPayloads[] = [
+ 'subscriptionId' => $subscriptionId,
+ 'channels' => $payload['channels'],
+ 'convertedChannels' => $convertedChannels,
+ 'queries' => $convertedQueries,
+ ];
+ }
+
+ foreach ($parsedPayloads as $parsedPayload) {
+ $subscriptionId = $parsedPayload['subscriptionId'];
+ $channels = $parsedPayload['convertedChannels'];
+ $queries = $parsedPayload['queries'];
+ $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries);
+ }
+ $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection));
+ $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
+ $subscriptionsRequested = \count($parsedPayloads);
+ if ($subscriptionDelta !== 0) {
+ $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
+ }
+
+ $responsePayload = json_encode([
+ 'type' => 'response',
+ 'data' => [
+ 'to' => 'subscribe',
+ 'success' => true,
+ 'subscriptions' => \array_map(function (array $parsedPayload) {
+ return [
+ 'subscriptionId' => $parsedPayload['subscriptionId'],
+ 'channels' => $parsedPayload['convertedChannels'],
+ 'queries' => \array_map(fn ($q) => $q->toString(), $parsedPayload['queries']),
+ ];
+ }, $parsedPayloads),
+ ]
+ ]);
+
+ $server->send([$connection], $responsePayload);
+ $outboundBytes += \strlen($responsePayload);
+
+ if ($project !== null && !$project->isEmpty()) {
+ $subscribeOutboundBytes = \strlen($responsePayload);
+
+ if ($subscribeOutboundBytes > 0) {
+ triggerStats([
+ METRIC_REALTIME_OUTBOUND => $subscribeOutboundBytes,
+ ], $project->getId());
+ }
+ }
+
+ break;
+
+ case 'unsubscribe':
+ if (!\is_array($message['data']) || !\array_is_list($message['data'])) {
+ throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.');
+ }
+
+ $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection));
+
+ // Validate every payload before executing any removal so an invalid entry
+ // later in the batch does not leave earlier entries half-applied on the server.
+ $validatedIds = [];
+ foreach ($message['data'] as $payload) {
+ if (
+ !\is_array($payload)
+ || !\array_key_exists('subscriptionId', $payload)
+ || !\is_string($payload['subscriptionId'])
+ || $payload['subscriptionId'] === ''
+ ) {
+ throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each unsubscribe payload must include a non-empty subscriptionId.');
+ }
+ $validatedIds[] = $payload['subscriptionId'];
+ }
+
+ $unsubscribeResults = [];
+ foreach ($validatedIds as $subscriptionId) {
+ $wasRemoved = $realtime->unsubscribeSubscription($connection, $subscriptionId);
+ $unsubscribeResults[] = [
+ 'subscriptionId' => $subscriptionId,
+ 'removed' => $wasRemoved,
+ ];
+ }
+ $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection));
+ $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
+ $subscriptionsRequested = \count($validatedIds);
+ $subscriptionsRemoved = \count(\array_filter($unsubscribeResults, fn (array $item) => $item['removed']));
+ if ($subscriptionDelta !== 0) {
+ $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
+ }
+
+ $unsubscribeResponsePayload = json_encode([
+ 'type' => 'response',
+ 'data' => [
+ 'to' => 'unsubscribe',
+ 'success' => true,
+ 'subscriptions' => $unsubscribeResults,
+ ],
+ ]);
+
+ $server->send([$connection], $unsubscribeResponsePayload);
+ $outboundBytes += \strlen($unsubscribeResponsePayload);
+
+ if ($project !== null && !$project->isEmpty()) {
+ $unsubscribeOutboundBytes = \strlen($unsubscribeResponsePayload);
+
+ if ($unsubscribeOutboundBytes > 0) {
+ triggerStats([
+ METRIC_REALTIME_OUTBOUND => $unsubscribeOutboundBytes,
+ ], $project->getId());
+ }
+ }
+
+ break;
+
default:
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.');
}
+ $success = true;
} catch (Throwable $th) {
logError($th, 'realtimeMessage', project: $project, authorization: $authorization);
$code = $th->getCode();
if (!is_int($code)) {
$code = 500;
}
+ $responseCode = $code;
$message = $th->getMessage();
// sanitize 0 && 5xx errors
- if (($code === 0 || $code >= 500) && !Http::isDevelopment()) {
+ if (($code === 0 || $code >= 500) && System::getEnv('_APP_ENV', 'production') !== 'development') {
$message = 'Error: Server Error';
}
@@ -976,26 +1342,83 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
]
];
- $server->send([$connection], json_encode($response));
+ $responsePayloadJson = json_encode($response);
+ $server->send([$connection], $responsePayloadJson);
+ $outboundBytes += \strlen($responsePayloadJson);
if ($th->getCode() === 1008) {
$server->close($connection, $th->getCode());
}
+ Span::error($th);
+ } finally {
+ Span::add('realtime.success', $success);
+ Span::add('realtime.response_code', $responseCode);
+ Span::add('realtime.subscription_delta', $subscriptionDelta);
+ Span::add('realtime.subscriptions_requested', $subscriptionsRequested);
+ Span::add('realtime.subscriptions_removed', $subscriptionsRemoved);
+ Span::add('realtime.subscribe.subscriptions_count', $subscriptionsRequested);
+ Span::add('realtime.outbound_bytes', $outboundBytes);
+ Span::add('project.id', $project?->getId() ?? $projectId);
+ Span::add('user.id', $realtime->connections[$connection]['userId'] ?? null);
+ Span::add('realtime.message_type', $messageType);
+ Span::current()?->finish();
}
});
$server->onClose(function (int $connection) use ($realtime, $stats, $register) {
+ $projectId = null;
+ $userId = null;
+ $subscriptionsBeforeClose = 0;
+ $success = false;
+
+ Span::init('realtime.close');
+ Span::add('realtime.connection.id', $connection);
+
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'] ?? null;
+ $userId = $realtime->connections[$connection]['userId'] ?? null;
+ }
- $projectId = $realtime->connections[$connection]['projectId'];
+ try {
+ if (array_key_exists($connection, $realtime->connections)) {
+ $stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal');
+ $register->get('telemetry.connectionCounter')->add(-1);
+ $register->get('telemetry.workerClientCounter')->add(-1, $register->get('telemetry.workerAttributes'));
+ $subscriptionsBeforeClose = \count($realtime->getSubscriptionMetadata($connection));
+ if ($subscriptionsBeforeClose > 0) {
+ $register->get('telemetry.workerSubscriptionCounter')->add(-$subscriptionsBeforeClose, $register->get('telemetry.workerAttributes'));
+ }
- triggerStats([
- METRIC_REALTIME_CONNECTIONS => -1,
- ], $projectId);
+ $projectId = $realtime->connections[$connection]['projectId'];
+
+ triggerStats([
+ METRIC_REALTIME_CONNECTIONS => -1,
+ ], $projectId);
+ }
+ $success = true;
+ } 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());
+ Span::error($th);
+ } finally {
+ try {
+ $realtime->unsubscribe($connection);
+ } catch (\Throwable $th) {
+ Console::error('Realtime onClose unsubscribe error: ' . $th->getMessage());
+ Span::error($th);
+ }
+
+ Span::add('realtime.success', $success);
+ if (!empty($projectId)) {
+ Span::add('project.id', $projectId);
+ }
+ if (!empty($userId)) {
+ Span::add('user.id', $userId);
+ }
+ Span::add('realtime.subscriptions_before_close', $subscriptionsBeforeClose);
+ Span::current()?->finish();
}
- $realtime->unsubscribe($connection);
Console::info('Connection close: ' . $connection);
});
diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml
index a62f6f7e8c..6ce1fb5cea 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'];
+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
- - = $dbService . "\n" ?>
+ redis:
+ condition: service_healthy
+ = $dbService ?>:
+ condition: service_healthy
# - clamav
environment:
- _APP_ENV
@@ -111,7 +120,6 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_SMTP_HOST
- _APP_SMTP_PORT
- _APP_SMTP_SECURE
@@ -185,7 +193,7 @@ $dbService = $this->getParam('database');
appwrite-console:
<<: *x-logging
container_name: appwrite-console
- image: /console:7.6.4
+ image: /console:7.8.26
restart: unless-stopped
networks:
- appwrite
@@ -227,8 +235,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- - redis
- - = $dbService . "\n" ?>
+ redis:
+ condition: service_healthy
+ = $dbService ?>:
+ condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -245,7 +255,6 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_USAGE_STATS
- _APP_LOGGING_CONFIG
@@ -258,8 +267,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- - redis
- - = $dbService . "\n" ?>
+ redis:
+ condition: service_healthy
+ = $dbService ?>:
+ condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -274,7 +285,6 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
appwrite-worker-webhooks:
@@ -286,8 +296,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- - redis
- - = $dbService . "\n" ?>
+ redis:
+ condition: service_healthy
+ = $dbService ?>:
+ condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -300,7 +312,6 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -316,8 +327,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- - redis
- - = $dbService . "\n" ?>
+ redis:
+ condition: service_healthy
+ = $dbService ?>:
+ condition: service_healthy
volumes:
- appwrite-uploads:/storage/uploads:rw
- appwrite-cache:/storage/cache:rw
@@ -339,7 +352,6 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_STORAGE_DEVICE
- _APP_STORAGE_S3_ACCESS_KEY
- _APP_STORAGE_S3_SECRET
@@ -381,8 +393,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- - redis
- - = $dbService . "\n" ?>
+ redis:
+ condition: service_healthy
+ = $dbService ?>:
+ condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -397,7 +411,6 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
appwrite-worker-builds:
@@ -409,8 +422,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- - redis
- - = $dbService . "\n" ?>
+ redis:
+ condition: service_healthy
+ = $dbService ?>:
+ condition: service_healthy
volumes:
- appwrite-functions:/storage/functions:rw
- appwrite-sites:/storage/sites:rw
@@ -432,7 +447,6 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
- _APP_VCS_GITHUB_APP_NAME
- _APP_VCS_GITHUB_PRIVATE_KEY
@@ -479,8 +493,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- - redis
- - = $dbService . "\n" ?>
+ redis:
+ condition: service_healthy
+ = $dbService ?>:
+ condition: service_healthy
volumes:
- appwrite-config:/storage/config:rw
- appwrite-certificates:/storage/certificates:rw
@@ -506,7 +522,35 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
+ - _APP_LOGGING_CONFIG
+
+ appwrite-worker-executions:
+ image: /:
+ entrypoint: worker-executions
+ <<: *x-logging
+ container_name: appwrite-worker-executions
+ restart: unless-stopped
+ networks:
+ - appwrite
+ depends_on:
+ redis:
+ condition: service_healthy
+ = $dbService ?>:
+ condition: service_healthy
+ environment:
+ - _APP_ENV
+ - _APP_WORKER_PER_CORE
+ - _APP_REDIS_HOST
+ - _APP_REDIS_PORT
+ - _APP_REDIS_USER
+ - _APP_REDIS_PASS
+ - _APP_ENV
- _APP_DB_ADAPTER
+ - _APP_DB_HOST
+ - _APP_DB_PORT
+ - _APP_DB_SCHEMA
+ - _APP_DB_USER
+ - _APP_DB_PASS
- _APP_LOGGING_CONFIG
appwrite-worker-functions:
@@ -518,9 +562,12 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- - redis
- - = $dbService . "\n" ?>
- - openruntimes-executor
+ redis:
+ condition: service_healthy
+ = $dbService ?>:
+ condition: service_healthy
+ openruntimes-executor:
+ condition: service_started
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -537,7 +584,6 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_FUNCTIONS_TIMEOUT
- _APP_SITES_TIMEOUT
- _APP_COMPUTE_BUILD_TIMEOUT
@@ -559,8 +605,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- - redis
- - = $dbService . "\n" ?>
+ redis:
+ condition: service_healthy
+ = $dbService ?>:
+ condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -573,7 +621,6 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -598,8 +645,10 @@ $dbService = $this->getParam('database');
volumes:
- appwrite-uploads:/storage/uploads:rw
depends_on:
- - redis
- - = $dbService . "\n" ?>
+ redis:
+ condition: service_healthy
+ = $dbService ?>:
+ condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -614,7 +663,6 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
- _APP_SMS_FROM
- _APP_SMS_PROVIDER
@@ -652,7 +700,8 @@ $dbService = $this->getParam('database');
volumes:
- appwrite-imports:/storage/imports:rw
depends_on:
- - = $dbService . "\n" ?>
+ = $dbService ?>:
+ condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -674,7 +723,6 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
- _APP_MIGRATIONS_FIREBASE_CLIENT_ID
- _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET
@@ -688,8 +736,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- - redis
- - = $dbService . "\n" ?>
+ redis:
+ condition: service_healthy
+ = $dbService ?>:
+ condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -711,7 +761,6 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_MAINTENANCE_INTERVAL
- _APP_MAINTENANCE_RETENTION_EXECUTION
- _APP_MAINTENANCE_RETENTION_CACHE
@@ -730,8 +779,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- - redis
- - = $dbService . "\n" ?>
+ redis:
+ condition: service_healthy
+ = $dbService ?>:
+ condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -742,7 +793,6 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -761,8 +811,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- - redis
- - = $dbService . "\n" ?>
+ redis:
+ condition: service_healthy
+ = $dbService ?>:
+ condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -773,7 +825,6 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -791,8 +842,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- - redis
- - = $dbService . "\n" ?>
+ redis:
+ condition: service_healthy
+ = $dbService ?>:
+ condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -803,7 +856,6 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -821,12 +873,21 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- - = $dbService . "\n" ?>
- - redis
+ = $dbService ?>:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_OPENSSL_KEY_V1
+ - _APP_OPTIONS_FORCE_HTTPS
+ - _APP_DOMAIN
+ - _APP_CONSOLE_DOMAIN
+ - _APP_DOMAIN_FUNCTIONS
+ - _APP_DOMAIN_SITES
+ - _APP_MIGRATION_HOST
+ - _APP_CONSOLE_SCHEMA
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -837,7 +898,6 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
appwrite-task-scheduler-executions:
image: /:
@@ -848,12 +908,21 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- - = $dbService . "\n" ?>
- - redis
+ = $dbService ?>:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_OPENSSL_KEY_V1
+ - _APP_OPTIONS_FORCE_HTTPS
+ - _APP_DOMAIN
+ - _APP_CONSOLE_DOMAIN
+ - _APP_DOMAIN_FUNCTIONS
+ - _APP_DOMAIN_SITES
+ - _APP_MIGRATION_HOST
+ - _APP_CONSOLE_SCHEMA
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -864,7 +933,6 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
appwrite-task-scheduler-messages:
image: /:
@@ -875,8 +943,10 @@ $dbService = $this->getParam('database');
networks:
- appwrite
depends_on:
- - = $dbService . "\n" ?>
- - redis
+ = $dbService ?>:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -891,7 +961,6 @@ $dbService = $this->getParam('database');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
appwrite-assistant:
@@ -919,7 +988,7 @@ $dbService = $this->getParam('database');
<<: *x-logging
restart: unless-stopped
stop_signal: SIGINT
- image: openruntimes/executor:0.7.22
+ image: openruntimes/executor:0.11.4
networks:
- appwrite
- runtimes
@@ -966,7 +1035,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 +1050,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
@@ -989,13 +1063,12 @@ $dbService = $this->getParam('database');
image: mongo:8.2.5
container_name: appwrite-mongodb
<<: *x-logging
+ restart: unless-stopped
networks:
- appwrite
volumes:
- appwrite-mongodb:/data/db
- appwrite-mongodb-keyfile:/data/keyfile
- ports:
- - "27017:27017"
environment:
- MONGO_INITDB_ROOT_USERNAME=root
- MONGO_INITDB_ROOT_PASSWORD=${_APP_DB_ROOT_PASS}
@@ -1059,18 +1132,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 +1167,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
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..d33838c8c6
--- /dev/null
+++ b/app/views/install/installer.phtml
@@ -0,0 +1,147 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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"
+
+>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Next
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/install/installer/css/styles.css b/app/views/install/installer/css/styles.css
new file mode 100644
index 0000000000..8fd28a12a3
--- /dev/null
+++ b/app/views/install/installer/css/styles.css
@@ -0,0 +1,1903 @@
+/* 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;
+}
+
+.installer-page[data-upgrade='true'] .installer-step {
+ min-height: 0;
+}
+
+.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-counter {
+ margin-left: auto;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ white-space: nowrap;
+ user-select: none;
+ color: var(--fgcolor-neutral-secondary);
+}
+
+.install-row[data-status='in-progress'] .install-counter:not(:empty) {
+ opacity: 1;
+}
+
+.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-global-actions {
+ display: flex;
+ justify-content: center;
+ gap: var(--gap-m);
+ padding: var(--space-4) 0;
+}
+
+.install-global-actions.is-hidden {
+ display: none;
+}
+
+.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);
+ }
+}
+
+.migration-option {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--gap-l);
+ 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: calc(var(--border-width-s) * -1);
+ cursor: pointer;
+ transition: outline-color 0.15s ease-in-out;
+}
+
+.migration-option:hover {
+ outline-color: var(--border-neutral-stronger);
+}
+
+.migration-option-content {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.migration-switch {
+ flex-shrink: 0;
+}
+
+.migration-switch-track {
+ position: relative;
+ display: block;
+ width: 32px;
+ height: 20px;
+ border-radius: 10px;
+ background: var(--bgcolor-neutral-invert-weaker);
+ transition: background 0.15s ease-in-out;
+}
+
+.migration-switch-thumb {
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--bgcolor-neutral-primary);
+ transition: transform 0.15s ease-in-out;
+}
+
+#run-migration:checked ~ .migration-switch-track {
+ background: var(--bgcolor-neutral-invert-weak);
+}
+
+#run-migration:checked ~ .migration-switch-track .migration-switch-thumb {
+ transform: translateX(12px);
+}
+
+#run-migration:focus-visible ~ .migration-switch-track {
+ box-shadow: 0 0 0 var(--border-width-l) var(--border-focus);
+}
+
+.migration-hint {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--gap-s);
+ padding: 0 var(--space-2);
+}
+
+.migration-hint-icon {
+ flex-shrink: 0;
+ width: 16px;
+ height: 16px;
+ color: var(--fgcolor-neutral-tertiary);
+ margin-top: 1px;
+}
+
+.migration-hint-icon svg {
+ width: 100%;
+ height: 100%;
+}
+
+.migration-code {
+ padding: 1px 4px;
+ border-radius: var(--border-radius-xs, 4px);
+ background: var(--bgcolor-neutral-secondary);
+ font-family: monospace;
+ font-size: inherit;
+}
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..07ec7bb1ef
--- /dev/null
+++ b/app/views/install/installer/js/installer.js
@@ -0,0 +1,457 @@
+(() => {
+ 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, 6, 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(6, 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') {
+ if (typeof validateInstallRequest === 'function') {
+ const isValid = await validateInstallRequest();
+ if (!isValid) {
+ return;
+ }
+ }
+ // Clear stale install data from previous runs so initStep5
+ // starts a fresh install instead of trying to resume.
+ const { clearInstallLock, clearInstallId } = window.InstallerStepsState || {};
+ clearInstallLock?.();
+ clearInstallId?.();
+ }
+ 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..6f215da899
--- /dev/null
+++ b/app/views/install/installer/js/modules/context.js
@@ -0,0 +1,119 @@
+(() => {
+ 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',
+ MIGRATION: 'migration',
+ SSL_CERTIFICATE: 'ssl-certificate',
+ REDIRECT: 'redirect'
+ });
+
+ 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.MIGRATION,
+ inProgress: 'Running database migration...',
+ done: 'Database migration completed'
+ }
+ ] : [
+ {
+ 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'
+ }
+ ]);
+
+ 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(6, 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..7c36fd7951
--- /dev/null
+++ b/app/views/install/installer/js/modules/progress.js
@@ -0,0 +1,1120 @@
+(() => {
+ 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, isIPAddress } = 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 message || step.inProgress;
+ };
+
+ const updateInstallRow = (row, step, status, message, details) => {
+ 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');
+ });
+ }
+ }
+
+ const counter = row.querySelector('[data-install-counter]');
+ if (counter) {
+ const started = details?.containerStarted ?? 0;
+ const total = details?.containerTotal;
+ counter.textContent = (status === STATUS.IN_PROGRESS && total > 0 && started < total)
+ ? `${started}/${total}`
+ : '';
+ }
+
+ // 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 = (protocol) => {
+ 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');
+ }
+ const port = protocol === 'https' ? httpsPort : httpPort;
+ const defaultPort = protocol === 'https' ? '443' : '80';
+ if (!hasPort && port && port !== defaultPort) {
+ host = `${host}:${port}`;
+ }
+ return `${protocol}://${host}`;
+ };
+
+ const normalizeHostname = (rawDomain) => {
+ const hostname = extractHostname?.(rawDomain)?.toLowerCase?.() ?? '';
+ if (hostname === '0.0.0.0' || hostname === 'traefik') return 'localhost';
+ return hostname;
+ };
+
+ const canUseHttps = () => {
+ const dataset = getBodyDataset?.() ?? {};
+ const rawDomain = (formState?.appDomain || dataset.defaultAppDomain || '').trim();
+ const httpsPort = (formState?.httpsPort || dataset.defaultHttpsPort || '').trim();
+ if (!httpsPort || httpsPort === '0') return false;
+ const hostname = normalizeHostname(rawDomain);
+ return !isLocalHost?.(hostname) && !isIPAddress?.(hostname);
+ };
+
+ const pollCertificate = async (domain, port, maxAttempts, intervalMs) => {
+ for (let i = 0; i < maxAttempts; i++) {
+ try {
+ const response = await fetch(
+ `/install/certificate?domain=${encodeURIComponent(domain)}&port=${encodeURIComponent(port)}`,
+ { cache: 'no-store' }
+ );
+ if (response.ok) {
+ const data = await response.json();
+ if (data.ready) return true;
+ }
+ } catch {
+ // Installer server may have shut down
+ }
+ if (i < maxAttempts - 1) {
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
+ }
+ }
+ return false;
+ };
+
+ const redirectToApp = (protocol) => {
+ const url = buildRedirectUrl(protocol);
+ if (!url) return;
+ 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() || (formState?.accountEmail || '').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,
+ migrate: formState?.migrate ?? false
+ };
+ };
+
+ 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;
+ let resolvedProtocol = 'http';
+
+ 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, state.details);
+ 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;
+ }
+ }
+ }
+ if (payload.status === STATUS.ERROR) {
+ showGlobalActions();
+ }
+ 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;
+ let hasErrors = false;
+ 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]
+ });
+ if (detail.status === STATUS.ERROR) {
+ hasErrors = true;
+ }
+ });
+ renderProgress();
+ if (hasErrors) {
+ showGlobalActions();
+ }
+ };
+
+ 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();
+ startSslCheck(sessionDetails);
+ };
+
+ 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);
+ clearInstallLock?.();
+ };
+
+ const SSL_STEP = {
+ id: STEP_IDS.SSL_CERTIFICATE,
+ inProgress: 'Generating SSL certificate...',
+ done: 'SSL certificate verified'
+ };
+
+ const REDIRECT_STEP = {
+ id: STEP_IDS.REDIRECT,
+ inProgress: 'Redirecting to console...',
+ done: 'Redirecting to console...'
+ };
+
+ const showRedirectStep = (sessionDetails, protocol) => {
+ animatePanelHeight(() => {
+ progressState.set(REDIRECT_STEP.id, {
+ status: STATUS.IN_PROGRESS,
+ message: REDIRECT_STEP.inProgress
+ });
+ const row = ensureRow(REDIRECT_STEP);
+ if (row) {
+ updateInstallRow(row, REDIRECT_STEP, STATUS.IN_PROGRESS, REDIRECT_STEP.inProgress);
+ }
+ });
+ startSyncedSpinnerRotation(list);
+
+ const completeId = activeInstall?.installId || getStoredInstallId?.();
+ notifyInstallComplete(completeId, sessionDetails).finally(() => {
+ setTimeout(() => redirectToApp(protocol), TIMINGS?.redirectDelay ?? 0);
+ });
+ };
+
+ const startSslCheck = (sessionDetails) => {
+ if (!canUseHttps()) {
+ showRedirectStep(sessionDetails, 'http');
+ return;
+ }
+
+ animatePanelHeight(() => {
+ progressState.set(SSL_STEP.id, {
+ status: STATUS.IN_PROGRESS,
+ message: SSL_STEP.inProgress
+ });
+ const row = ensureRow(SSL_STEP);
+ if (row) {
+ updateInstallRow(row, SSL_STEP, STATUS.IN_PROGRESS, SSL_STEP.inProgress);
+ }
+ });
+ startSyncedSpinnerRotation(list);
+
+ const dataset = getBodyDataset?.() ?? {};
+ const rawDomain = (formState?.appDomain || dataset.defaultAppDomain || '').trim();
+ const httpsPort = (formState?.httpsPort || dataset.defaultHttpsPort || '443').trim();
+ const domain = normalizeHostname(rawDomain);
+ pollCertificate(domain, httpsPort, 15, 2000).then((ready) => {
+ stopSyncedSpinnerRotation();
+ const certMessage = ready ? SSL_STEP.done : 'Certificate not ready, continuing over HTTP';
+ animatePanelHeight(() => {
+ progressState.set(SSL_STEP.id, {
+ status: STATUS.COMPLETED,
+ message: certMessage
+ });
+ const row = ensureRow(SSL_STEP);
+ if (row) {
+ updateInstallRow(row, SSL_STEP, STATUS.COMPLETED, certMessage);
+ }
+ });
+ resolvedProtocol = ready ? 'https' : 'http';
+ showRedirectStep(sessionDetails, resolvedProtocol);
+ });
+ };
+
+ 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();
+ startSslCheck(sessionDetails);
+ 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 isSnapshotTerminal = (snapshot) => {
+ if (!snapshot?.steps) return 'empty';
+ const stepEntries = Object.values(snapshot.steps);
+ if (stepEntries.length === 0) return 'empty';
+ const hasError = stepEntries.some((s) => s.status === STATUS.ERROR);
+ if (hasError) return 'error';
+ const allCompleted = INSTALLATION_STEPS.every((step) => {
+ const detail = snapshot.steps[step.id];
+ return detail && detail.status === STATUS.COMPLETED;
+ });
+ if (allCompleted) return 'completed';
+ return false;
+ };
+
+ const resumeInstall = async (installId) => {
+ const snapshot = await fetchInstallStatus(installId);
+ const terminal = isSnapshotTerminal(snapshot);
+ if (!snapshot || terminal) {
+ if (terminal === 'completed') {
+ return 'completed';
+ }
+ 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(resolvedProtocol);
+ return;
+ }
+
+ if (retryButton) {
+ const row = retryButton.closest('.install-row');
+ const stepId = row?.dataset.step;
+ retryInstallStep(stepId);
+ }
+ });
+
+ const globalActions = root.querySelector('[data-install-global-actions]');
+
+ const showGlobalActions = () => {
+ if (globalActions) {
+ globalActions.classList.remove('is-hidden');
+ }
+ };
+
+ const performReset = async (hard) => {
+ const installId = activeInstall?.installId || getInstallLock?.()?.installId || getStoredInstallId?.();
+
+ try {
+ const res = await fetch('/install/reset', {
+ method: 'POST',
+ headers: withCsrfHeader({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ installId: installId || '', hard })
+ });
+ if (hard && !res.ok) {
+ const data = await res.json().catch(() => ({}));
+ showToast?.({
+ status: 'error',
+ title: 'Reset failed',
+ description: data?.message || 'Could not stop containers. Try running "docker compose down -v" manually.',
+ dismissible: true
+ });
+ return;
+ }
+ } catch (e) {
+ console.error('Reset request failed:', e);
+ }
+
+ clearInstallLock?.();
+ clearInstallId?.();
+ cleanupInstallFlow();
+ window.location.href = '/?step=1';
+ };
+
+ const startOverButton = root.querySelector('[data-install-start-over]');
+ if (startOverButton) {
+ startOverButton.addEventListener('click', () => performReset(false));
+ }
+
+ const hardResetButton = root.querySelector('[data-install-hard-reset]');
+ if (hardResetButton) {
+ hardResetButton.addEventListener('click', () => {
+ const confirmed = window.confirm(
+ 'This will stop all containers, remove all volumes (including database data, uploads, and certificates), and delete configuration files.\n\nThis action cannot be undone. Continue?'
+ );
+ if (confirmed) {
+ performReset(true);
+ }
+ });
+ }
+
+ // 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 startFreshInstall = () => {
+ clearInstallId?.();
+ clearInstallLock?.();
+ const newInstallId = generateInstallId();
+ storeInstallId?.(newInstallId);
+ startInstallStream(newInstallId);
+ };
+
+ const recoverToLastStep = () => {
+ clearInstallId?.();
+ clearInstallLock?.();
+ const url = new URL(window.location.href);
+ const lastStep = url.searchParams.get('step');
+ // Stay on the current URL so the user keeps their place;
+ // only navigate away if we're already on step 5 (the
+ // progress screen) since there's nothing to show.
+ if (!lastStep || String(lastStep) === '5') {
+ window.location.href = '/?step=1';
+ }
+ };
+
+ const lock = getInstallLock?.();
+ const existingInstallId = lock?.installId || getStoredInstallId?.();
+ if (existingInstallId) {
+ resumeInstall(existingInstallId).then((result) => {
+ if (result === 'completed') {
+ // Install already finished — redirect to console
+ // instead of bouncing back to step 1.
+ stopSyncedSpinnerRotation();
+ setUnloadGuard(false);
+ clearInstallLock?.();
+ clearInstallId?.();
+ startSslCheck(null);
+ } else if (!result) {
+ recoverToLastStep();
+ }
+ });
+ } else {
+ startFreshInstall();
+ }
+ };
+
+ 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..3c7fcd2427
--- /dev/null
+++ b/app/views/install/installer/js/modules/state.js
@@ -0,0 +1,186 @@
+(() => {
+ const {
+ getBodyDataset,
+ isUpgradeMode,
+ getLockedDatabase
+ } = window.InstallerStepsContext || {};
+
+ const INSTALL_LOCK_KEY = 'appwrite-install-lock';
+ const INSTALL_ID_KEY = 'appwrite-install-id';
+ const INSTALL_LOCK_LOCAL_KEY = 'appwrite-install-lock-backup';
+ const INSTALL_ID_LOCAL_KEY = 'appwrite-install-id-backup';
+
+ 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) {
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === 'object') return parsed;
+ }
+ } catch (error) {}
+
+ try {
+ const raw = localStorage.getItem(INSTALL_LOCK_LOCAL_KEY);
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === 'object') {
+ sessionStorage.setItem(INSTALL_LOCK_KEY, raw);
+ 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) {}
+ try {
+ localStorage.setItem(INSTALL_LOCK_LOCAL_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) {}
+ try {
+ localStorage.removeItem(INSTALL_LOCK_LOCAL_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 {
+ const val = sessionStorage.getItem(INSTALL_ID_KEY);
+ if (val) return val;
+ } catch (error) {}
+ try {
+ return localStorage.getItem(INSTALL_ID_LOCAL_KEY);
+ } catch (error) {}
+ return null;
+ };
+
+ const storeInstallId = (installId) => {
+ try {
+ sessionStorage.setItem(INSTALL_ID_KEY, installId);
+ } catch (error) {}
+ try {
+ localStorage.setItem(INSTALL_ID_LOCAL_KEY, installId);
+ } catch (error) {}
+ };
+
+ const clearInstallId = () => {
+ try {
+ sessionStorage.removeItem(INSTALL_ID_KEY);
+ } catch (error) {}
+ try {
+ localStorage.removeItem(INSTALL_ID_LOCAL_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..a41a657602
--- /dev/null
+++ b/app/views/install/installer/js/modules/ui.js
@@ -0,0 +1,284 @@
+(() => {
+ 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 (key === 'emailCertificates' && !value) {
+ value = formState?.accountEmail;
+ }
+ 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..daa66eb8d6
--- /dev/null
+++ b/app/views/install/installer/js/modules/validation.js
@@ -0,0 +1,123 @@
+(() => {
+ 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);
+ };
+
+ const isIPAddress = (host) => {
+ if (!host) return false;
+ return isValidIPv4(host) || isValidIPv6(host);
+ };
+
+ window.InstallerStepsValidation = {
+ isValidEmail,
+ isValidPort,
+ isValidPassword,
+ isValidHostnameInput,
+ extractHostname,
+ isLocalHost,
+ isIPAddress
+ };
+})();
diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js
new file mode 100644
index 0000000000..b34389b561
--- /dev/null
+++ b/app/views/install/installer/js/steps.js
@@ -0,0 +1,473 @@
+(() => {
+ 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 initStep6 = (root) => {
+ if (!root) return;
+ syncInstallLockFlag?.();
+ applyLockPayload?.();
+ applyBodyDefaults?.();
+
+ const checkbox = root.querySelector('#run-migration');
+ if (checkbox) {
+ if (formState.migrate !== undefined) {
+ checkbox.checked = formState.migrate;
+ } else {
+ formState.migrate = checkbox.checked;
+ }
+ checkbox.addEventListener('change', () => {
+ formState.migrate = checkbox.checked;
+ dispatchStateChange?.('migrate');
+ });
+ }
+
+ 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);
+ if (normalized === 6) initStep6(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() && !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 @@
+
+
+
+
+
+
+
+ Hostname
+
+
+
+
+
+
+
+ Advanced settings
+
+
+
+
+
+
+
+
+
+
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.
+
+
+
+
+
+
+
+
+
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.
+
+
+
+
+
+
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..8468de30f4
--- /dev/null
+++ b/app/views/install/installer/templates/steps/step-4.phtml
@@ -0,0 +1,78 @@
+ 'MariaDB',
+ 'postgresql' => 'PostgreSQL',
+ default => 'MongoDB',
+};
+$badgeLabel = $defaultSecretKey !== '' ? 'Generated' : 'Missing';
+$badgeClass = $defaultSecretKey !== '' ? 'badge-success' : 'badge-warning';
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
SSL certificate email
+
+
+
Disabled
+
Appwrite Assistant
+
+
+
+
+
+
+
+
+
+
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..c18c3ea748
--- /dev/null
+++ b/app/views/install/installer/templates/steps/step-5.phtml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Retry
+
+
+
+ Navigate to Console
+
+
+
+
+
+
+
+
+
+ Start Over
+
+
+ Reset Everything
+
+
+
diff --git a/app/views/install/installer/templates/steps/step-6.phtml b/app/views/install/installer/templates/steps/step-6.phtml
new file mode 100644
index 0000000000..9a8838ae3a
--- /dev/null
+++ b/app/views/install/installer/templates/steps/step-6.phtml
@@ -0,0 +1,37 @@
+
+
+
+
+
Database migration
+
+ Run database migration after the update to apply schema changes.
+
+
+
+
+
+
+ Run migration automatically
+ Recommended when upgrading to a new version
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ To run manually later: docker compose exec appwrite migrate
+
+
+
+
+
diff --git a/app/worker.php b/app/worker.php
index 370b4a185a..169b8e9770 100644
--- a/app/worker.php
+++ b/app/worker.php
@@ -1,565 +1,72 @@
$register);
-
-Server::setResource('authorization', function () {
- $authorization = new Authorization();
- $authorization->disable();
- return $authorization;
-}, []);
-
-Server::setResource('dbForPlatform', function (Cache $cache, Registry $register, Authorization $authorization) {
- $pools = $register->get('pools');
- $adapter = new DatabasePool($pools->get('console'));
- $dbForPlatform = new Database($adapter, $cache);
-
- $dbForPlatform
- ->setDatabase(APP_DATABASE)
- ->setAuthorization($authorization)
- ->setNamespace('_console')
- ->setDocumentType('users', User::class)
- ;
-
-
- return $dbForPlatform;
-}, ['cache', 'register', 'authorization']);
-
-Server::setResource('project', function (Message $message, Database $dbForPlatform) {
- $payload = $message->getPayload() ?? [];
- $project = new Document($payload['project'] ?? []);
-
- if ($project->getId() === 'console') {
- return $project;
- }
-
- return $dbForPlatform->getDocument('projects', $project->getId());
-}, ['message', 'dbForPlatform']);
-
-Server::setResource('dbForProject', function (Cache $cache, Registry $register, Message $message, Document $project, Database $dbForPlatform, Authorization $authorization) {
- if ($project->isEmpty() || $project->getId() === 'console') {
- return $dbForPlatform;
- }
-
- $pools = $register->get('pools');
-
- try {
- $dsn = new DSN($project->getAttribute('database'));
- } catch (\InvalidArgumentException) {
- // TODO: Temporary until all projects are using shared tables
- $dsn = new DSN('mysql://' . $project->getAttribute('database'));
- }
-
- $adapter = new DatabasePool($pools->get($dsn->getHost()));
- $database = new Database($adapter, $cache);
- $database->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())
- ->setNamespace($dsn->getParam('namespace'));
- } else {
- $database
- ->setSharedTables(false)
- ->setTenant(null)
- ->setNamespace('_' . $project->getSequence());
- }
-
- $database
- ->setDatabase(APP_DATABASE)
- ->setAuthorization($authorization)
- ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER);
-
- return $database;
-}, ['cache', 'register', 'message', 'project', 'dbForPlatform', 'authorization']);
-
-Server::setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache, Authorization $authorization) {
- $databases = []; // TODO: @Meldiron This should probably be responsibility of utopia-php/pools
-
- return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$databases): Database {
- if ($project->isEmpty() || $project->getId() === 'console') {
- return $dbForPlatform;
- }
-
- try {
- $dsn = new DSN($project->getAttribute('database'));
- } catch (\InvalidArgumentException) {
- // TODO: Temporary until all projects are using shared tables
- $dsn = new DSN('mysql://' . $project->getAttribute('database'));
- }
-
- if (isset($databases[$dsn->getHost()])) {
- $database = $databases[$dsn->getHost()];
- $database->setAuthorization($authorization);
- $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
-
- if (\in_array($dsn->getHost(), $sharedTables)) {
- $database
- ->setSharedTables(true)
- ->setTenant((int)$project->getSequence())
- ->setNamespace($dsn->getParam('namespace'));
- } else {
- $database
- ->setSharedTables(false)
- ->setTenant(null)
- ->setNamespace('_' . $project->getSequence());
- }
-
- return $database;
- }
-
- $adapter = new DatabasePool($pools->get($dsn->getHost()));
- $database = new Database($adapter, $cache);
-
- $databases[$dsn->getHost()] = $database;
-
- $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
-
- if (\in_array($dsn->getHost(), $sharedTables)) {
- $database
- ->setSharedTables(true)
- ->setTenant((int)$project->getSequence())
- ->setNamespace($dsn->getParam('namespace'));
- } else {
- $database
- ->setSharedTables(false)
- ->setTenant(null)
- ->setNamespace('_' . $project->getSequence());
- }
-
- $database
- ->setDatabase(APP_DATABASE)
- ->setAuthorization($authorization)
- ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER);
-
- return $database;
- };
-}, ['pools', 'dbForPlatform', 'cache', 'authorization']);
-
-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());
- return $database;
- }
-
- $adapter = new DatabasePool($pools->get('logs'));
- $database = new Database($adapter, $cache);
-
- $database
- ->setDatabase(APP_DATABASE)
- ->setAuthorization($authorization)
- ->setSharedTables(true)
- ->setNamespace('logsV1')
- ->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());
- }
-
- return $database;
- };
-}, ['pools', 'cache', 'authorization']);
-
-Server::setResource('getDatabasesDB', function (Cache $cache, Registry $register, Document $project, Authorization $authorization) {
- return function (Document $database, ?Document $projectDocument = null) use ($cache, $register, $project, $authorization): Database {
- $projectDocument ??= $project;
- $databaseDSN = $database->getAttribute('database', '');
- $databaseType = $database->getAttribute('type', '');
-
- // Backwards‑compatibility: older or seeded legacy databases may not have a DSN stored
- // in the "database" attribute. In that case, fall back to the project's database DSN.
- if ($databaseDSN === '') {
- $databaseDSN = $projectDocument->getAttribute('database', '');
- }
-
- try {
- $databaseDSN = new DSN($databaseDSN);
- } catch (\InvalidArgumentException) {
- $databaseDSN = new DSN('mysql://'.$databaseDSN);
- }
-
- try {
- $dsn = new DSN($projectDocument->getAttribute('database'));
- } catch (\InvalidArgumentException) {
- // Temporary fallback until all projects use shared tables
- $dsn = new DSN('mysql://' . $projectDocument->getAttribute('database'));
- }
-
- $pools = $register->get('pools');
- $pool = $pools->get($databaseDSN->getHost());
-
- $adapter = new DatabasePool($pool);
- $database = new Database($adapter, $cache);
- $database
- ->setDatabase(APP_DATABASE)
- ->setAuthorization($authorization);
- $database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB);
-
- $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
-
- if (\in_array($dsn->getHost(), $sharedTables, true)) {
- $database
- ->setSharedTables(true)
- ->setTenant((int) $projectDocument->getSequence())
- ->setNamespace($dsn->getParam('namespace'));
- } else {
- $database
- ->setSharedTables(false)
- ->setTenant(null)
- ->setNamespace('_' . $projectDocument->getSequence());
- }
-
- $database->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER);
- return $database;
- };
-}, ['cache', 'register', 'project', 'authorization']);
-
-Server::setResource('abuseRetention', function () {
- return time() - (int) System::getEnv('_APP_MAINTENANCE_RETENTION_ABUSE', 86400); // 1 day
-});
-
-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']);
-
-Server::setResource('executionRetention', function () {
- return DateTime::addSeconds(new \DateTime(), -1 * System::getEnv('_APP_MAINTENANCE_RETENTION_EXECUTION', 1209600)); // 14 days
-});
-
-Server::setResource('cache', function (Registry $register) {
- $pools = $register->get('pools');
- $list = Config::getParam('pools-cache', []);
- $adapters = [];
-
- foreach ($list as $value) {
- $adapters[] = new CachePool($pools->get($value));
- }
-
- return new Cache(new Sharding($adapters));
-}, ['register']);
-
-Server::setResource('redis', function () {
- $host = System::getEnv('_APP_REDIS_HOST', 'localhost');
- $port = System::getEnv('_APP_REDIS_PORT', 6379);
- $pass = System::getEnv('_APP_REDIS_PASS', '');
-
- $redis = new \Redis();
- @$redis->pconnect($host, (int)$port);
- if ($pass) {
- $redis->auth($pass);
- }
- $redis->setOption(\Redis::OPT_READ_TIMEOUT, -1);
-
- return $redis;
-});
-
-Server::setResource('timelimit', function (\Redis $redis) {
- return function (string $key, int $limit, int $time) use ($redis) {
- return new TimeLimitRedis($key, $limit, $time, $redis);
- };
-}, ['redis']);
-
-Server::setResource('log', fn () => new Log());
-
-
-Server::setResource('publisher', function (Group $pools) {
- return new BrokerPool(publisher: $pools->get('publisher'));
-}, ['pools']);
-
-Server::setResource('publisherDatabases', function (BrokerPool $publisher) {
- return $publisher;
-}, ['publisher']);
-
-Server::setResource('publisherFunctions', function (BrokerPool $publisher) {
- return $publisher;
-}, ['publisher']);
-
-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']);
-
-Server::setResource('consumer', function (Group $pools) {
- return new BrokerPool(consumer: $pools->get('consumer'));
-}, ['pools']);
-
-Server::setResource('consumerDatabases', function (BrokerPool $consumer) {
- return $consumer;
-}, ['consumer']);
-
-Server::setResource('consumerMigrations', function (BrokerPool $consumer) {
- return $consumer;
-}, ['consumer']);
-
-Server::setResource('consumerStatsUsage', function (BrokerPool $consumer) {
- return $consumer;
-}, ['consumer']);
-
-Server::setResource('queueForStatsUsage', function (Publisher $publisher) {
- return new StatsUsage($publisher);
-}, ['publisher']);
-
-Server::setResource('queueForDatabase', function (Publisher $publisher) {
- return new EventDatabase($publisher);
-}, ['publisher']);
-
-Server::setResource('queueForMessaging', function (Publisher $publisher) {
- return new Messaging($publisher);
-}, ['publisher']);
-
-Server::setResource('queueForMails', function (Publisher $publisher) {
- return new Mail($publisher);
-}, ['publisher']);
-
-Server::setResource('queueForBuilds', function (Publisher $publisher) {
- return new Build($publisher);
-}, ['publisher']);
-
-Server::setResource('queueForScreenshots', function (Publisher $publisher) {
- return new Screenshot($publisher);
-}, ['publisher']);
-
-Server::setResource('queueForDeletes', function (Publisher $publisher) {
- return new Delete($publisher);
-}, ['publisher']);
-
-Server::setResource('queueForEvents', function (Publisher $publisher) {
- return new Event($publisher);
-}, ['publisher']);
-
-Server::setResource('queueForAudits', function (Publisher $publisher) {
- return new Audit($publisher);
-}, ['publisher']);
-
-Server::setResource('queueForWebhooks', function (Publisher $publisher) {
- return new Webhook($publisher);
-}, ['publisher']);
-
-Server::setResource('queueForFunctions', function (Publisher $publisher) {
- return new Func($publisher);
-}, ['publisher']);
-
-
-Server::setResource('queueForRealtime', function () {
- return new Realtime();
-}, []);
-
-Server::setResource('queueForCertificates', function (Publisher $publisher) {
- return new Certificate($publisher);
-}, ['publisher']);
-
-Server::setResource('queueForMigrations', function (Publisher $publisher) {
- return new Migration($publisher);
-}, ['publisher']);
-
-Server::setResource('logger', function (Registry $register) {
- return $register->get('logger');
-}, ['register']);
-
-Server::setResource('pools', function (Registry $register) {
+global $container;
+$container->set('pools', function ($register) {
return $register->get('pools');
}, ['register']);
-Server::setResource('telemetry', fn () => new NoTelemetry());
+$container->set('authorization', function () {
+ $authorization = new Authorization();
+ $authorization->disable();
-Server::setResource('deviceForSites', function (Document $project, Telemetry $telemetry) {
- return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
+ return $authorization;
+}, []);
-Server::setResource('deviceForMigrations', function (Document $project, Telemetry $telemetry) {
- return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
+$container->set('project', fn () => new Document([]), []);
-Server::setResource('deviceForFunctions', function (Document $project, Telemetry $telemetry) {
- return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
+$container->set('log', fn () => new Log(), []);
-Server::setResource('deviceForFiles', function (Document $project, Telemetry $telemetry) {
- return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
+$container->set('consumer', function (Group $pools) {
+ return new BrokerPool(consumer: $pools->get('consumer'));
+}, ['pools']);
-Server::setResource('deviceForBuilds', function (Document $project, Telemetry $telemetry) {
- return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
+$container->set('consumerDatabases', function (BrokerPool $consumer) {
+ return $consumer;
+}, ['consumer']);
-Server::setResource('deviceForCache', function (Document $project, Telemetry $telemetry) {
- return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_CACHE . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
+$container->set('consumerMigrations', function (BrokerPool $consumer) {
+ return $consumer;
+}, ['consumer']);
-Server::setResource(
- 'isResourceBlocked',
- fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false
-);
+$container->set('consumerStatsUsage', function (BrokerPool $consumer) {
+ return $consumer;
+}, ['consumer']);
-Server::setResource('plan', function (array $plan = []) {
- return [];
-});
-
-Server::setResource('certificates', function () {
+$container->set('certificates', function () {
$email = System::getEnv('_APP_EMAIL_CERTIFICATES', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS'));
if (empty($email)) {
throw new Exception('You must set a valid security email address (_APP_EMAIL_CERTIFICATES) to issue a LetsEncrypt SSL certificate.');
}
return new LetsEncrypt($email);
-});
+}, []);
-Server::setResource('logError', function (Registry $register, Document $project) {
- return function (Throwable $error, string $namespace, string $action, ?array $extras = null) use ($register, $project) {
- $logger = $register->get('logger');
-
- if ($logger) {
- $version = System::getEnv('_APP_VERSION', 'UNKNOWN');
-
- $log = new Log();
- $log->setNamespace($namespace);
- $log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
- $log->setVersion($version);
- $log->setType(Log::TYPE_ERROR);
- $log->setMessage($error->getMessage());
-
- $log->addTag('code', $error->getCode());
- $log->addTag('verboseType', get_class($error));
- $log->addTag('projectId', $project->getId() ?? '');
-
- $log->addExtra('file', $error->getFile());
- $log->addExtra('line', $error->getLine());
- $log->addExtra('trace', $error->getTraceAsString());
-
- if ($error->getPrevious() !== null) {
- if ($error->getPrevious()->getMessage() != $error->getMessage()) {
- $log->addExtra('previousMessage', $error->getPrevious()->getMessage());
- }
- $log->addExtra('previousFile', $error->getPrevious()->getFile());
- $log->addExtra('previousLine', $error->getPrevious()->getLine());
- }
-
- foreach (($extras ?? []) as $key => $value) {
- $log->addExtra($key, $value);
- }
-
- $log->setAction($action);
-
- $isProduction = System::getEnv('_APP_ENV', 'development') === 'production';
- $log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING);
-
- try {
- $responseCode = $logger->addLog($log);
- Console::info('Error log pushed with status code: ' . $responseCode);
- } catch (Throwable $th) {
- Console::error('Error pushing log: ' . $th->getMessage());
- }
- }
-
- Console::warning("Failed: {$error->getMessage()}");
- Console::warning($error->getTraceAsString());
-
- if ($error->getPrevious() !== null) {
- if ($error->getPrevious()->getMessage() != $error->getMessage()) {
- Console::warning("Previous Failed: {$error->getPrevious()->getMessage()}");
- }
- Console::warning("Previous File: {$error->getPrevious()->getFile()} Line: {$error->getPrevious()->getLine()}");
- }
- };
-}, ['register', 'project']);
-
-Server::setResource('executor', fn () => new Executor());
-
-Server::setResource('getAudit', function (Database $dbForPlatform, callable $getProjectDB) {
- 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']);
-
-Server::setResource('executionsRetentionCount', function (Document $project, array $plan) {
- if ($project->getId() === 'console' || empty($plan)) {
- return 0;
- }
-
- return (int) ($plan['executionsRetentionCount'] ?? 100);
-}, ['project', 'plan']);
-
-$pools = $register->get('pools');
$platform = new Appwrite();
-$args = $platform->getEnv('argv');
+$args = $_SERVER['argv'] ?? [];
-if (!isset($args[1])) {
+if (! isset($args[1])) {
Console::error('Missing worker name');
Console::exit(1);
}
@@ -573,42 +80,56 @@ if (\str_starts_with($workerName, 'databases')) {
$queueName = System::getEnv('_APP_QUEUE_NAME', 'v1-' . strtolower($workerName));
}
+/** @var \Utopia\Pools\Group $pools */
+$pools = $container->get('pools');
+
+$adapter = new Swoole(
+ $pools->get('consumer')->pop()->getResource(),
+ System::getEnv('_APP_WORKERS_NUM', 1),
+ $queueName
+);
+
+$worker = new Server($adapter, $container);
+
try {
- /**
- * Any worker can be configured with the following env vars:
- * - _APP_WORKERS_NUM The total number of worker processes
- * - _APP_WORKER_PER_CORE The number of worker processes per core (ignored if _APP_WORKERS_NUM is set)
- * - _APP_QUEUE_NAME The name of the queue to read for database events
- */
+ $worker->init()->action(function () use ($worker, $registerWorkerMessageResources, $queueName) {
+ $registerWorkerMessageResources($worker->getContainer());
+ Span::init("worker.{$queueName}");
+ });
+
+ $worker->shutdown()->action(function () {
+ Span::current()?->finish();
+ });
+
+ $container->set('bus', function ($register) use ($worker) {
+ return $register->get('bus')->setResolver(
+ fn (string $name) => $worker->getContainer()->get($name)
+ );
+ }, ['register']);
+
+ $platform->setWorker($worker);
$platform->init(Service::TYPE_WORKER, [
- 'workersNum' => System::getEnv('_APP_WORKERS_NUM', 1),
- 'connection' => $pools->get('consumer')->pop()->getResource(),
- 'workerName' => strtolower($workerName) ?? null,
- 'queueName' => $queueName
+ 'workerName' => strtolower($workerName),
]);
} catch (\Throwable $e) {
- Console::error($e->getMessage() . ', File: ' . $e->getFile() . ', Line: ' . $e->getLine());
+ Console::error($e->getMessage() . ', File: ' . $e->getFile() . ', Line: ' . $e->getLine());
+ Console::exit(1);
}
-$worker = $platform->getWorker();
-
-Server::setResource('bus', function ($register) use ($worker) {
- return $register->get('bus')->setResolver(fn (string $name) => $worker->getResource($name));
-}, ['register']);
-
$worker
->error()
->inject('error')
->inject('logger')
->inject('log')
- ->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, Document $project, Authorization $authorization) use ($queueName) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
+ Span::error($error);
+
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);
@@ -616,7 +137,7 @@ $worker
$log->setAction('appwrite-queue-' . $queueName);
$log->addTag('verboseType', get_class($error));
$log->addTag('code', $error->getCode());
- $log->addTag('projectId', $project->getId() ?? 'n/a');
+ $log->addTag('projectId', $project->getId());
$log->addExtra('file', $error->getFile());
$log->addExtra('line', $error->getLine());
$log->addExtra('trace', $error->getTraceAsString());
diff --git a/bin/time-travel b/bin/task-time-travel
similarity index 100%
rename from bin/time-travel
rename to bin/task-time-travel
diff --git a/composer.json b/composer.json
index 943ad57293..37e7a1ae0e 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 --memory-limit=1G",
+ "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": {
@@ -46,41 +49,43 @@
"ext-openssl": "*",
"ext-zlib": "*",
"ext-sockets": "*",
- "appwrite/php-runtimes": "0.19.*",
+ "appwrite/php-runtimes": "0.20.*",
"appwrite/php-clamav": "2.0.*",
- "utopia-php/abuse": "1.2.*",
+ "utopia-php/abuse": "1.3.*",
+ "utopia-php/agents": "1.2.*",
"utopia-php/analytics": "0.15.*",
- "utopia-php/audit": "2.2.*",
+ "utopia-php/audit": "2.3.*",
"utopia-php/auth": "0.5.*",
- "utopia-php/cache": "1.0.*",
- "utopia-php/cli": "0.22.*",
+ "utopia-php/cache": "^2.1",
+ "utopia-php/cli": "0.23.*",
"utopia-php/compression": "0.1.*",
"utopia-php/config": "1.*",
"utopia-php/console": "0.1.*",
"utopia-php/database": "5.*",
- "utopia-php/agents": "1.*",
"utopia-php/detector": "0.2.*",
- "utopia-php/domains": "1.*",
- "utopia-php/emails": "0.6.*",
- "utopia-php/dns": "1.6.*",
+ "utopia-php/domains": "2.*",
+ "utopia-php/emails": "0.7.*",
+ "utopia-php/dns": "1.7.*",
"utopia-php/dsn": "0.2.1",
- "utopia-php/framework": "0.33.*",
- "utopia-php/fetch": "0.5.*",
+ "utopia-php/http": "^2.0@RC",
+ "utopia-php/fetch": "^1.1",
+ "utopia-php/validators": "0.2.*",
"utopia-php/image": "0.8.*",
"utopia-php/locale": "0.8.*",
- "utopia-php/logger": "0.6.*",
- "utopia-php/messaging": "0.20.*",
- "utopia-php/migration": "dev-add-api-key-migration as 1.8.0",
- "utopia-php/platform": "0.7.*",
+ "utopia-php/logger": "0.8.*",
+ "utopia-php/messaging": "0.22.*",
+ "utopia-php/migration": "dev-add-api-key-migration as 1.12.0",
+ "utopia-php/platform": "^1.0@RC",
"utopia-php/pools": "1.*",
"utopia-php/span": "1.1.*",
"utopia-php/preloader": "0.2.*",
- "utopia-php/queue": "0.15.*",
+ "utopia-php/queue": "0.18.*",
+ "utopia-php/servers": "0.4.*",
"utopia-php/registry": "0.5.*",
- "utopia-php/storage": "1.0.*",
+ "utopia-php/storage": "2.*",
"utopia-php/system": "0.10.*",
"utopia-php/telemetry": "0.2.*",
- "utopia-php/vcs": "2.*",
+ "utopia-php/vcs": "4.*",
"utopia-php/websocket": "1.0.*",
"matomo/device-detector": "6.4.*",
"dragonmantank/cron-expression": "3.4.*",
@@ -88,7 +93,7 @@
"chillerlan/php-qrcode": "4.3.*",
"adhocore/jwt": "1.1.*",
"spomky-labs/otphp": "11.*",
- "webonyx/graphql-php": "14.11.*",
+ "webonyx/graphql-php": "15.32.*",
"league/csv": "9.14.*",
"enshrined/svg-sanitize": "0.22.*"
},
@@ -98,11 +103,10 @@
"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.*"
},
"provide": {
"ext-phpiredis": "*"
diff --git a/composer.lock b/composer.lock
index 744dbf245c..abb2eca686 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": "71811c2ce39b1a4cc2e9c17d6e7ab07b",
+ "content-hash": "9377e1b56bca8dbaf213ee3572ca15c0",
"packages": [
{
"name": "adhocore/jwt",
@@ -69,25 +69,25 @@
},
{
"name": "appwrite/appwrite",
- "version": "19.1.0",
+ "version": "23.1.0",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-for-php.git",
- "reference": "8738e812062f899c85b2598eef43d6a247f08a56"
+ "reference": "2f275921f10ceb7cff99f2d463f7328b296234fa"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/8738e812062f899c85b2598eef43d6a247f08a56",
- "reference": "8738e812062f899c85b2598eef43d6a247f08a56",
+ "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/2f275921f10ceb7cff99f2d463f7328b296234fa",
+ "reference": "2f275921f10ceb7cff99f2d463f7328b296234fa",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
- "php": ">=7.1.0"
+ "php": ">=8.2.0"
},
"require-dev": {
- "mockery/mockery": "^1.6.12",
+ "mockery/mockery": "1.6.12",
"phpunit/phpunit": "^10"
},
"type": "library",
@@ -100,14 +100,14 @@
"license": [
"BSD-3-Clause"
],
- "description": "Appwrite is an open-source self-hosted backend server that abstract and simplify complex and repetitive development tasks behind a very simple REST API",
+ "description": "Appwrite is an open-source self-hosted backend server that abstracts and simplifies complex and repetitive development tasks behind a very simple REST API",
"support": {
"email": "team@appwrite.io",
"issues": "https://github.com/appwrite/sdk-for-php/issues",
- "source": "https://github.com/appwrite/sdk-for-php/tree/19.1.0",
+ "source": "https://github.com/appwrite/sdk-for-php/tree/23.1.0",
"url": "https://appwrite.io/support"
},
- "time": "2025-12-18T08:07:43+00:00"
+ "time": "2026-05-08T13:44:58+00:00"
},
{
"name": "appwrite/php-clamav",
@@ -161,16 +161,16 @@
},
{
"name": "appwrite/php-runtimes",
- "version": "0.19.4",
+ "version": "0.20.0",
"source": {
"type": "git",
"url": "https://github.com/appwrite/runtimes.git",
- "reference": "eea9d1b3ca2540eab623b419c8afde09ef406c0b"
+ "reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/appwrite/runtimes/zipball/eea9d1b3ca2540eab623b419c8afde09ef406c0b",
- "reference": "eea9d1b3ca2540eab623b419c8afde09ef406c0b",
+ "url": "https://api.github.com/repos/appwrite/runtimes/zipball/7d9b7f4eef5c0a142a60907b06de2219d025c5c3",
+ "reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3",
"shasum": ""
},
"require": {
@@ -210,9 +210,9 @@
],
"support": {
"issues": "https://github.com/appwrite/runtimes/issues",
- "source": "https://github.com/appwrite/runtimes/tree/0.19.4"
+ "source": "https://github.com/appwrite/runtimes/tree/0.20.0"
},
- "time": "2026-02-17T10:04:39+00:00"
+ "time": "2026-05-01T07:47:07+00:00"
},
{
"name": "brick/math",
@@ -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",
@@ -1226,16 +1226,16 @@
},
{
"name": "open-telemetry/api",
- "version": "1.8.0",
+ "version": "1.9.0",
"source": {
"type": "git",
"url": "https://github.com/opentelemetry-php/api.git",
- "reference": "df5197c6fd0ddd8e9883b87de042d9341300e2ad"
+ "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/df5197c6fd0ddd8e9883b87de042d9341300e2ad",
- "reference": "df5197c6fd0ddd8e9883b87de042d9341300e2ad",
+ "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/6f8d237ce2c304ca85f31970f788e7f074d147be",
+ "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be",
"shasum": ""
},
"require": {
@@ -1292,20 +1292,20 @@
"issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
"source": "https://github.com/open-telemetry/opentelemetry-php"
},
- "time": "2026-01-21T04:14:03+00:00"
+ "time": "2026-02-25T13:24:05+00:00"
},
{
"name": "open-telemetry/context",
- "version": "1.4.0",
+ "version": "1.5.0",
"source": {
"type": "git",
"url": "https://github.com/opentelemetry-php/context.git",
- "reference": "d4c4470b541ce72000d18c339cfee633e4c8e0cf"
+ "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/d4c4470b541ce72000d18c339cfee633e4c8e0cf",
- "reference": "d4c4470b541ce72000d18c339cfee633e4c8e0cf",
+ "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/3c414b246e0dabb7d6145404e6a5e4536ca18d07",
+ "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07",
"shasum": ""
},
"require": {
@@ -1347,11 +1347,11 @@
],
"support": {
"chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V",
- "docs": "https://opentelemetry.io/docs/php",
+ "docs": "https://opentelemetry.io/docs/languages/php",
"issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
"source": "https://github.com/open-telemetry/opentelemetry-php"
},
- "time": "2025-09-19T00:05:49+00:00"
+ "time": "2025-10-19T06:44:33+00:00"
},
{
"name": "open-telemetry/exporter-otlp",
@@ -1419,16 +1419,16 @@
},
{
"name": "open-telemetry/gen-otlp-protobuf",
- "version": "1.8.0",
+ "version": "1.9.0",
"source": {
"type": "git",
"url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git",
- "reference": "673af5b06545b513466081884b47ef15a536edde"
+ "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/673af5b06545b513466081884b47ef15a536edde",
- "reference": "673af5b06545b513466081884b47ef15a536edde",
+ "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/a229cf161d42001d64c8f21e8f678581fe1c66b9",
+ "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9",
"shasum": ""
},
"require": {
@@ -1474,30 +1474,30 @@
],
"support": {
"chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V",
- "docs": "https://opentelemetry.io/docs/php",
+ "docs": "https://opentelemetry.io/docs/languages/php",
"issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
"source": "https://github.com/open-telemetry/opentelemetry-php"
},
- "time": "2025-09-17T23:10:12+00:00"
+ "time": "2025-10-19T06:44:33+00:00"
},
{
"name": "open-telemetry/sdk",
- "version": "1.13.0",
+ "version": "1.14.0",
"source": {
"type": "git",
"url": "https://github.com/opentelemetry-php/sdk.git",
- "reference": "c76f91203bf7ef98ab3f4e0a82ca21699af185e1"
+ "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/c76f91203bf7ef98ab3f4e0a82ca21699af185e1",
- "reference": "c76f91203bf7ef98ab3f4e0a82ca21699af185e1",
+ "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/6e3d0ce93e76555dd5e2f1d19443ff45b990e410",
+ "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410",
"shasum": ""
},
"require": {
"ext-json": "*",
"nyholm/psr7-server": "^1.1",
- "open-telemetry/api": "^1.7",
+ "open-telemetry/api": "^1.8",
"open-telemetry/context": "^1.4",
"open-telemetry/sem-conv": "^1.0",
"php": "^8.1",
@@ -1575,7 +1575,7 @@
"issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
"source": "https://github.com/open-telemetry/opentelemetry-php"
},
- "time": "2026-01-28T11:38:11+00:00"
+ "time": "2026-03-21T11:50:01+00:00"
},
{
"name": "open-telemetry/sem-conv",
@@ -1996,16 +1996,16 @@
},
{
"name": "phpseclib/phpseclib",
- "version": "3.0.49",
+ "version": "3.0.52",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
- "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9"
+ "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/6233a1e12584754e6b5daa69fe1289b47775c1b9",
- "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9",
+ "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce",
+ "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce",
"shasum": ""
},
"require": {
@@ -2086,7 +2086,7 @@
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
- "source": "https://github.com/phpseclib/phpseclib/tree/3.0.49"
+ "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52"
},
"funding": [
{
@@ -2102,7 +2102,7 @@
"type": "tidelift"
}
],
- "time": "2026-01-27T09:17:28+00:00"
+ "time": "2026-04-27T07:02:15+00:00"
},
{
"name": "psr/clock",
@@ -2641,16 +2641,16 @@
},
{
"name": "symfony/deprecation-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
- "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
"shasum": ""
},
"require": {
@@ -2663,7 +2663,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -2688,7 +2688,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -2699,25 +2699,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-25T14:21:43+00:00"
+ "time": "2026-04-13T15:52:40+00:00"
},
{
"name": "symfony/http-client",
- "version": "v7.4.7",
+ "version": "v7.4.9",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
- "reference": "1010624285470eb60e88ed10035102c75b4ea6af"
+ "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client/zipball/1010624285470eb60e88ed10035102c75b4ea6af",
- "reference": "1010624285470eb60e88ed10035102c75b4ea6af",
+ "url": "https://api.github.com/repos/symfony/http-client/zipball/7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6",
+ "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6",
"shasum": ""
},
"require": {
@@ -2785,7 +2789,7 @@
"http"
],
"support": {
- "source": "https://github.com/symfony/http-client/tree/v7.4.7"
+ "source": "https://github.com/symfony/http-client/tree/v7.4.9"
},
"funding": [
{
@@ -2805,20 +2809,20 @@
"type": "tidelift"
}
],
- "time": "2026-03-05T11:16:58+00:00"
+ "time": "2026-04-29T13:25:15+00:00"
},
{
"name": "symfony/http-client-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
- "reference": "75d7043853a42837e68111812f4d964b01e5101c"
+ "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
- "reference": "75d7043853a42837e68111812f4d964b01e5101c",
+ "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d",
+ "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d",
"shasum": ""
},
"require": {
@@ -2831,7 +2835,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -2867,7 +2871,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -2878,25 +2882,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-04-29T11:18:49+00:00"
+ "time": "2026-03-06T13:17:50+00:00"
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
+ "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
- "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315",
+ "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315",
"shasum": ""
},
"require": {
@@ -2948,7 +2956,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0"
},
"funding": [
{
@@ -2968,20 +2976,20 @@
"type": "tidelift"
}
],
- "time": "2024-12-23T08:48:59+00:00"
+ "time": "2026-04-10T17:25:58+00:00"
},
{
"name": "symfony/polyfill-php82",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php82.git",
- "reference": "5d2ed36f7734637dacc025f179698031951b1692"
+ "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/5d2ed36f7734637dacc025f179698031951b1692",
- "reference": "5d2ed36f7734637dacc025f179698031951b1692",
+ "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/34808efe3e68f69685796f7c253a2f1d8ea9df59",
+ "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59",
"shasum": ""
},
"require": {
@@ -3028,7 +3036,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php82/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php82/tree/v1.37.0"
},
"funding": [
{
@@ -3048,20 +3056,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-php83",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
- "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5"
+ "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5",
- "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5",
+ "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149",
+ "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149",
"shasum": ""
},
"require": {
@@ -3108,7 +3116,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0"
},
"funding": [
{
@@ -3128,20 +3136,20 @@
"type": "tidelift"
}
],
- "time": "2025-07-08T02:45:35+00:00"
+ "time": "2026-04-10T17:25:58+00:00"
},
{
"name": "symfony/polyfill-php85",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php85.git",
- "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91"
+ "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91",
- "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91",
+ "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee",
+ "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee",
"shasum": ""
},
"require": {
@@ -3188,7 +3196,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0"
},
"funding": [
{
@@ -3208,20 +3216,20 @@
"type": "tidelift"
}
],
- "time": "2025-06-23T16:12:55+00:00"
+ "time": "2026-04-26T13:10:57+00:00"
},
{
"name": "symfony/service-contracts",
- "version": "v3.6.1",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
- "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
+ "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
- "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a",
+ "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a",
"shasum": ""
},
"require": {
@@ -3239,7 +3247,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -3275,7 +3283,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
+ "source": "https://github.com/symfony/service-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -3295,7 +3303,7 @@
"type": "tidelift"
}
],
- "time": "2025-07-15T11:30:57+00:00"
+ "time": "2026-03-28T09:44:51+00:00"
},
{
"name": "tbachert/spi",
@@ -3351,24 +3359,24 @@
},
{
"name": "utopia-php/abuse",
- "version": "1.2.2",
+ "version": "1.3.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/abuse.git",
- "reference": "20bee84fd14dbe81d50ecabf1ffd81cceca06152"
+ "reference": "5d7efbe5c6b0cf7d06003114fd86e24ba785582f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/abuse/zipball/20bee84fd14dbe81d50ecabf1ffd81cceca06152",
- "reference": "20bee84fd14dbe81d50ecabf1ffd81cceca06152",
+ "url": "https://api.github.com/repos/utopia-php/abuse/zipball/5d7efbe5c6b0cf7d06003114fd86e24ba785582f",
+ "reference": "5d7efbe5c6b0cf7d06003114fd86e24ba785582f",
"shasum": ""
},
"require": {
- "appwrite/appwrite": "19.*",
+ "appwrite/appwrite": "23.*",
"ext-curl": "*",
"ext-pdo": "*",
"ext-redis": "*",
- "php": ">=8.0",
+ "php": ">=8.2",
"utopia-php/database": "5.*"
},
"require-dev": {
@@ -3397,27 +3405,27 @@
],
"support": {
"issues": "https://github.com/utopia-php/abuse/issues",
- "source": "https://github.com/utopia-php/abuse/tree/1.2.2"
+ "source": "https://github.com/utopia-php/abuse/tree/1.3.0"
},
- "time": "2026-02-02T10:43:10+00:00"
+ "time": "2026-05-11T08:07:02+00:00"
},
{
"name": "utopia-php/agents",
- "version": "1.2.1",
+ "version": "1.2.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/agents.git",
- "reference": "052227953678a30ecc4b5467401fcb0b2386471e"
+ "reference": "0703f4cae02261e09a1bf0d39a4b1ce649cae634"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/agents/zipball/052227953678a30ecc4b5467401fcb0b2386471e",
- "reference": "052227953678a30ecc4b5467401fcb0b2386471e",
+ "url": "https://api.github.com/repos/utopia-php/agents/zipball/0703f4cae02261e09a1bf0d39a4b1ce649cae634",
+ "reference": "0703f4cae02261e09a1bf0d39a4b1ce649cae634",
"shasum": ""
},
"require": {
"php": ">=8.3",
- "utopia-php/fetch": "0.5.*"
+ "utopia-php/fetch": "^1.1.0"
},
"require-dev": {
"laravel/pint": "^1.18",
@@ -3450,9 +3458,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/agents/issues",
- "source": "https://github.com/utopia-php/agents/tree/1.2.1"
+ "source": "https://github.com/utopia-php/agents/tree/1.2.2"
},
- "time": "2026-02-24T06:03:55+00:00"
+ "time": "2026-05-08T10:38:23+00:00"
},
{
"name": "utopia-php/analytics",
@@ -3502,22 +3510,23 @@
},
{
"name": "utopia-php/audit",
- "version": "2.2.1",
+ "version": "2.3.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/audit.git",
- "reference": "e3e2d6ad5c7f6377d9237df296a12eb7943892fd"
+ "reference": "e7b4049fc2ee9be34bcc18771fa593db3b0e9fe3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/audit/zipball/e3e2d6ad5c7f6377d9237df296a12eb7943892fd",
- "reference": "e3e2d6ad5c7f6377d9237df296a12eb7943892fd",
+ "url": "https://api.github.com/repos/utopia-php/audit/zipball/e7b4049fc2ee9be34bcc18771fa593db3b0e9fe3",
+ "reference": "e7b4049fc2ee9be34bcc18771fa593db3b0e9fe3",
"shasum": ""
},
"require": {
- "php": ">=8.0",
+ "php": ">=8.4",
"utopia-php/database": "5.*",
- "utopia-php/fetch": "0.5.*",
+ "utopia-php/fetch": "^1.1",
+ "utopia-php/query": "0.1.*",
"utopia-php/validators": "0.2.*"
},
"require-dev": {
@@ -3545,9 +3554,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/audit/issues",
- "source": "https://github.com/utopia-php/audit/tree/2.2.1"
+ "source": "https://github.com/utopia-php/audit/tree/2.3.2"
},
- "time": "2026-02-02T10:39:25+00:00"
+ "time": "2026-05-14T04:00:37+00:00"
},
{
"name": "utopia-php/auth",
@@ -3606,23 +3615,24 @@
},
{
"name": "utopia-php/cache",
- "version": "1.0.0",
+ "version": "2.1.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/cache.git",
- "reference": "7068870c086a6aea16173563a26b93ef3e408439"
+ "reference": "fc3b9ae33c4b83e0e2c91ecf60b4f40fb7ee8f8e"
},
"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/fc3b9ae33c4b83e0e2c91ecf60b4f40fb7ee8f8e",
+ "reference": "fc3b9ae33c4b83e0e2c91ecf60b4f40fb7ee8f8e",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-memcached": "*",
"ext-redis": "*",
- "php": ">=8.0",
+ "php": ">=8.3",
+ "utopia-php/circuit-breaker": "0.3.*",
"utopia-php/pools": "1.*",
"utopia-php/telemetry": "*"
},
@@ -3630,6 +3640,7 @@
"laravel/pint": "1.2.*",
"phpstan/phpstan": "^1.12",
"phpunit/phpunit": "^9.3",
+ "swoole/ide-helper": "^6.0",
"vimeo/psalm": "4.13.1"
},
"type": "library",
@@ -3652,27 +3663,89 @@
],
"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/2.1.0"
},
- "time": "2026-01-28T10:55:44+00:00"
+ "time": "2026-05-12T15:03:23+00:00"
},
{
- "name": "utopia-php/cli",
- "version": "0.22.0",
+ "name": "utopia-php/circuit-breaker",
+ "version": "0.3.0",
"source": {
"type": "git",
- "url": "https://github.com/utopia-php/cli.git",
- "reference": "a7ac387ee626fd27075a87e836fb72c5be38add4"
+ "url": "https://github.com/utopia-php/circuit-breaker.git",
+ "reference": "064243c1667778c00abf027ff53a735a228776de"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/cli/zipball/a7ac387ee626fd27075a87e836fb72c5be38add4",
- "reference": "a7ac387ee626fd27075a87e836fb72c5be38add4",
+ "url": "https://api.github.com/repos/utopia-php/circuit-breaker/zipball/064243c1667778c00abf027ff53a735a228776de",
+ "reference": "064243c1667778c00abf027ff53a735a228776de",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.29",
+ "phpstan/phpstan": "^2.1",
+ "phpunit/phpunit": "^10.0",
+ "utopia-php/telemetry": "0.2.*"
+ },
+ "suggest": {
+ "ext-opentelemetry": "Required by utopia-php/telemetry when using OpenTelemetry metrics.",
+ "ext-protobuf": "Required by utopia-php/telemetry when using OpenTelemetry metrics.",
+ "ext-redis": "Required when using Utopia\\CircuitBreaker\\Adapter\\Redis with the phpredis extension.",
+ "ext-swoole": "Required when using Utopia\\CircuitBreaker\\Adapter\\SwooleTable.",
+ "utopia-php/telemetry": "Required when passing telemetry adapters or running the local telemetry demo."
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Utopia\\CircuitBreaker\\": "src/CircuitBreaker"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Team Appwrite",
+ "email": "team@appwrite.io"
+ }
+ ],
+ "description": "Light & simple Circuit Breaker for PHP to prevent cascading failures in distributed systems.",
+ "keywords": [
+ "circuit-breaker",
+ "fault-tolerance",
+ "framework",
+ "php",
+ "resilience",
+ "upf",
+ "utopia"
+ ],
+ "support": {
+ "issues": "https://github.com/utopia-php/circuit-breaker/issues",
+ "source": "https://github.com/utopia-php/circuit-breaker/tree/0.3.0"
+ },
+ "time": "2026-05-12T04:27:08+00:00"
+ },
+ {
+ "name": "utopia-php/cli",
+ "version": "0.23.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/utopia-php/cli.git",
+ "reference": "3c45ae5bcdcd3c7916e1909d74c60b8e771610db"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/utopia-php/cli/zipball/3c45ae5bcdcd3c7916e1909d74c60b8e771610db",
+ "reference": "3c45ae5bcdcd3c7916e1909d74c60b8e771610db",
"shasum": ""
},
"require": {
"php": ">=7.4",
- "utopia-php/servers": "0.2.*"
+ "utopia-php/servers": "0.4.0"
},
"require-dev": {
"laravel/pint": "1.2.*",
@@ -3703,9 +3776,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/cli/issues",
- "source": "https://github.com/utopia-php/cli/tree/0.22.0"
+ "source": "https://github.com/utopia-php/cli/tree/0.23.3"
},
- "time": "2025-10-21T10:42:45+00:00"
+ "time": "2026-05-05T04:38:59+00:00"
},
{
"name": "utopia-php/compression",
@@ -3850,27 +3923,29 @@
},
{
"name": "utopia-php/database",
- "version": "5.3.8",
+ "version": "5.8.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/database.git",
- "reference": "4920bb60afb98d4bd81f4d331765716ae1d40255"
+ "reference": "3391c97318f0e7f94d2c1ea0f7d09e5ba8aad696"
},
"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/3391c97318f0e7f94d2c1ea0f7d09e5ba8aad696",
+ "reference": "3391c97318f0e7f94d2c1ea0f7d09e5ba8aad696",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-mongodb": "*",
"ext-pdo": "*",
+ "ext-redis": "*",
"php": ">=8.4",
- "utopia-php/cache": "1.*",
- "utopia-php/framework": "0.33.*",
+ "utopia-php/cache": "^2.0",
+ "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 +3955,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": {
@@ -3902,9 +3977,9 @@
],
"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.8.0"
},
- "time": "2026-03-11T01:03:34+00:00"
+ "time": "2026-05-12T12:52:44+00:00"
},
{
"name": "utopia-php/detector",
@@ -3953,25 +4028,26 @@
},
{
"name": "utopia-php/di",
- "version": "0.1.0",
+ "version": "0.3.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/di.git",
- "reference": "22490c95f7ac3898ed1c33f1b1b5dd577305ee31"
+ "reference": "07025d721ed5d9be27932e8e640acf1467fc4b9d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/di/zipball/22490c95f7ac3898ed1c33f1b1b5dd577305ee31",
- "reference": "22490c95f7ac3898ed1c33f1b1b5dd577305ee31",
+ "url": "https://api.github.com/repos/utopia-php/di/zipball/07025d721ed5d9be27932e8e640acf1467fc4b9d",
+ "reference": "07025d721ed5d9be27932e8e640acf1467fc4b9d",
"shasum": ""
},
"require": {
- "php": ">=8.2"
+ "php": ">=8.2",
+ "psr/container": "^2.0"
},
"require-dev": {
- "laravel/pint": "^1.2",
+ "laravel/pint": "^1.27",
"phpbench/phpbench": "^1.2",
- "phpstan/phpstan": "^1.10",
+ "phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^9.5.25",
"swoole/ide-helper": "4.8.3"
},
@@ -3988,34 +4064,36 @@
],
"description": "A simple and lite library for managing dependency injections",
"keywords": [
- "framework",
- "http",
+ "PSR-11",
+ "container",
+ "dependency-injection",
+ "di",
"php",
- "upf"
+ "utopia"
],
"support": {
"issues": "https://github.com/utopia-php/di/issues",
- "source": "https://github.com/utopia-php/di/tree/0.1.0"
+ "source": "https://github.com/utopia-php/di/tree/0.3.2"
},
- "time": "2024-08-08T14:35:19+00:00"
+ "time": "2026-03-21T07:42:10+00:00"
},
{
"name": "utopia-php/dns",
- "version": "1.6.5",
+ "version": "1.7.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/dns.git",
- "reference": "574327f0f5fabefa7048030c5634cde33ad10640"
+ "reference": "90bf1bc4a51ceca93590d09e7365317b28d1eb89"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/dns/zipball/574327f0f5fabefa7048030c5634cde33ad10640",
- "reference": "574327f0f5fabefa7048030c5634cde33ad10640",
+ "url": "https://api.github.com/repos/utopia-php/dns/zipball/90bf1bc4a51ceca93590d09e7365317b28d1eb89",
+ "reference": "90bf1bc4a51ceca93590d09e7365317b28d1eb89",
"shasum": ""
},
"require": {
"php": ">=8.3",
- "utopia-php/domains": "1.0.*",
+ "utopia-php/domains": "^2.0",
"utopia-php/span": "1.1.*",
"utopia-php/telemetry": "*",
"utopia-php/validators": "0.*"
@@ -4052,27 +4130,27 @@
],
"support": {
"issues": "https://github.com/utopia-php/dns/issues",
- "source": "https://github.com/utopia-php/dns/tree/1.6.5"
+ "source": "https://github.com/utopia-php/dns/tree/1.7.0"
},
- "time": "2026-02-19T16:06:46+00:00"
+ "time": "2026-05-13T07:11:31+00:00"
},
{
"name": "utopia-php/domains",
- "version": "1.0.2",
+ "version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/domains.git",
- "reference": "b4896a6746f0fbe29dfd5e32f7790bd94c1af1e6"
+ "reference": "7f76390998359ef67fcea168f614cbd63a4001e8"
},
"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/7f76390998359ef67fcea168f614cbd63a4001e8",
+ "reference": "7f76390998359ef67fcea168f614cbd63a4001e8",
"shasum": ""
},
"require": {
"php": ">=8.2",
- "utopia-php/cache": "1.0.*",
+ "utopia-php/cache": "^2.0",
"utopia-php/validators": "0.*"
},
"require-dev": {
@@ -4114,9 +4192,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/2.0.0"
},
- "time": "2026-02-25T08:18:25+00:00"
+ "time": "2026-05-12T12:52:53+00:00"
},
{
"name": "utopia-php/dsn",
@@ -4167,22 +4245,21 @@
},
{
"name": "utopia-php/emails",
- "version": "0.6.8",
+ "version": "0.7.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/emails.git",
- "reference": "25dfcd46ed47b862d2a7e7c98d92a3a4680b6f1b"
+ "reference": "115e24aa908e2b1f06c7ff3b94434a0bdbed9107"
},
"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/115e24aa908e2b1f06c7ff3b94434a0bdbed9107",
+ "reference": "115e24aa908e2b1f06c7ff3b94434a0bdbed9107",
"shasum": ""
},
"require": {
"php": ">=8.0",
- "utopia-php/domains": "^1.0",
- "utopia-php/fetch": "^0.5",
+ "utopia-php/domains": "^2.0",
"utopia-php/validators": "0.*"
},
"require-dev": {
@@ -4190,7 +4267,8 @@
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^9.3",
"utopia-php/cli": "^0.22",
- "utopia-php/console": "0.*"
+ "utopia-php/console": "0.*",
+ "utopia-php/fetch": "^1.1"
},
"type": "library",
"autoload": {
@@ -4222,22 +4300,22 @@
],
"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.7.0"
},
- "time": "2026-02-09T12:31:56+00:00"
+ "time": "2026-05-13T05:01:26+00:00"
},
{
"name": "utopia-php/fetch",
- "version": "0.5.1",
+ "version": "1.1.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/fetch.git",
- "reference": "a96a010e1c273f3888765449687baf58cbc61fcd"
+ "reference": "64f2b3a789480f1deb102ce684dac4217d8e98d5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/fetch/zipball/a96a010e1c273f3888765449687baf58cbc61fcd",
- "reference": "a96a010e1c273f3888765449687baf58cbc61fcd",
+ "url": "https://api.github.com/repos/utopia-php/fetch/zipball/64f2b3a789480f1deb102ce684dac4217d8e98d5",
+ "reference": "64f2b3a789480f1deb102ce684dac4217d8e98d5",
"shasum": ""
},
"require": {
@@ -4246,7 +4324,8 @@
"require-dev": {
"laravel/pint": "^1.5.0",
"phpstan/phpstan": "^1.10",
- "phpunit/phpunit": "^9.5"
+ "phpunit/phpunit": "^9.5",
+ "swoole/ide-helper": "^6.0"
},
"type": "library",
"autoload": {
@@ -4261,36 +4340,42 @@
"description": "A simple library that provides an interface for making HTTP Requests.",
"support": {
"issues": "https://github.com/utopia-php/fetch/issues",
- "source": "https://github.com/utopia-php/fetch/tree/0.5.1"
+ "source": "https://github.com/utopia-php/fetch/tree/1.1.2"
},
- "time": "2025-12-18T16:25:10+00:00"
+ "time": "2026-04-29T11:19:19+00:00"
},
{
- "name": "utopia-php/framework",
- "version": "0.33.41",
+ "name": "utopia-php/http",
+ "version": "2.0.0-rc1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/http.git",
- "reference": "0f3bf2377c867e547c929c3733b8224afee6ef06"
+ "reference": "3e3b431d443844c6bf810120dee735f45880856f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/http/zipball/0f3bf2377c867e547c929c3733b8224afee6ef06",
- "reference": "0f3bf2377c867e547c929c3733b8224afee6ef06",
+ "url": "https://api.github.com/repos/utopia-php/http/zipball/3e3b431d443844c6bf810120dee735f45880856f",
+ "reference": "3e3b431d443844c6bf810120dee735f45880856f",
"shasum": ""
},
"require": {
"php": ">=8.3",
"utopia-php/compression": "0.1.*",
+ "utopia-php/di": "0.3.*",
+ "utopia-php/servers": "0.4.0",
"utopia-php/telemetry": "0.2.*",
"utopia-php/validators": "0.2.*"
},
"require-dev": {
+ "doctrine/instantiator": "^1.5",
"laravel/pint": "1.*",
- "phpbench/phpbench": "1.*",
- "phpstan/phpstan": "1.*",
- "phpunit/phpunit": "9.*",
- "swoole/ide-helper": "^6.0"
+ "phpstan/phpstan": "^2.1",
+ "phpunit/phpunit": "^12.0",
+ "rector/rector": "^2.4",
+ "swoole/ide-helper": "4.8.3"
+ },
+ "suggest": {
+ "ext-swoole": "Required to use the Swoole server adapter (\\Utopia\\Http\\Adapter\\Swoole\\Server)."
},
"type": "library",
"autoload": {
@@ -4302,30 +4387,31 @@
"license": [
"MIT"
],
- "description": "A simple, light and advanced PHP framework",
+ "description": "A simple, light and advanced PHP HTTP framework",
"keywords": [
"framework",
+ "http",
"php",
"upf"
],
"support": {
"issues": "https://github.com/utopia-php/http/issues",
- "source": "https://github.com/utopia-php/http/tree/0.33.41"
+ "source": "https://github.com/utopia-php/http/tree/2.0.0-rc1"
},
- "time": "2026-02-24T12:01:28+00:00"
+ "time": "2026-05-05T15:00:03+00:00"
},
{
"name": "utopia-php/image",
- "version": "0.8.4",
+ "version": "0.8.6",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/image.git",
- "reference": "ce788ff0121a79286fdbe3ef3eba566de646df65"
+ "reference": "85ab7027873e11bc901110d8f7830252247ba724"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/image/zipball/ce788ff0121a79286fdbe3ef3eba566de646df65",
- "reference": "ce788ff0121a79286fdbe3ef3eba566de646df65",
+ "url": "https://api.github.com/repos/utopia-php/image/zipball/85ab7027873e11bc901110d8f7830252247ba724",
+ "reference": "85ab7027873e11bc901110d8f7830252247ba724",
"shasum": ""
},
"require": {
@@ -4334,10 +4420,12 @@
"php": ">=8.1"
},
"require-dev": {
- "laravel/pint": "1.2.*",
- "phpstan/phpstan": "^1.10.0",
- "phpunit/phpunit": "^9.3",
- "vimeo/psalm": "4.13.1"
+ "laravel/pint": "1.24.*",
+ "phpstan/phpstan": "2.1.*",
+ "phpunit/phpunit": "10.5.*"
+ },
+ "suggest": {
+ "ext-imagick": "Imagick extension is required for Imagick adapter"
},
"type": "library",
"autoload": {
@@ -4359,9 +4447,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/image/issues",
- "source": "https://github.com/utopia-php/image/tree/0.8.4"
+ "source": "https://github.com/utopia-php/image/tree/0.8.6"
},
- "time": "2025-06-03T08:32:20+00:00"
+ "time": "2026-04-19T12:52:59+00:00"
},
{
"name": "utopia-php/locale",
@@ -4412,20 +4500,21 @@
},
{
"name": "utopia-php/logger",
- "version": "0.6.2",
+ "version": "0.8.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/logger.git",
- "reference": "25b5bd2ad8bb51292f76332faa7034644fd0941d"
+ "reference": "132236c42222cd614cb882938a48f8729ef3118b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/logger/zipball/25b5bd2ad8bb51292f76332faa7034644fd0941d",
- "reference": "25b5bd2ad8bb51292f76332faa7034644fd0941d",
+ "url": "https://api.github.com/repos/utopia-php/logger/zipball/132236c42222cd614cb882938a48f8729ef3118b",
+ "reference": "132236c42222cd614cb882938a48f8729ef3118b",
"shasum": ""
},
"require": {
- "php": ">=8.0"
+ "php": ">=8.1",
+ "utopia-php/fetch": "^1.1"
},
"require-dev": {
"laravel/pint": "1.2.*",
@@ -4460,29 +4549,29 @@
],
"support": {
"issues": "https://github.com/utopia-php/logger/issues",
- "source": "https://github.com/utopia-php/logger/tree/0.6.2"
+ "source": "https://github.com/utopia-php/logger/tree/0.8.0"
},
- "time": "2024-10-14T16:02:49+00:00"
+ "time": "2026-05-05T06:04:27+00:00"
},
{
"name": "utopia-php/messaging",
- "version": "0.20.1",
+ "version": "0.22.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/messaging.git",
- "reference": "fcb4c3c46a48008a677957690bd45ec934dd33b0"
+ "reference": "a6ac04fd204fb6a16bf8c75a84d0b9fc10aa5030"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/messaging/zipball/fcb4c3c46a48008a677957690bd45ec934dd33b0",
- "reference": "fcb4c3c46a48008a677957690bd45ec934dd33b0",
+ "url": "https://api.github.com/repos/utopia-php/messaging/zipball/a6ac04fd204fb6a16bf8c75a84d0b9fc10aa5030",
+ "reference": "a6ac04fd204fb6a16bf8c75a84d0b9fc10aa5030",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-openssl": "*",
"giggsey/libphonenumber-for-php-lite": "9.0.23",
- "php": ">=8.0.0",
+ "php": ">=8.1.0",
"phpmailer/phpmailer": "6.9.1"
},
"require-dev": {
@@ -4511,33 +4600,33 @@
],
"support": {
"issues": "https://github.com/utopia-php/messaging/issues",
- "source": "https://github.com/utopia-php/messaging/tree/0.20.1"
+ "source": "https://github.com/utopia-php/messaging/tree/0.22.0"
},
- "time": "2026-02-06T09:56:06+00:00"
+ "time": "2026-04-02T04:09:19+00:00"
},
{
"name": "utopia-php/migration",
- "version": "dev-add-api-key-migration",
+ "version": "1.12.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
- "reference": "86843355dced5e4ad763b19764a766a3821fb6b2"
+ "reference": "3ee6e12af256726bddc3a0402c94535132abecc6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/migration/zipball/86843355dced5e4ad763b19764a766a3821fb6b2",
- "reference": "86843355dced5e4ad763b19764a766a3821fb6b2",
+ "url": "https://api.github.com/repos/utopia-php/migration/zipball/3ee6e12af256726bddc3a0402c94535132abecc6",
+ "reference": "3ee6e12af256726bddc3a0402c94535132abecc6",
"shasum": ""
},
"require": {
- "appwrite/appwrite": "19.*",
+ "appwrite/appwrite": "23.*",
"ext-curl": "*",
"ext-openssl": "*",
"halaxa/json-machine": "^1.2",
- "php": ">=8.1",
+ "php": ">=8.2",
"utopia-php/database": "5.*",
"utopia-php/dsn": "0.2.*",
- "utopia-php/storage": "1.0.*"
+ "utopia-php/storage": "2.*"
},
"require-dev": {
"ext-pdo": "*",
@@ -4566,22 +4655,22 @@
],
"support": {
"issues": "https://github.com/utopia-php/migration/issues",
- "source": "https://github.com/utopia-php/migration/tree/add-api-key-migration"
+ "source": "https://github.com/utopia-php/migration/tree/1.12.0"
},
- "time": "2026-03-14T23:10:53+00:00"
+ "time": "2026-05-14T07:30:09+00:00"
},
{
"name": "utopia-php/mongo",
- "version": "1.0.0",
+ "version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/mongo.git",
- "reference": "45bedf36c2c946ec7a0a3e59b9f12f772de0b01d"
+ "reference": "73593682deee4696525a04e26524c1c1226e1530"
},
"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/73593682deee4696525a04e26524c1c1226e1530",
+ "reference": "73593682deee4696525a04e26524c1c1226e1530",
"shasum": ""
},
"require": {
@@ -4627,36 +4716,36 @@
],
"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.1.0"
},
- "time": "2026-02-12T05:54:06+00:00"
+ "time": "2026-04-24T06:15:10+00:00"
},
{
"name": "utopia-php/platform",
- "version": "0.7.16",
+ "version": "1.0.0-rc1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/platform.git",
- "reference": "34e67e4b80b5741c380071fe765fbc12a132de4f"
+ "reference": "36c0a8b2f3d96ca056d724701a302a127111e933"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/platform/zipball/34e67e4b80b5741c380071fe765fbc12a132de4f",
- "reference": "34e67e4b80b5741c380071fe765fbc12a132de4f",
+ "url": "https://api.github.com/repos/utopia-php/platform/zipball/36c0a8b2f3d96ca056d724701a302a127111e933",
+ "reference": "36c0a8b2f3d96ca056d724701a302a127111e933",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-redis": "*",
- "php": ">=8.0",
- "utopia-php/cli": "0.22.*",
- "utopia-php/framework": "0.33.*",
- "utopia-php/queue": "0.15.*"
+ "php": ">=8.3",
+ "utopia-php/cli": "0.23.3",
+ "utopia-php/http": "^2.0@RC",
+ "utopia-php/queue": "0.18.2",
+ "utopia-php/servers": "0.4.0"
},
"require-dev": {
- "laravel/pint": "1.*",
- "phpstan/phpstan": "2.*",
- "phpunit/phpunit": "9.*"
+ "laravel/pint": "1.2.*",
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"autoload": {
@@ -4678,9 +4767,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/platform/issues",
- "source": "https://github.com/utopia-php/platform/tree/0.7.16"
+ "source": "https://github.com/utopia-php/platform/tree/1.0.0-rc1"
},
- "time": "2026-02-11T06:36:48+00:00"
+ "time": "2026-05-05T15:09:27+00:00"
},
{
"name": "utopia-php/pools",
@@ -4789,33 +4878,79 @@
"time": "2020-10-24T07:04:59+00:00"
},
{
- "name": "utopia-php/queue",
- "version": "0.15.6",
+ "name": "utopia-php/query",
+ "version": "0.1.1",
"source": {
"type": "git",
- "url": "https://github.com/utopia-php/queue.git",
- "reference": "08e361d69610f371382b344c369eef355ca414b4"
+ "url": "https://github.com/utopia-php/query.git",
+ "reference": "964a10ed3185490505f4c0062f2eb7b89287fb27"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/queue/zipball/08e361d69610f371382b344c369eef355ca414b4",
- "reference": "08e361d69610f371382b344c369eef355ca414b4",
+ "url": "https://api.github.com/repos/utopia-php/query/zipball/964a10ed3185490505f4c0062f2eb7b89287fb27",
+ "reference": "964a10ed3185490505f4c0062f2eb7b89287fb27",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "laravel/pint": "*",
+ "phpstan/phpstan": "*",
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Utopia\\Query\\": "src/Query"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A simple library providing a query abstraction for filtering, ordering, and pagination",
+ "keywords": [
+ "framework",
+ "php",
+ "query",
+ "upf",
+ "utopia"
+ ],
+ "support": {
+ "issues": "https://github.com/utopia-php/query/issues",
+ "source": "https://github.com/utopia-php/query/tree/0.1.1"
+ },
+ "time": "2026-03-03T09:05:14+00:00"
+ },
+ {
+ "name": "utopia-php/queue",
+ "version": "0.18.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/utopia-php/queue.git",
+ "reference": "f85ca003c99ff475708c05466643d067403c0c22"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/utopia-php/queue/zipball/f85ca003c99ff475708c05466643d067403c0c22",
+ "reference": "f85ca003c99ff475708c05466643d067403c0c22",
"shasum": ""
},
"require": {
"php": ">=8.3",
"php-amqplib/php-amqplib": "^3.7",
- "utopia-php/fetch": "0.5.*",
+ "utopia-php/di": "0.3.*",
"utopia-php/pools": "1.*",
- "utopia-php/servers": "0.2.*",
+ "utopia-php/servers": "0.4.0",
"utopia-php/telemetry": "0.2.*",
"utopia-php/validators": "0.2.*"
},
"require-dev": {
"ext-redis": "*",
- "laravel/pint": "^0.2.3",
+ "laravel/pint": "^1.0",
"phpstan/phpstan": "^1.8",
- "phpunit/phpunit": "^9.5.5",
+ "phpunit/phpunit": "^11.0",
"swoole/ide-helper": "4.8.8",
"workerman/workerman": "^4.0"
},
@@ -4850,9 +4985,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/queue/issues",
- "source": "https://github.com/utopia-php/queue/tree/0.15.6"
+ "source": "https://github.com/utopia-php/queue/tree/0.18.2"
},
- "time": "2026-02-23T13:03:51+00:00"
+ "time": "2026-05-05T04:38:59+00:00"
},
{
"name": "utopia-php/registry",
@@ -4908,21 +5043,21 @@
},
{
"name": "utopia-php/servers",
- "version": "0.2.5",
+ "version": "0.4.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/servers.git",
- "reference": "4770e879a90685af4ba14e7e5d95d0a17c7fdf03"
+ "reference": "7db346ef377503efe0acafe0791085270cd9ed70"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/servers/zipball/4770e879a90685af4ba14e7e5d95d0a17c7fdf03",
- "reference": "4770e879a90685af4ba14e7e5d95d0a17c7fdf03",
+ "url": "https://api.github.com/repos/utopia-php/servers/zipball/7db346ef377503efe0acafe0791085270cd9ed70",
+ "reference": "7db346ef377503efe0acafe0791085270cd9ed70",
"shasum": ""
},
"require": {
- "php": ">=8.0",
- "utopia-php/di": "0.1.*",
+ "php": ">=8.2",
+ "utopia-php/di": "0.3.*",
"utopia-php/validators": "0.*"
},
"require-dev": {
@@ -4956,9 +5091,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/servers/issues",
- "source": "https://github.com/utopia-php/servers/tree/0.2.5"
+ "source": "https://github.com/utopia-php/servers/tree/0.4.0"
},
- "time": "2026-02-10T04:21:53+00:00"
+ "time": "2026-05-05T04:08:30+00:00"
},
{
"name": "utopia-php/span",
@@ -5006,16 +5141,16 @@
},
{
"name": "utopia-php/storage",
- "version": "1.0.1",
+ "version": "2.0.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/storage.git",
- "reference": "f014be445f0baa635d0764e1673196f412511618"
+ "reference": "64e132a3768e22243eda36fe4262da22fd204f3c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/storage/zipball/f014be445f0baa635d0764e1673196f412511618",
- "reference": "f014be445f0baa635d0764e1673196f412511618",
+ "url": "https://api.github.com/repos/utopia-php/storage/zipball/64e132a3768e22243eda36fe4262da22fd204f3c",
+ "reference": "64e132a3768e22243eda36fe4262da22fd204f3c",
"shasum": ""
},
"require": {
@@ -5029,9 +5164,8 @@
"utopia-php/validators": "0.2.*"
},
"require-dev": {
- "laravel/pint": "1.2.*",
- "phpunit/phpunit": "^9.3",
- "vimeo/psalm": "4.0.1"
+ "laravel/pint": "^1.21",
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"autoload": {
@@ -5053,22 +5187,22 @@
],
"support": {
"issues": "https://github.com/utopia-php/storage/issues",
- "source": "https://github.com/utopia-php/storage/tree/1.0.1"
+ "source": "https://github.com/utopia-php/storage/tree/2.0.2"
},
- "time": "2026-02-23T05:59:32+00:00"
+ "time": "2026-05-01T15:06:16+00:00"
},
{
"name": "utopia-php/system",
- "version": "0.10.0",
+ "version": "0.10.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/system.git",
- "reference": "6441a9c180958a373e5ddb330264dd638539dfdb"
+ "reference": "04229a822b147c1abaf1a92fb42c2d7aad4625df"
},
"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/04229a822b147c1abaf1a92fb42c2d7aad4625df",
+ "reference": "04229a822b147c1abaf1a92fb42c2d7aad4625df",
"shasum": ""
},
"require": {
@@ -5109,9 +5243,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.2"
},
- "time": "2025-10-15T19:12:00+00:00"
+ "time": "2026-05-05T14:33:41+00:00"
},
{
"name": "utopia-php/telemetry",
@@ -5170,16 +5304,16 @@
},
{
"name": "utopia-php/validators",
- "version": "0.2.0",
+ "version": "0.2.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/validators.git",
- "reference": "30b6030a5b100fc1dff34506e5053759594b2a20"
+ "reference": "5d7d494e64457cd4eb67fdcfd9481f2c89796aa6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/validators/zipball/30b6030a5b100fc1dff34506e5053759594b2a20",
- "reference": "30b6030a5b100fc1dff34506e5053759594b2a20",
+ "url": "https://api.github.com/repos/utopia-php/validators/zipball/5d7d494e64457cd4eb67fdcfd9481f2c89796aa6",
+ "reference": "5d7d494e64457cd4eb67fdcfd9481f2c89796aa6",
"shasum": ""
},
"require": {
@@ -5209,35 +5343,35 @@
],
"support": {
"issues": "https://github.com/utopia-php/validators/issues",
- "source": "https://github.com/utopia-php/validators/tree/0.2.0"
+ "source": "https://github.com/utopia-php/validators/tree/0.2.2"
},
- "time": "2026-01-13T09:16:51+00:00"
+ "time": "2026-04-27T16:30:24+00:00"
},
{
"name": "utopia-php/vcs",
- "version": "2.0.0",
+ "version": "4.0.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/vcs.git",
- "reference": "058049326e04a2a0c2f0ce8ad00c7e84825aba14"
+ "reference": "c14ec4d1188e6cc2e8f5256a4b26e531e4f9ac4e"
},
"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/c14ec4d1188e6cc2e8f5256a4b26e531e4f9ac4e",
+ "reference": "c14ec4d1188e6cc2e8f5256a4b26e531e4f9ac4e",
"shasum": ""
},
"require": {
"adhocore/jwt": "^1.1",
- "php": ">=8.0",
- "utopia-php/cache": "1.0.*",
- "utopia-php/framework": "0.*.*",
- "utopia-php/system": "0.10.*"
+ "php": ">=8.2",
+ "utopia-php/cache": "^2.0",
+ "utopia-php/fetch": "^1.1"
},
"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 +5392,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/4.0.0"
},
- "time": "2026-02-25T11:36:45+00:00"
+ "time": "2026-05-13T04:20:45+00:00"
},
{
"name": "utopia-php/websocket",
@@ -5371,38 +5505,49 @@
},
{
"name": "webonyx/graphql-php",
- "version": "v14.11.10",
+ "version": "v15.32.3",
"source": {
"type": "git",
"url": "https://github.com/webonyx/graphql-php.git",
- "reference": "d9c2fdebc6aa01d831bc2969da00e8588cffef19"
+ "reference": "993bf0bea17f870412ad8a90f60c41cb8d5f1145"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/d9c2fdebc6aa01d831bc2969da00e8588cffef19",
- "reference": "d9c2fdebc6aa01d831bc2969da00e8588cffef19",
+ "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/993bf0bea17f870412ad8a90f60c41cb8d5f1145",
+ "reference": "993bf0bea17f870412ad8a90f60c41cb8d5f1145",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
- "php": "^7.1 || ^8"
+ "php": "^7.4 || ^8"
},
"require-dev": {
- "amphp/amp": "^2.3",
- "doctrine/coding-standard": "^6.0",
- "nyholm/psr7": "^1.2",
+ "amphp/amp": "^2.6 || ^3",
+ "amphp/http-server": "^2.1 || ^3",
+ "dms/phpunit-arraysubset-asserts": "dev-master",
+ "ergebnis/composer-normalize": "^2.28",
+ "friendsofphp/php-cs-fixer": "3.95.1",
+ "mll-lab/php-cs-fixer-config": "5.13.0",
+ "nyholm/psr7": "^1.5",
"phpbench/phpbench": "^1.2",
- "phpstan/extension-installer": "^1.0",
- "phpstan/phpstan": "0.12.82",
- "phpstan/phpstan-phpunit": "0.12.18",
- "phpstan/phpstan-strict-rules": "0.12.9",
- "phpunit/phpunit": "^7.2 || ^8.5",
- "psr/http-message": "^1.0",
- "react/promise": "2.*",
- "simpod/php-coveralls-mirror": "^3.0"
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "2.1.51",
+ "phpstan/phpstan-phpunit": "2.0.16",
+ "phpstan/phpstan-strict-rules": "2.0.10",
+ "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11",
+ "psr/http-message": "^1 || ^2",
+ "react/http": "^1.6",
+ "react/promise": "^2.0 || ^3.0",
+ "rector/rector": "^2.0",
+ "symfony/polyfill-php81": "^1.23",
+ "symfony/var-exporter": "^5 || ^6 || ^7 || ^8",
+ "thecodingmachine/safe": "^1.3 || ^2 || ^3",
+ "ticketswap/phpstan-error-formatter": "1.3.0"
},
"suggest": {
+ "amphp/amp": "To leverage async resolving on AMPHP platform (v3 with AmpFutureAdapter, v2 with AmpPromiseAdapter)",
+ "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform",
"psr/http-message": "To use standard GraphQL server",
"react/promise": "To leverage async resolving on React PHP platform"
},
@@ -5424,30 +5569,34 @@
],
"support": {
"issues": "https://github.com/webonyx/graphql-php/issues",
- "source": "https://github.com/webonyx/graphql-php/tree/v14.11.10"
+ "source": "https://github.com/webonyx/graphql-php/tree/v15.32.3"
},
"funding": [
+ {
+ "url": "https://github.com/spawnia",
+ "type": "github"
+ },
{
"url": "https://opencollective.com/webonyx-graphql-php",
"type": "open_collective"
}
],
- "time": "2023-07-05T14:23:37+00:00"
+ "time": "2026-04-24T13:49:35+00:00"
}
],
"packages-dev": [
{
"name": "appwrite/sdk-generator",
- "version": "1.11.1",
+ "version": "1.29.2",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-generator.git",
- "reference": "6ff411f26f2750eea05c7598c14bb3a2ada898cb"
+ "reference": "31248a984a4d478d20a780dda8f5897984ee4e8f"
},
"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/31248a984a4d478d20a780dda8f5897984ee4e8f",
+ "reference": "31248a984a4d478d20a780dda8f5897984ee4e8f",
"shasum": ""
},
"require": {
@@ -5483,22 +5632,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.29.2"
},
- "time": "2026-02-25T07:15:19+00:00"
+ "time": "2026-05-13T04:47:38+00:00"
},
{
"name": "brianium/paratest",
- "version": "v7.19.0",
+ "version": "v7.20.0",
"source": {
"type": "git",
"url": "https://github.com/paratestphp/paratest.git",
- "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6"
+ "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/paratestphp/paratest/zipball/7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6",
- "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6",
+ "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d",
+ "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d",
"shasum": ""
},
"require": {
@@ -5512,9 +5661,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 +5671,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.44",
+ "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 +5715,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.20.0"
},
"funding": [
{
@@ -5578,7 +5727,7 @@
"type": "paypal"
}
],
- "time": "2026-02-06T10:53:26+00:00"
+ "time": "2026-03-29T15:46:14+00:00"
},
{
"name": "czproject/git-php",
@@ -5644,160 +5793,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 +5916,16 @@
},
{
"name": "laravel/pint",
- "version": "v1.27.1",
+ "version": "v1.29.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/pint.git",
- "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5"
+ "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5",
- "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5",
+ "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80",
+ "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80",
"shasum": ""
},
"require": {
@@ -5941,13 +5936,14 @@
"php": "^8.2.0"
},
"require-dev": {
- "friendsofphp/php-cs-fixer": "^3.93.1",
- "illuminate/view": "^12.51.0",
- "larastan/larastan": "^3.9.2",
- "laravel-zero/framework": "^12.0.5",
+ "friendsofphp/php-cs-fixer": "^3.95.1",
+ "illuminate/view": "^12.56.0",
+ "larastan/larastan": "^3.9.6",
+ "laravel-zero/framework": "^12.1.0",
"mockery/mockery": "^1.6.12",
- "nunomaduro/termwind": "^2.3.3",
- "pestphp/pest": "^3.8.5"
+ "nunomaduro/termwind": "^2.4.0",
+ "pestphp/pest": "^3.8.6",
+ "shipfastlabs/agent-detector": "^1.1.3"
},
"bin": [
"builds/pint"
@@ -5984,7 +5980,7 @@
"issues": "https://github.com/laravel/pint/issues",
"source": "https://github.com/laravel/pint"
},
- "time": "2026-02-10T20:00:20+00:00"
+ "time": "2026-04-20T15:26:14+00:00"
},
{
"name": "matthiasmullie/minify",
@@ -6345,166 +6341,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.54",
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8",
- "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
+ "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
"shasum": ""
},
"require": {
- "php": "^7.2|^8.0"
+ "php": "^7.4|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
@@ -6545,20 +6392,20 @@
"type": "github"
}
],
- "time": "2025-09-30T10:16:31+00:00"
+ "time": "2026-04-29T13:31:09+00:00"
},
{
"name": "phpunit/php-code-coverage",
- "version": "12.5.3",
+ "version": "12.5.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
- "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d"
+ "reference": "876099a072646c7745f673d7aeab5382c4439691"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d",
- "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691",
+ "reference": "876099a072646c7745f673d7aeab5382c4439691",
"shasum": ""
},
"require": {
@@ -6567,7 +6414,6 @@
"ext-xmlwriter": "*",
"nikic/php-parser": "^5.7.0",
"php": ">=8.3",
- "phpunit/php-file-iterator": "^6.0",
"phpunit/php-text-template": "^5.0",
"sebastian/complexity": "^5.0",
"sebastian/environment": "^8.0.3",
@@ -6614,7 +6460,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
- "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3"
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6"
},
"funding": [
{
@@ -6634,7 +6480,7 @@
"type": "tidelift"
}
],
- "time": "2026-02-06T06:01:44+00:00"
+ "time": "2026-04-15T08:23:17+00:00"
},
{
"name": "phpunit/php-file-iterator",
@@ -6895,16 +6741,16 @@
},
{
"name": "phpunit/phpunit",
- "version": "12.5.14",
+ "version": "12.5.24",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0"
+ "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0",
- "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d75dd30597caa80e72fad2ef7904601a30ef1046",
+ "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046",
"shasum": ""
},
"require": {
@@ -6918,15 +6764,15 @@
"phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1",
"php": ">=8.3",
- "phpunit/php-code-coverage": "^12.5.3",
+ "phpunit/php-code-coverage": "^12.5.6",
"phpunit/php-file-iterator": "^6.0.1",
"phpunit/php-invoker": "^6.0.0",
"phpunit/php-text-template": "^5.0.0",
"phpunit/php-timer": "^8.0.0",
"sebastian/cli-parser": "^4.2.0",
- "sebastian/comparator": "^7.1.4",
+ "sebastian/comparator": "^7.1.6",
"sebastian/diff": "^7.0.0",
- "sebastian/environment": "^8.0.3",
+ "sebastian/environment": "^8.1.0",
"sebastian/exporter": "^7.0.2",
"sebastian/global-state": "^8.0.2",
"sebastian/object-enumerator": "^7.0.0",
@@ -6973,80 +6819,15 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.24"
},
"funding": [
{
- "url": "https://phpunit.de/sponsors.html",
- "type": "custom"
- },
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- },
- {
- "url": "https://liberapay.com/sebastianbergmann",
- "type": "liberapay"
- },
- {
- "url": "https://thanks.dev/u/gh/sebastianbergmann",
- "type": "thanks_dev"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
- "type": "tidelift"
+ "url": "https://phpunit.de/sponsoring.html",
+ "type": "other"
}
],
- "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"
+ "time": "2026-05-01T04:21:04+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -7119,16 +6900,16 @@
},
{
"name": "sebastian/comparator",
- "version": "7.1.4",
+ "version": "7.1.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
- "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6"
+ "reference": "c769009dee98f494e0edc3fd4f4087501688f11e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6",
- "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c769009dee98f494e0edc3fd4f4087501688f11e",
+ "reference": "c769009dee98f494e0edc3fd4f4087501688f11e",
"shasum": ""
},
"require": {
@@ -7187,7 +6968,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues",
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
- "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4"
+ "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.6"
},
"funding": [
{
@@ -7207,7 +6988,7 @@
"type": "tidelift"
}
],
- "time": "2026-01-24T09:28:48+00:00"
+ "time": "2026-04-14T08:23:15+00:00"
},
{
"name": "sebastian/complexity",
@@ -7336,16 +7117,16 @@
},
{
"name": "sebastian/environment",
- "version": "8.0.3",
+ "version": "8.1.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/environment.git",
- "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68"
+ "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68",
- "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6",
+ "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6",
"shasum": ""
},
"require": {
@@ -7360,7 +7141,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "8.0-dev"
+ "dev-main": "8.1-dev"
}
},
"autoload": {
@@ -7388,7 +7169,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.1.0"
},
"funding": [
{
@@ -7408,7 +7189,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-12T14:11:56+00:00"
+ "time": "2026-04-15T12:13:01+00:00"
},
{
"name": "sebastian/exporter",
@@ -7945,70 +7726,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 +7812,16 @@
},
{
"name": "symfony/console",
- "version": "v8.0.4",
+ "version": "v8.0.9",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b"
+ "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/ace03c4cf9805080ff40cbeec69fca180c339a3b",
- "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b",
+ "url": "https://api.github.com/repos/symfony/console/zipball/7113778e2e91f4709cb3194a75dfa9c0d028d94d",
+ "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d",
"shasum": ""
},
"require": {
@@ -8161,7 +7878,7 @@
"terminal"
],
"support": {
- "source": "https://github.com/symfony/console/tree/v8.0.4"
+ "source": "https://github.com/symfony/console/tree/v8.0.9"
},
"funding": [
{
@@ -8181,229 +7898,20 @@
"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-04-29T15:02:55+00:00"
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
- "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
- "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2",
"shasum": ""
},
"require": {
@@ -8453,7 +7961,7 @@
"portable"
],
"support": {
- "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
},
"funding": [
{
@@ -8473,20 +7981,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-intl-grapheme",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
- "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70"
+ "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70",
- "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e",
+ "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e",
"shasum": ""
},
"require": {
@@ -8535,7 +8043,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0"
},
"funding": [
{
@@ -8555,11 +8063,11 @@
"type": "tidelift"
}
],
- "time": "2025-06-27T09:58:17+00:00"
+ "time": "2026-04-26T13:13:48+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
@@ -8620,7 +8128,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0"
},
"funding": [
{
@@ -8644,7 +8152,7 @@
},
{
"name": "symfony/polyfill-php81",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php81.git",
@@ -8700,7 +8208,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0"
},
"funding": [
{
@@ -8724,16 +8232,16 @@
},
{
"name": "symfony/process",
- "version": "v8.0.5",
+ "version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674"
+ "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674",
- "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674",
+ "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc",
+ "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc",
"shasum": ""
},
"require": {
@@ -8765,7 +8273,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/process/tree/v8.0.5"
+ "source": "https://github.com/symfony/process/tree/v8.0.8"
},
"funding": [
{
@@ -8785,20 +8293,20 @@
"type": "tidelift"
}
],
- "time": "2026-01-26T15:08:38+00:00"
+ "time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/string",
- "version": "v8.0.4",
+ "version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
- "reference": "758b372d6882506821ed666032e43020c4f57194"
+ "reference": "ae9488f874d7603f9d2dfbf120203882b645d963"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194",
- "reference": "758b372d6882506821ed666032e43020c4f57194",
+ "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963",
+ "reference": "ae9488f874d7603f9d2dfbf120203882b645d963",
"shasum": ""
},
"require": {
@@ -8855,7 +8363,7 @@
"utf8"
],
"support": {
- "source": "https://github.com/symfony/string/tree/v8.0.4"
+ "source": "https://github.com/symfony/string/tree/v8.0.8"
},
"funding": [
{
@@ -8875,7 +8383,7 @@
"type": "tidelift"
}
],
- "time": "2026-01-12T12:37:40+00:00"
+ "time": "2026-03-30T15:14:47+00:00"
},
{
"name": "textalk/websocket",
@@ -9054,68 +8562,13 @@
}
],
"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": [
- {
- "package": "utopia-php/migration",
- "version": "dev-add-api-key-migration",
- "alias": "1.8.0",
- "alias_normalized": "1.8.0.0"
}
],
+ "aliases": [],
"minimum-stability": "dev",
"stability-flags": {
- "utopia-php/migration": 20
+ "utopia-php/http": 5,
+ "utopia-php/platform": 5
},
"prefer-stable": true,
"prefer-lowest": false,
diff --git a/docker-compose.override.yml b/docker-compose.override.yml
new file mode 100644
index 0000000000..29182f01d2
--- /dev/null
+++ b/docker-compose.override.yml
@@ -0,0 +1,144 @@
+# Dev tools for local development only.
+# This file is automatically loaded by `docker compose` alongside docker-compose.yml.
+# CI sets COMPOSE_FILE=docker-compose.yml explicitly, so these services are excluded from test runs.
+
+services:
+ appwrite-mongo-express:
+ profiles: ["mongodb"]
+ image: mongo-express
+ container_name: appwrite-mongo-express
+ networks:
+ - appwrite
+ ports:
+ - "8082:8081"
+ environment:
+ ME_CONFIG_MONGODB_URL: "mongodb://root:${_APP_DB_ROOT_PASS}@appwrite-mongodb:27017/?replicaSet=rs0&directConnection=true"
+ ME_CONFIG_BASICAUTH_USERNAME: ${_APP_DB_USER}
+ ME_CONFIG_BASICAUTH_PASSWORD: ${_APP_DB_PASS}
+ depends_on:
+ - mongodb
+
+ adminer:
+ image: adminer
+ container_name: appwrite-adminer
+ restart: always
+ ports:
+ - 9506:8080
+ networks:
+ - appwrite
+ - gateway
+ environment:
+ - ADMINER_DESIGN=pepa-linha
+ - ADMINER_DEFAULT_SERVER=mariadb
+ - ADMINER_DEFAULT_USERNAME=root
+ - ADMINER_DEFAULT_PASSWORD=rootsecretpassword
+ - ADMINER_DEFAULT_DB=appwrite
+ configs:
+ - source: adminer-index.php
+ target: /var/www/html/index.php
+ mode: 0755
+ labels:
+ - "traefik.enable=true"
+ - "traefik.constraint-label-stack=appwrite"
+ - "traefik.docker.network=gateway"
+ - "traefik.http.services.appwrite_adminer.loadbalancer.server.port=8080"
+ - "traefik.http.routers.appwrite_adminer_http.entrypoints=appwrite_web"
+ - "traefik.http.routers.appwrite_adminer_http.rule=Host(`mysql.localhost`)"
+ - "traefik.http.routers.appwrite_adminer_http.service=appwrite_adminer"
+ - "traefik.http.routers.appwrite_adminer_https.entrypoints=appwrite_websecure"
+ - "traefik.http.routers.appwrite_adminer_https.rule=Host(`mysql.localhost`)"
+ - "traefik.http.routers.appwrite_adminer_https.service=appwrite_adminer"
+ - "traefik.http.routers.appwrite_adminer_https.tls=true"
+
+ redis-insight:
+ image: redis/redisinsight:latest
+ restart: unless-stopped
+ networks:
+ - appwrite
+ - gateway
+ environment:
+ - RI_PRE_SETUP_DATABASES_PATH=/mnt/connections.json
+ configs:
+ - source: redisinsight-connections.json
+ target: /mnt/connections.json
+ mode: 0755
+ labels:
+ - "traefik.enable=true"
+ - "traefik.constraint-label-stack=appwrite"
+ - "traefik.docker.network=gateway"
+ - "traefik.http.services.appwrite_redisinsight.loadbalancer.server.port=5540"
+ - "traefik.http.routers.appwrite_redisinsight_http.entrypoints=appwrite_web"
+ - "traefik.http.routers.appwrite_redisinsight_http.rule=Host(`redis.localhost`)"
+ - "traefik.http.routers.appwrite_redisinsight_http.service=appwrite_redisinsight"
+ - "traefik.http.routers.appwrite_redisinsight_https.entrypoints=appwrite_websecure"
+ - "traefik.http.routers.appwrite_redisinsight_https.rule=Host(`redis.localhost`)"
+ - "traefik.http.routers.appwrite_redisinsight_https.service=appwrite_redisinsight"
+ - "traefik.http.routers.appwrite_redisinsight_https.tls=true"
+ ports:
+ - "8081:5540"
+
+ graphql-explorer:
+ container_name: appwrite-graphql-explorer
+ image: appwrite/altair:0.3.0
+ restart: unless-stopped
+ networks:
+ - appwrite
+ ports:
+ - "9509:3000"
+ environment:
+ - SERVER_URL=http://localhost/v1/graphql
+
+configs:
+ redisinsight-connections.json:
+ content: |
+ [
+ {
+ "compressor": "NONE",
+ "id": "104dc90a-21ef-4d5e-8912-b30baabb152f",
+ "host": "redis",
+ "port": 6379,
+ "name": "redis:6379",
+ "db": 0,
+ "username": "default",
+ "password": null,
+ "connectionType": "STANDALONE",
+ "nameFromProvider": null,
+ "provider": "REDIS",
+ "lastConnection": "2025-10-16T09:22:02.591Z",
+ "modules": [
+ {
+ "name": "ReJSON",
+ "version": 20808,
+ "semanticVersion": "2.8.8"
+ },
+ {
+ "name": "search",
+ "version": 21015,
+ "semanticVersion": "2.10.15"
+ }
+ ],
+ "tls": false,
+ "tlsServername": null,
+ "verifyServerCert": null,
+ "caCert": null,
+ "clientCert": null,
+ "ssh": false,
+ "sshOptions": null,
+ "forceStandalone": false,
+ "tags": []
+ }
+ ]
+
+ adminer-index.php:
+ content: |
+ $$_ENV['ADMINER_DEFAULT_SERVER'],
+ 'driver' => 'server', /* seems to autodetect the driver from server settings */
+ 'username' => $$_ENV['ADMINER_DEFAULT_USERNAME'],
+ 'password' => $$_ENV['ADMINER_DEFAULT_PASSWORD'],
+ 'db' => $$_ENV['ADMINER_DEFAULT_DB'],
+ ];
+ }
+ include './adminer.php';
diff --git a/docker-compose.yml b/docker-compose.yml
index 0ab317c0e9..76f06c672a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,6 +10,15 @@ x-logging: &x-logging
max-file: "5"
max-size: "10m"
+x-build: &x-build
+ build:
+ context: .
+ target: development
+ args:
+ DEBUG: false
+ TESTING: true
+ VERSION: dev
+
services:
traefik:
image: traefik:3.6
@@ -50,15 +59,13 @@ services:
appwrite:
container_name: appwrite
- <<: *x-logging
+ <<: [*x-logging, *x-build]
image: appwrite-dev
- build:
- context: .
- target: development
- args:
- DEBUG: false
- TESTING: true
- VERSION: dev
+ healthcheck:
+ test: ["CMD", "doctor"]
+ interval: 5s
+ timeout: 5s
+ retries: 12
ports:
- 9501:80
networks:
@@ -101,11 +108,12 @@ services:
- ./dev:/usr/src/code/dev
depends_on:
- - ${_APP_DB_HOST:-mongodb}
- - redis
- - coredns
- - ollama
- # - clamav
+ redis:
+ condition: service_healthy
+ coredns:
+ condition: service_started
+ ollama:
+ condition: service_started
entrypoint:
- php
- -e
@@ -234,19 +242,20 @@ services:
- _APP_EXPERIMENT_LOGGING_PROVIDER
- _APP_EXPERIMENT_LOGGING_CONFIG
- _APP_DATABASE_SHARED_TABLES
- - _APP_DATABASE_SHARED_TABLES_V1
- _APP_DATABASE_SHARED_NAMESPACE
- _APP_FUNCTIONS_CREATION_ABUSE_LIMIT
- _APP_CUSTOM_DOMAIN_DENY_LIST
- _APP_TRUSTED_HEADERS
- _APP_MIGRATION_HOST
+ - _TESTS_OAUTH2_GITHUB_CLIENT_ID
+ - _TESTS_OAUTH2_GITHUB_CLIENT_SECRET
extra_hosts:
- "host.docker.internal:host-gateway"
appwrite-console:
<<: *x-logging
container_name: appwrite-console
- image: appwrite/console:7.5.7
+ image: appwrite/console:8
restart: unless-stopped
networks:
- appwrite
@@ -267,7 +276,7 @@ services:
appwrite-realtime:
entrypoint: realtime
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-realtime
image: appwrite-dev
restart: unless-stopped
@@ -326,7 +335,7 @@ services:
appwrite-worker-audits:
entrypoint: worker-audits
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-worker-audits
image: appwrite-dev
networks:
@@ -358,7 +367,7 @@ services:
appwrite-worker-webhooks:
entrypoint: worker-webhooks
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-worker-webhooks
image: appwrite-dev
networks:
@@ -394,7 +403,7 @@ services:
appwrite-worker-deletes:
entrypoint: worker-deletes
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-worker-deletes
image: appwrite-dev
networks:
@@ -454,14 +463,13 @@ services:
- _APP_EXECUTOR_SECRET
- _APP_EXECUTOR_HOST
- _APP_DATABASE_SHARED_TABLES
- - _APP_DATABASE_SHARED_TABLES_V1
- _APP_EMAIL_CERTIFICATES
- _APP_MAINTENANCE_RETENTION_AUDIT
- _APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE
appwrite-worker-databases:
entrypoint: worker-databases
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-worker-databases
image: appwrite-dev
networks:
@@ -502,7 +510,7 @@ services:
appwrite-worker-builds:
entrypoint: worker-builds
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-worker-builds
image: appwrite-dev
networks:
@@ -578,7 +586,7 @@ services:
appwrite-worker-screenshots:
entrypoint: worker-screenshots
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-worker-screenshots
image: appwrite-dev
networks:
@@ -641,7 +649,7 @@ services:
appwrite-worker-certificates:
entrypoint: worker-certificates
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-worker-certificates
image: appwrite-dev
networks:
@@ -684,7 +692,7 @@ services:
appwrite-worker-executions:
entrypoint: worker-executions
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-worker-executions
image: appwrite-dev
networks:
@@ -714,7 +722,7 @@ services:
appwrite-worker-functions:
entrypoint: worker-functions
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-worker-functions
image: appwrite-dev
networks:
@@ -758,7 +766,7 @@ services:
appwrite-worker-mails:
entrypoint: worker-mails
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-worker-mails
image: appwrite-dev
networks:
@@ -800,7 +808,7 @@ services:
appwrite-worker-messaging:
entrypoint: worker-messaging
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-worker-messaging
restart: unless-stopped
image: appwrite-dev
@@ -857,7 +865,7 @@ services:
appwrite-worker-migrations:
entrypoint: worker-migrations
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-worker-migrations
restart: unless-stopped
image: appwrite-dev
@@ -903,7 +911,7 @@ services:
appwrite-task-maintenance:
entrypoint: maintenance
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-task-maintenance
image: appwrite-dev
networks:
@@ -949,7 +957,7 @@ services:
appwrite-task-interval:
entrypoint: interval
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-task-interval
image: appwrite-dev
networks:
@@ -990,7 +998,7 @@ services:
appwrite-task-stats-resources:
container_name: appwrite-task-stats-resources
entrypoint: stats-resources
- <<: *x-logging
+ <<: [*x-logging, *x-build]
image: appwrite-dev
networks:
- appwrite
@@ -1022,7 +1030,7 @@ services:
appwrite-worker-stats-resources:
entrypoint: worker-stats-resources
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-worker-stats-resources
image: appwrite-dev
networks:
@@ -1055,7 +1063,7 @@ services:
appwrite-worker-stats-usage:
entrypoint: worker-stats-usage
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-worker-stats-usage
image: appwrite-dev
networks:
@@ -1089,7 +1097,7 @@ services:
appwrite-task-scheduler-functions:
entrypoint: schedule-functions
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-task-scheduler-functions
image: appwrite-dev
networks:
@@ -1106,6 +1114,13 @@ services:
- _APP_WORKER_PER_CORE
- _APP_POOL_ADAPTER
- _APP_OPENSSL_KEY_V1
+ - _APP_OPTIONS_FORCE_HTTPS
+ - _APP_DOMAIN
+ - _APP_CONSOLE_DOMAIN
+ - _APP_DOMAIN_FUNCTIONS
+ - _APP_DOMAIN_SITES
+ - _APP_MIGRATION_HOST
+ - _APP_CONSOLE_SCHEMA
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -1120,7 +1135,7 @@ services:
appwrite-task-scheduler-executions:
entrypoint: schedule-executions
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-task-scheduler-executions
image: appwrite-dev
networks:
@@ -1137,6 +1152,13 @@ services:
- _APP_WORKER_PER_CORE
- _APP_POOL_ADAPTER
- _APP_OPENSSL_KEY_V1
+ - _APP_OPTIONS_FORCE_HTTPS
+ - _APP_DOMAIN
+ - _APP_CONSOLE_DOMAIN
+ - _APP_DOMAIN_FUNCTIONS
+ - _APP_DOMAIN_SITES
+ - _APP_MIGRATION_HOST
+ - _APP_CONSOLE_SCHEMA
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -1147,10 +1169,11 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
+ - _APP_DATABASE_SHARED_TABLES
appwrite-task-scheduler-messages:
entrypoint: schedule-messages
- <<: *x-logging
+ <<: [*x-logging, *x-build]
container_name: appwrite-task-scheduler-messages
image: appwrite-dev
networks:
@@ -1269,11 +1292,17 @@ services:
- MYSQL_PASSWORD=${_APP_DB_PASS}
- MARIADB_AUTO_UPGRADE=1
command: "mysqld --innodb-flush-method=fsync"
+ healthcheck:
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
+ interval: 5s
+ timeout: 5s
+ retries: 12
mongodb:
image: mongo:8.2.5
container_name: appwrite-mongodb
<<: *x-logging
+ restart: on-failure:3
networks:
- appwrite
volumes:
@@ -1306,20 +1335,6 @@ services:
retries: 10
start_period: 30s
- appwrite-mongo-express:
- image: mongo-express
- container_name: appwrite-mongo-express
- networks:
- - appwrite
- ports:
- - "8082:8081"
- environment:
- ME_CONFIG_MONGODB_URL: "mongodb://root:${_APP_DB_ROOT_PASS}@appwrite-mongodb:27017/?replicaSet=rs0&directConnection=true"
- ME_CONFIG_BASICAUTH_USERNAME: ${_APP_DB_USER}
- ME_CONFIG_BASICAUTH_PASSWORD: ${_APP_DB_PASS}
- depends_on:
- - mongodb
-
postgresql:
image: appwrite/postgres:0.1.0
container_name: appwrite-postgresql
@@ -1371,6 +1386,11 @@ services:
- appwrite
volumes:
- appwrite-redis:/data:rw
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 12
coredns: # DNS server for testing purposes (Proxy APIs)
image: coredns/coredns:1.12.4
@@ -1442,78 +1462,8 @@ services:
networks:
- appwrite
- adminer:
- image: adminer
- container_name: appwrite-adminer
- <<: *x-logging
- restart: always
- ports:
- - 9506:8080
- networks:
- - appwrite
- - gateway
- environment:
- - ADMINER_DESIGN=pepa-linha
- - ADMINER_DEFAULT_SERVER=mariadb
- - ADMINER_DEFAULT_USERNAME=root
- - ADMINER_DEFAULT_PASSWORD=rootsecretpassword
- - ADMINER_DEFAULT_DB=appwrite
- configs:
- - source: adminer-index.php
- target: /var/www/html/index.php
- mode: 0755
- labels:
- - "traefik.enable=true"
- - "traefik.constraint-label-stack=appwrite"
- - "traefik.docker.network=gateway"
- - "traefik.http.services.appwrite_adminer.loadbalancer.server.port=8080"
- - "traefik.http.routers.appwrite_adminer_http.entrypoints=appwrite_web"
- - "traefik.http.routers.appwrite_adminer_http.rule=Host(`mysql.localhost`)"
- - "traefik.http.routers.appwrite_adminer_http.service=appwrite_adminer"
- - "traefik.http.routers.appwrite_adminer_https.entrypoints=appwrite_websecure"
- - "traefik.http.routers.appwrite_adminer_https.rule=Host(`mysql.localhost`)"
- - "traefik.http.routers.appwrite_adminer_https.service=appwrite_adminer"
- - "traefik.http.routers.appwrite_adminer_https.tls=true"
-
- redis-insight:
- image: redis/redisinsight:latest
- restart: unless-stopped
- networks:
- - appwrite
- - gateway
- environment:
- - RI_PRE_SETUP_DATABASES_PATH=/mnt/connections.json
- configs:
- - source: redisinsight-connections.json
- target: /mnt/connections.json
- mode: 0755
- labels:
- - "traefik.enable=true"
- - "traefik.constraint-label-stack=appwrite"
- - "traefik.docker.network=gateway"
- - "traefik.http.services.appwrite_redisinsight.loadbalancer.server.port=5540"
- - "traefik.http.routers.appwrite_redisinsight_http.entrypoints=appwrite_web"
- - "traefik.http.routers.appwrite_redisinsight_http.rule=Host(`redis.localhost`)"
- - "traefik.http.routers.appwrite_redisinsight_http.service=appwrite_redisinsight"
- - "traefik.http.routers.appwrite_redisinsight_https.entrypoints=appwrite_websecure"
- - "traefik.http.routers.appwrite_redisinsight_https.rule=Host(`redis.localhost`)"
- - "traefik.http.routers.appwrite_redisinsight_https.service=appwrite_redisinsight"
- - "traefik.http.routers.appwrite_redisinsight_https.tls=true"
- ports:
- - "8081:5540"
-
- graphql-explorer:
- container_name: appwrite-graphql-explorer
- image: appwrite/altair:0.3.0
- restart: unless-stopped
- networks:
- - appwrite
- ports:
- - "9509:3000"
- environment:
- - SERVER_URL=http://localhost/v1/graphql
-
- # Dev Tools End ------------------------------------------------------------------------------------------
+ # Dev tools (adminer, redis-insight, mongo-express, graphql-explorer)
+ # are defined in docker-compose.override.yml
networks:
gateway:
@@ -1526,60 +1476,7 @@ networks:
runtimes:
name: runtimes
-configs:
- redisinsight-connections.json:
- content: |
- [
- {
- "compressor": "NONE",
- "id": "104dc90a-21ef-4d5e-8912-b30baabb152f",
- "host": "redis",
- "port": 6379,
- "name": "redis:6379",
- "db": 0,
- "username": "default",
- "password": null,
- "connectionType": "STANDALONE",
- "nameFromProvider": null,
- "provider": "REDIS",
- "lastConnection": "2025-10-16T09:22:02.591Z",
- "modules": [
- {
- "name": "ReJSON",
- "version": 20808,
- "semanticVersion": "2.8.8"
- },
- {
- "name": "search",
- "version": 21015,
- "semanticVersion": "2.10.15"
- }
- ],
- "tls": false,
- "tlsServername": null,
- "verifyServerCert": null,
- "caCert": null,
- "clientCert": null,
- "ssh": false,
- "sshOptions": null,
- "forceStandalone": false,
- "tags": []
- }
- ]
- adminer-index.php:
- content: |
- $$_ENV['ADMINER_DEFAULT_SERVER'],
- 'driver' => 'server', /* seems to autodetect the driver from server settings */
- 'username' => $$_ENV['ADMINER_DEFAULT_USERNAME'],
- 'password' => $$_ENV['ADMINER_DEFAULT_PASSWORD'],
- 'db' => $$_ENV['ADMINER_DEFAULT_DB'],
- ];
- }
- include './adminer.php';
volumes:
appwrite-mariadb:
@@ -1595,4 +1492,4 @@ volumes:
appwrite-sites:
appwrite-builds:
appwrite-config:
- appwrite-models:
\ No newline at end of file
+ appwrite-models:
diff --git a/docs/references/account/create-2fa-challenge.md b/docs/references/account/create-2fa-challenge.md
deleted file mode 100644
index ee6ef2f2ac..0000000000
--- a/docs/references/account/create-2fa-challenge.md
+++ /dev/null
@@ -1 +0,0 @@
-Initialize an MFA challenge of the specified factor. The factor must be available on the account.
\ No newline at end of file
diff --git a/docs/references/account/delete-session-current.md b/docs/references/account/delete-session-current.md
deleted file mode 100644
index d38520f479..0000000000
--- a/docs/references/account/delete-session-current.md
+++ /dev/null
@@ -1 +0,0 @@
-Use this endpoint to log out the currently logged in user from their account. When successful this endpoint will delete the user session and remove the session secret cookie from the user client.
\ No newline at end of file
diff --git a/docs/references/advisor/delete-report.md b/docs/references/advisor/delete-report.md
new file mode 100644
index 0000000000..b32ba845e2
--- /dev/null
+++ b/docs/references/advisor/delete-report.md
@@ -0,0 +1 @@
+Delete an analyzer report by its unique ID. Nested insights and CTA metadata are removed asynchronously by the deletes worker.
diff --git a/docs/references/advisor/get-insight.md b/docs/references/advisor/get-insight.md
new file mode 100644
index 0000000000..7e1e795c22
--- /dev/null
+++ b/docs/references/advisor/get-insight.md
@@ -0,0 +1 @@
+Get an insight by its unique ID, scoped to its parent report.
diff --git a/docs/references/advisor/get-report.md b/docs/references/advisor/get-report.md
new file mode 100644
index 0000000000..731c10dc8a
--- /dev/null
+++ b/docs/references/advisor/get-report.md
@@ -0,0 +1 @@
+Get an analyzer report by its unique ID. The response includes the report's metadata and the nested insights it produced.
diff --git a/docs/references/advisor/list-insights.md b/docs/references/advisor/list-insights.md
new file mode 100644
index 0000000000..56d6a2fca0
--- /dev/null
+++ b/docs/references/advisor/list-insights.md
@@ -0,0 +1 @@
+List the insights produced under a single analyzer report. You can use the query params to filter your results further.
diff --git a/docs/references/advisor/list-reports.md b/docs/references/advisor/list-reports.md
new file mode 100644
index 0000000000..04b91c541a
--- /dev/null
+++ b/docs/references/advisor/list-reports.md
@@ -0,0 +1 @@
+Get a list of all the project's analyzer reports. You can use the query params to filter your results.
diff --git a/docs/references/console/variables.md b/docs/references/console/variables.md
deleted file mode 100644
index ddfa2b9b72..0000000000
--- a/docs/references/console/variables.md
+++ /dev/null
@@ -1 +0,0 @@
-Get all Environment Variables that are relevant for the console.
\ No newline at end of file
diff --git a/docs/references/databases/create-bigint-attribute.md b/docs/references/databases/create-bigint-attribute.md
new file mode 100644
index 0000000000..6fb607304b
--- /dev/null
+++ b/docs/references/databases/create-bigint-attribute.md
@@ -0,0 +1 @@
+Create a bigint attribute. Optionally, minimum and maximum values can be provided.
diff --git a/docs/references/databases/update-bigint-attribute.md b/docs/references/databases/update-bigint-attribute.md
new file mode 100644
index 0000000000..4a301c2216
--- /dev/null
+++ b/docs/references/databases/update-bigint-attribute.md
@@ -0,0 +1 @@
+Update a bigint attribute. Changing the `default` value will not update already existing documents.
diff --git a/docs/references/documentsdb/create-operations.md b/docs/references/documentsdb/create-operations.md
new file mode 100644
index 0000000000..a737b95a55
--- /dev/null
+++ b/docs/references/documentsdb/create-operations.md
@@ -0,0 +1 @@
+Create multiple operations in a single transaction.
\ No newline at end of file
diff --git a/docs/references/documentsdb/create-transaction.md b/docs/references/documentsdb/create-transaction.md
new file mode 100644
index 0000000000..fdf369a789
--- /dev/null
+++ b/docs/references/documentsdb/create-transaction.md
@@ -0,0 +1 @@
+Create a new transaction.
\ No newline at end of file
diff --git a/docs/references/documentsdb/delete-transaction.md b/docs/references/documentsdb/delete-transaction.md
new file mode 100644
index 0000000000..f1395c228f
--- /dev/null
+++ b/docs/references/documentsdb/delete-transaction.md
@@ -0,0 +1 @@
+Delete a transaction by its unique ID.
\ No newline at end of file
diff --git a/docs/references/documentsdb/get-collection-logs.md b/docs/references/documentsdb/get-collection-logs.md
deleted file mode 100644
index 8578cef03c..0000000000
--- a/docs/references/documentsdb/get-collection-logs.md
+++ /dev/null
@@ -1 +0,0 @@
-Get the collection activity logs list by its unique ID.
\ No newline at end of file
diff --git a/docs/references/documentsdb/get-document-logs.md b/docs/references/documentsdb/get-document-logs.md
deleted file mode 100644
index 9b96df5ad4..0000000000
--- a/docs/references/documentsdb/get-document-logs.md
+++ /dev/null
@@ -1 +0,0 @@
-Get the document activity logs list by its unique ID.
\ No newline at end of file
diff --git a/docs/references/documentsdb/get-transaction.md b/docs/references/documentsdb/get-transaction.md
new file mode 100644
index 0000000000..41900f7468
--- /dev/null
+++ b/docs/references/documentsdb/get-transaction.md
@@ -0,0 +1 @@
+Get a transaction by its unique ID.
\ No newline at end of file
diff --git a/docs/references/documentsdb/list-attributes.md b/docs/references/documentsdb/list-attributes.md
deleted file mode 100644
index 72ad6d727f..0000000000
--- a/docs/references/documentsdb/list-attributes.md
+++ /dev/null
@@ -1 +0,0 @@
-List attributes in the collection.
\ No newline at end of file
diff --git a/docs/references/documentsdb/list-transactions.md b/docs/references/documentsdb/list-transactions.md
new file mode 100644
index 0000000000..9a63d9f04a
--- /dev/null
+++ b/docs/references/documentsdb/list-transactions.md
@@ -0,0 +1 @@
+List transactions across all databases.
\ No newline at end of file
diff --git a/docs/references/documentsdb/update-transaction.md b/docs/references/documentsdb/update-transaction.md
new file mode 100644
index 0000000000..d9d5f45439
--- /dev/null
+++ b/docs/references/documentsdb/update-transaction.md
@@ -0,0 +1 @@
+Update a transaction, to either commit or roll back its operations.
\ No newline at end of file
diff --git a/docs/references/functions/create-build.md b/docs/references/functions/create-build.md
deleted file mode 100644
index 160a04c291..0000000000
--- a/docs/references/functions/create-build.md
+++ /dev/null
@@ -1 +0,0 @@
-Create a new build for an existing function deployment. This endpoint allows you to rebuild a deployment with the updated function configuration, including its entrypoint and build commands if they have been modified. The build process will be queued and executed asynchronously. The original deployment's code will be preserved and used for the new build.
\ No newline at end of file
diff --git a/docs/references/functions/create-deployment.md b/docs/references/functions/create-deployment.md
deleted file mode 100644
index 3bbdbfc848..0000000000
--- a/docs/references/functions/create-deployment.md
+++ /dev/null
@@ -1,5 +0,0 @@
-Create a new function code deployment. Use this endpoint to upload a new version of your code function. To execute your newly uploaded code, you'll need to update the function's deployment to use your new deployment UID.
-
-This endpoint accepts a tar.gz file compressed with your code. Make sure to include any dependencies your code has within the compressed file. You can learn more about code packaging in the [Appwrite Cloud Functions tutorial](https://appwrite.io/docs/functions).
-
-Use the "command" param to set the entrypoint used to execute your code.
\ No newline at end of file
diff --git a/docs/references/functions/create-execution.md b/docs/references/functions/create-execution.md
deleted file mode 100644
index 6089c4ff01..0000000000
--- a/docs/references/functions/create-execution.md
+++ /dev/null
@@ -1 +0,0 @@
-Trigger a function execution. The returned object will return you the current execution status. You can ping the `Get Execution` endpoint to get updates on the current execution status. Once this endpoint is called, your function execution process will start asynchronously.
\ No newline at end of file
diff --git a/docs/references/functions/create-function.md b/docs/references/functions/create-function.md
deleted file mode 100644
index 1ac9143f45..0000000000
--- a/docs/references/functions/create-function.md
+++ /dev/null
@@ -1 +0,0 @@
-Create a new function. You can pass a list of [permissions](https://appwrite.io/docs/permissions) to allow different project users or team with access to execute the function using the client API.
\ No newline at end of file
diff --git a/docs/references/functions/create-variable.md b/docs/references/functions/create-variable.md
deleted file mode 100644
index 40fabd75a8..0000000000
--- a/docs/references/functions/create-variable.md
+++ /dev/null
@@ -1 +0,0 @@
-Create a new function environment variable. These variables can be accessed in the function at runtime as environment variables.
\ No newline at end of file
diff --git a/docs/references/functions/delete-deployment.md b/docs/references/functions/delete-deployment.md
deleted file mode 100644
index 19c74965bd..0000000000
--- a/docs/references/functions/delete-deployment.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a code deployment by its unique ID.
\ No newline at end of file
diff --git a/docs/references/functions/delete-execution.md b/docs/references/functions/delete-execution.md
deleted file mode 100644
index d7cad98ac1..0000000000
--- a/docs/references/functions/delete-execution.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a function execution by its unique ID.
diff --git a/docs/references/functions/delete-function.md b/docs/references/functions/delete-function.md
deleted file mode 100644
index 92835e3c82..0000000000
--- a/docs/references/functions/delete-function.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a function by its unique ID.
\ No newline at end of file
diff --git a/docs/references/functions/delete-variable.md b/docs/references/functions/delete-variable.md
deleted file mode 100644
index 9b1326d96f..0000000000
--- a/docs/references/functions/delete-variable.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a variable by its unique ID.
\ No newline at end of file
diff --git a/docs/references/functions/get-deployment-download.md b/docs/references/functions/get-deployment-download.md
deleted file mode 100644
index e662ae2733..0000000000
--- a/docs/references/functions/get-deployment-download.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a Deployment's contents by its unique ID. This endpoint supports range requests for partial or streaming file download.
\ No newline at end of file
diff --git a/docs/references/functions/get-deployment.md b/docs/references/functions/get-deployment.md
deleted file mode 100644
index 6d73976eb1..0000000000
--- a/docs/references/functions/get-deployment.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a code deployment by its unique ID.
\ No newline at end of file
diff --git a/docs/references/functions/get-execution.md b/docs/references/functions/get-execution.md
deleted file mode 100644
index fc38260bdb..0000000000
--- a/docs/references/functions/get-execution.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a function execution log by its unique ID.
\ No newline at end of file
diff --git a/docs/references/functions/get-function-usage.md b/docs/references/functions/get-function-usage.md
deleted file mode 100644
index 4498abb05b..0000000000
--- a/docs/references/functions/get-function-usage.md
+++ /dev/null
@@ -1 +0,0 @@
-Get usage metrics and statistics for a for a specific function. View statistics including total deployments, builds, executions, storage usage, and compute time. The response includes both current totals and historical data for each metric. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, defaults to 30 days.
\ No newline at end of file
diff --git a/docs/references/functions/get-function.md b/docs/references/functions/get-function.md
deleted file mode 100644
index 557ec316ba..0000000000
--- a/docs/references/functions/get-function.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a function by its unique ID.
\ No newline at end of file
diff --git a/docs/references/functions/get-functions-usage.md b/docs/references/functions/get-functions-usage.md
deleted file mode 100644
index 14427d335d..0000000000
--- a/docs/references/functions/get-functions-usage.md
+++ /dev/null
@@ -1 +0,0 @@
-Get usage metrics and statistics for a for all functions. View statistics including total functions, deployments, builds, executions, storage usage, and compute time. The response includes both current totals and historical data for each metric. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, defaults to 30 days.
\ No newline at end of file
diff --git a/docs/references/functions/get-template.md b/docs/references/functions/get-template.md
deleted file mode 100644
index ccdcce7352..0000000000
--- a/docs/references/functions/get-template.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a function template using ID. You can use template details in [createFunction](/docs/references/cloud/server-nodejs/functions#create) method.
\ No newline at end of file
diff --git a/docs/references/functions/get-variable.md b/docs/references/functions/get-variable.md
deleted file mode 100644
index f0fa853655..0000000000
--- a/docs/references/functions/get-variable.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a variable by its unique ID.
\ No newline at end of file
diff --git a/docs/references/functions/list-deployments.md b/docs/references/functions/list-deployments.md
deleted file mode 100644
index 80bbba1bf6..0000000000
--- a/docs/references/functions/list-deployments.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a list of all the function's code deployments. You can use the query params to filter your results.
\ No newline at end of file
diff --git a/docs/references/functions/list-executions.md b/docs/references/functions/list-executions.md
deleted file mode 100644
index 168c795b20..0000000000
--- a/docs/references/functions/list-executions.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a list of all the current user function execution logs. You can use the query params to filter your results.
\ No newline at end of file
diff --git a/docs/references/functions/list-functions.md b/docs/references/functions/list-functions.md
deleted file mode 100644
index 9ad432fdc0..0000000000
--- a/docs/references/functions/list-functions.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a list of all the project's functions. You can use the query params to filter your results.
\ No newline at end of file
diff --git a/docs/references/functions/list-runtimes.md b/docs/references/functions/list-runtimes.md
deleted file mode 100644
index d4d3d23b18..0000000000
--- a/docs/references/functions/list-runtimes.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a list of all runtimes that are currently active on your instance.
\ No newline at end of file
diff --git a/docs/references/functions/list-specifications.md b/docs/references/functions/list-specifications.md
deleted file mode 100644
index d65a215827..0000000000
--- a/docs/references/functions/list-specifications.md
+++ /dev/null
@@ -1 +0,0 @@
-List allowed function specifications for this instance.
diff --git a/docs/references/functions/list-templates.md b/docs/references/functions/list-templates.md
deleted file mode 100644
index ed43b9cbf4..0000000000
--- a/docs/references/functions/list-templates.md
+++ /dev/null
@@ -1 +0,0 @@
-List available function templates. You can use template details in [createFunction](/docs/references/cloud/server-nodejs/functions#create) method.
\ No newline at end of file
diff --git a/docs/references/functions/list-variables.md b/docs/references/functions/list-variables.md
deleted file mode 100644
index 68bd5e17e1..0000000000
--- a/docs/references/functions/list-variables.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a list of all variables of a specific function.
\ No newline at end of file
diff --git a/docs/references/functions/update-deployment-build.md b/docs/references/functions/update-deployment-build.md
deleted file mode 100644
index d047990adf..0000000000
--- a/docs/references/functions/update-deployment-build.md
+++ /dev/null
@@ -1 +0,0 @@
-Cancel an ongoing function deployment build. If the build is already in progress, it will be stopped and marked as canceled. If the build hasn't started yet, it will be marked as canceled without executing. You cannot cancel builds that have already completed (status 'ready') or failed. The response includes the final build status and details.
\ No newline at end of file
diff --git a/docs/references/functions/update-function-deployment.md b/docs/references/functions/update-function-deployment.md
deleted file mode 100644
index 7a85188842..0000000000
--- a/docs/references/functions/update-function-deployment.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the function code deployment ID using the unique function ID. Use this endpoint to switch the code deployment that should be executed by the execution endpoint.
\ No newline at end of file
diff --git a/docs/references/functions/update-function.md b/docs/references/functions/update-function.md
deleted file mode 100644
index 5a9a84ad94..0000000000
--- a/docs/references/functions/update-function.md
+++ /dev/null
@@ -1 +0,0 @@
-Update function by its unique ID.
\ No newline at end of file
diff --git a/docs/references/functions/update-variable.md b/docs/references/functions/update-variable.md
deleted file mode 100644
index af2c38aea2..0000000000
--- a/docs/references/functions/update-variable.md
+++ /dev/null
@@ -1 +0,0 @@
-Update variable by its unique ID.
\ No newline at end of file
diff --git a/docs/references/health/get-queue-audits.md b/docs/references/health/get-queue-audits.md
index 75010cc2f4..bac075581f 100644
--- a/docs/references/health/get-queue-audits.md
+++ b/docs/references/health/get-queue-audits.md
@@ -1 +1 @@
-Get the number of audit logs that are waiting to be processed in the Appwrite internal queue server.
\ No newline at end of file
+Get the number of audit logs that are waiting to be processed in the Appwrite internal queue server.
diff --git a/docs/references/health/get-queue-stats-usage-dump.md b/docs/references/health/get-queue-stats-usage-dump.md
deleted file mode 100644
index 3c95da1b8a..0000000000
--- a/docs/references/health/get-queue-stats-usage-dump.md
+++ /dev/null
@@ -1 +0,0 @@
-Get the number of projects containing metrics that are waiting to be processed in the Appwrite internal queue server.
\ No newline at end of file
diff --git a/docs/references/health/get-queue-tasks.md b/docs/references/health/get-queue-tasks.md
deleted file mode 100644
index ea6fa22087..0000000000
--- a/docs/references/health/get-queue-tasks.md
+++ /dev/null
@@ -1 +0,0 @@
-Get the number of tasks that are waiting to be processed in the Appwrite internal queue server.
\ No newline at end of file
diff --git a/docs/references/health/get-queue.md b/docs/references/health/get-queue.md
deleted file mode 100644
index e4558f941f..0000000000
--- a/docs/references/health/get-queue.md
+++ /dev/null
@@ -1 +0,0 @@
-Check the Appwrite queue messaging servers are up and connection is successful.
\ No newline at end of file
diff --git a/docs/references/messaging/delete.md b/docs/references/messaging/delete.md
deleted file mode 100644
index b07d020900..0000000000
--- a/docs/references/messaging/delete.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a message by its unique ID.
\ No newline at end of file
diff --git a/docs/references/migrations/migration-json-export.md b/docs/references/migrations/migration-json-export.md
new file mode 100644
index 0000000000..8a955c5990
--- /dev/null
+++ b/docs/references/migrations/migration-json-export.md
@@ -0,0 +1 @@
+Export documents to a JSON file from your Appwrite database. This endpoint allows you to export documents to a JSON file stored in a secure internal bucket. You'll receive an email with a download link when the export is complete.
diff --git a/docs/references/migrations/migration-json-import.md b/docs/references/migrations/migration-json-import.md
new file mode 100644
index 0000000000..2eeeaf5619
--- /dev/null
+++ b/docs/references/migrations/migration-json-import.md
@@ -0,0 +1 @@
+Import documents from a JSON file into your Appwrite database. This endpoint allows you to import documents from a JSON file uploaded to Appwrite Storage bucket.
diff --git a/docs/references/project/create-variable.md b/docs/references/project/create-variable.md
deleted file mode 100644
index 2bbee5bf99..0000000000
--- a/docs/references/project/create-variable.md
+++ /dev/null
@@ -1 +0,0 @@
-Create a new project variable. This variable will be accessible in all Appwrite Functions at runtime.
\ No newline at end of file
diff --git a/docs/references/project/delete-variable.md b/docs/references/project/delete-variable.md
deleted file mode 100644
index 9be15f83ca..0000000000
--- a/docs/references/project/delete-variable.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a project variable by its unique ID.
\ No newline at end of file
diff --git a/docs/references/project/get-variable.md b/docs/references/project/get-variable.md
deleted file mode 100644
index 8636768434..0000000000
--- a/docs/references/project/get-variable.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a project variable by its unique ID.
\ No newline at end of file
diff --git a/docs/references/project/list-variables.md b/docs/references/project/list-variables.md
deleted file mode 100644
index fbe191178a..0000000000
--- a/docs/references/project/list-variables.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a list of all project variables. These variables will be accessible in all Appwrite Functions at runtime.
\ No newline at end of file
diff --git a/docs/references/project/update-variable.md b/docs/references/project/update-variable.md
deleted file mode 100644
index 603622b2c7..0000000000
--- a/docs/references/project/update-variable.md
+++ /dev/null
@@ -1 +0,0 @@
-Update project variable by its unique ID. This variable will be accessible in all Appwrite Functions at runtime.
\ No newline at end of file
diff --git a/docs/references/projects/create-key.md b/docs/references/projects/create-key.md
deleted file mode 100644
index d6633d936d..0000000000
--- a/docs/references/projects/create-key.md
+++ /dev/null
@@ -1 +0,0 @@
-Create a new API key. It's recommended to have multiple API keys with strict scopes for separate functions within your project.
\ No newline at end of file
diff --git a/docs/references/projects/create-platform.md b/docs/references/projects/create-platform.md
deleted file mode 100644
index b5d8be0ff9..0000000000
--- a/docs/references/projects/create-platform.md
+++ /dev/null
@@ -1 +0,0 @@
-Create a new platform for your project. Use this endpoint to register a new platform where your users will run your application which will interact with the Appwrite API.
\ No newline at end of file
diff --git a/docs/references/projects/create-webhook.md b/docs/references/projects/create-webhook.md
deleted file mode 100644
index cd0e93332b..0000000000
--- a/docs/references/projects/create-webhook.md
+++ /dev/null
@@ -1 +0,0 @@
-Create a new webhook. Use this endpoint to configure a URL that will receive events from Appwrite when specific events occur.
\ No newline at end of file
diff --git a/docs/references/projects/delete-key.md b/docs/references/projects/delete-key.md
deleted file mode 100644
index 9f3774b419..0000000000
--- a/docs/references/projects/delete-key.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a key by its unique ID. Once deleted, the key can no longer be used to authenticate API calls.
\ No newline at end of file
diff --git a/docs/references/projects/delete-platform.md b/docs/references/projects/delete-platform.md
deleted file mode 100644
index 7d538cac26..0000000000
--- a/docs/references/projects/delete-platform.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a platform by its unique ID. This endpoint removes the platform and all its configurations from the project.
\ No newline at end of file
diff --git a/docs/references/projects/delete-sms-template.md b/docs/references/projects/delete-sms-template.md
deleted file mode 100644
index c5a7e6cac9..0000000000
--- a/docs/references/projects/delete-sms-template.md
+++ /dev/null
@@ -1 +0,0 @@
-Reset a custom SMS template to its default value. This endpoint removes any custom message and restores the template to its original state.
\ No newline at end of file
diff --git a/docs/references/projects/delete-webhook.md b/docs/references/projects/delete-webhook.md
deleted file mode 100644
index 74fee2bcec..0000000000
--- a/docs/references/projects/delete-webhook.md
+++ /dev/null
@@ -1 +0,0 @@
-Delete a webhook by its unique ID. Once deleted, the webhook will no longer receive project events.
\ No newline at end of file
diff --git a/docs/references/projects/get-key.md b/docs/references/projects/get-key.md
deleted file mode 100644
index bd6351f420..0000000000
--- a/docs/references/projects/get-key.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a key by its unique ID. This endpoint returns details about a specific API key in your project including it's scopes.
\ No newline at end of file
diff --git a/docs/references/projects/get-platform.md b/docs/references/projects/get-platform.md
deleted file mode 100644
index 87129b829d..0000000000
--- a/docs/references/projects/get-platform.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a platform by its unique ID. This endpoint returns the platform's details, including its name, type, and key configurations.
\ No newline at end of file
diff --git a/docs/references/projects/get-sms-template.md b/docs/references/projects/get-sms-template.md
deleted file mode 100644
index 6ef1d93029..0000000000
--- a/docs/references/projects/get-sms-template.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a custom SMS template for the specified locale and type returning it's contents.
\ No newline at end of file
diff --git a/docs/references/projects/get-webhook.md b/docs/references/projects/get-webhook.md
deleted file mode 100644
index 559c73c748..0000000000
--- a/docs/references/projects/get-webhook.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a webhook by its unique ID. This endpoint returns details about a specific webhook configured for a project.
\ No newline at end of file
diff --git a/docs/references/projects/list-keys.md b/docs/references/projects/list-keys.md
deleted file mode 100644
index a7b701b0d7..0000000000
--- a/docs/references/projects/list-keys.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a list of all API keys from the current project.
\ No newline at end of file
diff --git a/docs/references/projects/list-platforms.md b/docs/references/projects/list-platforms.md
deleted file mode 100644
index ed9ade0852..0000000000
--- a/docs/references/projects/list-platforms.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a list of all platforms in the project. This endpoint returns an array of all platforms and their configurations.
\ No newline at end of file
diff --git a/docs/references/projects/list-webhooks.md b/docs/references/projects/list-webhooks.md
deleted file mode 100644
index bbbf4c7376..0000000000
--- a/docs/references/projects/list-webhooks.md
+++ /dev/null
@@ -1 +0,0 @@
-Get a list of all webhooks belonging to the project. You can use the query params to filter your results.
\ No newline at end of file
diff --git a/docs/references/projects/update-api-status-all.md b/docs/references/projects/update-api-status-all.md
deleted file mode 100644
index 654070759f..0000000000
--- a/docs/references/projects/update-api-status-all.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the status of all API types. Use this endpoint to enable or disable API types such as REST, GraphQL and Realtime all at once.
\ No newline at end of file
diff --git a/docs/references/projects/update-api-status.md b/docs/references/projects/update-api-status.md
deleted file mode 100644
index af10a0d4f4..0000000000
--- a/docs/references/projects/update-api-status.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the status of a specific API type. Use this endpoint to enable or disable API types such as REST, GraphQL and Realtime.
\ No newline at end of file
diff --git a/docs/references/projects/update-auth-duration.md b/docs/references/projects/update-auth-duration.md
deleted file mode 100644
index bdc75fa6f0..0000000000
--- a/docs/references/projects/update-auth-duration.md
+++ /dev/null
@@ -1 +0,0 @@
-Update how long sessions created within a project should stay active for.
\ No newline at end of file
diff --git a/docs/references/projects/update-auth-limit.md b/docs/references/projects/update-auth-limit.md
deleted file mode 100644
index c8faa3fe37..0000000000
--- a/docs/references/projects/update-auth-limit.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the maximum number of users allowed in this project. Set to 0 for unlimited users.
\ No newline at end of file
diff --git a/docs/references/projects/update-auth-password-dictionary.md b/docs/references/projects/update-auth-password-dictionary.md
deleted file mode 100644
index 1d47d30bb5..0000000000
--- a/docs/references/projects/update-auth-password-dictionary.md
+++ /dev/null
@@ -1 +0,0 @@
-Enable or disable checking user passwords against common passwords dictionary. This helps ensure users don't use common and insecure passwords.
\ No newline at end of file
diff --git a/docs/references/projects/update-auth-password-history.md b/docs/references/projects/update-auth-password-history.md
deleted file mode 100644
index 3a892915d5..0000000000
--- a/docs/references/projects/update-auth-password-history.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the authentication password history requirement. Use this endpoint to require new passwords to be different than the last X amount of previously used ones.
\ No newline at end of file
diff --git a/docs/references/projects/update-auth-sessions-limit.md b/docs/references/projects/update-auth-sessions-limit.md
deleted file mode 100644
index 7d5fdffae7..0000000000
--- a/docs/references/projects/update-auth-sessions-limit.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the maximum number of sessions allowed per user within the project, if the limit is hit the oldest session will be deleted to make room for new sessions.
\ No newline at end of file
diff --git a/docs/references/projects/update-key.md b/docs/references/projects/update-key.md
deleted file mode 100644
index 4934a51497..0000000000
--- a/docs/references/projects/update-key.md
+++ /dev/null
@@ -1 +0,0 @@
-Update a key by its unique ID. Use this endpoint to update the name, scopes, or expiration time of an API key.
\ No newline at end of file
diff --git a/docs/references/projects/update-memberships-privacy.md b/docs/references/projects/update-memberships-privacy.md
deleted file mode 100644
index a1affc1166..0000000000
--- a/docs/references/projects/update-memberships-privacy.md
+++ /dev/null
@@ -1 +0,0 @@
-Update project membership privacy settings. Use this endpoint to control what user information is visible to other team members, such as user name, email, and MFA status.
\ No newline at end of file
diff --git a/docs/references/projects/update-personal-data-check.md b/docs/references/projects/update-personal-data-check.md
deleted file mode 100644
index 42847fdbfc..0000000000
--- a/docs/references/projects/update-personal-data-check.md
+++ /dev/null
@@ -1 +0,0 @@
-Enable or disable checking user passwords against their personal data. This helps prevent users from using personal information in their passwords.
\ No newline at end of file
diff --git a/docs/references/projects/update-platform.md b/docs/references/projects/update-platform.md
deleted file mode 100644
index d04b07bafd..0000000000
--- a/docs/references/projects/update-platform.md
+++ /dev/null
@@ -1 +0,0 @@
-Update a platform by its unique ID. Use this endpoint to update the platform's name, key, platform store ID, or hostname.
\ No newline at end of file
diff --git a/docs/references/projects/update-service-status-all.md b/docs/references/projects/update-service-status-all.md
deleted file mode 100644
index f05e7d8c5c..0000000000
--- a/docs/references/projects/update-service-status-all.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the status of all services. Use this endpoint to enable or disable all optional services at once.
\ No newline at end of file
diff --git a/docs/references/projects/update-service-status.md b/docs/references/projects/update-service-status.md
deleted file mode 100644
index 9d3b0743a8..0000000000
--- a/docs/references/projects/update-service-status.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the status of a specific service. Use this endpoint to enable or disable a service in your project.
\ No newline at end of file
diff --git a/docs/references/projects/update-session-alerts.md b/docs/references/projects/update-session-alerts.md
deleted file mode 100644
index 36859e0c1e..0000000000
--- a/docs/references/projects/update-session-alerts.md
+++ /dev/null
@@ -1 +0,0 @@
-Enable or disable session email alerts. When enabled, users will receive email notifications when new sessions are created.
\ No newline at end of file
diff --git a/docs/references/projects/update-session-invalidation.md b/docs/references/projects/update-session-invalidation.md
deleted file mode 100644
index cbaf378624..0000000000
--- a/docs/references/projects/update-session-invalidation.md
+++ /dev/null
@@ -1 +0,0 @@
-Invalidate all existing sessions. An optional auth security setting for projects, and enabled by default for console project.
\ No newline at end of file
diff --git a/docs/references/projects/update-sms-template.md b/docs/references/projects/update-sms-template.md
deleted file mode 100644
index 3e67f613b7..0000000000
--- a/docs/references/projects/update-sms-template.md
+++ /dev/null
@@ -1 +0,0 @@
-Update a custom SMS template for the specified locale and type. Use this endpoint to modify the content of your SMS templates.
\ No newline at end of file
diff --git a/docs/references/projects/update-webhook-signature.md b/docs/references/projects/update-webhook-signature.md
deleted file mode 100644
index 8525a05777..0000000000
--- a/docs/references/projects/update-webhook-signature.md
+++ /dev/null
@@ -1 +0,0 @@
-Update the webhook signature key. This endpoint can be used to regenerate the signature key used to sign and validate payload deliveries for a specific webhook.
\ No newline at end of file
diff --git a/docs/references/projects/update-webhook.md b/docs/references/projects/update-webhook.md
deleted file mode 100644
index 745e4aebe1..0000000000
--- a/docs/references/projects/update-webhook.md
+++ /dev/null
@@ -1 +0,0 @@
-Update a webhook by its unique ID. Use this endpoint to update the URL, events, or status of an existing webhook.
\ No newline at end of file
diff --git a/docs/references/tablesdb/create-bigint-column.md b/docs/references/tablesdb/create-bigint-column.md
new file mode 100644
index 0000000000..7bbbb5aac6
--- /dev/null
+++ b/docs/references/tablesdb/create-bigint-column.md
@@ -0,0 +1 @@
+Create a bigint column. Optionally, minimum and maximum values can be provided.
diff --git a/docs/references/tablesdb/update-bigint-column.md b/docs/references/tablesdb/update-bigint-column.md
new file mode 100644
index 0000000000..0dde070f6f
--- /dev/null
+++ b/docs/references/tablesdb/update-bigint-column.md
@@ -0,0 +1 @@
+Update a bigint column. Changing the `default` value will not update already existing rows.
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/references/vcs/list-repository-branches.md b/docs/references/vcs/list-repository-branches.md
index eea1795a3e..b614c2ad13 100644
--- a/docs/references/vcs/list-repository-branches.md
+++ b/docs/references/vcs/list-repository-branches.md
@@ -1 +1 @@
-Get a list of all branches from a GitHub repository in your installation. This endpoint returns the names of all branches in the repository and their total count. The GitHub installation must be properly configured and have access to the requested repository for this endpoint to work.
+Get a list of branches from a GitHub repository in your installation. This endpoint supports filtering by a search term and pagination using query strings such as `Query.limit()`, `Query.offset()`, `Query.cursorAfter()`, and `Query.cursorBefore()`. It returns branch names along with the total number of matches. The GitHub installation must be properly configured and have access to the requested repository for this endpoint to work.
diff --git a/docs/references/vectorsdb/create-collection.md b/docs/references/vectorsdb/create-collection.md
new file mode 100644
index 0000000000..c6293a4c38
--- /dev/null
+++ b/docs/references/vectorsdb/create-collection.md
@@ -0,0 +1 @@
+Create a new Collection. Before using this route, you should create a new database resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/create-document.md b/docs/references/vectorsdb/create-document.md
new file mode 100644
index 0000000000..197744b4a0
--- /dev/null
+++ b/docs/references/vectorsdb/create-document.md
@@ -0,0 +1 @@
+Create a new Document. Before using this route, you should create a new collection resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/create-documents.md b/docs/references/vectorsdb/create-documents.md
new file mode 100644
index 0000000000..9f4a4a1396
--- /dev/null
+++ b/docs/references/vectorsdb/create-documents.md
@@ -0,0 +1 @@
+Create new Documents. Before using this route, you should create a new collection resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/create-index.md b/docs/references/vectorsdb/create-index.md
new file mode 100644
index 0000000000..164b754161
--- /dev/null
+++ b/docs/references/vectorsdb/create-index.md
@@ -0,0 +1,2 @@
+Creates an index on the attributes listed. Your index should include all the attributes you will query in a single request.
+Attributes can be `key`, `fulltext`, and `unique`.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/create-operations.md b/docs/references/vectorsdb/create-operations.md
new file mode 100644
index 0000000000..a737b95a55
--- /dev/null
+++ b/docs/references/vectorsdb/create-operations.md
@@ -0,0 +1 @@
+Create multiple operations in a single transaction.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/create-transaction.md b/docs/references/vectorsdb/create-transaction.md
new file mode 100644
index 0000000000..fdf369a789
--- /dev/null
+++ b/docs/references/vectorsdb/create-transaction.md
@@ -0,0 +1 @@
+Create a new transaction.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/create.md b/docs/references/vectorsdb/create.md
new file mode 100644
index 0000000000..b608485341
--- /dev/null
+++ b/docs/references/vectorsdb/create.md
@@ -0,0 +1 @@
+Create a new Database.
diff --git a/docs/references/vectorsdb/delete-collection.md b/docs/references/vectorsdb/delete-collection.md
new file mode 100644
index 0000000000..90f7aa6aa5
--- /dev/null
+++ b/docs/references/vectorsdb/delete-collection.md
@@ -0,0 +1 @@
+Delete a collection by its unique ID. Only users with write permissions have access to delete this resource.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/delete-document.md b/docs/references/vectorsdb/delete-document.md
new file mode 100644
index 0000000000..36fbf6802d
--- /dev/null
+++ b/docs/references/vectorsdb/delete-document.md
@@ -0,0 +1 @@
+Delete a document by its unique ID.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/delete-documents.md b/docs/references/vectorsdb/delete-documents.md
new file mode 100644
index 0000000000..a7b05503de
--- /dev/null
+++ b/docs/references/vectorsdb/delete-documents.md
@@ -0,0 +1 @@
+Bulk delete documents using queries, if no queries are passed then all documents are deleted.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/delete-index.md b/docs/references/vectorsdb/delete-index.md
new file mode 100644
index 0000000000..c5b8f49e5f
--- /dev/null
+++ b/docs/references/vectorsdb/delete-index.md
@@ -0,0 +1 @@
+Delete an index.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/delete-transaction.md b/docs/references/vectorsdb/delete-transaction.md
new file mode 100644
index 0000000000..f1395c228f
--- /dev/null
+++ b/docs/references/vectorsdb/delete-transaction.md
@@ -0,0 +1 @@
+Delete a transaction by its unique ID.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/delete.md b/docs/references/vectorsdb/delete.md
new file mode 100644
index 0000000000..605fa290d3
--- /dev/null
+++ b/docs/references/vectorsdb/delete.md
@@ -0,0 +1 @@
+Delete a database by its unique ID. Only API keys with with databases.write scope can delete a database.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/get-collection-usage.md b/docs/references/vectorsdb/get-collection-usage.md
new file mode 100644
index 0000000000..48682a075f
--- /dev/null
+++ b/docs/references/vectorsdb/get-collection-usage.md
@@ -0,0 +1 @@
+Get usage metrics and statistics for a collection. Returning the total number of documents. The response includes both current totals and historical data over time. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, range defaults to 30 days.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/get-collection.md b/docs/references/vectorsdb/get-collection.md
new file mode 100644
index 0000000000..97b39e8474
--- /dev/null
+++ b/docs/references/vectorsdb/get-collection.md
@@ -0,0 +1 @@
+Get a collection by its unique ID. This endpoint response returns a JSON object with the collection metadata.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/get-database-usage.md b/docs/references/vectorsdb/get-database-usage.md
new file mode 100644
index 0000000000..2c2628a464
--- /dev/null
+++ b/docs/references/vectorsdb/get-database-usage.md
@@ -0,0 +1 @@
+Get usage metrics and statistics for a database. You can view the total number of collections, documents, and storage usage. The response includes both current totals and historical data over time. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, range defaults to 30 days.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/get-document.md b/docs/references/vectorsdb/get-document.md
new file mode 100644
index 0000000000..4e4d76bec0
--- /dev/null
+++ b/docs/references/vectorsdb/get-document.md
@@ -0,0 +1 @@
+Get a document by its unique ID. This endpoint response returns a JSON object with the document data.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/get-index.md b/docs/references/vectorsdb/get-index.md
new file mode 100644
index 0000000000..cdea5b4f27
--- /dev/null
+++ b/docs/references/vectorsdb/get-index.md
@@ -0,0 +1 @@
+Get index by ID.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/get-logs.md b/docs/references/vectorsdb/get-logs.md
new file mode 100644
index 0000000000..8e49da4603
--- /dev/null
+++ b/docs/references/vectorsdb/get-logs.md
@@ -0,0 +1 @@
+Get the database activity logs list by its unique ID.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/get-transaction.md b/docs/references/vectorsdb/get-transaction.md
new file mode 100644
index 0000000000..41900f7468
--- /dev/null
+++ b/docs/references/vectorsdb/get-transaction.md
@@ -0,0 +1 @@
+Get a transaction by its unique ID.
\ No newline at end of file
diff --git a/docs/references/tablesdb/get-database.md b/docs/references/vectorsdb/get.md
similarity index 100%
rename from docs/references/tablesdb/get-database.md
rename to docs/references/vectorsdb/get.md
diff --git a/docs/references/vectorsdb/list-collections.md b/docs/references/vectorsdb/list-collections.md
new file mode 100644
index 0000000000..e437674915
--- /dev/null
+++ b/docs/references/vectorsdb/list-collections.md
@@ -0,0 +1 @@
+Get a list of all collections that belong to the provided databaseId. You can use the search parameter to filter your results.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/list-documents.md b/docs/references/vectorsdb/list-documents.md
new file mode 100644
index 0000000000..4e2ae91792
--- /dev/null
+++ b/docs/references/vectorsdb/list-documents.md
@@ -0,0 +1 @@
+Get a list of all the user's documents in a given collection. You can use the query params to filter your results.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/list-indexes.md b/docs/references/vectorsdb/list-indexes.md
new file mode 100644
index 0000000000..a8c687fb2b
--- /dev/null
+++ b/docs/references/vectorsdb/list-indexes.md
@@ -0,0 +1 @@
+List indexes in the collection.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/list-transactions.md b/docs/references/vectorsdb/list-transactions.md
new file mode 100644
index 0000000000..9a63d9f04a
--- /dev/null
+++ b/docs/references/vectorsdb/list-transactions.md
@@ -0,0 +1 @@
+List transactions across all databases.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/list-usage.md b/docs/references/vectorsdb/list-usage.md
new file mode 100644
index 0000000000..a88e76680e
--- /dev/null
+++ b/docs/references/vectorsdb/list-usage.md
@@ -0,0 +1 @@
+List usage metrics and statistics for all databases in the project. You can view the total number of databases, collections, documents, and storage usage. The response includes both current totals and historical data over time. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, range defaults to 30 days.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/list.md b/docs/references/vectorsdb/list.md
new file mode 100644
index 0000000000..d93fb9d7a8
--- /dev/null
+++ b/docs/references/vectorsdb/list.md
@@ -0,0 +1 @@
+Get a list of all databases from the current Appwrite project. You can use the search parameter to filter your results.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/update-collection.md b/docs/references/vectorsdb/update-collection.md
new file mode 100644
index 0000000000..b8f6bef997
--- /dev/null
+++ b/docs/references/vectorsdb/update-collection.md
@@ -0,0 +1 @@
+Update a collection by its unique ID.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/update-document.md b/docs/references/vectorsdb/update-document.md
new file mode 100644
index 0000000000..526f3971d1
--- /dev/null
+++ b/docs/references/vectorsdb/update-document.md
@@ -0,0 +1 @@
+Update a document by its unique ID. Using the patch method you can pass only specific fields that will get updated.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/update-documents.md b/docs/references/vectorsdb/update-documents.md
new file mode 100644
index 0000000000..5f560c6435
--- /dev/null
+++ b/docs/references/vectorsdb/update-documents.md
@@ -0,0 +1 @@
+Update all documents that match your queries, if no queries are submitted then all documents are updated. You can pass only specific fields to be updated.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/update-transaction.md b/docs/references/vectorsdb/update-transaction.md
new file mode 100644
index 0000000000..d9d5f45439
--- /dev/null
+++ b/docs/references/vectorsdb/update-transaction.md
@@ -0,0 +1 @@
+Update a transaction, to either commit or roll back its operations.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/update.md b/docs/references/vectorsdb/update.md
new file mode 100644
index 0000000000..4e99bf2e07
--- /dev/null
+++ b/docs/references/vectorsdb/update.md
@@ -0,0 +1 @@
+Update a database by its unique ID.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/upsert-document.md b/docs/references/vectorsdb/upsert-document.md
new file mode 100644
index 0000000000..f1b68d13d5
--- /dev/null
+++ b/docs/references/vectorsdb/upsert-document.md
@@ -0,0 +1 @@
+Create or update a Document. Before using this route, you should create a new collection resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console.
\ No newline at end of file
diff --git a/docs/references/vectorsdb/upsert-documents.md b/docs/references/vectorsdb/upsert-documents.md
new file mode 100644
index 0000000000..4feb473076
--- /dev/null
+++ b/docs/references/vectorsdb/upsert-documents.md
@@ -0,0 +1 @@
+Create or update Documents. Before using this route, you should create a new collection resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console.
diff --git a/docs/sdks/markdown/CHANGELOG.md b/docs/sdks/markdown/CHANGELOG.md
deleted file mode 100644
index 26fcb5bbce..0000000000
--- a/docs/sdks/markdown/CHANGELOG.md
+++ /dev/null
@@ -1,17 +0,0 @@
-# Change Log
-
-## 0.3.0
-
-* Add `bytesMax` and `bytesUsed` properties to Collection and Table documentation
-* Add `queries` parameter to `listKeys` and `keyId` parameter to `createKey` documentation
-* Add `dart-3.10` and `flutter-3.38` runtimes
-* Fix Teams membership docs to use `string[]` instead of `Roles[]`
-
-## 0.2.0
-
-* Document array-based enum parameters in Markdown examples (e.g., `permissions: BrowserPermission[]`).
-* Breaking change: `Output` enum has been removed; use `ImageFormat` instead.
-
-## 0.1.0
-
-* Initial release
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/docs/sdks/rust/CHANGELOG.md b/docs/sdks/rust/CHANGELOG.md
new file mode 100644
index 0000000000..bbfc68354e
--- /dev/null
+++ b/docs/sdks/rust/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Change Log
+
+## 0.1.0
+
+* Initial release
diff --git a/docs/sdks/rust/GETTING_STARTED.md b/docs/sdks/rust/GETTING_STARTED.md
new file mode 100644
index 0000000000..123315b442
--- /dev/null
+++ b/docs/sdks/rust/GETTING_STARTED.md
@@ -0,0 +1,68 @@
+## Getting Started
+
+### Init your SDK
+Initialize your SDK with your Appwrite server API endpoint and project ID which can be found on your project settings page and your new API secret Key from project's API keys section.
+
+```rust
+use appwrite::client::Client;
+
+let client = Client::new()
+ .set_endpoint("https://[HOSTNAME_OR_IP]/v1") // Your API Endpoint
+ .set_project("5df5acd0d48c2") // Your project ID
+ .set_key("919c2d18fb5d4...a2ae413da83346ad2") // Your secret API key
+ .set_self_signed(true); // Use only on dev mode with a self-signed SSL cert
+```
+
+### 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.
+
+```rust
+use appwrite::client::Client;
+use appwrite::services::users::Users;
+use appwrite::id::ID;
+
+let client = Client::new()
+ .set_endpoint("https://[HOSTNAME_OR_IP]/v1")
+ .set_project("5df5acd0d48c2")
+ .set_key("919c2d18fb5d4...a2ae413da83346ad2")
+ .set_self_signed(true);
+
+let users = Users::new(&client);
+
+let user = users.create(
+ ID::unique(),
+ Some("email@example.com"),
+ Some("+123456789"),
+ Some("password"),
+ Some("Walter O'Brien"),
+).await?;
+
+println!("{}", user.name);
+println!("{}", user.email);
+```
+
+### Error Handling
+The Appwrite Rust SDK returns `Result` types. You can handle errors using standard Rust error handling patterns. Below is an example.
+
+```rust
+use appwrite::error::AppwriteError;
+
+match users.create(
+ ID::unique(),
+ Some("email@example.com"),
+ Some("+123456789"),
+ Some("password"),
+ Some("Walter O'Brien"),
+).await {
+ Ok(user) => println!("{}", user.name),
+ Err(AppwriteError { message, code, .. }) => {
+ eprintln!("Error {}: {}", code, message);
+ }
+}
+```
+
+### Learn more
+You can use the following resources to learn more and get help
+- 🚀 [Getting Started Tutorial](https://appwrite.io/docs/getting-started-for-server)
+- 📜 [Appwrite Docs](https://appwrite.io/docs)
+- 💬 [Discord Community](https://appwrite.io/discord)
diff --git a/docs/services/advisor.md b/docs/services/advisor.md
new file mode 100644
index 0000000000..2fa3943829
--- /dev/null
+++ b/docs/services/advisor.md
@@ -0,0 +1,3 @@
+The Advisor service provides read access to analyzer reports and their nested insights for a project.
+
+Use the reports endpoints to list and fetch analyzer runs, then use the insights endpoints to inspect individual findings attached to a report.
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.neon b/phpstan.neon
index 90f28e7539..0b8761c19e 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -1,13 +1,14 @@
parameters:
- level: 8
+ level: 4
+ tmpDir: .phpstan-cache
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..32e865fe35 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -34,8 +34,11 @@
./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/Project
+ ./tests/e2e/Services/Advisor
./tests/e2e/Services/Functions/FunctionsBase.php
./tests/e2e/Services/Functions/FunctionsCustomServerTest.php
./tests/e2e/Services/Functions/FunctionsCustomClientTest.php
diff --git a/public/images/sites/templates/crm-dashboard-react-admin-dark.png b/public/images/sites/templates/dashboard-react-admin-dark.png
similarity index 100%
rename from public/images/sites/templates/crm-dashboard-react-admin-dark.png
rename to public/images/sites/templates/dashboard-react-admin-dark.png
diff --git a/public/images/sites/templates/crm-dashboard-react-admin-light.png b/public/images/sites/templates/dashboard-react-admin-light.png
similarity index 100%
rename from public/images/sites/templates/crm-dashboard-react-admin-light.png
rename to public/images/sites/templates/dashboard-react-admin-light.png
diff --git a/src/Appwrite/Advisor/Validator/CTAs.php b/src/Appwrite/Advisor/Validator/CTAs.php
new file mode 100644
index 0000000000..14f7d788e7
--- /dev/null
+++ b/src/Appwrite/Advisor/Validator/CTAs.php
@@ -0,0 +1,83 @@
+allowedServices = $allowedServices ?? ADVISOR_CTA_SERVICES;
+ $this->allowedMethods = $allowedMethods ?? ADVISOR_CTA_METHODS;
+ }
+
+ public function getDescription(): string
+ {
+ return $this->message;
+ }
+
+ public function isArray(): bool
+ {
+ return true;
+ }
+
+ public function getType(): string
+ {
+ return self::TYPE_ARRAY;
+ }
+
+ public function isValid($value): bool
+ {
+ if (!\is_array($value)) {
+ return false;
+ }
+
+ if (\count($value) > $this->maxCount) {
+ $this->message = "A maximum of {$this->maxCount} CTAs are allowed per insight.";
+ return false;
+ }
+
+ foreach ($value as $entry) {
+ if (!\is_array($entry)) {
+ return false;
+ }
+
+ $maxLengths = ['label' => 256, 'service' => 64, 'method' => 64];
+ foreach ($maxLengths as $required => $maxLength) {
+ if (!isset($entry[$required]) || !\is_string($entry[$required]) || $entry[$required] === '') {
+ return false;
+ }
+ if (\strlen($entry[$required]) > $maxLength) {
+ $this->message = "CTA `{$required}` must not exceed {$maxLength} characters.";
+ return false;
+ }
+ }
+
+ if (!empty($this->allowedServices) && !\in_array($entry['service'], $this->allowedServices, true)) {
+ $this->message = "CTA `service` must be one of: " . \implode(', ', $this->allowedServices) . '.';
+ return false;
+ }
+
+ if (!empty($this->allowedMethods) && !\in_array($entry['method'], $this->allowedMethods, true)) {
+ $this->message = "CTA `method` must be one of: " . \implode(', ', $this->allowedMethods) . '.';
+ return false;
+ }
+
+ if (isset($entry['params']) && !\is_array($entry['params']) && !\is_object($entry['params'])) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/Appwrite/Auth/Key.php b/src/Appwrite/Auth/Key.php
index 8788e6d791..a8783c1c99 100644
--- a/src/Appwrite/Auth/Key.php
+++ b/src/Appwrite/Auth/Key.php
@@ -28,8 +28,6 @@ class Key
protected bool $projectCheckDisabled = false,
protected bool $previewAuthDisabled = false,
protected bool $deploymentStatusIgnored = false,
- protected string $scopedProjectId = '',
- protected string $source = '',
) {
}
@@ -105,19 +103,9 @@ class Key
return $this->projectCheckDisabled;
}
- public function getScopedProjectId(): string
- {
- return $this->scopedProjectId;
- }
-
- public function getSource(): string
- {
- return $this->source;
- }
-
/**
* Decode the given secret key into a Key object, containing the project ID, type, role, scopes, and name.
- * Can be a stored API key or a dynamic key (JWT).
+ * Can be a stored API key or an ephemeral key (JWT).
*
* @throws Exception
*/
@@ -150,7 +138,9 @@ class Key
);
switch ($type) {
- case API_KEY_DYNAMIC:
+ // Dynamic supported for backwards compatibility
+ case API_KEY_EPHEMERAL:
+ case 'dynamic':
$jwtObj = new JWT(
key: System::getEnv('_APP_OPENSSL_KEY_V1'),
algo: 'HS256',
@@ -165,7 +155,7 @@ class Key
$expired = true;
}
- $name = $payload['name'] ?? 'Dynamic Key';
+ $name = $payload['name'] ?? 'Ephemeral Key';
$projectId = $payload['projectId'] ?? '';
$disabledMetrics = $payload['disabledMetrics'] ?? [];
$hostnameOverride = $payload['hostnameOverride'] ?? false;
@@ -173,15 +163,8 @@ class Key
$projectCheckDisabled = $payload['projectCheckDisabled'] ?? false;
$previewAuthDisabled = $payload['previewAuthDisabled'] ?? false;
$deploymentStatusIgnored = $payload['deploymentStatusIgnored'] ?? false;
- $scopedProjectId = $payload['scopedProjectId'] ?? '';
- $source = $payload['source'] ?? '';
- // Keys with a scoped project are restricted — only use explicit JWT scopes
- if (!empty($scopedProjectId)) {
- $scopes = $payload['scopes'] ?? [];
- } else {
- $scopes = \array_merge($payload['scopes'] ?? [], $scopes);
- }
+ $scopes = \array_merge($payload['scopes'] ?? [], $scopes);
if (!$projectCheckDisabled && $projectId !== $project->getId()) {
return $guestKey;
@@ -202,8 +185,6 @@ class Key
$projectCheckDisabled,
$previewAuthDisabled,
$deploymentStatusIgnored,
- $scopedProjectId,
- $source
);
case API_KEY_STANDARD:
$key = $project->find(
diff --git a/src/Appwrite/Auth/OAuth2.php b/src/Appwrite/Auth/OAuth2.php
index 9358c89547..958b28ed18 100644
--- a/src/Appwrite/Auth/OAuth2.php
+++ b/src/Appwrite/Auth/OAuth2.php
@@ -155,7 +155,7 @@ abstract class OAuth2
/**
* @param string $code
*
- * @return string
+ * @return int
*/
public function getAccessTokenExpiry(string $code): int
{
@@ -206,8 +206,6 @@ abstract class OAuth2
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- \curl_close($ch);
-
if ($code >= 400) {
throw new Exception($response, $code);
}
diff --git a/src/Appwrite/Auth/OAuth2/Apple.php b/src/Appwrite/Auth/OAuth2/Apple.php
index 0b4ec50881..bae3446fcb 100644
--- a/src/Appwrite/Auth/OAuth2/Apple.php
+++ b/src/Appwrite/Auth/OAuth2/Apple.php
@@ -165,9 +165,9 @@ class Apple extends OAuth2
protected function getAppSecret(): string
{
- try {
- $secret = \json_decode($this->appSecret, true);
- } catch (\Throwable $th) {
+ $secret = \json_decode($this->appSecret, true);
+
+ if (!\is_array($secret)) {
throw new Exception('Invalid secret');
}
diff --git a/src/Appwrite/Auth/OAuth2/Authentik.php b/src/Appwrite/Auth/OAuth2/Authentik.php
index 5d2445088b..aa4b126ae8 100644
--- a/src/Appwrite/Auth/OAuth2/Authentik.php
+++ b/src/Appwrite/Auth/OAuth2/Authentik.php
@@ -37,6 +37,13 @@ class Authentik extends OAuth2
return 'authentik';
}
+ public function verifyCredentials(): void
+ {
+ if (empty($this->getAuthentikDomain())) {
+ throw new \Exception('Authentik endpoint is required.');
+ }
+ }
+
/**
* @return string
*/
diff --git a/src/Appwrite/Auth/OAuth2/Disqus.php b/src/Appwrite/Auth/OAuth2/Disqus.php
index 58b7f48914..738c6d503e 100644
--- a/src/Appwrite/Auth/OAuth2/Disqus.php
+++ b/src/Appwrite/Auth/OAuth2/Disqus.php
@@ -108,7 +108,7 @@ class Disqus extends OAuth2
}
/**
- * @param string $token
+ * @param string $accessToken
*
* @return string
*/
diff --git a/src/Appwrite/Auth/OAuth2/Etsy.php b/src/Appwrite/Auth/OAuth2/Etsy.php
index 7ff16fcb78..6e0da14437 100644
--- a/src/Appwrite/Auth/OAuth2/Etsy.php
+++ b/src/Appwrite/Auth/OAuth2/Etsy.php
@@ -11,11 +11,6 @@ class Etsy extends OAuth2
*/
private string $endpoint = 'https://api.etsy.com/v3/public';
- /**
- * @var string
- */
- private string $version = '2022-07-14';
-
/**
* @var array
*/
diff --git a/src/Appwrite/Auth/OAuth2/FusionAuth.php b/src/Appwrite/Auth/OAuth2/FusionAuth.php
new file mode 100644
index 0000000000..fa8b45dc72
--- /dev/null
+++ b/src/Appwrite/Auth/OAuth2/FusionAuth.php
@@ -0,0 +1,233 @@
+getFusionAuthDomain())) {
+ throw new \Exception('FusionAuth endpoint is required.');
+ }
+ }
+
+ /**
+ * @return string
+ */
+ public function getLoginURL(): string
+ {
+ return 'https://' . $this->getFusionAuthDomain() . '/oauth2/authorize?' . \http_build_query([
+ 'client_id' => $this->appID,
+ 'redirect_uri' => $this->callback,
+ 'state' => \json_encode($this->state),
+ 'scope' => \implode(' ', $this->getScopes()),
+ 'response_type' => 'code'
+ ]);
+ }
+
+ /**
+ * @param string $code
+ *
+ * @return array
+ */
+ protected function getTokens(string $code): array
+ {
+ if (empty($this->tokens)) {
+ $headers = ['Content-Type: application/x-www-form-urlencoded'];
+ $this->tokens = \json_decode($this->request(
+ 'POST',
+ 'https://' . $this->getFusionAuthDomain() . '/oauth2/token',
+ $headers,
+ \http_build_query([
+ 'code' => $code,
+ 'client_id' => $this->appID,
+ 'client_secret' => $this->getClientSecret(),
+ 'redirect_uri' => $this->callback,
+ 'scope' => \implode(' ', $this->getScopes()),
+ 'grant_type' => 'authorization_code'
+ ])
+ ), true);
+ }
+ return $this->tokens;
+ }
+
+ /**
+ * @param string $refreshToken
+ *
+ * @return array
+ */
+ public function refreshTokens(string $refreshToken): array
+ {
+ $headers = ['Content-Type: application/x-www-form-urlencoded'];
+ $this->tokens = \json_decode($this->request(
+ 'POST',
+ 'https://' . $this->getFusionAuthDomain() . '/oauth2/token',
+ $headers,
+ \http_build_query([
+ 'refresh_token' => $refreshToken,
+ 'client_id' => $this->appID,
+ 'client_secret' => $this->getClientSecret(),
+ 'grant_type' => 'refresh_token'
+ ])
+ ), true);
+
+ if (empty($this->tokens['refresh_token'])) {
+ $this->tokens['refresh_token'] = $refreshToken;
+ }
+
+ return $this->tokens;
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserID(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ if (isset($user['sub'])) {
+ return $user['sub'];
+ }
+
+ return '';
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserEmail(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ if (isset($user['email'])) {
+ return $user['email'];
+ }
+
+ return '';
+ }
+
+ /**
+ * Check if the User email is verified
+ *
+ * @param string $accessToken
+ *
+ * @return bool
+ */
+ public function isEmailVerified(string $accessToken): bool
+ {
+ $user = $this->getUser($accessToken);
+
+ if ($user['email_verified'] ?? false) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserName(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ if (isset($user['name'])) {
+ return $user['name'];
+ }
+
+ return '';
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return array
+ */
+ protected function getUser(string $accessToken): array
+ {
+ if (empty($this->user)) {
+ $headers = ['Authorization: Bearer ' . \urlencode($accessToken)];
+ $user = $this->request('GET', 'https://' . $this->getFusionAuthDomain() . '/oauth2/userinfo', $headers);
+ $this->user = \json_decode($user, true);
+ }
+
+ return $this->user;
+ }
+
+ /**
+ * Extracts the Client Secret from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getClientSecret(): string
+ {
+ $secret = $this->getAppSecret();
+
+ return $secret['clientSecret'] ?? '';
+ }
+
+ /**
+ * Extracts the FusionAuth Domain from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getFusionAuthDomain(): string
+ {
+ $secret = $this->getAppSecret();
+ return $secret['fusionAuthDomain'] ?? '';
+ }
+
+ /**
+ * Decode the JSON stored in appSecret
+ *
+ * @return array
+ */
+ protected function getAppSecret(): array
+ {
+ try {
+ $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR);
+ } catch (\Throwable $th) {
+ throw new \Exception('Invalid secret');
+ }
+ return $secret;
+ }
+}
diff --git a/src/Appwrite/Auth/OAuth2/Github.php b/src/Appwrite/Auth/OAuth2/Github.php
index 1cefc397c5..d5d3b07918 100644
--- a/src/Appwrite/Auth/OAuth2/Github.php
+++ b/src/Appwrite/Auth/OAuth2/Github.php
@@ -3,6 +3,7 @@
namespace Appwrite\Auth\OAuth2;
use Appwrite\Auth\OAuth2;
+use Utopia\Fetch\Client as FetchClient;
class Github extends OAuth2
{
@@ -219,4 +220,34 @@ class Github extends OAuth2
$repository = \json_decode($repository, true);
return $repository;
}
+
+ public function verifyCredentials(): void
+ {
+ $client = new FetchClient();
+ $client->addHeader('Accept', 'application/json');
+
+ $response = $client->fetch(
+ url: 'https://github.com/login/oauth/access_token',
+ method: FetchClient::METHOD_POST,
+ query: [
+ 'client_id' => $this->appID,
+ 'client_secret' => $this->appSecret,
+ 'code' => 'intentionally-invalid-code',
+ 'redirect_uri' => 'intentionally-invalid-redirect',
+ ]
+ );
+
+ $json = \json_decode($response->getBody(), true);
+
+ if (isset($json['error']) && $json['error'] === "Not Found") {
+ throw new \Exception('GitHub application with provided Client ID is does not exist.');
+ }
+
+ if (isset($json['error']) && $json['error'] === "incorrect_client_credentials") {
+ throw new \Exception('GitHub application with provided Client ID is valid, but the provided Client Secret is incorrect.');
+ }
+
+ // We still expect error, like redirect_uri_mismatch or bad_verification_code,
+ // but that indicates valid credentials
+ }
}
diff --git a/src/Appwrite/Auth/OAuth2/Google.php b/src/Appwrite/Auth/OAuth2/Google.php
index 79894c2422..1166a313c6 100644
--- a/src/Appwrite/Auth/OAuth2/Google.php
+++ b/src/Appwrite/Auth/OAuth2/Google.php
@@ -55,7 +55,7 @@ class Google extends OAuth2
'state' => \json_encode($this->state),
'response_type' => 'code',
'access_type' => 'offline',
- 'prompt' => 'consent'
+ 'prompt' => $this->getPrompt()
]);
}
@@ -72,7 +72,7 @@ class Google extends OAuth2
'https://oauth2.googleapis.com/token?' . \http_build_query([
'code' => $code,
'client_id' => $this->appID,
- 'client_secret' => $this->appSecret,
+ 'client_secret' => $this->getClientSecret(),
'redirect_uri' => $this->callback,
'scope' => null,
'grant_type' => 'authorization_code'
@@ -95,7 +95,7 @@ class Google extends OAuth2
'https://oauth2.googleapis.com/token?' . \http_build_query([
'refresh_token' => $refreshToken,
'client_id' => $this->appID,
- 'client_secret' => $this->appSecret,
+ 'client_secret' => $this->getClientSecret(),
'grant_type' => 'refresh_token'
])
), true);
@@ -177,4 +177,54 @@ class Google extends OAuth2
return $this->user;
}
+
+ /**
+ * Extracts the Client Secret from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getClientSecret(): string
+ {
+ $secret = $this->getAppSecret();
+
+ return $secret['clientSecret'] ?? $this->appSecret;
+ }
+
+ /**
+ * Extracts the prompt values from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getPrompt(): string
+ {
+ $secret = $this->getAppSecret();
+ $prompt = $secret['prompt'] ?? [];
+
+ if (empty($prompt)) {
+ $prompt = ['consent'];
+ }
+
+ return \implode(' ', $prompt);
+ }
+
+ /**
+ * Decode the JSON stored in appSecret.
+ * Falls back to treating the raw string as the client secret for backwards compatibility.
+ *
+ * @return array
+ */
+ protected function getAppSecret(): array
+ {
+ try {
+ $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR);
+ } catch (\Throwable $th) {
+ return ['clientSecret' => $this->appSecret];
+ }
+
+ if (!\is_array($secret)) {
+ return ['clientSecret' => $this->appSecret];
+ }
+
+ return $secret;
+ }
}
diff --git a/src/Appwrite/Auth/OAuth2/Keycloak.php b/src/Appwrite/Auth/OAuth2/Keycloak.php
new file mode 100644
index 0000000000..b53b08e2d9
--- /dev/null
+++ b/src/Appwrite/Auth/OAuth2/Keycloak.php
@@ -0,0 +1,260 @@
+getKeycloakDomain())) {
+ throw new \Exception('Keycloak endpoint is required.');
+ }
+
+ if (empty($this->getKeycloakRealm())) {
+ throw new \Exception('Keycloak realm name is required.');
+ }
+ }
+
+ /**
+ * @return string
+ */
+ public function getLoginURL(): string
+ {
+ return $this->getRealmBaseURL() . '/protocol/openid-connect/auth?' . \http_build_query([
+ 'client_id' => $this->appID,
+ 'redirect_uri' => $this->callback,
+ 'state' => \json_encode($this->state),
+ 'scope' => \implode(' ', $this->getScopes()),
+ 'response_type' => 'code'
+ ]);
+ }
+
+ /**
+ * @param string $code
+ *
+ * @return array
+ */
+ protected function getTokens(string $code): array
+ {
+ if (empty($this->tokens)) {
+ $headers = ['Content-Type: application/x-www-form-urlencoded'];
+ $this->tokens = \json_decode($this->request(
+ 'POST',
+ $this->getRealmBaseURL() . '/protocol/openid-connect/token',
+ $headers,
+ \http_build_query([
+ 'code' => $code,
+ 'client_id' => $this->appID,
+ 'client_secret' => $this->getClientSecret(),
+ 'redirect_uri' => $this->callback,
+ 'scope' => \implode(' ', $this->getScopes()),
+ 'grant_type' => 'authorization_code'
+ ])
+ ), true);
+ }
+ return $this->tokens;
+ }
+
+ /**
+ * @param string $refreshToken
+ *
+ * @return array
+ */
+ public function refreshTokens(string $refreshToken): array
+ {
+ $headers = ['Content-Type: application/x-www-form-urlencoded'];
+ $this->tokens = \json_decode($this->request(
+ 'POST',
+ $this->getRealmBaseURL() . '/protocol/openid-connect/token',
+ $headers,
+ \http_build_query([
+ 'refresh_token' => $refreshToken,
+ 'client_id' => $this->appID,
+ 'client_secret' => $this->getClientSecret(),
+ 'grant_type' => 'refresh_token'
+ ])
+ ), true);
+
+ if (empty($this->tokens['refresh_token'])) {
+ $this->tokens['refresh_token'] = $refreshToken;
+ }
+
+ return $this->tokens;
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserID(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ if (isset($user['sub'])) {
+ return $user['sub'];
+ }
+
+ return '';
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserEmail(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ if (isset($user['email'])) {
+ return $user['email'];
+ }
+
+ return '';
+ }
+
+ /**
+ * Check if the User email is verified
+ *
+ * @param string $accessToken
+ *
+ * @return bool
+ */
+ public function isEmailVerified(string $accessToken): bool
+ {
+ $user = $this->getUser($accessToken);
+
+ if ($user['email_verified'] ?? false) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserName(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ if (isset($user['name'])) {
+ return $user['name'];
+ }
+
+ return '';
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return array
+ */
+ protected function getUser(string $accessToken): array
+ {
+ if (empty($this->user)) {
+ $headers = ['Authorization: Bearer ' . \urlencode($accessToken)];
+ $user = $this->request('GET', $this->getRealmBaseURL() . '/protocol/openid-connect/userinfo', $headers);
+ $this->user = \json_decode($user, true);
+ }
+
+ return $this->user;
+ }
+
+ /**
+ * Extracts the Client Secret from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getClientSecret(): string
+ {
+ $secret = $this->getAppSecret();
+
+ return $secret['clientSecret'] ?? '';
+ }
+
+ /**
+ * Extracts the Keycloak Domain from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getKeycloakDomain(): string
+ {
+ $secret = $this->getAppSecret();
+ return $secret['keycloakDomain'] ?? '';
+ }
+
+ /**
+ * Extracts the Keycloak Realm from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getKeycloakRealm(): string
+ {
+ $secret = $this->getAppSecret();
+ return $secret['keycloakRealm'] ?? '';
+ }
+
+ /**
+ * Build the realm-scoped base URL: `https://{domain}/realms/{realm}`.
+ * Keycloak realm names allow spaces and other characters that must be
+ * percent-encoded in URLs (e.g. `my realm` → `my%20realm`).
+ *
+ * @return string
+ */
+ protected function getRealmBaseURL(): string
+ {
+ return 'https://' . $this->getKeycloakDomain() . '/realms/' . \rawurlencode($this->getKeycloakRealm());
+ }
+
+ /**
+ * Decode the JSON stored in appSecret
+ *
+ * @return array
+ */
+ protected function getAppSecret(): array
+ {
+ try {
+ $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR);
+ } catch (\Throwable $th) {
+ throw new \Exception('Invalid secret');
+ }
+ return $secret;
+ }
+}
diff --git a/src/Appwrite/Auth/OAuth2/Kick.php b/src/Appwrite/Auth/OAuth2/Kick.php
new file mode 100644
index 0000000000..85b447fcd8
--- /dev/null
+++ b/src/Appwrite/Auth/OAuth2/Kick.php
@@ -0,0 +1,230 @@
+state;
+ $state[self::PKCE_STATE_KEY] = $this->getPKCEVerifier();
+
+ return 'https://id.kick.com/oauth/authorize?' . \http_build_query([
+ 'response_type' => 'code',
+ 'client_id' => $this->appID,
+ 'redirect_uri' => $this->callback,
+ 'scope' => \implode(' ', $this->getScopes()),
+ 'state' => \json_encode($state),
+ 'code_challenge' => $this->getPKCEChallenge(),
+ 'code_challenge_method' => 'S256',
+ ]);
+ }
+
+ /**
+ * @param string $code
+ *
+ * @return array
+ */
+ protected function getTokens(string $code): array
+ {
+ if (empty($this->tokens)) {
+ $headers = ['Content-Type: application/x-www-form-urlencoded'];
+ $this->tokens = \json_decode($this->request(
+ 'POST',
+ 'https://id.kick.com/oauth/token',
+ $headers,
+ \http_build_query([
+ 'grant_type' => 'authorization_code',
+ 'client_id' => $this->appID,
+ 'client_secret' => $this->appSecret,
+ 'redirect_uri' => $this->callback,
+ 'code_verifier' => $this->getPKCEVerifier(),
+ 'code' => $code,
+ ])
+ ), true);
+ }
+
+ return $this->tokens;
+ }
+
+ /**
+ * @param string $refreshToken
+ *
+ * @return array
+ */
+ public function refreshTokens(string $refreshToken): array
+ {
+ $headers = ['Content-Type: application/x-www-form-urlencoded'];
+ $this->tokens = \json_decode($this->request(
+ 'POST',
+ 'https://id.kick.com/oauth/token',
+ $headers,
+ \http_build_query([
+ 'grant_type' => 'refresh_token',
+ 'client_id' => $this->appID,
+ 'client_secret' => $this->appSecret,
+ 'refresh_token' => $refreshToken,
+ ])
+ ), true);
+
+ if (empty($this->tokens['refresh_token'])) {
+ $this->tokens['refresh_token'] = $refreshToken;
+ }
+
+ return $this->tokens;
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserID(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ return isset($user['user_id']) ? (string)$user['user_id'] : '';
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserEmail(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ return $user['email'] ?? '';
+ }
+
+ /**
+ * Check if the OAuth email is verified.
+ *
+ * Kick only returns an email when the user has granted the `user:read`
+ * scope and the account email is verified, so a non-empty email is
+ * treated as verified.
+ *
+ * @param string $accessToken
+ *
+ * @return bool
+ */
+ public function isEmailVerified(string $accessToken): bool
+ {
+ return !empty($this->getUserEmail($accessToken));
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserName(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ return $user['name'] ?? '';
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return array
+ */
+ protected function getUser(string $accessToken): array
+ {
+ if (empty($this->user)) {
+ $headers = ['Authorization: Bearer ' . $accessToken];
+ $response = \json_decode($this->request(
+ 'GET',
+ 'https://api.kick.com/public/v1/users',
+ $headers
+ ), true);
+
+ $this->user = $response['data'][0] ?? [];
+ }
+
+ return $this->user;
+ }
+
+ /**
+ * Extract the PKCE verifier from the state on the callback so the same
+ * value generated in getLoginURL() can be sent to the token endpoint.
+ *
+ * @param string $state
+ *
+ * @return array|null
+ */
+ public function parseState(string $state): ?array
+ {
+ $parsed = \json_decode($state, true);
+
+ if (!\is_array($parsed)) {
+ return null;
+ }
+
+ $verifier = $parsed[self::PKCE_STATE_KEY] ?? null;
+ if (\is_string($verifier)) {
+ $this->pkceVerifier = $verifier;
+ }
+
+ unset($parsed[self::PKCE_STATE_KEY]);
+
+ return $parsed;
+ }
+
+ private function getPKCEVerifier(): string
+ {
+ if ($this->pkceVerifier === '') {
+ $this->pkceVerifier = \rtrim(\strtr(\base64_encode(\random_bytes(64)), '+/', '-_'), '=');
+ }
+
+ return $this->pkceVerifier;
+ }
+
+ private function getPKCEChallenge(): string
+ {
+ return \rtrim(\strtr(\base64_encode(\hash('sha256', $this->getPKCEVerifier(), true)), '+/', '-_'), '=');
+ }
+}
diff --git a/src/Appwrite/Auth/OAuth2/Microsoft.php b/src/Appwrite/Auth/OAuth2/Microsoft.php
index bc05843b37..19966ec1ac 100644
--- a/src/Appwrite/Auth/OAuth2/Microsoft.php
+++ b/src/Appwrite/Auth/OAuth2/Microsoft.php
@@ -36,6 +36,13 @@ class Microsoft extends OAuth2
return 'microsoft';
}
+ public function verifyCredentials(): void
+ {
+ if (empty($this->getTenantID())) {
+ throw new \Exception('Microsoft tenant is required.');
+ }
+ }
+
/**
* @return string
*/
@@ -201,7 +208,7 @@ class Microsoft extends OAuth2
}
/**
- * Extracts the Tenant Id from the JSON stored in appSecret. Defaults to 'common' as a fallback
+ * Extracts the Tenant Id from the JSON stored in appSecret.
*
* @return string
*/
@@ -209,6 +216,6 @@ class Microsoft extends OAuth2
{
$secret = $this->getAppSecret();
- return $secret['tenantID'] ?? 'common';
+ return $secret['tenantID'] ?? '';
}
}
diff --git a/src/Appwrite/Auth/OAuth2/Podio.php b/src/Appwrite/Auth/OAuth2/Podio.php
index 0b1f35414b..6a977da854 100644
--- a/src/Appwrite/Auth/OAuth2/Podio.php
+++ b/src/Appwrite/Auth/OAuth2/Podio.php
@@ -121,7 +121,7 @@ class Podio extends OAuth2
{
$user = $this->getUser($accessToken);
- return \strval($user['user_id']) ?? '';
+ return \strval($user['user_id']);
}
/**
diff --git a/src/Appwrite/Auth/OAuth2/X.php b/src/Appwrite/Auth/OAuth2/X.php
new file mode 100644
index 0000000000..d12ce25b33
--- /dev/null
+++ b/src/Appwrite/Auth/OAuth2/X.php
@@ -0,0 +1,320 @@
+state;
+ $state[self::PKCE_STATE_KEY] = $this->encryptPKCEVerifier($this->getPKCEVerifier());
+
+ return 'https://x.com/i/oauth2/authorize?' . \http_build_query([
+ 'response_type' => 'code',
+ 'client_id' => $this->appID,
+ 'redirect_uri' => $this->callback,
+ 'scope' => \implode(' ', $this->getScopes()),
+ 'state' => $this->base64UrlEncode(\json_encode($state, JSON_THROW_ON_ERROR)),
+ 'code_challenge' => $this->getPKCEChallenge(),
+ 'code_challenge_method' => 'S256',
+ ]);
+ }
+
+ /**
+ * @param string $code
+ *
+ * @return array
+ */
+ protected function getTokens(string $code): array
+ {
+ if (empty($this->tokens)) {
+ $this->tokens = $this->decodeJsonObject($this->request(
+ 'POST',
+ 'https://api.x.com/2/oauth2/token',
+ $this->tokenEndpointHeaders(),
+ \http_build_query([
+ 'code' => $code,
+ 'client_id' => $this->appID,
+ 'grant_type' => 'authorization_code',
+ 'redirect_uri' => $this->callback,
+ 'code_verifier' => $this->getPKCEVerifier(),
+ ])
+ ));
+ }
+
+ return $this->tokens;
+ }
+
+ /**
+ * @param string $refreshToken
+ *
+ * @return array
+ */
+ public function refreshTokens(string $refreshToken): array
+ {
+ $this->tokens = $this->decodeJsonObject($this->request(
+ 'POST',
+ 'https://api.x.com/2/oauth2/token',
+ $this->tokenEndpointHeaders(),
+ \http_build_query([
+ 'client_id' => $this->appID,
+ 'refresh_token' => $refreshToken,
+ 'grant_type' => 'refresh_token',
+ ])
+ ));
+
+ if (empty($this->tokens['refresh_token'])) {
+ $this->tokens['refresh_token'] = $refreshToken;
+ }
+
+ return $this->tokens;
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserID(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ return $user['data']['id'] ?? '';
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserEmail(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ return $user['data']['confirmed_email'] ?? '';
+ }
+
+ /**
+ * Check if the OAuth email is verified.
+ *
+ * X returns a confirmed email only when the app has email access enabled
+ * and the authenticated user has a confirmed email address.
+ *
+ * @param string $accessToken
+ *
+ * @return bool
+ */
+ public function isEmailVerified(string $accessToken): bool
+ {
+ return !empty($this->getUserEmail($accessToken));
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return string
+ */
+ public function getUserName(string $accessToken): string
+ {
+ $user = $this->getUser($accessToken);
+
+ return $user['data']['name'] ?? '';
+ }
+
+ /**
+ * @param string $accessToken
+ *
+ * @return array
+ */
+ protected function getUser(string $accessToken): array
+ {
+ if (empty($this->user)) {
+ $this->user = $this->decodeJsonObject($this->request(
+ 'GET',
+ 'https://api.x.com/2/users/me?user.fields=confirmed_email',
+ ['Authorization: Bearer ' . $accessToken]
+ ));
+ }
+
+ return $this->user;
+ }
+
+ /**
+ * @return array|null
+ */
+ public function parseState(string $state): ?array
+ {
+ $decoded = $this->base64UrlDecode($state);
+ if ($decoded === false) {
+ return null;
+ }
+
+ $parsed = \json_decode($decoded, true);
+
+ if (!\is_array($parsed)) {
+ return null;
+ }
+
+ $pkce = $parsed[self::PKCE_STATE_KEY] ?? null;
+
+ if (\is_array($pkce)) {
+ $this->pkceVerifier = $this->decryptPKCEVerifier($pkce);
+ }
+
+ unset($parsed[self::PKCE_STATE_KEY]);
+
+ return $parsed;
+ }
+
+ /**
+ * @return list
+ */
+ private function tokenEndpointHeaders(): array
+ {
+ return [
+ 'Authorization: Basic ' . \base64_encode($this->appID . ':' . $this->appSecret),
+ 'Content-Type: application/x-www-form-urlencoded',
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ private function decodeJsonObject(string $json): array
+ {
+ $decoded = \json_decode($json, true);
+
+ return \is_array($decoded) ? $decoded : [];
+ }
+
+ private function getPKCEVerifier(): string
+ {
+ if ($this->pkceVerifier === '') {
+ $this->pkceVerifier = $this->base64UrlEncode(\random_bytes(64));
+ }
+
+ return $this->pkceVerifier;
+ }
+
+ private function getPKCEChallenge(): string
+ {
+ return $this->base64UrlEncode(\hash('sha256', $this->getPKCEVerifier(), true));
+ }
+
+ private function encryptPKCEVerifier(string $verifier): array
+ {
+ $iv = OpenSSL::randomPseudoBytes(OpenSSL::cipherIVLength(OpenSSL::CIPHER_AES_128_GCM));
+ $key = $this->getPKCEStateKey();
+ $tag = null;
+
+ $data = OpenSSL::encrypt($verifier, OpenSSL::CIPHER_AES_128_GCM, $key, OPENSSL_RAW_DATA, $iv, $tag);
+
+ if ($data === false || $tag === null) {
+ throw new \Exception('Failed to encrypt PKCE verifier.');
+ }
+
+ return [
+ 'data' => $this->base64UrlEncode($data),
+ 'iv' => \bin2hex($iv),
+ 'tag' => \bin2hex($tag),
+ ];
+ }
+
+ private function decryptPKCEVerifier(array $payload): string
+ {
+ $data = $payload['data'] ?? '';
+ $iv = $payload['iv'] ?? '';
+ $tag = $payload['tag'] ?? '';
+
+ if ($data === '' || $iv === '' || $tag === '') {
+ return '';
+ }
+
+ $decodedData = $this->base64UrlDecode($data);
+ $decodedIv = \hex2bin($iv);
+ $decodedTag = \hex2bin($tag);
+
+ if ($decodedData === false || $decodedIv === false || $decodedTag === false) {
+ return '';
+ }
+
+ return OpenSSL::decrypt(
+ $decodedData,
+ OpenSSL::CIPHER_AES_128_GCM,
+ $this->getPKCEStateKey(),
+ OPENSSL_RAW_DATA,
+ $decodedIv,
+ $decodedTag
+ ) ?: '';
+ }
+
+ private function getPKCEStateKey(): string
+ {
+ $key = System::getEnv('_APP_OPENSSL_KEY_V1', '');
+
+ if ($key === '') {
+ throw new \Exception('X OAuth2 requires _APP_OPENSSL_KEY_V1 to encrypt PKCE state.');
+ }
+
+ return $key;
+ }
+
+ private function base64UrlEncode(string $value): string
+ {
+ return \rtrim(\strtr(\base64_encode($value), '+/', '-_'), '=');
+ }
+
+ private function base64UrlDecode(string $value): string|false
+ {
+ $padding = \strlen($value) % 4;
+ if ($padding > 0) {
+ $value .= \str_repeat('=', 4 - $padding);
+ }
+
+ return \base64_decode(\strtr($value, '-_', '+/'), true);
+ }
+
+}
diff --git a/src/Appwrite/Auth/OAuth2/Yahoo.php b/src/Appwrite/Auth/OAuth2/Yahoo.php
index c70a2fb6c9..1a6c6b860d 100644
--- a/src/Appwrite/Auth/OAuth2/Yahoo.php
+++ b/src/Appwrite/Auth/OAuth2/Yahoo.php
@@ -23,8 +23,9 @@ class Yahoo extends OAuth2
* @var array
*/
protected array $scopes = [
- 'sdct-r',
- 'sdpp-w',
+ 'openid',
+ 'profile',
+ 'email',
];
/**
diff --git a/src/Appwrite/Auth/OAuth2/Zoom.php b/src/Appwrite/Auth/OAuth2/Zoom.php
index 9dad22212a..a4967741a9 100644
--- a/src/Appwrite/Auth/OAuth2/Zoom.php
+++ b/src/Appwrite/Auth/OAuth2/Zoom.php
@@ -11,11 +11,6 @@ class Zoom extends OAuth2
*/
private string $endpoint = 'https://zoom.us';
- /**
- * @var string
- */
- private string $version = '2022-03-26';
-
/**
* @var array
*/
diff --git a/src/Appwrite/Auth/Validator/PersonalData.php b/src/Appwrite/Auth/Validator/PersonalData.php
index 8eaae002f6..b047e5dd2f 100644
--- a/src/Appwrite/Auth/Validator/PersonalData.php
+++ b/src/Appwrite/Auth/Validator/PersonalData.php
@@ -33,7 +33,7 @@ class PersonalData extends Password
/**
* Is valid.
*
- * @param mixed $value
+ * @param mixed $password
*
* @return bool
*/
@@ -59,7 +59,7 @@ class PersonalData extends Password
return false;
}
- if ($this->email && strpos($password, explode('@', $this->email)[0] ?? '') !== false) {
+ if ($this->email && strpos($password, explode('@', $this->email)[0]) !== false) {
return false;
}
diff --git a/src/Appwrite/Bus/Events/SessionCreated.php b/src/Appwrite/Bus/Events/SessionCreated.php
new file mode 100644
index 0000000000..b662ce2ad5
--- /dev/null
+++ b/src/Appwrite/Bus/Events/SessionCreated.php
@@ -0,0 +1,21 @@
+ $user
+ * @param array $project
+ * @param array $session
+ */
+ public function __construct(
+ public readonly array $user,
+ public readonly array $project,
+ public readonly array $session,
+ public readonly string $locale,
+ ) {
+ }
+}
diff --git a/src/Appwrite/Bus/Listeners/Log.php b/src/Appwrite/Bus/Listeners/Log.php
index 9bd539d5fe..e0376a4f81 100644
--- a/src/Appwrite/Bus/Listeners/Log.php
+++ b/src/Appwrite/Bus/Listeners/Log.php
@@ -3,10 +3,11 @@
namespace Appwrite\Bus\Listeners;
use Appwrite\Bus\Events\ExecutionCompleted;
-use Appwrite\Event\Execution;
+use Appwrite\Event\Message\Execution as ExecutionMessage;
+use Appwrite\Event\Publisher\Execution as ExecutionPublisher;
use Utopia\Bus\Listener;
use Utopia\Database\Document;
-use Utopia\Queue\Publisher;
+use Utopia\Span\Span;
class Log extends Listener
{
@@ -24,16 +25,26 @@ class Log extends Listener
{
$this
->desc('Persists execution logs to database via queue')
- ->inject('publisher')
+ ->inject('publisherForExecutions')
->callback($this->handle(...));
}
- public function handle(ExecutionCompleted $event, Publisher $publisher): void
+ public function handle(ExecutionCompleted $event, ExecutionPublisher $publisherForExecutions): void
{
- $queueForExecutions = new Execution($publisher);
- $queueForExecutions
- ->setExecution(new Document($event->execution))
- ->setProject(new Document($event->project))
- ->trigger();
+ $project = new Document($event->project);
+ $execution = new Document($event->execution);
+
+ if ($execution->getAttribute('resourceType', '') === 'functions') {
+ Span::add('project.id', $project->getId());
+ Span::add('function.id', $execution->getAttribute('resourceId', ''));
+ Span::add('execution.id', $execution->getId());
+ Span::add('deployment.id', $execution->getAttribute('deploymentId', ''));
+ Span::add('execution.status', $execution->getAttribute('status', ''));
+ }
+
+ $publisherForExecutions->enqueue(new ExecutionMessage(
+ project: $project,
+ execution: $execution,
+ ));
}
}
diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php
new file mode 100644
index 0000000000..eb36e0d394
--- /dev/null
+++ b/src/Appwrite/Bus/Listeners/Mails.php
@@ -0,0 +1,155 @@
+desc('Sends session alert emails')
+ ->inject('publisherForMails')
+ ->inject('locale')
+ ->inject('platform')
+ ->inject('dbForProject')
+ ->callback($this->handle(...));
+ }
+
+ public function handle(SessionCreated $event, MailPublisher $publisherForMails, Locale $locale, array $platform, Database $dbForProject): void
+ {
+ $project = new Document($event->project);
+
+ if (!($project->getAttribute('auths', [])['sessionAlerts'] ?? false)) {
+ return;
+ }
+
+ if (empty($event->user['email'])) {
+ return;
+ }
+
+ $provider = $event->session['provider'] ?? '';
+ $factors = $event->session['factors'] ?? [];
+
+ if (\in_array($provider, [SESSION_PROVIDER_MAGIC_URL, SESSION_PROVIDER_TOKEN]) && \in_array(Type::EMAIL, $factors)) {
+ return;
+ }
+
+ if ($dbForProject->count('sessions', [Query::equal('userId', [$event->user['$id']])]) === 1) {
+ return;
+ }
+
+ $locale->setDefault($event->locale);
+
+ $session = new Document($event->session);
+ $smtp = $project->getAttribute('smtp', []);
+ $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base');
+
+ if (!(new FileName())->isValid($smtpBaseTemplate)) {
+ throw new \Exception('Invalid template path');
+ }
+
+ $customTemplate =
+ $project->getAttribute('templates', [])["email.sessionAlert-" . $locale->default] ??
+ $project->getAttribute('templates', [])['email.sessionAlert-' . $locale->fallback] ?? [];
+ $isBranded = $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE;
+
+ $subject = $customTemplate['subject'] ?? $locale->getText('emails.sessionAlert.subject');
+ $preview = $locale->getText('emails.sessionAlert.preview');
+
+ $body = empty($customTemplate['message'])
+ ? Template::fromFile(__DIR__ . '/../../../../app/config/locale/templates/email-session-alert.tpl')
+ ->setParam('{{hello}}', $locale->getText('emails.sessionAlert.hello'))
+ ->setParam('{{body}}', $locale->getText('emails.sessionAlert.body'))
+ ->setParam('{{listDevice}}', $locale->getText('emails.sessionAlert.listDevice'))
+ ->setParam('{{listIpAddress}}', $locale->getText('emails.sessionAlert.listIpAddress'))
+ ->setParam('{{listCountry}}', $locale->getText('emails.sessionAlert.listCountry'))
+ ->setParam('{{footer}}', $locale->getText('emails.sessionAlert.footer'))
+ ->setParam('{{thanks}}', $locale->getText('emails.sessionAlert.thanks'))
+ ->setParam('{{signature}}', $locale->getText('emails.sessionAlert.signature'))
+ ->render()
+ : $customTemplate['message'];
+
+ $clientName = $session->getAttribute('clientName')
+ ?: ($session->getAttribute('userAgent') ?: 'UNKNOWN');
+
+ $projectName = $project->getId() === 'console'
+ ? $platform['platformName']
+ : $project->getAttribute('name');
+
+ $emailVariables = [
+ 'direction' => $locale->getText('settings.direction'),
+ 'date' => (new \DateTime())->format('F j'),
+ 'year' => (new \DateTime())->format('YYYY'),
+ 'time' => (new \DateTime())->format('H:i:s'),
+ 'user' => $event->user['name'] ?? '',
+ 'project' => $projectName,
+ 'device' => $clientName,
+ 'ipAddress' => $session->getAttribute('ip'),
+ 'country' => $locale->getText('countries.' . $session->getAttribute('countryCode'), $locale->getText('locale.country.unknown')),
+ ];
+
+ if ($isBranded) {
+ $emailVariables += [
+ 'accentColor' => $platform['accentColor'],
+ 'logoUrl' => $platform['logoUrl'],
+ 'twitter' => $platform['twitterUrl'],
+ 'discord' => $platform['discordUrl'],
+ 'github' => $platform['githubUrl'],
+ 'terms' => $platform['termsUrl'],
+ 'privacy' => $platform['privacyUrl'],
+ 'platform' => $platform['platformName'],
+ ];
+ }
+
+ $smtpConfig = [];
+ if ($smtp['enabled'] ?? false) {
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '', // Includes backwards compatibility
+ 'replyToName' => $customTemplate['replyToName'] ?? $smtp['replyToName'] ?? '',
+ 'senderEmail' => $customTemplate['senderEmail'] ?? $smtp['senderEmail'] ?? System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM),
+ 'senderName' => $customTemplate['senderName'] ?? $smtp['senderName'] ?? System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'),
+ ];
+ }
+
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $event->user['email'],
+ subject: $subject,
+ bodyTemplate: __DIR__ . '/../../../../app/config/locale/templates/' . $smtpBaseTemplate . '.tpl',
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ customMailOptions: $isBranded ? ['senderName' => $platform['emailSenderName']] : [],
+ platform: $platform,
+ ));
+ }
+}
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/Detector/Detector.php b/src/Appwrite/Detector/Detector.php
index 61286835f5..73259673dd 100644
--- a/src/Appwrite/Detector/Detector.php
+++ b/src/Appwrite/Detector/Detector.php
@@ -6,14 +6,8 @@ use DeviceDetector\DeviceDetector;
class Detector
{
- /**
- * @param string
- */
protected $userAgent = '';
- /**
- * @param DeviceDetector
- */
protected $detctor;
/**
diff --git a/src/Appwrite/Docker/Compose.php b/src/Appwrite/Docker/Compose.php
index 241e281ed8..9ea6420d2d 100644
--- a/src/Appwrite/Docker/Compose.php
+++ b/src/Appwrite/Docker/Compose.php
@@ -12,9 +12,6 @@ class Compose
*/
protected $compose = [];
- /**
- * @var string $data
- */
public function __construct(string $data)
{
$this->compose = yaml_parse($data);
diff --git a/src/Appwrite/Docker/Compose/Service.php b/src/Appwrite/Docker/Compose/Service.php
index a3f9c91253..e7993d6927 100644
--- a/src/Appwrite/Docker/Compose/Service.php
+++ b/src/Appwrite/Docker/Compose/Service.php
@@ -11,9 +11,6 @@ class Service
*/
protected $service = [];
- /**
- * @var string $path
- */
public function __construct(array $service)
{
$this->service = $service;
@@ -24,7 +21,7 @@ class Service
array_walk($ports, function (&$value, $key) {
$split = explode(':', $value);
$this->service['ports'][
- (isset($split[0])) ? $split[0] : ''
+ $split[0]
] = (isset($split[1])) ? $split[1] : '';
});
diff --git a/src/Appwrite/Docker/Env.php b/src/Appwrite/Docker/Env.php
index 51ec9e167c..7e44a6c5cf 100644
--- a/src/Appwrite/Docker/Env.php
+++ b/src/Appwrite/Docker/Env.php
@@ -9,17 +9,23 @@ class Env
*/
protected $vars = [];
- /**
- * @var string $data
- */
public function __construct(string $data)
{
$data = explode("\n", $data);
foreach ($data as &$row) {
$row = explode('=', $row, 2);
- $key = (isset($row[0])) ? trim($row[0]) : null;
- $value = (isset($row[1])) ? trim($row[1]) : null;
+ $key = trim($row[0]);
+ $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/Build.php b/src/Appwrite/Event/Build.php
deleted file mode 100644
index 4eaf108f15..0000000000
--- a/src/Appwrite/Event/Build.php
+++ /dev/null
@@ -1,146 +0,0 @@
-setQueue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME))
- ->setClass(System::getEnv('_APP_BUILDS_CLASS_NAME', Event::BUILDS_CLASS_NAME));
- }
-
- /**
- * Sets template for the build event.
- *
- * @param Document $template
- * @return self
- */
- public function setTemplate(Document $template): self
- {
- $this->template = $template;
-
- return $this;
- }
-
- /**
- * Sets resource document for the build event.
- *
- * @param Document $resource
- * @return self
- */
- public function setResource(Document $resource): self
- {
- $this->resource = $resource;
-
- return $this;
- }
-
- /**
- * Returns set resource document for the build event.
- *
- * @return null|Document
- */
- public function getResource(): ?Document
- {
- return $this->resource;
- }
-
- /**
- * Sets deployment for the build event.
- *
- * @param Document $deployment
- * @return self
- */
- public function setDeployment(Document $deployment): self
- {
- $this->deployment = $deployment;
-
- return $this;
- }
-
- /**
- * Returns set deployment for the build event.
- *
- * @return null|Document
- */
- public function getDeployment(): ?Document
- {
- return $this->deployment;
- }
-
- /**
- * Sets type for the build event.
- *
- * @param string $type Can be `BUILD_TYPE_DEPLOYMENT` or `BUILD_TYPE_RETRY`.
- * @return self
- */
- public function setType(string $type): self
- {
- $this->type = $type;
-
- return $this;
- }
-
- /**
- * Returns set type for the function event.
- *
- * @return string
- */
- public function getType(): string
- {
- return $this->type;
- }
-
- /**
- * Prepare payload for queue.
- *
- * @return array
- */
- protected function preparePayload(): array
- {
- $platform = $this->platform;
- if (empty($platform)) {
- $platform = Config::getParam('platform', []);
- }
-
- return [
- 'project' => $this->project,
- 'resource' => $this->resource,
- 'deployment' => $this->deployment,
- 'type' => $this->type,
- 'template' => $this->template,
- 'platform' => $platform,
- ];
- }
-
- /**
- * Resets event.
- *
- * @return self
- */
- public function reset(): self
- {
- $this->type = '';
- $this->resource = null;
- $this->deployment = null;
- $this->template = null;
- $this->platform = [];
- parent::reset();
-
- return $this;
- }
-}
diff --git a/src/Appwrite/Event/Context/Audit.php b/src/Appwrite/Event/Context/Audit.php
new file mode 100644
index 0000000000..1d41890476
--- /dev/null
+++ b/src/Appwrite/Event/Context/Audit.php
@@ -0,0 +1,34 @@
+project === null
+ && $this->user === null
+ && $this->mode === ''
+ && $this->userAgent === ''
+ && $this->ip === ''
+ && $this->hostname === ''
+ && $this->event === ''
+ && $this->resource === ''
+ && $this->payload === [];
+ }
+}
diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php
index bf6339f8a0..357442a07c 100644
--- a/src/Appwrite/Event/Event.php
+++ b/src/Appwrite/Event/Event.php
@@ -285,7 +285,7 @@ class Event
*
* @param string $key
* @param Document $context
- * @return self
+ * @return static
*/
public function setContext(string $key, Document $context): self
{
@@ -309,7 +309,7 @@ class Event
/**
* Set class used for this event.
* @param string $class
- * @return self
+ * @return static
*/
public function setClass(string $class): self
{
@@ -459,7 +459,7 @@ class Event
/**
* Identify all sections of the pattern.
*/
- $type = $parts[0] ?? false;
+ $type = $parts[0];
$resource = $parts[1] ?? false;
$hasSubResource = $count > 3 && \str_starts_with($parts[3], '[');
$hasSubSubResource = $count > 5 && \str_starts_with($parts[5], '[') && $hasSubResource;
@@ -637,9 +637,11 @@ class Event
*/
$eventValues = \array_values($events);
- /**
- * Return a combined list of table, collection events and if tablesdb present then include all for backward compatibility
- */
+ $databaseType = $database?->getAttribute('type', 'legacy');
+ if ($database !== null && !\in_array($databaseType, ['legacy', 'tablesdb'], true)) {
+ return $eventValues;
+ }
+
return Event::mirrorCollectionEvents($pattern, $eventValues[0], $eventValues);
}
@@ -648,10 +650,8 @@ class Event
*
* @param Event $event
*
- * @return self
- *
*/
- public function from(Event $event): self
+ public function from(Event $event): static
{
$this->project = $event->getProject();
$this->user = $event->getUser();
@@ -664,21 +664,30 @@ class Event
}
/**
- * Adds `table` events for `collection` events.
+ * Adds table/collection counterpart events for backward compatibility.
*
* Example:
*
* `databases.*.collections.*.documents.*.update` →\
* `[databases.*.collections.*.documents.*.update, databases.*.tables.*.rows.*.update]`
+ *
+ * `databases.*.tables.*.rows.*.update` →\
+ * `[databases.*.tables.*.rows.*.update, databases.*.collections.*.documents.*.update]`
*/
private static function mirrorCollectionEvents(string $pattern, string $firstEvent, array $events): array
{
- $tableEventMap = [
+ $collectionsToTablesMap = [
'documents' => 'rows',
'collections' => 'tables',
'attributes' => 'columns',
];
+ $tablesToCollectionsMap = [
+ 'rows' => 'documents',
+ 'tables' => 'collections',
+ 'columns' => 'attributes',
+ ];
+
$databasesEventMap = [
'tablesdb' => 'databases',
'tables' => 'collections',
@@ -689,7 +698,10 @@ class Event
if (
(
str_contains($pattern, 'databases.') &&
- str_contains($firstEvent, 'collections')
+ (
+ str_contains($firstEvent, 'collections') ||
+ str_contains($firstEvent, 'tables')
+ )
) ||
(
str_contains($firstEvent, 'tablesdb.')
@@ -700,25 +712,16 @@ class Event
$pairedEvents[] = $event;
// tablesdb needs databases event with tables and collections
if (str_contains($event, 'tablesdb')) {
- $databasesSideEvent = str_replace(
- array_keys($databasesEventMap),
- array_values($databasesEventMap),
- $event
- );
+ $databasesSideEvent = self::replaceEventSegments($event, $databasesEventMap);
$pairedEvents[] = $databasesSideEvent;
- $tableSideEvent = str_replace(
- array_keys($tableEventMap),
- array_values($tableEventMap),
- $databasesSideEvent
- );
+ $tableSideEvent = self::replaceEventSegments($databasesSideEvent, $collectionsToTablesMap);
$pairedEvents[] = $tableSideEvent;
} elseif (str_contains($event, 'collections')) {
- $tableSideEvent = str_replace(
- array_keys($tableEventMap),
- array_values($tableEventMap),
- $event
- );
+ $tableSideEvent = self::replaceEventSegments($event, $collectionsToTablesMap);
$pairedEvents[] = $tableSideEvent;
+ } elseif (str_contains($event, 'tables')) {
+ $collectionSideEvent = self::replaceEventSegments($event, $tablesToCollectionsMap);
+ $pairedEvents[] = $collectionSideEvent;
}
}
@@ -730,6 +733,20 @@ class Event
return array_values(array_unique($events));
}
+ /**
+ * Replace only exact event path segments, never partial substrings.
+ */
+ private static function replaceEventSegments(string $event, array $map): string
+ {
+ $parts = \explode('.', $event);
+ $parts = \array_map(
+ fn (string $part) => $map[$part] ?? $part,
+ $parts
+ );
+
+ return \implode('.', $parts);
+ }
+
/**
* Maps event terminology based on database type
*/
diff --git a/src/Appwrite/Event/Execution.php b/src/Appwrite/Event/Execution.php
index 398025565c..9e735991ba 100644
--- a/src/Appwrite/Event/Execution.php
+++ b/src/Appwrite/Event/Execution.php
@@ -53,4 +53,23 @@ class Execution extends Event
'execution' => $this->execution,
];
}
+
+ /**
+ * Trim payload for the execution event.
+ * Only the project ID is needed — the worker DI fetches the full project from the platform database.
+ *
+ * @return array
+ */
+ protected function trimPayload(): array
+ {
+ $trimmed = [];
+
+ if ($this->project) {
+ $trimmed['project'] = new Document([
+ '$id' => $this->project->getId(),
+ ]);
+ }
+
+ return $trimmed;
+ }
}
diff --git a/src/Appwrite/Event/Mail.php b/src/Appwrite/Event/Mail.php
deleted file mode 100644
index 2d12aa542c..0000000000
--- a/src/Appwrite/Event/Mail.php
+++ /dev/null
@@ -1,554 +0,0 @@
-setQueue(System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME))
- ->setClass(System::getEnv('_APP_MAILS_CLASS_NAME', Event::MAILS_CLASS_NAME));
- }
-
- /**
- * Sets subject for the mail event.
- *
- * @param string $subject
- * @return self
- */
- public function setSubject(string $subject): self
- {
- $this->subject = $subject;
-
- return $this;
- }
-
- /**
- * Returns subject for the mail event.
- *
- * @return string
- */
- public function getSubject(): string
- {
- return $this->subject;
- }
-
- /**
- * Sets recipient for the mail event.
- *
- * @param string $recipient
- * @return self
- */
- public function setRecipient(string $recipient): self
- {
- $this->recipient = $recipient;
-
- return $this;
- }
-
- /**
- * Returns set recipient for mail event.
- *
- * @return string
- */
- public function getRecipient(): string
- {
- return $this->recipient;
- }
-
- /**
- * Sets body for the mail event.
- *
- * @param string $body
- * @return self
- */
- public function setBody(string $body): self
- {
- $this->body = $body;
-
- return $this;
- }
-
- /**
- * Returns body for the mail event.
- *
- * @return string
- */
- public function getBody(): string
- {
- return $this->body;
- }
-
- /**
- * Sets preview for the mail event.
- *
- * @return string
- */
- public function setPreview(string $preview): self
- {
- $this->preview = $preview;
-
- return $this;
- }
-
- /**
- * Returns preview for the mail event.
- *
- * @return string
- */
- public function getPreview(string $preview): string
- {
- return $this->preview;
- }
-
- /**
- * Sets name for the mail event.
- *
- * @param string $name
- * @return self
- */
- public function setName(string $name): self
- {
- $this->name = $name;
-
- return $this;
- }
-
- /**
- * Returns set name for the mail event.
- *
- * @return string
- */
- public function getName(): string
- {
- return $this->name;
- }
-
- /**
- * Sets bodyTemplate for the mail event.
- *
- * @param string $bodyTemplate
- * @return self
- */
- public function setBodyTemplate(string $bodyTemplate): self
- {
- $this->bodyTemplate = $bodyTemplate;
-
- return $this;
- }
-
- /**
- * Returns subject for the mail event.
- *
- * @return string
- */
- public function getBodyTemplate(): string
- {
- return $this->bodyTemplate;
- }
-
- /**
- * Set SMTP Host
- *
- * @param string $host
- * @return self
- */
- public function setSmtpHost(string $host): self
- {
- $this->smtp['host'] = $host;
- return $this;
- }
-
- /**
- * Set SMTP port
- *
- * @param int port
- * @return self
- */
- public function setSmtpPort(int $port): self
- {
- $this->smtp['port'] = $port;
- return $this;
- }
-
- /**
- * Set SMTP username
- *
- * @param string $username
- * @return self
- */
- public function setSmtpUsername(string $username): self
- {
- $this->smtp['username'] = $username;
- return $this;
- }
-
- /**
- * Set SMTP password
- *
- * @param string $password
- * @return self
- */
- public function setSmtpPassword(string $password): self
- {
- $this->smtp['password'] = $password;
- return $this;
- }
-
- /**
- * Set SMTP secure
- *
- * @param string $password
- * @return self
- */
- public function setSmtpSecure(string $secure): self
- {
- $this->smtp['secure'] = $secure;
- return $this;
- }
-
- /**
- * Set SMTP sender email
- *
- * @param string $senderEmail
- * @return self
- */
- public function setSmtpSenderEmail(string $senderEmail): self
- {
- $this->smtp['senderEmail'] = $senderEmail;
- return $this;
- }
-
- /**
- * Set SMTP sender name
- *
- * @param string $senderName
- * @return self
- */
- public function setSmtpSenderName(string $senderName): self
- {
- $this->smtp['senderName'] = $senderName;
- return $this;
- }
-
- /**
- * Set SMTP reply to
- *
- * @param string $replyTo
- * @return self
- */
- public function setSmtpReplyTo(string $replyTo): self
- {
- $this->smtp['replyTo'] = $replyTo;
- return $this;
- }
-
- /**
- * Get SMTP
- *
- * @return string
- */
- public function getSmtpHost(): string
- {
- return $this->smtp['host'] ?? '';
- }
-
- /**
- * Get SMTP port
- *
- * @return integer
- */
- public function getSmtpPort(): int
- {
- return $this->smtp['port'] ?? 0;
- }
-
- /**
- * Get SMTP username
- *
- * @return string
- */
- public function getSmtpUsername(): string
- {
- return $this->smtp['username'] ?? '';
- }
-
- /**
- * Get SMTP password
- *
- * @return string
- */
- public function getSmtpPassword(): string
- {
- return $this->smtp['password'] ?? '';
- }
-
- /**
- * Get SMTP secure
- *
- * @return string
- */
- public function getSmtpSecure(): string
- {
- return $this->smtp['secure'] ?? '';
- }
-
- /**
- * Get SMTP sender email
- *
- * @return string
- */
- public function getSmtpSenderEmail(): string
- {
- return $this->smtp['senderEmail'] ?? '';
- }
-
- /**
- * Get SMTP sender name
- *
- * @return string
- */
- public function getSmtpSenderName(): string
- {
- return $this->smtp['senderName'] ?? '';
- }
-
- /**
- * Get SMTP reply to
- *
- * @return string
- */
- public function getSmtpReplyTo(): string
- {
- return $this->smtp['replyTo'] ?? '';
- }
-
- /**
- * Get Email Variables
- *
- * @return array
- */
- public function getVariables(): array
- {
- return $this->variables;
- }
-
- /**
- * Set Email Variables
- *
- * @param array $variables
- * @return self
- */
- public function setVariables(array $variables): self
- {
- $this->variables = $variables;
- return $this;
- }
-
- /**
- * Append variables to the email event.
- *
- * @param array $variables
- * @return self
- */
- public function appendVariables(array $variables): self
- {
- $this->variables = \array_merge($this->variables, $variables);
- return $this;
- }
-
- /**
- * Set attachment
- * @param string $content
- * @param string $filename
- * @param string $encoding
- * @param string $type
- * @return self
- */
- public function setAttachment(string $content, string $filename, string $encoding = 'base64', string $type = 'plain/text')
- {
- $this->attachment = [
- 'content' => base64_encode($content),
- 'filename' => $filename,
- 'encoding' => $encoding,
- 'type' => $type,
- ];
- return $this;
- }
-
- /**
- * Get attachment
- *
- * @return array
- */
- public function getAttachment(): array
- {
- return $this->attachment;
- }
-
- /**
- * Reset attachment
- *
- * @return self
- */
- public function resetAttachment(): self
- {
- $this->attachment = [];
- return $this;
- }
-
- /**
- * Set sender email
- *
- * @param string $email
- * @return self
- */
- public function setSenderEmail(string $email): self
- {
- $this->customMailOptions['senderEmail'] = $email;
- return $this;
- }
-
- /**
- * Get sender email
- *
- * @return string
- */
- public function getSenderEmail(): string
- {
- return $this->customMailOptions['senderEmail'] ?? '';
- }
-
- /**
- * Set sender name
- *
- * @param string $name
- * @return self
- */
- public function setSenderName(string $name): self
- {
- $this->customMailOptions['senderName'] = $name;
- return $this;
- }
-
- /**
- * Get sender name
- *
- * @return string
- */
- public function getSenderName(): string
- {
- return $this->customMailOptions['senderName'] ?? '';
- }
-
- /**
- * Set reply-to email
- *
- * @param string $email
- * @return self
- */
- public function setReplyToEmail(string $email): self
- {
- $this->customMailOptions['replyToEmail'] = $email;
- return $this;
- }
-
- /**
- * Get reply-to email
- *
- * @return string
- */
- public function getReplyToEmail(): string
- {
- return $this->customMailOptions['replyToEmail'] ?? '';
- }
-
- /**
- * Set reply-to name
- *
- * @param string $name
- * @return self
- */
- public function setReplyToName(string $name): self
- {
- $this->customMailOptions['replyToName'] = $name;
- return $this;
- }
-
- /**
- * Get reply-to name
- *
- * @return string
- */
- public function getReplyToName(): string
- {
- return $this->customMailOptions['replyToName'] ?? '';
- }
-
- /**
- * Reset
- *
- * @return self
- */
- public function reset(): self
- {
- $this->project = null;
- $this->recipient = '';
- $this->name = '';
- $this->subject = '';
- $this->body = '';
- $this->variables = [];
- $this->bodyTemplate = '';
- $this->attachment = [];
- $this->customMailOptions = [];
- return $this;
- }
-
- /**
- * Prepare the payload for the event
- *
- * @return array
- */
- protected function preparePayload(): array
- {
- $platform = $this->platform;
- if (empty($platform)) {
- $platform = Config::getParam('platform', []);
- }
-
- return [
- 'project' => $this->project,
- 'recipient' => $this->recipient,
- 'name' => $this->name,
- 'subject' => $this->subject,
- 'bodyTemplate' => $this->bodyTemplate,
- 'body' => $this->body,
- 'preview' => $this->preview,
- 'smtp' => $this->smtp,
- 'variables' => $this->variables,
- 'attachment' => $this->attachment,
- 'customMailOptions' => $this->customMailOptions,
- 'events' => Event::generateEvents($this->getEvent(), $this->getParams()),
- 'platform' => $platform,
- ];
- }
-}
diff --git a/src/Appwrite/Event/Message/Audit.php b/src/Appwrite/Event/Message/Audit.php
new file mode 100644
index 0000000000..ae5831c3b9
--- /dev/null
+++ b/src/Appwrite/Event/Message/Audit.php
@@ -0,0 +1,71 @@
+ [
+ '$id' => $this->project->getId(),
+ '$sequence' => $this->project->getSequence(),
+ 'database' => $this->project->getAttribute('database', ''),
+ ],
+ 'user' => $this->user->getArrayCopy(),
+ 'payload' => $this->payload,
+ 'resource' => $this->resource,
+ 'mode' => $this->mode,
+ 'ip' => $this->ip,
+ 'userAgent' => $this->userAgent,
+ 'event' => $this->event,
+ 'hostname' => $this->hostname,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ event: $data['event'] ?? '',
+ payload: $data['payload'] ?? [],
+ project: new Document($data['project'] ?? []),
+ user: new Document($data['user'] ?? []),
+ resource: $data['resource'] ?? '',
+ mode: $data['mode'] ?? '',
+ ip: $data['ip'] ?? '',
+ userAgent: $data['userAgent'] ?? '',
+ hostname: $data['hostname'] ?? '',
+ );
+ }
+
+ public static function fromContext(AuditContext $context): static
+ {
+ return new self(
+ event: $context->event,
+ payload: $context->payload,
+ project: $context->project ?? new Document(),
+ user: $context->user ?? new Document(),
+ resource: $context->resource,
+ mode: $context->mode,
+ ip: $context->ip,
+ userAgent: $context->userAgent,
+ hostname: $context->hostname,
+ );
+ }
+}
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 @@
+platform) ? $this->platform : Config::getParam('platform', []);
+
+ return [
+ 'project' => $this->project->getArrayCopy(),
+ 'resource' => $this->resource->getArrayCopy(),
+ 'deployment' => $this->deployment->getArrayCopy(),
+ 'type' => $this->type,
+ 'template' => $this->template?->getArrayCopy(),
+ 'platform' => $platform,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: new Document($data['project'] ?? []),
+ resource: new Document($data['resource'] ?? []),
+ deployment: new Document($data['deployment'] ?? []),
+ type: $data['type'] ?? '',
+ template: !empty($data['template']) ? new Document($data['template']) : null,
+ platform: $data['platform'] ?? [],
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/Certificate.php b/src/Appwrite/Event/Message/Certificate.php
new file mode 100644
index 0000000000..a189bb8187
--- /dev/null
+++ b/src/Appwrite/Event/Message/Certificate.php
@@ -0,0 +1,43 @@
+ [
+ '$id' => $this->project->getId(),
+ '$sequence' => $this->project->getSequence(),
+ 'database' => $this->project->getAttribute('database', ''),
+ ],
+ 'domain' => $this->domain->getArrayCopy(),
+ 'skipRenewCheck' => $this->skipRenewCheck,
+ 'validationDomain' => $this->validationDomain,
+ 'action' => $this->action,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: new Document($data['project'] ?? []),
+ domain: new Document($data['domain'] ?? []),
+ skipRenewCheck: $data['skipRenewCheck'] ?? false,
+ validationDomain: $data['validationDomain'] ?? null,
+ action: $data['action'] ?? \Appwrite\Event\Certificate::ACTION_GENERATION,
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/Execution.php b/src/Appwrite/Event/Message/Execution.php
new file mode 100644
index 0000000000..0943c82e4a
--- /dev/null
+++ b/src/Appwrite/Event/Message/Execution.php
@@ -0,0 +1,30 @@
+ $this->project->getArrayCopy(),
+ 'execution' => $this->execution->getArrayCopy(),
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: new Document($data['project'] ?? []),
+ execution: new Document($data['execution'] ?? []),
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/Func.php b/src/Appwrite/Event/Message/Func.php
new file mode 100644
index 0000000000..2a2ae9d90f
--- /dev/null
+++ b/src/Appwrite/Event/Message/Func.php
@@ -0,0 +1,92 @@
+platform) ? $this->platform : Config::getParam('platform', []);
+
+ return [
+ 'project' => $this->project?->getArrayCopy(),
+ 'user' => $this->user?->getArrayCopy(),
+ 'userId' => $this->userId,
+ 'function' => $this->function?->getArrayCopy(),
+ 'functionId' => $this->functionId,
+ 'execution' => $this->execution?->getArrayCopy(),
+ 'type' => $this->type,
+ 'jwt' => $this->jwt,
+ 'payload' => $this->payload,
+ 'events' => $this->events,
+ 'body' => $this->body,
+ 'path' => $this->path,
+ 'headers' => $this->headers,
+ 'method' => $this->method,
+ 'platform' => $platform,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: !empty($data['project']) ? new Document($data['project']) : null,
+ user: !empty($data['user']) ? new Document($data['user']) : null,
+ userId: $data['userId'] ?? null,
+ function: !empty($data['function']) ? new Document($data['function']) : null,
+ functionId: $data['functionId'] ?? null,
+ execution: !empty($data['execution']) ? new Document($data['execution']) : null,
+ type: $data['type'] ?? '',
+ jwt: $data['jwt'] ?? '',
+ payload: $data['payload'] ?? [],
+ events: $data['events'] ?? [],
+ body: $data['body'] ?? '',
+ path: $data['path'] ?? '',
+ headers: $data['headers'] ?? [],
+ method: $data['method'] ?? '',
+ platform: $data['platform'] ?? [],
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/Mail.php b/src/Appwrite/Event/Message/Mail.php
new file mode 100644
index 0000000000..aeeea8a616
--- /dev/null
+++ b/src/Appwrite/Event/Message/Mail.php
@@ -0,0 +1,66 @@
+platform) ? $this->platform : Config::getParam('platform', []);
+
+ return [
+ 'project' => $this->project?->getArrayCopy(),
+ 'recipient' => $this->recipient,
+ 'name' => $this->name,
+ 'subject' => $this->subject,
+ 'bodyTemplate' => $this->bodyTemplate,
+ 'body' => $this->body,
+ 'preview' => $this->preview,
+ 'smtp' => $this->smtp,
+ 'variables' => $this->variables,
+ 'attachment' => $this->attachment,
+ 'customMailOptions' => $this->customMailOptions,
+ 'events' => $this->events,
+ 'platform' => $platform,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: !empty($data['project']) ? new Document($data['project']) : null,
+ recipient: $data['recipient'] ?? '',
+ name: $data['name'] ?? '',
+ subject: $data['subject'] ?? '',
+ bodyTemplate: $data['bodyTemplate'] ?? '',
+ body: $data['body'] ?? '',
+ preview: $data['preview'] ?? '',
+ smtp: $data['smtp'] ?? [],
+ variables: $data['variables'] ?? [],
+ attachment: $data['attachment'] ?? [],
+ customMailOptions: $data['customMailOptions'] ?? [],
+ events: $data['events'] ?? [],
+ platform: $data['platform'] ?? [],
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/Messaging.php b/src/Appwrite/Event/Message/Messaging.php
new file mode 100644
index 0000000000..7f0f918217
--- /dev/null
+++ b/src/Appwrite/Event/Message/Messaging.php
@@ -0,0 +1,45 @@
+ $this->type,
+ 'project' => $this->project->getArrayCopy(),
+ 'user' => $this->user?->getArrayCopy(),
+ 'messageId' => $this->messageId,
+ 'message' => $this->message?->getArrayCopy(),
+ 'recipients' => $this->recipients,
+ 'providerType' => $this->providerType,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ type: $data['type'] ?? '',
+ project: new Document($data['project'] ?? []),
+ user: !empty($data['user']) ? new Document($data['user']) : null,
+ messageId: $data['messageId'] ?? null,
+ message: !empty($data['message']) ? new Document($data['message']) : null,
+ recipients: $data['recipients'] ?? null,
+ providerType: $data['providerType'] ?? null,
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/Migration.php b/src/Appwrite/Event/Message/Migration.php
new file mode 100644
index 0000000000..ceeec45461
--- /dev/null
+++ b/src/Appwrite/Event/Message/Migration.php
@@ -0,0 +1,33 @@
+ $this->project->getArrayCopy(),
+ 'migration' => $this->migration->getArrayCopy(),
+ 'platform' => $this->platform,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: new Document($data['project'] ?? []),
+ migration: new Document($data['migration'] ?? []),
+ platform: $data['platform'] ?? [],
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/Screenshot.php b/src/Appwrite/Event/Message/Screenshot.php
new file mode 100644
index 0000000000..a06cdfbfc0
--- /dev/null
+++ b/src/Appwrite/Event/Message/Screenshot.php
@@ -0,0 +1,34 @@
+ [
+ '$id' => $this->project->getId(),
+ '$sequence' => $this->project->getSequence(),
+ 'database' => $this->project->getAttribute('database', ''),
+ ],
+ 'deploymentId' => $this->deploymentId,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: new Document($data['project'] ?? []),
+ deploymentId: $data['deploymentId'] ?? '',
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/StatsResources.php b/src/Appwrite/Event/Message/StatsResources.php
new file mode 100644
index 0000000000..584cbc137a
--- /dev/null
+++ b/src/Appwrite/Event/Message/StatsResources.php
@@ -0,0 +1,27 @@
+ $this->project->getArrayCopy(),
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: new Document($data['project'] ?? []),
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/Usage.php b/src/Appwrite/Event/Message/Usage.php
new file mode 100644
index 0000000000..c72bc8ae2a
--- /dev/null
+++ b/src/Appwrite/Event/Message/Usage.php
@@ -0,0 +1,50 @@
+ $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
+ {
+ /** @phpstan-ignore new.static (subclass constructors are backwards-compatible via optional params) */
+ return new static(
+ 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/Messaging.php b/src/Appwrite/Event/Messaging.php
deleted file mode 100644
index 8c13185e0b..0000000000
--- a/src/Appwrite/Event/Messaging.php
+++ /dev/null
@@ -1,182 +0,0 @@
-setQueue(System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME))
- ->setClass(System::getEnv('_APP_MESSAGING_CLASS_NAME', Event::MESSAGING_CLASS_NAME));
- }
-
- /**
- * Sets type for the build event.
- *
- * @param string $type Can be `MESSAGE_SEND_TYPE_INTERNAL` or `MESSAGE_SEND_TYPE_EXTERNAL`.
- * @return self
- */
- public function setType(string $type): self
- {
- $this->type = $type;
-
- return $this;
- }
-
- /**
- * Returns set type for the function event.
- *
- * @return string
- */
- public function getType(): string
- {
- return $this->type;
- }
-
- /**
- * Sets recipient for the messaging event.
- *
- * @param string[] $recipients
- * @return self
- */
- public function setRecipients(array $recipients): self
- {
- $this->recipients = $recipients;
-
- return $this;
- }
-
- /**
- * Returns set recipient for messaging event.
- *
- * @return string[]
- */
- public function getRecipient(): array
- {
- return $this->recipients;
- }
-
- /**
- * Sets message document for the messaging event.
- *
- * @param Document $message
- * @return self
- */
- public function setMessage(Document $message): self
- {
- $this->message = $message;
-
- return $this;
- }
-
- /**
- * Returns message document for the messaging event.
- *
- * @return string
- */
- public function getMessage(): Document
- {
- return $this->message;
- }
-
- /**
- * Sets message ID for the messaging event.
- *
- * @param string $message
- * @return self
- */
- public function setMessageId(string $messageId): self
- {
- $this->messageId = $messageId;
-
- return $this;
- }
-
- /**
- * Returns set message ID for the messaging event.
- *
- * @return string
- */
- public function getMessageId(): string
- {
- return $this->messageId;
- }
-
- /**
- * Sets provider type for the messaging event.
- *
- * @param string $providerType
- * @return self
- */
- public function setProviderType(string $providerType): self
- {
- $this->providerType = $providerType;
-
- return $this;
- }
-
- /**
- * Returns set provider type for the messaging event.
- *
- * @return string
- */
- public function getProviderType(): string
- {
- return $this->providerType;
- }
-
- /**
- * Sets Scheduled delivery time for the messaging event.
- *
- * @param string $scheduledAt
- * @return self
- */
- public function setScheduledAt(string $scheduledAt): self
- {
- $this->scheduledAt = $scheduledAt;
-
- return $this;
- }
-
- /**
- * Returns set Delivery Time for the messaging event.
- *
- * @return string
- */
- public function getScheduledAt(): string
- {
- return $this->scheduledAt;
- }
-
- /**
- * Prepare the payload for the event
- *
- * @return array
- */
- protected function preparePayload(): array
- {
- return [
- 'type' => $this->type,
- 'project' => $this->project,
- 'user' => $this->user,
- 'messageId' => $this->messageId,
- 'message' => $this->message,
- 'recipients' => $this->recipients,
- 'providerType' => $this->providerType,
- ];
- }
-}
diff --git a/src/Appwrite/Event/Publisher/Audit.php b/src/Appwrite/Event/Publisher/Audit.php
new file mode 100644
index 0000000000..daa9a01fce
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Audit.php
@@ -0,0 +1,35 @@
+publish($this->queue, $message);
+ } catch (\Throwable $th) {
+ Console::error('[Audit] Failed to publish audit message: ' . $th->getMessage());
+
+ return false;
+ }
+ }
+
+ public function getSize(bool $failed = false): int
+ {
+ return $this->getQueueSize($this->queue, $failed);
+ }
+}
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/Build.php b/src/Appwrite/Event/Publisher/Build.php
new file mode 100644
index 0000000000..9b2a3b68a0
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Build.php
@@ -0,0 +1,27 @@
+publish($queue ?? $this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false, ?Queue $queue = null): int
+ {
+ return $this->getQueueSize($queue ?? $this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/Certificate.php b/src/Appwrite/Event/Publisher/Certificate.php
new file mode 100644
index 0000000000..472fb0d701
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Certificate.php
@@ -0,0 +1,27 @@
+publish($this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false): int
+ {
+ return $this->getQueueSize($this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/Execution.php b/src/Appwrite/Event/Publisher/Execution.php
new file mode 100644
index 0000000000..05ea28d540
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Execution.php
@@ -0,0 +1,27 @@
+publish($this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false): int
+ {
+ return $this->getQueueSize($this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/Func.php b/src/Appwrite/Event/Publisher/Func.php
new file mode 100644
index 0000000000..46f748a59f
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Func.php
@@ -0,0 +1,27 @@
+publish($queue ?? $this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false, ?Queue $queue = null): int
+ {
+ return $this->getQueueSize($queue ?? $this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/Mail.php b/src/Appwrite/Event/Publisher/Mail.php
new file mode 100644
index 0000000000..16d48be044
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Mail.php
@@ -0,0 +1,27 @@
+publish($queue ?? $this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false, ?Queue $queue = null): int
+ {
+ return $this->getQueueSize($queue ?? $this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/Messaging.php b/src/Appwrite/Event/Publisher/Messaging.php
new file mode 100644
index 0000000000..69863566a1
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Messaging.php
@@ -0,0 +1,27 @@
+publish($queue ?? $this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false, ?Queue $queue = null): int
+ {
+ return $this->getQueueSize($queue ?? $this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/Migration.php b/src/Appwrite/Event/Publisher/Migration.php
new file mode 100644
index 0000000000..fc455a7e95
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Migration.php
@@ -0,0 +1,27 @@
+publish($this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false): int
+ {
+ return $this->getQueueSize($this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/Screenshot.php b/src/Appwrite/Event/Publisher/Screenshot.php
new file mode 100644
index 0000000000..2a0fa1e0f8
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Screenshot.php
@@ -0,0 +1,27 @@
+publish($this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false): int
+ {
+ return $this->getQueueSize($this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/StatsResources.php b/src/Appwrite/Event/Publisher/StatsResources.php
new file mode 100644
index 0000000000..4c04583b15
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/StatsResources.php
@@ -0,0 +1,34 @@
+publish($this->queue, $message);
+ } catch (\Throwable $th) {
+ Console::error('[StatsResources] Failed to publish stats resources message: ' . $th->getMessage());
+ return false;
+ }
+ }
+
+ public function getSize(bool $failed = false): int
+ {
+ return $this->getQueueSize($this->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/Realtime.php b/src/Appwrite/Event/Realtime.php
index 419863191e..f040d91468 100644
--- a/src/Appwrite/Event/Realtime.php
+++ b/src/Appwrite/Event/Realtime.php
@@ -4,6 +4,7 @@ namespace Appwrite\Event;
use Appwrite\Messaging\Adapter;
use Appwrite\Messaging\Adapter\Realtime as RealtimeAdapter;
+use Utopia\Console;
use Utopia\Database\Document;
use Utopia\Database\Exception;
@@ -60,6 +61,26 @@ class Realtime extends Event
return $this->subscribers;
}
+ /**
+ * Reset the event state for long-lived worker processes.
+ *
+ * `Event::reset()` clears params/sensitive/event/payload only. Realtime routing also
+ * depends on `context`, `subscribers`, and `project`/`user` fields, so we clear them too
+ * to prevent stale state from affecting subsequent triggers.
+ */
+ public function reset(): self
+ {
+ parent::reset();
+
+ $this->subscribers = [];
+ $this->context = [];
+ $this->project = null;
+ $this->user = null;
+ $this->userId = null;
+
+ return $this;
+ }
+
/**
* Execute Event.
*
@@ -96,17 +117,21 @@ class Realtime extends Event
: [$target['projectId'] ?? $this->getProject()->getId()];
foreach ($projectIds as $projectId) {
- $this->realtime->send(
- projectId: $projectId,
- payload: $this->getRealtimePayload(),
- events: $allEvents,
- channels: $target['channels'],
- roles: $target['roles'],
- options: [
- 'permissionsChanged' => $target['permissionsChanged'],
- 'userId' => $this->getParam('userId')
- ]
- );
+ try {
+ $this->realtime->send(
+ projectId: $projectId,
+ payload: $this->getRealtimePayload(),
+ events: $allEvents,
+ channels: $target['channels'],
+ roles: $target['roles'],
+ options: [
+ 'permissionsChanged' => $target['permissionsChanged'],
+ 'userId' => $this->getParam('userId')
+ ]
+ );
+ } catch (\Exception $e) {
+ Console::error('Realtime send failed: '.$e->getMessage());
+ }
}
return true;
diff --git a/src/Appwrite/Event/StatsUsage.php b/src/Appwrite/Event/StatsUsage.php
deleted file mode 100644
index c0d6bd845a..0000000000
--- a/src/Appwrite/Event/StatsUsage.php
+++ /dev/null
@@ -1,97 +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;
- }),
- 'context' => $this->context,
- ];
- }
-
- public function reset(): Event
- {
- $this->metrics = [];
- parent::reset();
- return $this;
- }
-}
diff --git a/src/Appwrite/Event/Validator/Event.php b/src/Appwrite/Event/Validator/Event.php
index a3605e4df5..7a4f4fbcf8 100644
--- a/src/Appwrite/Event/Validator/Event.php
+++ b/src/Appwrite/Event/Validator/Event.php
@@ -44,7 +44,7 @@ class Event extends Validator
/**
* Identify all sections of the pattern.
*/
- $type = $parts[0] ?? false;
+ $type = $parts[0];
$resource = $parts[1] ?? false;
$hasSubResource = $count > 3 && ($events[$type]['$resource'] ?? false) && ($events[$type][$parts[2]]['$resource'] ?? false);
$hasSubSubResource = $count > 5 && $hasSubResource && ($events[$type][$parts[2]][$parts[4]]['$resource'] ?? false);
@@ -61,9 +61,6 @@ class Event extends Validator
if ($hasSubSubResource) {
$subSubType = $parts[4];
$subSubResource = $parts[5];
- if ($count === 8) {
- $attribute = $parts[7];
- }
}
if ($hasSubResource && !$hasSubSubResource) {
diff --git a/src/Appwrite/Event/Webhook.php b/src/Appwrite/Event/Webhook.php
index f6d16c8b14..5cd773a18f 100644
--- a/src/Appwrite/Event/Webhook.php
+++ b/src/Appwrite/Event/Webhook.php
@@ -24,7 +24,7 @@ class Webhook extends Event
public function trimPayload(): array
{
$trimmed = parent::trimPayload();
- if (isset($this->context)) {
+ if (!empty($this->context)) {
$trimmed['context'] = [];
}
return $trimmed;
diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php
index 4370078592..a0553d00b8 100644
--- a/src/Appwrite/Extend/Exception.php
+++ b/src/Appwrite/Extend/Exception.php
@@ -82,6 +82,9 @@ class Exception extends \Exception
public const string USER_PASSWORD_RECENTLY_USED = 'password_recently_used';
public const string USER_PASSWORD_PERSONAL_DATA = 'password_personal_data';
public const string USER_EMAIL_ALREADY_EXISTS = 'user_email_already_exists';
+ public const string USER_EMAIL_DISPOSABLE = 'user_email_disposable';
+ public const string USER_EMAIL_FREE = 'user_email_free';
+ public const string USER_EMAIL_NOT_CANONICAL = 'user_email_not_canonical';
public const string USER_PASSWORD_MISMATCH = 'user_password_mismatch';
public const string USER_SESSION_NOT_FOUND = 'user_session_not_found';
public const string USER_IDENTITY_NOT_FOUND = 'user_identity_not_found';
@@ -175,6 +178,7 @@ class Exception extends \Exception
public const string FUNCTION_RUNTIME_UNSUPPORTED = 'function_runtime_unsupported';
public const string FUNCTION_ENTRYPOINT_MISSING = 'function_entrypoint_missing';
public const string FUNCTION_SYNCHRONOUS_TIMEOUT = 'function_synchronous_timeout';
+ public const string FUNCTION_ASYNCHRONOUS_TIMEOUT = 'function_asynchronous_timeout';
public const string FUNCTION_TEMPLATE_NOT_FOUND = 'function_template_not_found';
public const string FUNCTION_RUNTIME_NOT_DETECTED = 'function_runtime_not_detected';
public const string FUNCTION_EXECUTE_PERMISSION_MISSING = 'function_execute_permission_missing';
@@ -189,6 +193,7 @@ class Exception extends \Exception
public const string BUILD_ALREADY_COMPLETED = 'build_already_completed';
public const string BUILD_CANCELED = 'build_canceled';
public const string BUILD_FAILED = 'build_failed';
+ public const string BUILD_TIMEOUT = 'build_timeout';
/** Execution */
public const string EXECUTION_NOT_FOUND = 'execution_not_found';
@@ -307,6 +312,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';
@@ -329,6 +335,8 @@ class Exception extends \Exception
/** Platform */
public const string PLATFORM_NOT_FOUND = 'platform_not_found';
+ public const string PLATFORM_METHOD_UNSUPPORTED = 'platform_method_unsupported';
+ public const string PLATFORM_ALREADY_EXISTS = 'platform_already_exists';
/** GraphqQL */
public const string GRAPHQL_NO_QUERY = 'graphql_no_query';
@@ -340,6 +348,10 @@ class Exception extends \Exception
public const string MIGRATION_IN_PROGRESS = 'migration_in_progress';
public const string MIGRATION_PROVIDER_ERROR = 'migration_provider_error';
public const string MIGRATION_DATABASE_TYPE_UNSUPPORTED = 'migration_database_type_unsupported';
+ public const string MIGRATION_SOURCE_PROJECT_ID_REQUIRED = 'migration_source_project_id_required';
+ public const string MIGRATION_SOURCE_PROJECT_NOT_FOUND = 'migration_source_project_not_found';
+ public const string MIGRATION_SOURCE_TYPE_INVALID = 'migration_source_type_invalid';
+ public const string MIGRATION_DESTINATION_TYPE_INVALID = 'migration_destination_type_invalid';
/** Realtime */
public const string REALTIME_MESSAGE_FORMAT_INVALID = 'realtime_message_format_invalid';
@@ -378,6 +390,11 @@ class Exception extends \Exception
public const string MESSAGE_TARGET_NOT_PUSH = 'message_target_not_push';
public const string MESSAGE_MISSING_SCHEDULE = 'message_missing_schedule';
+ /** Mocks */
+ public const string MOCK_NUMBER_ALREADY_EXISTS = 'mock_number_already_exists';
+ public const string MOCK_NUMBER_NOT_FOUND = 'mock_number_not_found';
+ public const string MOCK_NUMBER_LIMIT_EXCEEDED = 'mock_number_limit_exceeded';
+
/** Targets */
public const string TARGET_PROVIDER_INVALID_TYPE = 'target_provider_invalid_type';
@@ -389,6 +406,14 @@ class Exception extends \Exception
public const string TOKEN_EXPIRED = 'token_expired';
public const string TOKEN_RESOURCE_TYPE_INVALID = 'token_resource_type_invalid';
+ /** Advisor */
+ public const string INSIGHT_NOT_FOUND = 'insight_not_found';
+ public const string INSIGHT_ALREADY_EXISTS = 'insight_already_exists';
+
+ /** Reports */
+ public const string REPORT_NOT_FOUND = 'report_not_found';
+ public const string REPORT_ALREADY_EXISTS = 'report_already_exists';
+
protected string $type = '';
protected array $errors = [];
protected bool $publish;
diff --git a/src/Appwrite/Functions/EventProcessor.php b/src/Appwrite/Functions/EventProcessor.php
index e9c3b7241a..d41ee56c5d 100644
--- a/src/Appwrite/Functions/EventProcessor.php
+++ b/src/Appwrite/Functions/EventProcessor.php
@@ -8,6 +8,18 @@ use Utopia\Database\Query;
class EventProcessor
{
+ /**
+ * @param array $events
+ * @return array
+ */
+ private function getEventMap(array $events): array
+ {
+ return \array_fill_keys(
+ \array_map('strval', \array_unique($events)),
+ true
+ );
+ }
+
/**
* Get function events for a project, using Redis cache
* @param Document|null $project
@@ -26,7 +38,7 @@ class EventProcessor
$cacheKey = \sprintf(
'%s-cache-%s:%s:%s:project:%s:functions:events',
$dbForProject->getCacheName(),
- $hostname ?? '',
+ $hostname,
$dbForProject->getNamespace(),
$dbForProject->getTenant(),
$project->getId()
@@ -36,7 +48,9 @@ class EventProcessor
$cachedFunctionEvents = $dbForProject->getCache()->load($cacheKey, $ttl);
if ($cachedFunctionEvents !== false) {
- return \json_decode($cachedFunctionEvents, true) ?? [];
+ $decoded = \json_decode($cachedFunctionEvents, true);
+
+ return \is_array($decoded) ? $this->getEventMap(\array_keys($decoded)) : [];
}
$events = [];
@@ -63,7 +77,7 @@ class EventProcessor
}
}
- $uniqueEvents = \array_flip(\array_unique($events));
+ $uniqueEvents = $this->getEventMap($events);
$dbForProject->getCache()->save($cacheKey, \json_encode($uniqueEvents));
return $uniqueEvents;
@@ -97,6 +111,6 @@ class EventProcessor
}
}
- return \array_flip(\array_unique($events));
+ return $this->getEventMap($events);
}
}
diff --git a/src/Appwrite/GraphQL/Promises/Adapter.php b/src/Appwrite/GraphQL/Promises/Adapter.php
index 86270f2a8b..1d9cc4557f 100644
--- a/src/Appwrite/GraphQL/Promises/Adapter.php
+++ b/src/Appwrite/GraphQL/Promises/Adapter.php
@@ -81,8 +81,8 @@ abstract class Adapter implements PromiseAdapter
/**
* Create a new promise that resolves when all passed in promises resolve.
*
- * @param array $promisesOrValues
+ * @param iterable $promisesOrValues
* @return GQLPromise
*/
- abstract public function all(array $promisesOrValues): GQLPromise;
+ abstract public function all(iterable $promisesOrValues): GQLPromise;
}
diff --git a/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php b/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php
index efe6eb2f50..af6441ad6d 100644
--- a/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php
+++ b/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php
@@ -35,8 +35,12 @@ class Swoole extends Adapter
return new GQLPromise($promise, $this);
}
- public function all(array $promisesOrValues): GQLPromise
+ public function all(iterable $promisesOrValues): GQLPromise
{
+ if ($promisesOrValues instanceof \Traversable) {
+ $promisesOrValues = \iterator_to_array($promisesOrValues);
+ }
+
return new GQLPromise(SwoolePromise::all($promisesOrValues), $this);
}
}
diff --git a/src/Appwrite/GraphQL/ResolverLock.php b/src/Appwrite/GraphQL/ResolverLock.php
new file mode 100644
index 0000000000..b1cdcf3d53
--- /dev/null
+++ b/src/Appwrite/GraphQL/ResolverLock.php
@@ -0,0 +1,55 @@
+channel = new Channel(1);
+ }
+
+ /**
+ * Acquire the lock. Re-entering from the same coroutine only
+ * increments depth to avoid self-deadlock.
+ */
+ public function acquire(): void
+ {
+ $cid = Coroutine::getCid();
+
+ if ($this->owner === $cid) {
+ $this->depth++;
+ return;
+ }
+
+ $this->channel->push(true);
+ $this->owner = $cid;
+ $this->depth = 1;
+ }
+
+ /**
+ * Release the lock.
+ */
+ public function release(): void
+ {
+ if ($this->owner !== Coroutine::getCid()) {
+ return;
+ }
+
+ $this->depth--;
+
+ if ($this->depth > 0) {
+ return;
+ }
+
+ $this->owner = null;
+ $this->channel->pop();
+ }
+}
diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php
index 484cafb0ab..6e8c30b6ec 100644
--- a/src/Appwrite/GraphQL/Resolvers.php
+++ b/src/Appwrite/GraphQL/Resolvers.php
@@ -13,6 +13,59 @@ use Utopia\System\System;
class Resolvers
{
+ /**
+ * Request-scoped locks keyed by the per-request GraphQL Http instance.
+ *
+ * @var array
+ */
+ private static array $locks = [];
+
+ /**
+ * Preserve response side effects that callers depend on, such as session
+ * cookies created by account auth routes.
+ */
+ private static function mergeResponseSideEffects(Response $from, Response $to): void
+ {
+ foreach ($from->getCookies() as $cookie) {
+ $to->removeCookie($cookie['name']);
+ $to->addCookie(
+ $cookie['name'],
+ $cookie['value'],
+ $cookie['expire'],
+ $cookie['path'],
+ $cookie['domain'],
+ $cookie['secure'],
+ $cookie['httponly'],
+ $cookie['samesite']
+ );
+ }
+
+ $headers = $from->getHeaders();
+ $fallbackCookies = $headers['X-Fallback-Cookies'] ?? null;
+ if ($fallbackCookies === null) {
+ return;
+ }
+
+ $to->removeHeader('X-Fallback-Cookies');
+ foreach ((array) $fallbackCookies as $value) {
+ $to->addHeader('X-Fallback-Cookies', $value);
+ }
+ }
+
+ /**
+ * Get the request-scoped lock shared by GraphQL resolver coroutines
+ * for the current HTTP request.
+ */
+ private static function getLock(Http $utopia): ResolverLock
+ {
+ $key = \spl_object_hash($utopia);
+ if (!isset(self::$locks[$key])) {
+ self::$locks[$key] = new ResolverLock();
+ }
+
+ return self::$locks[$key];
+ }
+
/**
* Create a resolver for a given API {@see Route}.
*
@@ -24,38 +77,39 @@ class Resolvers
Http $utopia,
?Route $route,
): callable {
- return static fn ($type, $args, $context, $info) => new Swoole(
- function (callable $resolve, callable $reject) use ($utopia, $route, $args, $context, $info) {
- /** @var Http $utopia */
- /** @var Response $response */
- /** @var Request $request */
+ return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $route, $args) {
+ $utopia = $utopia->context()->get('utopia:graphql');
+ $request = $utopia->context()->get('request');
+ $response = $utopia->context()->get('response');
- $utopia = $utopia->getResource('utopia:graphql', true);
- $request = $utopia->getResource('request', true);
- $response = $utopia->getResource('response', true);
+ self::resolve(
+ $utopia,
+ $request,
+ $response,
+ $resolve,
+ $reject,
+ prepareRequest: static function (Request $request) use ($route, $args): void {
+ $path = $route->getPath();
+ foreach ($args as $key => $value) {
+ if (\str_contains($path, '/:' . $key)) {
+ $path = \str_replace(':' . $key, $value, $path);
+ }
+ }
- $path = $route->getPath();
- foreach ($args as $key => $value) {
- if (\str_contains($path, '/:' . $key)) {
- $path = \str_replace(':' . $key, $value, $path);
+ $request->setMethod($route->getMethod());
+ $request->setURI($path);
+
+ switch ($route->getMethod()) {
+ case 'GET':
+ $request->setQueryString($args);
+ break;
+ default:
+ $request->setPayload($args);
+ break;
}
}
-
- $request->setMethod($route->getMethod());
- $request->setURI($path);
-
- switch ($route->getMethod()) {
- case 'GET':
- $request->setQueryString($args);
- break;
- default:
- $request->setPayload($args);
- break;
- }
-
- self::resolve($utopia, $request, $response, $resolve, $reject);
- }
- );
+ );
+ });
}
/**
@@ -95,18 +149,23 @@ class Resolvers
string $collectionId,
callable $url,
): callable {
- return static fn ($type, $args, $context, $info) => new Swoole(
- function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $type, $args) {
- $utopia = $utopia->getResource('utopia:graphql', true);
- $request = $utopia->getResource('request', true);
- $response = $utopia->getResource('response', true);
+ return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) {
+ $utopia = $utopia->context()->get('utopia:graphql');
+ $request = $utopia->context()->get('request');
+ $response = $utopia->context()->get('response');
- $request->setMethod('GET');
- $request->setURI($url($databaseId, $collectionId, $args));
-
- self::resolve($utopia, $request, $response, $resolve, $reject);
- }
- );
+ self::resolve(
+ $utopia,
+ $request,
+ $response,
+ $resolve,
+ $reject,
+ prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $args): void {
+ $request->setMethod('GET');
+ $request->setURI($url($databaseId, $collectionId, $args));
+ }
+ );
+ });
}
/**
@@ -126,23 +185,29 @@ class Resolvers
callable $url,
callable $params,
): callable {
- return static fn ($type, $args, $context, $info) => new Swoole(
- function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $type, $args) {
- $utopia = $utopia->getResource('utopia:graphql', true);
- $request = $utopia->getResource('request', true);
- $response = $utopia->getResource('response', true);
+ return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) {
+ $utopia = $utopia->context()->get('utopia:graphql');
+ $request = $utopia->context()->get('request');
+ $response = $utopia->context()->get('response');
- $request->setMethod('GET');
- $request->setURI($url($databaseId, $collectionId, $args));
- $request->setQueryString($params($databaseId, $collectionId, $args));
+ $beforeResolve = function ($payload) {
+ return $payload['documents'];
+ };
- $beforeResolve = function ($payload) {
- return $payload['documents'];
- };
-
- self::resolve($utopia, $request, $response, $resolve, $reject, $beforeResolve);
- }
- );
+ self::resolve(
+ $utopia,
+ $request,
+ $response,
+ $resolve,
+ $reject,
+ beforeResolve: $beforeResolve,
+ prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void {
+ $request->setMethod('GET');
+ $request->setURI($url($databaseId, $collectionId, $args));
+ $request->setQueryString($params($databaseId, $collectionId, $args));
+ }
+ );
+ });
}
/**
@@ -162,19 +227,24 @@ class Resolvers
callable $url,
callable $params,
): callable {
- return static fn ($type, $args, $context, $info) => new Swoole(
- function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $type, $args) {
- $utopia = $utopia->getResource('utopia:graphql', true);
- $request = $utopia->getResource('request', true);
- $response = $utopia->getResource('response', true);
+ return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) {
+ $utopia = $utopia->context()->get('utopia:graphql');
+ $request = $utopia->context()->get('request');
+ $response = $utopia->context()->get('response');
- $request->setMethod('POST');
- $request->setURI($url($databaseId, $collectionId, $args));
- $request->setPayload($params($databaseId, $collectionId, $args));
-
- self::resolve($utopia, $request, $response, $resolve, $reject);
- }
- );
+ self::resolve(
+ $utopia,
+ $request,
+ $response,
+ $resolve,
+ $reject,
+ prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void {
+ $request->setMethod('POST');
+ $request->setURI($url($databaseId, $collectionId, $args));
+ $request->setPayload($params($databaseId, $collectionId, $args));
+ }
+ );
+ });
}
/**
@@ -194,19 +264,24 @@ class Resolvers
callable $url,
callable $params,
): callable {
- return static fn ($type, $args, $context, $info) => new Swoole(
- function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $type, $args) {
- $utopia = $utopia->getResource('utopia:graphql', true);
- $request = $utopia->getResource('request', true);
- $response = $utopia->getResource('response', true);
+ return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) {
+ $utopia = $utopia->context()->get('utopia:graphql');
+ $request = $utopia->context()->get('request');
+ $response = $utopia->context()->get('response');
- $request->setMethod('PATCH');
- $request->setURI($url($databaseId, $collectionId, $args));
- $request->setPayload($params($databaseId, $collectionId, $args));
-
- self::resolve($utopia, $request, $response, $resolve, $reject);
- }
- );
+ self::resolve(
+ $utopia,
+ $request,
+ $response,
+ $resolve,
+ $reject,
+ prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void {
+ $request->setMethod('PATCH');
+ $request->setURI($url($databaseId, $collectionId, $args));
+ $request->setPayload($params($databaseId, $collectionId, $args));
+ }
+ );
+ });
}
/**
@@ -224,18 +299,23 @@ class Resolvers
string $collectionId,
callable $url,
): callable {
- return static fn ($type, $args, $context, $info) => new Swoole(
- function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $type, $args) {
- $utopia = $utopia->getResource('utopia:graphql', true);
- $request = $utopia->getResource('request', true);
- $response = $utopia->getResource('response', true);
+ return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) {
+ $utopia = $utopia->context()->get('utopia:graphql');
+ $request = $utopia->context()->get('request');
+ $response = $utopia->context()->get('response');
- $request->setMethod('DELETE');
- $request->setURI($url($databaseId, $collectionId, $args));
-
- self::resolve($utopia, $request, $response, $resolve, $reject);
- }
- );
+ self::resolve(
+ $utopia,
+ $request,
+ $response,
+ $resolve,
+ $reject,
+ prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $args): void {
+ $request->setMethod('DELETE');
+ $request->setURI($url($databaseId, $collectionId, $args));
+ }
+ );
+ });
}
/**
@@ -245,7 +325,7 @@ class Resolvers
* @param callable $resolve
* @param callable $reject
* @param callable|null $beforeResolve
- * @param callable|null $beforeReject
+ * @param callable|null $prepareRequest
* @return void
* @throws Exception
*/
@@ -256,38 +336,66 @@ class Resolvers
callable $resolve,
callable $reject,
?callable $beforeResolve = null,
- ?callable $beforeReject = null,
+ ?callable $prepareRequest = null,
): void {
- // Drop json content type so post args are used directly
- if (\str_starts_with($request->getHeader('content-type'), 'application/json')) {
- $request->removeHeader('content-type');
- }
+ $lock = self::getLock($utopia);
- $request = clone $request;
- $utopia->setResource('request', static fn () => $request);
- $response->setContentType(Response::CONTENT_TYPE_NULL);
+ $lock->acquire();
+ $original = $utopia->getRoute();
try {
- $route = $utopia->match($request, fresh: true);
+ $request = clone $request;
- $utopia->execute($route, $request, $response);
- } catch (\Throwable $e) {
- if ($beforeReject) {
- $e = $beforeReject($e);
+ // Drop json content type so post args are used directly.
+ if (\str_starts_with($request->getHeader('content-type'), 'application/json')) {
+ $request->removeHeader('content-type');
}
+
+ if ($prepareRequest) {
+ $prepareRequest($request);
+ }
+
+ /** @var Response $resolverResponse */
+ $resolverResponse = clone $utopia->context()->get('response');
+ $utopia->context()->set('request', static fn () => $request);
+ $utopia->context()->set('response', static fn () => $resolverResponse);
+ $resolverResponse->setContentType(Response::CONTENT_TYPE_NULL);
+ $resolverResponse->setSent(false);
+
+ $route = $utopia->match($request, fresh: true);
+ $request->setRoute($route);
+
+ $utopia->execute($route, $request, $resolverResponse);
+
+ self::mergeResponseSideEffects($resolverResponse, $response);
+
+ if ($resolverResponse->isSent()) {
+ $response
+ ->setStatusCode($resolverResponse->getStatusCode())
+ ->setSent(true);
+
+ $resolve(null);
+ return;
+ }
+
+ $payload = $resolverResponse->getPayload();
+ $statusCode = $resolverResponse->getStatusCode();
+ } catch (\Throwable $e) {
$reject($e);
return;
+ } finally {
+ if ($original !== null) {
+ $utopia->setRoute($original);
+ }
+
+ $lock->release();
+ unset(self::$locks[\spl_object_hash($utopia)]);
}
- $payload = $response->getPayload();
-
- if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 400) {
- if ($beforeReject) {
- $payload = $beforeReject($payload);
- }
+ if ($statusCode < 200 || $statusCode >= 400) {
$reject(new GQLException(
message: $payload['message'],
- code: $response->getStatusCode()
+ code: $statusCode
));
return;
}
diff --git a/src/Appwrite/GraphQL/Schema.php b/src/Appwrite/GraphQL/Schema.php
index 57115ff027..a689655e31 100644
--- a/src/Appwrite/GraphQL/Schema.php
+++ b/src/Appwrite/GraphQL/Schema.php
@@ -32,10 +32,6 @@ class Schema
array $urls,
array $params,
): GQLSchema {
- Http::setResource('utopia:graphql', static function () use ($utopia) {
- return $utopia;
- });
-
if (!empty(self::$schema)) {
return self::$schema;
}
@@ -88,7 +84,7 @@ class Schema
protected static function api(Http $utopia, callable $complexity): array
{
Mapper::init($utopia
- ->getResource('response')
+ ->context()->get('response')
->getModels());
$queries = [];
@@ -98,10 +94,9 @@ class Schema
foreach ($routes as $route) {
/** @var Route $route */
- /** @var \Appwrite\SDK\Method $sdk */
$sdk = $route->getLabel('sdk', false);
- if (empty($sdk)) {
+ if ($sdk === false) {
continue;
}
@@ -177,7 +172,7 @@ class Schema
$required = $attr['required'];
$default = $attr['default'];
$escapedKey = str_replace('$', '', $key);
- $collections[$collectionId][$escapedKey] = [
+ $collections[$databaseId][$collectionId][$escapedKey] = [
'type' => Mapper::attribute(
$type,
$array,
@@ -187,80 +182,82 @@ class Schema
];
}
- foreach ($collections as $collectionId => $attributes) {
- $objectType = new ObjectType([
- 'name' => $collectionId,
- 'fields' => \array_merge(
- ["_id" => ['type' => Type::string()]],
- $attributes
- ),
- ]);
- $attributes = \array_merge(
- $attributes,
- Mapper::args('mutate')
- );
-
- $queryFields[$collectionId . 'Get'] = [
- 'type' => $objectType,
- 'args' => Mapper::args('id'),
- 'resolve' => Resolvers::documentGet(
- $utopia,
- $databaseId,
- $collectionId,
- $urls['get'],
- )
- ];
- $queryFields[$collectionId . 'List'] = [
- 'type' => Type::listOf($objectType),
- 'args' => Mapper::args('list'),
- 'resolve' => Resolvers::documentList(
- $utopia,
- $databaseId,
- $collectionId,
- $urls['list'],
- $params['list'],
- ),
- 'complexity' => $complexity,
- ];
-
- $mutationFields[$collectionId . 'Create'] = [
- 'type' => $objectType,
- 'args' => $attributes,
- 'resolve' => Resolvers::documentCreate(
- $utopia,
- $databaseId,
- $collectionId,
- $urls['create'],
- $params['create'],
- )
- ];
- $mutationFields[$collectionId . 'Update'] = [
- 'type' => $objectType,
- 'args' => \array_merge(
- Mapper::args('id'),
- \array_map(
- fn ($attr) => $attr['type'] = Type::getNullableType($attr['type']),
+ foreach ($collections as $databaseId => $databaseCollections) {
+ foreach ($databaseCollections as $collectionId => $attributes) {
+ $objectType = new ObjectType([
+ 'name' => $collectionId,
+ 'fields' => \array_merge(
+ ["_id" => ['type' => Type::string()]],
$attributes
+ ),
+ ]);
+ $attributes = \array_merge(
+ $attributes,
+ Mapper::args('mutate')
+ );
+
+ $queryFields[$collectionId . 'Get'] = [
+ 'type' => $objectType,
+ 'args' => Mapper::args('id'),
+ 'resolve' => Resolvers::documentGet(
+ $utopia,
+ $databaseId,
+ $collectionId,
+ $urls['get'],
)
- ),
- 'resolve' => Resolvers::documentUpdate(
- $utopia,
- $databaseId,
- $collectionId,
- $urls['update'],
- $params['update'],
- )
- ];
- $mutationFields[$collectionId . 'Delete'] = [
- 'type' => Mapper::model('none'),
- 'args' => Mapper::args('id'),
- 'resolve' => Resolvers::documentDelete(
- $utopia,
- $databaseId,
- $collectionId,
- $urls['delete'],
- )
- ];
+ ];
+ $queryFields[$collectionId . 'List'] = [
+ 'type' => Type::listOf($objectType),
+ 'args' => Mapper::args('list'),
+ 'resolve' => Resolvers::documentList(
+ $utopia,
+ $databaseId,
+ $collectionId,
+ $urls['list'],
+ $params['list'],
+ ),
+ 'complexity' => $complexity,
+ ];
+
+ $mutationFields[$collectionId . 'Create'] = [
+ 'type' => $objectType,
+ 'args' => $attributes,
+ 'resolve' => Resolvers::documentCreate(
+ $utopia,
+ $databaseId,
+ $collectionId,
+ $urls['create'],
+ $params['create'],
+ )
+ ];
+ $mutationFields[$collectionId . 'Update'] = [
+ 'type' => $objectType,
+ 'args' => \array_merge(
+ Mapper::args('id'),
+ \array_map(
+ fn ($attr) => $attr['type'] = Type::getNullableType($attr['type']),
+ $attributes
+ )
+ ),
+ 'resolve' => Resolvers::documentUpdate(
+ $utopia,
+ $databaseId,
+ $collectionId,
+ $urls['update'],
+ $params['update'],
+ )
+ ];
+ $mutationFields[$collectionId . 'Delete'] = [
+ 'type' => Mapper::model('none'),
+ 'args' => Mapper::args('id'),
+ 'resolve' => Resolvers::documentDelete(
+ $utopia,
+ $databaseId,
+ $collectionId,
+ $urls['delete'],
+ )
+ ];
+ }
}
$offset += $limit;
}
diff --git a/src/Appwrite/GraphQL/Types.php b/src/Appwrite/GraphQL/Types.php
index 279cac2068..3d5979dc18 100644
--- a/src/Appwrite/GraphQL/Types.php
+++ b/src/Appwrite/GraphQL/Types.php
@@ -15,10 +15,13 @@ class Types
*
* @return Json
*/
- public static function json(): Type
+ public static function json(): Json
{
if (Registry::has(Json::class)) {
- return Registry::get(Json::class);
+ $type = Registry::get(Json::class);
+ if ($type instanceof Json) {
+ return $type;
+ }
}
$type = new Json();
Registry::set(Json::class, $type);
@@ -28,12 +31,15 @@ class Types
/**
* Get the JSON type.
*
- * @return Json
+ * @return Assoc
*/
- public static function assoc(): Type
+ public static function assoc(): Assoc
{
if (Registry::has(Assoc::class)) {
- return Registry::get(Assoc::class);
+ $type = Registry::get(Assoc::class);
+ if ($type instanceof Assoc) {
+ return $type;
+ }
}
$type = new Assoc();
Registry::set(Assoc::class, $type);
@@ -45,10 +51,13 @@ class Types
*
* @return InputFile
*/
- public static function inputFile(): Type
+ public static function inputFile(): InputFile
{
if (Registry::has(InputFile::class)) {
- return Registry::get(InputFile::class);
+ $type = Registry::get(InputFile::class);
+ if ($type instanceof InputFile) {
+ return $type;
+ }
}
$type = new InputFile();
Registry::set(InputFile::class, $type);
diff --git a/src/Appwrite/GraphQL/Types/Assoc.php b/src/Appwrite/GraphQL/Types/Assoc.php
index f76b23dd7a..15bd742d1d 100644
--- a/src/Appwrite/GraphQL/Types/Assoc.php
+++ b/src/Appwrite/GraphQL/Types/Assoc.php
@@ -3,12 +3,13 @@
namespace Appwrite\GraphQL\Types;
use GraphQL\Language\AST\Node;
+use GraphQL\Language\AST\StringValueNode;
// https://github.com/webonyx/graphql-php/issues/129#issuecomment-309366803
class Assoc extends Json
{
- public $name = 'Assoc';
- public $description = 'The `Assoc` scalar type represents associative array values.';
+ public string $name = 'Assoc';
+ public ?string $description = 'The `Assoc` scalar type represents associative array values.';
public function serialize($value)
{
@@ -30,6 +31,10 @@ class Assoc extends Json
public function parseLiteral(Node $valueNode, ?array $variables = null)
{
- return \json_decode($valueNode->value, true);
+ if ($valueNode instanceof StringValueNode) {
+ return \json_decode($valueNode->value, true);
+ }
+
+ return parent::parseLiteral($valueNode, $variables);
}
}
diff --git a/src/Appwrite/GraphQL/Types/InputFile.php b/src/Appwrite/GraphQL/Types/InputFile.php
index 39fd4e23b3..daa771911b 100644
--- a/src/Appwrite/GraphQL/Types/InputFile.php
+++ b/src/Appwrite/GraphQL/Types/InputFile.php
@@ -8,8 +8,8 @@ use GraphQL\Type\Definition\ScalarType;
class InputFile extends ScalarType
{
- public $name = 'InputFile';
- public $description = 'The `InputFile` special type represents a file to be uploaded in the same HTTP request as specified by
+ public string $name = 'InputFile';
+ public ?string $description = 'The `InputFile` special type represents a file to be uploaded in the same HTTP request as specified by
[graphql-multipart-request-spec](https://github.com/jaydenseric/graphql-multipart-request-spec).';
public function serialize($value)
diff --git a/src/Appwrite/GraphQL/Types/Json.php b/src/Appwrite/GraphQL/Types/Json.php
index 18d27322a1..627b9081d3 100644
--- a/src/Appwrite/GraphQL/Types/Json.php
+++ b/src/Appwrite/GraphQL/Types/Json.php
@@ -14,8 +14,8 @@ use GraphQL\Type\Definition\ScalarType;
// https://github.com/webonyx/graphql-php/issues/129#issuecomment-309366803
class Json extends ScalarType
{
- public $name = 'Json';
- public $description = 'The `JSON` scalar type represents JSON values as specified by
+ public string $name = 'Json';
+ public ?string $description = 'The `JSON` scalar type represents JSON values as specified by
[ECMA-404](https://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).';
public function serialize($value)
diff --git a/src/Appwrite/GraphQL/Types/Mapper.php b/src/Appwrite/GraphQL/Types/Mapper.php
index 9cd190613d..92c7753ad2 100644
--- a/src/Appwrite/GraphQL/Types/Mapper.php
+++ b/src/Appwrite/GraphQL/Types/Mapper.php
@@ -91,26 +91,20 @@ class Mapper
}
}
- $responses = $method->getResponses() ?? [];
+ $responses = $method->getResponses();
- // If responses is an array, map each response to its model
- if (\is_array($responses)) {
- $models = [];
- foreach ($responses as $response) {
- $modelName = $response->getModel();
+ // Map each response to its model
+ $models = [];
+ foreach ($responses as $response) {
+ $modelName = $response->getModel();
- if (\is_array($modelName)) {
- foreach ($modelName as $name) {
- $models[] = static::$models[$name];
- }
- } else {
- $models[] = static::$models[$modelName];
+ if (\is_array($modelName)) {
+ foreach ($modelName as $name) {
+ $models[] = self::$models[$name];
}
+ } else {
+ $models[] = self::$models[$modelName];
}
- } else {
- // If single response, get its model and wrap in array
- $modelName = $responses->getModel();
- $models = [static::$models[$modelName]];
}
foreach ($models as $model) {
@@ -260,7 +254,7 @@ class Mapper
array $injections
): Type {
$validator = \is_callable($validator)
- ? \call_user_func_array($validator, $utopia->getResources($injections))
+ ? \call_user_func_array($validator, \array_map($utopia->context()->get(...), $injections))
: $validator;
$isNullable = $validator instanceof Nullable;
@@ -273,11 +267,9 @@ class Mapper
case \Appwrite\Auth\Validator\Password::class:
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:
case \Appwrite\Task\Validator\Cron::class:
case \Appwrite\Utopia\Database\Validator\CustomId::class:
case \Utopia\Database\Validator\Key::class:
@@ -286,7 +278,7 @@ class Mapper
case \Utopia\Validator\HexColor::class:
case \Utopia\Validator\Host::class:
case \Utopia\Validator\IP::class:
- case \Utopia\Validator\Origin::class:
+ case \Appwrite\Network\Validator\Origin::class:
case \Utopia\Validator\Text::class:
case \Utopia\Validator\URL::class:
case \Utopia\Validator\WhiteList::class:
@@ -425,7 +417,7 @@ class Mapper
'name' => $unionName,
'types' => $types,
'resolveType' => static function ($object) use ($unionName) {
- return static::getUnionImplementation($unionName, $object);
+ return self::getUnionImplementation($unionName, $object);
},
]);
@@ -440,11 +432,11 @@ class Mapper
switch ($name) {
case 'Attributes':
- return static::getColumnImplementation($object);
+ return self::getColumnImplementation($object);
case 'Columns':
- return static::getColumnImplementation($object, true);
+ return self::getColumnImplementation($object, true);
case 'HashOptions':
- return static::getHashOptionsImplementation($object);
+ return self::getHashOptionsImplementation($object);
}
throw new Exception('Unknown union type: ' . $name);
diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php
index 7a2b6fe19a..c4cd2c08d5 100644
--- a/src/Appwrite/Messaging/Adapter/Realtime.php
+++ b/src/Appwrite/Messaging/Adapter/Realtime.php
@@ -14,12 +14,35 @@ use Utopia\Database\Query;
class Realtime extends MessagingAdapter
{
+ public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert', 'delete'];
+
+ // Resources whose channels receive an action-suffixed sibling at publish time.
+ // The suffix loop in fromPayload() treats any channel whose last OR second-to-last
+ // segment matches an entry here as a candidate for `.{action}` suffixing.
+ //
+ // `functions` is intentionally a parent-only entry: fromPayload publishes
+ // `functions.{functionId}` (suffixed to `functions.{functionId}.{action}`) but
+ // never emits a bare `functions` channel — so subscribing to bare
+ // `functions.{action}` is a silent no-op. Per-function filters
+ // (`functions.{functionId}.{action}`) are the supported form.
+ private const RESOURCE_LEAF_NAMES = [
+ 'documents',
+ 'rows',
+ 'files',
+ 'executions',
+ 'functions',
+ 'account',
+ 'teams',
+ 'memberships',
+ ];
+
/**
* Connection Tree
*
* [CONNECTION_ID] ->
* 'projectId' -> [PROJECT_ID]
* 'roles' -> [ROLE_x, ROLE_Y]
+ * 'userId' -> [USER_ID]
* 'channels' -> [CHANNEL_NAME_X, CHANNEL_NAME_Y, CHANNEL_NAME_Z]
*/
public array $connections = [];
@@ -44,8 +67,6 @@ class Realtime extends MessagingAdapter
/**
* Get the PubSubPool instance, initializing it lazily if needed.
* This allows unit tests to work without requiring the global $register.
- *
- * @return PubSubPool
*/
private function getPubSubPool(): PubSubPool
{
@@ -53,6 +74,7 @@ class Realtime extends MessagingAdapter
global $register;
$this->pubSubPool = new PubSubPool($register->get('pools')->get('pubsub'));
}
+
return $this->pubSubPool;
}
@@ -67,25 +89,35 @@ class Realtime extends MessagingAdapter
* @param array $queryGroup Array of Query objects for this subscription (AND logic within subscription)
* @return void
*/
- public function subscribe(string $projectId, mixed $identifier, string $subscriptionId, array $roles, array $channels, array $queryGroup = []): void
- {
+ public function subscribe(
+ string $projectId,
+ mixed $identifier,
+ string $subscriptionId,
+ array $roles,
+ array $channels,
+ array $queryGroup = [],
+ ?string $userId = null
+ ): void {
if (!isset($this->subscriptions[$projectId])) { // Init Project
$this->subscriptions[$projectId] = [];
}
$strings = [];
- if (empty($queryGroup)) {
- $strings[] = Query::select(['*'])->toString();
- } else {
- foreach ($queryGroup as $query) {
- $strings[] = $query->toString();
- }
- }
+ $data = [];
- $data = [
- 'strings' => $strings,
- 'compiled' => RuntimeQuery::compile($queryGroup),
- ];
+ if (!empty($channels)) {
+ if (empty($queryGroup)) {
+ $strings[] = Query::select(['*'])->toString();
+ } else {
+ foreach ($queryGroup as $query) {
+ $strings[] = $query->toString();
+ }
+ }
+ $data = [
+ 'strings' => $strings,
+ 'compiled' => RuntimeQuery::compile($queryGroup),
+ ];
+ }
foreach ($roles as $role) {
if (!isset($this->subscriptions[$projectId][$role])) {
@@ -103,12 +135,24 @@ class Realtime extends MessagingAdapter
}
}
- // Update connection info
- $this->connections[$identifier] = [
+ // Union channels/roles across all subscriptions on the connection; overwriting would
+ // leave getSubscriptionMetadata and full unsubscribe operating on stale state.
+ $existing = $this->connections[$identifier] ?? [];
+ $existingChannels = $existing['channels'] ?? [];
+ $existingRoles = $existing['roles'] ?? [];
+
+ $entry = [
'projectId' => $projectId,
- 'roles' => $roles,
- 'channels' => $channels
+ 'roles' => \array_values(\array_unique(\array_merge($existingRoles, $roles))),
+ 'userId' => $userId ?? ($existing['userId'] ?? ''),
+ 'channels' => \array_values(\array_unique(\array_merge($existingChannels, $channels))),
];
+
+ if (\array_key_exists('authorization', $existing)) {
+ $entry['authorization'] = $existing['authorization'];
+ }
+
+ $this->connections[$identifier] = $entry;
}
/**
@@ -124,7 +168,7 @@ class Realtime extends MessagingAdapter
$roles = $this->connections[$connection]['roles'] ?? [];
$channels = $this->connections[$connection]['channels'] ?? [];
- if (!$projectId || empty($roles) || empty($channels)) {
+ if (! $projectId || empty($roles) || empty($channels)) {
return [];
}
@@ -145,7 +189,7 @@ class Realtime extends MessagingAdapter
if (!isset($subscriptions[$subscriptionId])) {
$subscriptions[$subscriptionId] = [
'channels' => [],
- 'queries' => $data['strings'] ?? []
+ 'queries' => $data['strings'] ?? [],
];
}
if (!\in_array($channel, $subscriptions[$subscriptionId]['channels'])) {
@@ -193,6 +237,87 @@ class Realtime extends MessagingAdapter
}
}
+ /**
+ * Removes a single subscription from a connection, keeping the connection alive so
+ * the client can resubscribe. Idempotent — returns true only when something was removed.
+ *
+ * @param mixed $connection
+ * @param string $subscriptionId
+ * @return bool
+ */
+ public function unsubscribeSubscription(mixed $connection, string $subscriptionId): bool
+ {
+ $projectId = $this->connections[$connection]['projectId'] ?? '';
+ if ($projectId === '' || !isset($this->subscriptions[$projectId])) {
+ return false;
+ }
+
+ $removed = false;
+
+ foreach ($this->subscriptions[$projectId] as $role => $byChannel) {
+ foreach ($byChannel as $channel => $byConnection) {
+ if (!isset($byConnection[$connection][$subscriptionId])) {
+ continue;
+ }
+
+ unset($this->subscriptions[$projectId][$role][$channel][$connection][$subscriptionId]);
+ $removed = true;
+
+ if (empty($this->subscriptions[$projectId][$role][$channel][$connection])) {
+ unset($this->subscriptions[$projectId][$role][$channel][$connection]);
+ }
+ if (empty($this->subscriptions[$projectId][$role][$channel])) {
+ unset($this->subscriptions[$projectId][$role][$channel]);
+ }
+ }
+ if (empty($this->subscriptions[$projectId][$role])) {
+ unset($this->subscriptions[$projectId][$role]);
+ }
+ }
+
+ if (empty($this->subscriptions[$projectId])) {
+ unset($this->subscriptions[$projectId]);
+ }
+
+ if ($removed) {
+ $this->recomputeConnectionState($connection);
+ }
+
+ return $removed;
+ }
+
+ /**
+ * Recomputes the cached channels on the connection entry from the subscriptions tree.
+ * Called after per-subscription removal so stale channel entries do not linger for later reads.
+ *
+ * Roles are deliberately NOT recomputed here. They represent the connection's authorization
+ * context (set at onOpen, replaced on `authentication` / permission-change) and must survive
+ * per-subscription removal — otherwise a client that unsubscribes every subscription and then
+ * resubscribes would subscribe with an empty roles array and silently receive nothing.
+ *
+ * @param mixed $connection
+ * @return void
+ */
+ private function recomputeConnectionState(mixed $connection): void
+ {
+ if (!isset($this->connections[$connection])) {
+ return;
+ }
+
+ $projectId = $this->connections[$connection]['projectId'] ?? '';
+ $channels = [];
+
+ foreach ($this->subscriptions[$projectId] ?? [] as $byChannel) {
+ foreach ($byChannel as $channel => $byConnection) {
+ if (isset($byConnection[$connection])) {
+ $channels[$channel] = true;
+ }
+ }
+ }
+
+ $this->connections[$connection]['channels'] = \array_keys($channels);
+ }
+
/**
* Checks if Channel has a subscriber.
* @param string $projectId
@@ -202,7 +327,7 @@ class Realtime extends MessagingAdapter
*/
public function hasSubscriber(string $projectId, string $role, string $channel = ''): bool
{
- //TODO: look into moving it to an abstract class in the parent class
+ // TODO: look into moving it to an abstract class in the parent class
if (empty($channel)) {
return array_key_exists($projectId, $this->subscriptions)
&& array_key_exists($role, $this->subscriptions[$projectId]);
@@ -223,6 +348,7 @@ class Realtime extends MessagingAdapter
* @param array $roles
* @param array $options
* @return void
+ *
* @throws \Exception
*/
public function send(string $projectId, array $payload, array $events, array $channels, array $roles, array $options = []): void
@@ -243,8 +369,8 @@ class Realtime extends MessagingAdapter
'events' => $events,
'channels' => $channels,
'timestamp' => DateTime::formatTz(DateTime::now()),
- 'payload' => $payload
- ]
+ 'payload' => $payload,
+ ],
]));
}
@@ -257,7 +383,6 @@ class Realtime extends MessagingAdapter
* - 1.5 ms | 1,000 Connections / 10,000 Subscriptions
* - 15 ms | 10,000 Connections / 100,000 Subscriptions
*
- * @param array $event
* @return array Map of connection IDs to matched query groups
*/
public function getSubscribers(array $event): array
@@ -273,7 +398,7 @@ class Realtime extends MessagingAdapter
foreach ($this->subscriptions[$event['project']] as $role => $subscriptionsByChannel) {
foreach ($event['data']['channels'] as $channel) {
if (
- !\array_key_exists($channel, $subscriptionsByChannel)
+ ! \array_key_exists($channel, $subscriptionsByChannel)
|| (!\in_array($role, $event['roles']) && !\in_array(Role::any()->toString(), $event['roles']))
) {
continue;
@@ -306,6 +431,12 @@ class Realtime extends MessagingAdapter
/**
* Converts the channels from the Query Params into an array.
+ * Also renames the account channel to account.USER_ID, rewrites action-suffixed
+ * account variants (`account.create`, `account.update`, `account.upsert`,
+ * `account.delete`) to `account.USER_ID.{action}` so they match the channels
+ * fromPayload() publishes for top-level user events, and removes all other
+ * illegal account channel variations (e.g. another user's `account.{otherId}`).
+ *
* Also renames the account channel to account.USER_ID and removes all illegal account channel variations.
* @param array $channels
* @param string $userId
@@ -317,27 +448,94 @@ class Realtime extends MessagingAdapter
foreach ($channels as $key => $value) {
switch (true) {
- case str_starts_with($key, 'account.'):
- unset($channels[$key]);
- break;
-
case $key === 'account':
if (!empty($userId)) {
- $channels['account.' . $userId] = $value;
+ $channels['account.'.$userId] = $value;
}
break;
+
+ case \in_array(\substr($key, \strlen('account.')), self::SUPPORTED_ACTIONS, true) && str_starts_with($key, 'account.'):
+ // Authenticated: rewrite `account.{action}` → `account.{userId}.{action}`
+ // so the subscriber only receives their own account events.
+ // Guest: keep the literal `account.{action}` so the action filter
+ // applies to the broadcast `account.{action}` channel that fromPayload
+ // emits for top-level user events. On in-band auth, rebindAccountChannels
+ // rewrites the literal to the user-scoped form.
+ if (!empty($userId)) {
+ unset($channels[$key]);
+ $action = \substr($key, \strlen('account.'));
+ $channels['account.'.$userId.'.'.$action] = $value;
+ }
+ break;
+
+ case str_starts_with($key, 'account.'):
+ unset($channels[$key]);
+ break;
}
}
return $channels;
}
+ /**
+ * Rewrites stored account channels to match a new userId. Used when in-band
+ * authentication changes the connection's user identity:
+ *
+ * - guest → authenticated: rewrites the literal `account.{action}` form
+ * that convertChannels preserves for guests into `account.{userId}.{action}`.
+ * - reauth as a different user: rewrites `account.{oldUserId}` and
+ * `account.{oldUserId}.{action}` to the new userId.
+ *
+ * Returns channels unchanged when there's nothing to do — same user, or an
+ * empty target (defensive: avoids producing malformed `account.` strings if
+ * a caller ever passes `$newUserId = ''`, e.g. an in-band logout flow).
+ */
+ public static function rebindAccountChannels(array $channels, string $oldUserId, string $newUserId): array
+ {
+ if ($newUserId === '' || $oldUserId === $newUserId) {
+ return $channels;
+ }
+
+ return \array_map(function (string $channel) use ($oldUserId, $newUserId) {
+ if (!\str_starts_with($channel, 'account.')) {
+ return $channel;
+ }
+
+ // Guest origin: literal `account.{action}` (preserved by convertChannels
+ // for unauthenticated connections) becomes `account.{newUserId}.{action}`.
+ if ($oldUserId === '') {
+ $suffix = \substr($channel, \strlen('account.'));
+ if (\in_array($suffix, self::SUPPORTED_ACTIONS, true)) {
+ return 'account.'.$newUserId.'.'.$suffix;
+ }
+
+ return $channel;
+ }
+
+ // Authenticated → different user.
+ if ($channel === 'account.'.$oldUserId) {
+ return 'account.'.$newUserId;
+ }
+
+ $oldPrefix = 'account.'.$oldUserId.'.';
+ if (\str_starts_with($channel, $oldPrefix)) {
+ $action = \substr($channel, \strlen($oldPrefix));
+ if (\in_array($action, self::SUPPORTED_ACTIONS, true)) {
+ return 'account.'.$newUserId.'.'.$action;
+ }
+ }
+
+ return $channel;
+ }, $channels);
+ }
+
/**
* Constructs subscriptions from query parameters.
*
* @param array $channelNames
* @param callable $getQueryParam
* @return array [index => ['channels' => string[], 'queries' => Query[]]]
+ *
* @throws QueryException
*/
public static function constructSubscriptions(array $channelNames, callable $getQueryParam): array
@@ -348,6 +546,7 @@ class Realtime extends MessagingAdapter
* Reserved channel params with expected type
* If matched the expected type then skip the query parsing like in project
*/
+ /** @var array $reservedParamExpectedTypes */
$reservedParamExpectedTypes = [
'project' => 'string',
];
@@ -361,7 +560,6 @@ class Realtime extends MessagingAdapter
$isExpectedType = match ($expectedType) {
'array' => \is_array($params),
'string' => \is_string($params),
- default => false,
};
// If the value matches the expected type dont use it the queries
@@ -378,10 +576,11 @@ class Realtime extends MessagingAdapter
if (empty($subscriptions[0]['queries'])) {
$subscriptions[0]['queries'] = [Query::select(['*'])];
}
+
continue;
}
- if (!\is_array($params)) {
+ if (! \is_array($params)) {
$params = [$params];
}
@@ -408,6 +607,7 @@ class Realtime extends MessagingAdapter
* Converts the queries from the Query Params into an array.
* @param array|string $queries
* @return array
+ *
* @throws QueryException
*/
public static function convertQueries(mixed $queries): array
@@ -420,7 +620,7 @@ class Realtime extends MessagingAdapter
$query = array_pop($stack);
$method = $query->getMethod();
- if (!in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) {
+ if (! in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) {
throw new QueryException(
"Query method '{$method}' is not supported in Realtime queries. Allowed: {$allowed}"
);
@@ -574,13 +774,70 @@ class Realtime extends MessagingAdapter
$roles = [Role::team($project->getAttribute('teamId'))->toString()];
}
break;
+ case 'reports':
+ // Plain report event: `reports.{reportId}.{action}`
+ $channels[] = 'reports';
+ if (isset($parts[1])) {
+ $channels[] = 'reports.' . $parts[1];
+ }
+ // Nested insight event: `reports.{reportId}.insights.{insightId}.{action}`
+ if (isset($parts[2]) && $parts[2] === 'insights') {
+ $channels[] = 'reports.' . $parts[1] . '.insights';
+ if (isset($parts[3])) {
+ $channels[] = 'reports.' . $parts[1] . '.insights.' . $parts[3];
+ }
+ }
+ $roles = [Role::team($project->getAttribute('teamId'))->toString()];
+ break;
+ }
+
+ // Action is the last segment for plain CRUD events (e.g. `documents.X.create`),
+ // and the second-to-last segment for attribute-trailing events
+ // (e.g. `users.U.update.email`, `teams.T.update.prefs`,
+ // `teams.T.memberships.M.update.status`). Without the second-to-last fallback
+ $count = \count($parts);
+ $action = null;
+ if (\in_array($parts[$count - 1], self::SUPPORTED_ACTIONS, true)) {
+ $action = $parts[$count - 1];
+ } elseif ($count >= 2 && \in_array($parts[$count - 2], self::SUPPORTED_ACTIONS, true)) {
+ $action = $parts[$count - 2];
+ }
+
+ // The `users` branch emits only user-level account channels
+ // (`account`, `account.{userId}`) regardless of event depth, so nested events
+ // like `users.U.sessions.S.create` or `users.U.challenges.C.create` would
+ // otherwise be suffixed as `account.create` — making a subscription to
+ // `account.create` receive unrelated session/challenge/recovery/verification
+ // events. Restrict suffixing to top-level user events where the action sits
+ // at parts[2] (`users.U.create`, `users.U.update.email`, etc.).
+ if (
+ $action !== null
+ && $parts[0] === 'users'
+ && ($parts[2] ?? null) !== $action
+ ) {
+ $action = null;
+ }
+
+ if ($action !== null && !empty($channels)) {
+ $augmented = $channels;
+ foreach ($channels as $channel) {
+ $segments = \explode('.', $channel);
+ $segCount = \count($segments);
+ $leafIsResource = \in_array($segments[$segCount - 1], self::RESOURCE_LEAF_NAMES, true);
+ $parentIsResource = $segCount >= 2 && \in_array($segments[$segCount - 2], self::RESOURCE_LEAF_NAMES, true);
+
+ if ($leafIsResource || $parentIsResource) {
+ $augmented[] = $channel. '.' .$action;
+ }
+ }
+ $channels = \array_values(\array_unique($augmented));
}
return [
'channels' => $channels,
'roles' => $roles,
'permissionsChanged' => $permissionsChanged,
- 'projectId' => $projectId
+ 'projectId' => $projectId,
];
}
diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php
index 519f05de2c..77c62bce96 100644
--- a/src/Appwrite/Migration/Migration.php
+++ b/src/Appwrite/Migration/Migration.php
@@ -13,6 +13,7 @@ use Utopia\Database\Exception\Limit;
use Utopia\Database\Exception\Structure;
use Utopia\Database\Helpers\ID;
use Utopia\Database\PDO;
+use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
abstract class Migration
@@ -91,6 +92,12 @@ abstract class Migration
'1.7.4' => 'V22',
'1.8.0' => 'V23',
'1.8.1' => 'V23',
+ '1.9.0' => 'V24',
+ '1.9.1' => 'V24',
+ '1.9.2' => 'V24',
+ '1.9.3' => 'V24',
+ '1.9.4' => 'V24',
+ '1.9.5' => 'V24',
];
/**
@@ -203,6 +210,30 @@ abstract class Migration
}
}
+ /**
+ * @param array $queries
+ * @return \Generator
+ * @throws Exception
+ */
+ protected function documentsIterator(string $collection, array $queries = []): \Generator
+ {
+ $offset = 0;
+
+ do {
+ $documents = $this->dbForProject->find($collection, [
+ ...$queries,
+ Query::limit($this->limit),
+ Query::offset($offset),
+ ]);
+
+ foreach ($documents as $document) {
+ yield $document;
+ }
+
+ $offset += \count($documents);
+ } while (\count($documents) === $this->limit);
+ }
+
/**
* Creates collection from the config collection.
*
diff --git a/src/Appwrite/Migration/Version/V15.php b/src/Appwrite/Migration/Version/V15.php
index 66037660c0..eefa84ec22 100644
--- a/src/Appwrite/Migration/Version/V15.php
+++ b/src/Appwrite/Migration/Version/V15.php
@@ -1224,7 +1224,7 @@ class V15 extends Migration
* @param \Utopia\Database\Document $document
* @return \Utopia\Database\Document
*/
- protected function fixDocument(Document $document)
+ protected function fixDocument(Document $document): Document
{
switch ($document->getCollection()) {
case 'cache':
@@ -1234,7 +1234,7 @@ class V15 extends Migration
* skipping migration for 'cache' and 'variables'.
* 'users' already migrated.
*/
- return;
+ return $document;
case '_metadata':
/**
@@ -1480,7 +1480,6 @@ class V15 extends Migration
* Filter from the 'encrypt' filter.
*
* @param string $value
- * @return string|false
*/
protected function encryptFilter(string $value): string
{
@@ -1492,8 +1491,8 @@ class V15 extends Migration
'data' => OpenSSL::encrypt($value, OpenSSL::CIPHER_AES_128_GCM, $key, 0, $iv, $tag),
'method' => OpenSSL::CIPHER_AES_128_GCM,
'iv' => \bin2hex($iv),
- 'tag' => \bin2hex($tag ?? ''),
+ 'tag' => \bin2hex($tag),
'version' => '1',
- ]);
+ ]) ?: '';
}
}
diff --git a/src/Appwrite/Migration/Version/V17.php b/src/Appwrite/Migration/Version/V17.php
index 3297206ccd..862ab7f26c 100644
--- a/src/Appwrite/Migration/Version/V17.php
+++ b/src/Appwrite/Migration/Version/V17.php
@@ -262,7 +262,7 @@ class V17 extends Migration
* Set default maxSessions
*/
$document->setAttribute('auths', array_merge($document->getAttribute('auths', []), [
- 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT
+ 'maxSessions' => 10
]));
break;
case 'users':
diff --git a/src/Appwrite/Migration/Version/V20.php b/src/Appwrite/Migration/Version/V20.php
index 3c13815949..e3458c815e 100644
--- a/src/Appwrite/Migration/Version/V20.php
+++ b/src/Appwrite/Migration/Version/V20.php
@@ -452,7 +452,7 @@ class V20 extends Migration
Query::equal('period', ['1d']),
]);
- $value = $query ?? 0;
+ $value = $query;
$this->createInfMetric($to, $value);
}
diff --git a/src/Appwrite/Migration/Version/V24.php b/src/Appwrite/Migration/Version/V24.php
new file mode 100644
index 0000000000..a2d9d7907b
--- /dev/null
+++ b/src/Appwrite/Migration/Version/V24.php
@@ -0,0 +1,410 @@
+ 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 'users':
+ try {
+ $this->createAttributeFromCollection($this->dbForProject, $id, 'impersonator');
+ } catch (Throwable $th) {
+ Console::warning("Failed to create attribute \"impersonator\" in collection {$id}: {$th->getMessage()}");
+ }
+ try {
+ $this->createIndexFromCollection($this->dbForProject, $id, 'impersonator');
+ } catch (Throwable $th) {
+ Console::warning("Failed to create index \"impersonator\" 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/Cors.php b/src/Appwrite/Network/Cors.php
index 9fc47c5808..593c82ab24 100644
--- a/src/Appwrite/Network/Cors.php
+++ b/src/Appwrite/Network/Cors.php
@@ -48,7 +48,7 @@ final class Cors
/**
* Build CORS headers for a given request origin.
*
- * @return array
+ * @return array
*/
public function headers(string $origin): array
{
diff --git a/src/Appwrite/Network/Platform.php b/src/Appwrite/Network/Platform.php
index ea64ff98c1..23fb087010 100644
--- a/src/Appwrite/Network/Platform.php
+++ b/src/Appwrite/Network/Platform.php
@@ -6,20 +6,10 @@ class Platform
{
public const TYPE_UNKNOWN = 'unknown';
public const TYPE_WEB = 'web';
- public const TYPE_FLUTTER_IOS = 'flutter-ios';
- public const TYPE_FLUTTER_ANDROID = 'flutter-android';
- public const TYPE_FLUTTER_MACOS = 'flutter-macos';
- public const TYPE_FLUTTER_WINDOWS = 'flutter-windows';
- public const TYPE_FLUTTER_LINUX = 'flutter-linux';
- public const TYPE_FLUTTER_WEB = 'flutter-web';
- public const TYPE_APPLE_IOS = 'apple-ios';
- public const TYPE_APPLE_MACOS = 'apple-macos';
- public const TYPE_APPLE_WATCHOS = 'apple-watchos';
- public const TYPE_APPLE_TVOS = 'apple-tvos';
+ public const TYPE_APPLE = 'apple';
public const TYPE_ANDROID = 'android';
- public const TYPE_UNITY = 'unity';
- public const TYPE_REACT_NATIVE_IOS = 'react-native-ios';
- public const TYPE_REACT_NATIVE_ANDROID = 'react-native-android';
+ public const TYPE_WINDOWS = 'windows';
+ public const TYPE_LINUX = 'linux';
public const TYPE_SCHEME = 'scheme';
public const SCHEME_HTTP = 'http';
@@ -35,6 +25,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,8 +44,39 @@ 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)',
];
+ /**
+ * Map deprecated platform types to their new consolidated types.
+ *
+ * The 1.9.x refactor consolidated ~15 platform types into 5 new ones.
+ * Existing platforms in the database may still have old type values.
+ *
+ * @param string $type
+ * @return string The mapped type, or the original if not deprecated.
+ */
+ public static function mapDeprecatedType(string $type): string
+ {
+ $mapping = [
+ 'flutter-web' => self::TYPE_WEB,
+ 'unity' => self::TYPE_WEB,
+ 'flutter-ios' => self::TYPE_APPLE,
+ 'flutter-macos' => self::TYPE_APPLE,
+ 'apple-ios' => self::TYPE_APPLE,
+ 'apple-macos' => self::TYPE_APPLE,
+ 'apple-watchos' => self::TYPE_APPLE,
+ 'apple-tvos' => self::TYPE_APPLE,
+ 'react-native-ios' => self::TYPE_APPLE,
+ 'flutter-android' => self::TYPE_ANDROID,
+ 'react-native-android' => self::TYPE_ANDROID,
+ 'flutter-windows' => self::TYPE_WINDOWS,
+ 'flutter-linux' => self::TYPE_LINUX,
+ ];
+
+ return $mapping[$type] ?? $type;
+ }
+
/**
* Get user-friendly platform name from a scheme.
*
@@ -75,25 +97,28 @@ class Platform
$key = strtolower($platform['key'] ?? '');
switch ($type) {
+ case 'flutter-web':
+ case 'unity':
case self::TYPE_WEB:
- case self::TYPE_FLUTTER_WEB:
if (!empty($hostname)) {
$hostnames[] = $hostname;
}
break;
- case self::TYPE_FLUTTER_IOS:
- case self::TYPE_FLUTTER_ANDROID:
- case self::TYPE_FLUTTER_MACOS:
- case self::TYPE_FLUTTER_WINDOWS:
- case self::TYPE_FLUTTER_LINUX:
+ case 'flutter-android':
+ case 'react-native-android':
case self::TYPE_ANDROID:
- case self::TYPE_APPLE_IOS:
- case self::TYPE_APPLE_MACOS:
- case self::TYPE_APPLE_WATCHOS:
- case self::TYPE_APPLE_TVOS:
- case self::TYPE_REACT_NATIVE_IOS:
- case self::TYPE_REACT_NATIVE_ANDROID:
- case self::TYPE_UNITY:
+ case 'flutter-windows':
+ case self::TYPE_WINDOWS:
+ case 'flutter-linux':
+ case self::TYPE_LINUX:
+ case 'flutter-ios':
+ case 'flutter-macos':
+ case 'apple-ios':
+ case 'apple-macos':
+ case 'apple-watchos':
+ case 'apple-tvos':
+ case 'react-native-ios':
+ case self::TYPE_APPLE:
if (!empty($key)) {
$hostnames[] = $key;
}
@@ -118,38 +143,38 @@ class Platform
$schemes[] = $scheme;
}
break;
+ case 'flutter-web':
+ case 'unity':
case self::TYPE_WEB:
- case self::TYPE_FLUTTER_WEB:
$schemes[] = self::SCHEME_HTTP;
$schemes[] = self::SCHEME_HTTPS;
break;
- case self::TYPE_FLUTTER_IOS:
- case self::TYPE_APPLE_IOS:
- case self::TYPE_REACT_NATIVE_IOS:
- $schemes[] = self::SCHEME_IOS;
- break;
- case self::TYPE_FLUTTER_ANDROID:
+ case 'flutter-android':
+ case 'react-native-android':
case self::TYPE_ANDROID:
- case self::TYPE_REACT_NATIVE_ANDROID:
$schemes[] = self::SCHEME_ANDROID;
break;
- case self::TYPE_FLUTTER_MACOS:
- case self::TYPE_APPLE_MACOS:
+ case 'flutter-ios':
+ case 'flutter-macos':
+ case 'apple-ios':
+ case 'apple-macos':
+ case 'apple-watchos':
+ case 'apple-tvos':
+ case 'react-native-ios':
+ case self::TYPE_APPLE:
+ $schemes[] = self::SCHEME_WATCHOS;
$schemes[] = self::SCHEME_MACOS;
+ $schemes[] = self::SCHEME_TVOS;
+ $schemes[] = self::SCHEME_IOS;
break;
- case self::TYPE_FLUTTER_WINDOWS:
- case self::TYPE_UNITY:
+ case 'flutter-windows':
+ case self::TYPE_WINDOWS:
$schemes[] = self::SCHEME_WINDOWS;
break;
- case self::TYPE_FLUTTER_LINUX:
+ case 'flutter-linux':
+ case self::TYPE_LINUX:
$schemes[] = self::SCHEME_LINUX;
break;
- case self::TYPE_APPLE_WATCHOS:
- $schemes[] = self::SCHEME_WATCHOS;
- break;
- case self::TYPE_APPLE_TVOS:
- $schemes[] = self::SCHEME_TVOS;
- break;
default:
break;
}
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/OpenSSL/OpenSSL.php b/src/Appwrite/OpenSSL/OpenSSL.php
index 1965a3c858..89c52f069e 100644
--- a/src/Appwrite/OpenSSL/OpenSSL.php
+++ b/src/Appwrite/OpenSSL/OpenSSL.php
@@ -16,9 +16,9 @@ class OpenSSL
* @param string $aad
* @param int $tag_length
*
- * @return string
+ * @return string|false
*/
- public static function encrypt($data, $method, $key, $options = 0, $iv = '', &$tag = null, $aad = '', $tag_length = 16)
+ public static function encrypt($data, $method, $key, $options = 0, $iv = '', ?string &$tag = null, $aad = '', $tag_length = 16)
{
return \openssl_encrypt($data, $method, $key, $options, $iv, $tag, $aad, $tag_length);
}
diff --git a/src/Appwrite/Platform/Action.php b/src/Appwrite/Platform/Action.php
index 01ac92a45c..0aa0da7149 100644
--- a/src/Appwrite/Platform/Action.php
+++ b/src/Appwrite/Platform/Action.php
@@ -37,7 +37,7 @@ class Action extends UtopiaAction
* Foreach Document
* Call provided callback for each document in the collection
*
- * @param string $projectId
+ * @param Database $database
* @param string $collection
* @param array $queries
* @param callable $callback
diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php
index 681e1038c3..a9cd1a8e2f 100644
--- a/src/Appwrite/Platform/Appwrite.php
+++ b/src/Appwrite/Platform/Appwrite.php
@@ -3,12 +3,15 @@
namespace Appwrite\Platform;
use Appwrite\Platform\Modules\Account;
+use Appwrite\Platform\Modules\Advisor;
use Appwrite\Platform\Modules\Avatars;
use Appwrite\Platform\Modules\Console;
use Appwrite\Platform\Modules\Core;
use Appwrite\Platform\Modules\Databases;
use Appwrite\Platform\Modules\Functions;
use Appwrite\Platform\Modules\Health;
+use Appwrite\Platform\Modules\Migrations;
+use Appwrite\Platform\Modules\Project;
use Appwrite\Platform\Modules\Projects;
use Appwrite\Platform\Modules\Proxy;
use Appwrite\Platform\Modules\Sites;
@@ -16,6 +19,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 +40,9 @@ class Appwrite extends Platform
$this->addModule(new Tokens\Module());
$this->addModule(new Storage\Module());
$this->addModule(new VCS\Module());
+ $this->addModule(new Webhooks\Module());
+ $this->addModule(new Migrations\Module());
+ $this->addModule(new Project\Module());
+ $this->addModule(new Advisor\Module());
}
}
diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php b/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php
new file mode 100644
index 0000000000..876dc00215
--- /dev/null
+++ b/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php
@@ -0,0 +1,90 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/install/certificate')
+ ->desc('Check if SSL certificate is ready for a domain')
+ ->param('domain', '', new AppDomain(), 'Domain to check')
+ ->param('port', 443, new Range(1, 65535), 'HTTPS port to check', true)
+ ->inject('response')
+ ->callback($this->action(...));
+ }
+
+ public function action(string $domain, int $port, Response $response): void
+ {
+ $domain = trim($domain);
+ if ($domain === '') {
+ $response->json(['ready' => false]);
+ return;
+ }
+
+ $ready = $this->checkHttps($domain, $port);
+ $response->json(['ready' => $ready]);
+ }
+
+ private function checkHttps(string $domain, int $port): bool
+ {
+ $gateway = $this->getDockerGateway();
+
+ $ch = curl_init();
+ $options = [
+ CURLOPT_URL => 'https://' . $domain . ':' . $port . '/',
+ CURLOPT_NOBODY => true,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_CONNECTTIMEOUT => self::CONNECTION_TIMEOUT_SECONDS,
+ CURLOPT_TIMEOUT => self::CONNECTION_TIMEOUT_SECONDS,
+ CURLOPT_SSL_VERIFYPEER => true,
+ CURLOPT_SSL_VERIFYHOST => 2,
+ ];
+
+ if ($gateway !== '') {
+ $options[CURLOPT_RESOLVE] = [$domain . ':' . $port . ':' . $gateway];
+ }
+
+ curl_setopt_array($ch, $options);
+ curl_exec($ch);
+ $errno = curl_errno($ch);
+
+ return $errno === 0;
+ }
+
+ private function getDockerGateway(): string
+ {
+ $route = @file_get_contents('/proc/net/route');
+ if ($route === false) {
+ return '';
+ }
+
+ foreach (explode("\n", $route) as $line) {
+ $fields = preg_split('/\s+/', trim($line));
+ if (isset($fields[1]) && $fields[1] === '00000000' && isset($fields[2])) {
+ $hex = $fields[2];
+ if (strlen($hex) !== 8) {
+ continue;
+ }
+ $ip = long2ip((int) hexdec($hex[6] . $hex[7] . $hex[4] . $hex[5] . $hex[2] . $hex[3] . $hex[0] . $hex[1]));
+ return $ip;
+ }
+ }
+
+ return '';
+ }
+}
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..69f7d4b072
--- /dev/null
+++ b/src/Appwrite/Platform/Installer/Http/Installer/Complete.php
@@ -0,0 +1,124 @@
+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);
+
+ $progressData = ($installId !== '') ? $state->readProgressFile($installId) : [];
+
+ if (!$sessionSecret) {
+ $details = $progressData['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;
+ }
+ }
+ $appDomain = $progressData['payload']['appDomain'] ?? '';
+ $cookieDomain = $this->buildCookieDomain($appDomain ?: $request->getHostname());
+
+ $response->addCookie('a_session_console', $sessionSecret, $expires, '/', $cookieDomain, $isHttps, true, $sameSite);
+ $response->addCookie('a_session_console_legacy', $sessionSecret, $expires, '/', $cookieDomain, $isHttps, true, $sameSite);
+ if ($sessionId) {
+ $response->addHeader('X-Appwrite-Session', $sessionId);
+ }
+ }
+
+ @unlink(Server::INSTALLER_CONFIG_FILE);
+
+ $response->json(['success' => true]);
+ }
+
+ /**
+ * Compute the cookie domain to match Appwrite's convention in general.php.
+ *
+ * For localhost and IP addresses the domain is left empty (host-only cookie).
+ * For real hostnames, the domain is prefixed with a dot so the cookie matches
+ * Appwrite's default `'.' . $request->getHostname()` behaviour and lives in
+ * the same cookie-jar slot — preventing stale ghost cookies after logout.
+ */
+ private function buildCookieDomain(string $raw): string
+ {
+ $hostname = $this->extractHostname($raw);
+ if ($hostname === '' || $hostname === 'localhost' || $hostname === '0.0.0.0' || $hostname === 'traefik') {
+ return '';
+ }
+ if (filter_var($hostname, FILTER_VALIDATE_IP) !== false) {
+ return '';
+ }
+ return '.' . $hostname;
+ }
+
+ /**
+ * Extract the bare hostname from an appDomain value, stripping any port
+ * suffix or IPv6 bracket notation.
+ */
+ private function extractHostname(string $domain): string
+ {
+ $domain = trim($domain);
+ if ($domain === '') {
+ return '';
+ }
+ if (str_starts_with($domain, '[')) {
+ $end = strpos($domain, ']');
+ return $end !== false ? substr($domain, 1, $end - 1) : '';
+ }
+ $parts = explode(':', $domain);
+ return count($parts) <= 2 ? strtolower($parts[0]) : strtolower($domain);
+ }
+}
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..e7e9008e3b
--- /dev/null
+++ b/src/Appwrite/Platform/Installer/Http/Installer/Install.php
@@ -0,0 +1,431 @@
+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(allowEmpty: true), 'Email for SSL certificates', true)
+ ->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)
+ ->param('migrate', false, new \Utopia\Validator\Boolean(true), 'Run database migration after upgrade', 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,
+ bool $migrate,
+ 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);
+ if ($emailCertificates === '') {
+ $emailCertificates = trim($accountEmail);
+ }
+ $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);
+
+ $state->clearStaleLockIfNeeded();
+
+ 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) {
+ $previousHadError = isset($existing['error']);
+ $allCompleted = !$previousHadError && $this->allStepsCompleted($existing['steps']);
+
+ if ($previousHadError || $allCompleted) {
+ @unlink($existingPath);
+ $existing = null;
+ } else {
+ $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,
+ ];
+
+ $previousHadError = is_array($existing) && isset($existing['error']);
+ if ($this->hasPayload($existing) && !$previousHadError) {
+ $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) {
+ $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)) {
+ $state->updateGlobalLock($installId, Server::STATUS_ERROR);
+ $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch');
+ return;
+ }
+ } elseif (isset($stored[$field]) && $incomingValue !== '' && (string) $stored[$field] !== $incomingValue) {
+ $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);
+ }
+ };
+
+ $responseSent = false;
+ $onComplete = function () use ($wantsStream, $swooleResponse, $response, $installId, $state, &$responseSent) {
+ if ($responseSent) {
+ return;
+ }
+ $responseSent = true;
+ $state->updateGlobalLock($installId, Server::STATUS_COMPLETED);
+ 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',
+ ]);
+ }
+ };
+
+ $installer->performInstallation(
+ $httpPort ?: $config->getDefaultHttpPort(),
+ $httpsPort ?: $config->getDefaultHttpsPort(),
+ $config->getOrganization(),
+ $config->getImage(),
+ $envVars,
+ $config->getNoStart(),
+ $progress,
+ $retryStep,
+ $config->isUpgrade(),
+ $account,
+ $onComplete,
+ $migrate,
+ );
+
+ $onComplete();
+ } 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);
+ }
+
+ 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 allStepsCompleted(array $steps): bool
+ {
+ foreach ($steps as $step) {
+ if (($step['status'] ?? '') !== Server::STATUS_COMPLETED) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ 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/Reset.php b/src/Appwrite/Platform/Installer/Http/Installer/Reset.php
new file mode 100644
index 0000000000..8e5b877473
--- /dev/null
+++ b/src/Appwrite/Platform/Installer/Http/Installer/Reset.php
@@ -0,0 +1,110 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/install/reset')
+ ->desc('Reset installation state')
+ ->param('installId', '', new Text(64, 0), 'Installation ID', true)
+ ->param('hard', false, new Boolean(true), 'Remove all data including volumes and config files', true)
+ ->inject('request')
+ ->inject('response')
+ ->inject('installerState')
+ ->inject('installerConfig')
+ ->callback($this->action(...));
+ }
+
+ public function action(string $installId, bool $hard, Request $request, Response $response, State $state, Config $config): 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 !== '') {
+ @unlink($state->progressFilePath($installId));
+ $state->updateGlobalLock($installId, Server::STATUS_COMPLETED);
+ }
+
+ // Use direct clearStaleLock (not throttled) since reset is an
+ // explicit user action that should guarantee all stale state is gone.
+ $state->clearStaleLock();
+
+ if ($hard) {
+ $error = $this->performHardReset($config);
+ if ($error !== null) {
+ $response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR);
+ $response->json(['success' => false, 'message' => $error]);
+ return;
+ }
+ }
+
+ $response->json(['success' => true]);
+ }
+
+ private function performHardReset(Config $config): ?string
+ {
+ $isLocal = $config->isLocal();
+ $composeFileName = $isLocal ? 'docker-compose.web-installer.yml' : 'docker-compose.yml';
+ $envFileName = $isLocal ? '.env.web-installer' : '.env';
+ $path = $isLocal ? '/usr/src/code' : '/usr/src/code/appwrite';
+
+ $composeFile = $path . '/' . $composeFileName;
+
+ if (file_exists($composeFile)) {
+ $command = array_map(escapeshellarg(...), [
+ 'docker', 'compose',
+ '-f', $composeFile,
+ ...($isLocal ? ['--project-name', 'appwrite'] : []),
+ '--project-directory', $path,
+ 'down', '-v', '--remove-orphans',
+ ]);
+
+ $output = [];
+ @exec(implode(' ', $command) . ' 2>&1', $output, $exitCode);
+
+ if ($exitCode !== 0) {
+ return 'Failed to stop containers: ' . trim(implode("\n", $output));
+ }
+
+ @unlink($composeFile);
+ }
+
+ $envFile = $path . '/' . $envFileName;
+ if (file_exists($envFile)) {
+ @unlink($envFile);
+ }
+
+ @unlink(Server::INSTALLER_CONFIG_FILE);
+ @unlink(Server::INSTALLER_LOCK_FILE);
+
+ $tempDir = sys_get_temp_dir();
+ foreach ((array) glob($tempDir . '/appwrite-install-*.json') as $file) {
+ @unlink($file);
+ }
+
+ return null;
+ }
+}
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..204ace077c
--- /dev/null
+++ b/src/Appwrite/Platform/Installer/Http/Installer/Status.php
@@ -0,0 +1,67 @@
+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
+ {
+ $state->clearStaleLockIfNeeded();
+
+ $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 (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 (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..dea356eaaf
--- /dev/null
+++ b/src/Appwrite/Platform/Installer/Http/Installer/View.php
@@ -0,0 +1,94 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/')
+ ->desc('Serve installer UI')
+ ->param('step', 1, new Integer(true), 'Step number (1-6)', 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(6, $step));
+ if ($isUpgrade && ($step === 2 || $step === 3)) {
+ $step = 4;
+ }
+ if (!$isUpgrade && $step === 6) {
+ $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..6142e47152
--- /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 array $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..3cbcc51fa6
--- /dev/null
+++ b/src/Appwrite/Platform/Installer/Runtime/State.php
@@ -0,0 +1,477 @@
+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 clearStaleLockIfNeeded(): void
+ {
+ $now = time();
+ if ($now - $this->lastStaleLockClearAt < self::STALE_LOCK_CHECK_INTERVAL_SECONDS) {
+ return;
+ }
+ $this->lastStaleLockClearAt = $now;
+ $this->clearStaleLock();
+ }
+
+ 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..26d82adf24
--- /dev/null
+++ b/src/Appwrite/Platform/Installer/Server.php
@@ -0,0 +1,395 @@
+initPaths();
+
+ $this->state = new State();
+
+ 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
+ {
+ Runtime::enableCoroutine(SWOOLE_HOOK_ALL);
+
+ $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();
+ $this->autoDetectUpgrade($config);
+ $paths = $this->paths;
+ $state = $this->state;
+
+ $adapter = new class ($host, $port, ['worker_num' => 1]) extends SwooleAdapter {
+ public function getNativeServer(): SwooleServer
+ {
+ return $this->server;
+ }
+ };
+
+ $nativeServer = $adapter->getNativeServer();
+
+ $container = $adapter->resources();
+ $container->set('installerState', fn () => $state);
+ $container->set('installerConfig', fn () => $config);
+ $container->set('installerPaths', fn () => $paths);
+ $container->set('swooleServer', fn () => $nativeServer);
+
+ // 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(...));
+
+ $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 ($adapter, $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($adapter, 'UTC');
+ $app->run($request, $response);
+ });
+
+ $adapter->start();
+ }
+
+ /**
+ * Auto-detect upgrade mode by checking for existing config files.
+ * Sets isUpgrade and lockedDatabase on the config when an existing
+ * installation is found and these values aren't already set.
+ */
+ private function autoDetectUpgrade(Config $config): void
+ {
+ if ($config->isUpgrade()) {
+ return;
+ }
+
+ $basePath = $config->isLocal() ? '/usr/src/code' : (getcwd() ?: '.');
+ $composePath = $basePath . '/docker-compose.yml';
+ $envPath = $basePath . '/.env';
+
+ if (!file_exists($composePath) && !file_exists($envPath)) {
+ return;
+ }
+
+ $config->setIsUpgrade(true);
+
+ if ($config->getLockedDatabase() !== null) {
+ return;
+ }
+
+ $database = $this->detectDatabaseFromFiles($composePath, $envPath);
+ if ($database !== null) {
+ $config->setLockedDatabase($database);
+ }
+ }
+
+ private function detectDatabaseFromFiles(string $composePath, string $envPath): ?string
+ {
+ $dbServices = ['mariadb', 'mongodb', 'postgresql'];
+
+ $composeData = @file_get_contents($composePath);
+ if ($composeData !== false) {
+ if (preg_match_all('/^\s*(?:container_name:\s*appwrite-(\w+)|(\w+):)\s*$/m', $composeData, $matches)) {
+ $serviceNames = array_filter(array_merge($matches[1], $matches[2]));
+ foreach ($dbServices as $db) {
+ if (in_array($db, $serviceNames, true)) {
+ return $db;
+ }
+ }
+ }
+ foreach ($dbServices as $db) {
+ if (preg_match('/^\s*' . preg_quote($db, '/') . ':\s*$/m', $composeData)) {
+ return $db;
+ }
+ }
+ }
+
+ $envData = @file_get_contents($envPath);
+ if ($envData !== false) {
+ if (preg_match('/^_APP_DB_ADAPTER=(.+)$/m', $envData, $m)) {
+ $adapter = trim($m[1], " \t\n\r\"'");
+ if (in_array($adapter, $dbServices, true)) {
+ return $adapter;
+ }
+ }
+ if (preg_match('/^_APP_DB_HOST=(.+)$/m', $envData, $m)) {
+ $host = trim($m[1], " \t\n\r\"'");
+ if (in_array($host, $dbServices, true)) {
+ return $host;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ 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..b410e67a26
--- /dev/null
+++ b/src/Appwrite/Platform/Installer/Services/Http.php
@@ -0,0 +1,30 @@
+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(Reset::getName(), new Reset());
+ $this->addAction(Install::getName(), new Install());
+ $this->addAction(CertificateGet::getName(), new CertificateGet());
+ }
+}
diff --git a/src/Appwrite/Platform/Installer/Validator/AppDomain.php b/src/Appwrite/Platform/Installer/Validator/AppDomain.php
new file mode 100644
index 0000000000..5d18b5214a
--- /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/Authenticators/Delete.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php
index 754255be15..5765c5bf6e 100644
--- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php
+++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php
@@ -37,8 +37,8 @@ class Delete extends Action
->label('event', 'users.[userId].delete.mfa')
->label('scope', 'account')
->label('audits.event', 'user.update')
- ->label('audits.resource', 'user/{response.$id}')
- ->label('audits.userId', '{response.$id}')
+ ->label('audits.resource', 'user/{user.$id}')
+ ->label('audits.userId', '{user.$id}')
->label('sdk', [
new Method(
namespace: 'account',
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..285875eb35 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
@@ -5,9 +5,10 @@ namespace Appwrite\Platform\Modules\Account\Http\Account\MFA\Challenges;
use Appwrite\Auth\MFA\Type;
use Appwrite\Detector\Detector;
use Appwrite\Event\Event;
-use Appwrite\Event\Mail;
-use Appwrite\Event\Messaging;
-use Appwrite\Event\StatsUsage;
+use Appwrite\Event\Message\Mail as MailMessage;
+use Appwrite\Event\Message\Messaging as MessagingMessage;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -15,6 +16,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;
@@ -101,10 +103,10 @@ class Create extends Action
->inject('platform')
->inject('request')
->inject('queueForEvents')
- ->inject('queueForMessaging')
- ->inject('queueForMails')
+ ->inject('publisherForMessaging')
+ ->inject('publisherForMails')
->inject('timelimit')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('plan')
->inject('proofForToken')
->inject('proofForCode')
@@ -121,10 +123,10 @@ class Create extends Action
array $platform,
Request $request,
Event $queueForEvents,
- Messaging $queueForMessaging,
- Mail $queueForMails,
+ MessagingPublisher $publisherForMessaging,
+ MailPublisher $publisherForMails,
callable $timelimit,
- StatsUsage $queueForStatsUsage,
+ Context $usage,
array $plan,
ProofsToken $proofForToken,
ProofsCode $proofForCode
@@ -170,11 +172,6 @@ class Create extends Action
$message = Template::fromFile($templatesPath . '/sms-base.tpl');
- $customTemplate = $project->getAttribute('templates', [])['sms.mfaChallenge-' . $locale->default] ?? [];
- if (!empty($customTemplate)) {
- $message = $customTemplate['message'] ?? $message;
- }
-
$messageContent = Template::fromString($locale->getText("sms.verification.body"));
$messageContent
->setParam('{{project}}', $projectName)
@@ -185,32 +182,30 @@ class Create extends Action
$message = $message->render();
$phone = $user->getAttribute('phone');
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_INTERNAL)
- ->setMessage(new Document([
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_INTERNAL,
+ project: $project,
+ message: new Document([
'$id' => $challenge->getId(),
'data' => [
'content' => $code,
],
- ]))
- ->setRecipients([$phone])
- ->setProviderType(MESSAGE_TYPE_SMS);
+ ]),
+ recipients: [$phone],
+ providerType: MESSAGE_TYPE_SMS,
+ ));
$helper = PhoneNumberUtil::getInstance();
try {
$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'))) {
@@ -227,7 +222,9 @@ class Create extends Action
$preview = $locale->getText("emails.mfaChallenge.preview");
$heading = $locale->getText("emails.mfaChallenge.heading");
- $customTemplate = $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->default] ?? [];
+ $customTemplate =
+ $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->default] ??
+ $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->fallback] ?? [];
$smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base');
$validator = new FileName();
@@ -257,7 +254,9 @@ 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 = "";
+ $replyToEmail = '';
+ $replyToName = '';
+ $smtpConfig = [];
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -266,16 +265,14 @@ class Create extends Action
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
- if (!empty($smtp['replyTo'])) {
- $replyTo = $smtp['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
+ if (!empty($smtpReplyToEmail)) {
+ $replyToEmail = $smtpReplyToEmail;
+ }
+ if (!empty($smtp['replyToName'])) {
+ $replyToName = $smtp['replyToName'];
}
-
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '');
if (!empty($customTemplate)) {
if (!empty($customTemplate['senderEmail'])) {
@@ -284,18 +281,30 @@ class Create extends Action
if (!empty($customTemplate['senderName'])) {
$senderName = $customTemplate['senderName'];
}
- if (!empty($customTemplate['replyTo'])) {
- $replyTo = $customTemplate['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? '';
+ if (!empty($customReplyToEmail)) {
+ $replyToEmail = $customReplyToEmail;
+ }
+ if (!empty($customTemplate['replyToName'])) {
+ $replyToName = $customTemplate['replyToName'];
}
$body = $customTemplate['message'] ?? '';
$subject = $customTemplate['subject'] ?? $subject;
}
- $queueForMails
- ->setSmtpReplyTo($replyTo)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ];
}
$emailVariables = [
@@ -322,20 +331,18 @@ class Create extends Action
]);
}
- $queueForMails
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body)
- ->setBodyTemplate($bodyTemplate)
- ->appendVariables($emailVariables)
- ->setRecipient($user->getAttribute('email'));
-
- // since this is console project, set email sender name!
- if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) {
- $queueForMails->setSenderName($platform['emailSenderName']);
- }
-
- $queueForMails->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $user->getAttribute('email'),
+ subject: $subject,
+ bodyTemplate: $bodyTemplate,
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ customMailOptions: $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE ? ['senderName' => $platform['emailSenderName']] : [],
+ platform: $platform,
+ ));
break;
}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Enums/InsightCTAMethod.php b/src/Appwrite/Platform/Modules/Advisor/Enums/InsightCTAMethod.php
new file mode 100644
index 0000000000..31d578a991
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Enums/InsightCTAMethod.php
@@ -0,0 +1,8 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/reports/:reportId/insights/:insightId')
+ ->desc('Get insight')
+ ->groups(['api', 'advisor'])
+ ->label('scope', 'insights.read')
+ ->label('resourceType', RESOURCE_TYPE_INSIGHTS)
+ ->label('sdk', new Method(
+ namespace: 'advisor',
+ group: 'insights',
+ name: 'getInsight',
+ description: '/docs/references/advisor/get-insight.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_INSIGHT,
+ ),
+ ]
+ ))
+ ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Parent report ID.', false, ['dbForPlatform'])
+ ->param('insightId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForPlatform'])
+ ->inject('response')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $reportId,
+ string $insightId,
+ Response $response,
+ Document $project,
+ Database $dbForPlatform
+ ) {
+ // Skip the insights subquery — we only need ownership metadata.
+ $report = $dbForPlatform->skipFilters(
+ fn () => $dbForPlatform->getDocument('reports', $reportId),
+ ['subQueryReportInsights'],
+ );
+
+ if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
+ throw new Exception(Exception::REPORT_NOT_FOUND);
+ }
+
+ $insight = $dbForPlatform->getDocument('insights', $insightId);
+
+ if (
+ $insight->isEmpty()
+ || $insight->getAttribute('projectInternalId') !== $project->getSequence()
+ || $insight->getAttribute('reportInternalId') !== $report->getSequence()
+ ) {
+ throw new Exception(Exception::INSIGHT_NOT_FOUND);
+ }
+
+ $response->dynamic($insight, Response::MODEL_INSIGHT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Insights/XList.php b/src/Appwrite/Platform/Modules/Advisor/Http/Insights/XList.php
new file mode 100644
index 0000000000..64d3676c08
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Http/Insights/XList.php
@@ -0,0 +1,126 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/reports/:reportId/insights')
+ ->desc('List insights')
+ ->groups(['api', 'advisor'])
+ ->label('scope', 'insights.read')
+ ->label('resourceType', RESOURCE_TYPE_INSIGHTS)
+ ->label('sdk', new Method(
+ namespace: 'advisor',
+ group: 'insights',
+ name: 'listInsights',
+ description: '/docs/references/advisor/list-insights.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_INSIGHT_LIST,
+ ),
+ ]
+ ))
+ ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Parent report ID.', false, ['dbForPlatform'])
+ ->param('queries', [], new Insights(), '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(', ', Insights::ALLOWED_ATTRIBUTES), true)
+ ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
+ ->inject('response')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $reportId,
+ array $queries,
+ bool $includeTotal,
+ Response $response,
+ Document $project,
+ Database $dbForPlatform
+ ) {
+ // Skip the insights subquery — we're about to fetch a filtered, paginated slice ourselves.
+ $report = $dbForPlatform->skipFilters(
+ fn () => $dbForPlatform->getDocument('reports', $reportId),
+ ['subQueryReportInsights'],
+ );
+
+ if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
+ throw new Exception(Exception::REPORT_NOT_FOUND);
+ }
+
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $queries[] = Query::equal('projectInternalId', [$project->getSequence()]);
+ $queries[] = Query::equal('reportInternalId', [$report->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());
+ }
+
+ $insightId = $cursor->getValue();
+ $cursorDocument = $dbForPlatform->getDocument('insights', $insightId);
+
+ if (
+ $cursorDocument->isEmpty()
+ || $cursorDocument->getAttribute('projectInternalId') !== $project->getSequence()
+ || $cursorDocument->getAttribute('reportInternalId') !== $report->getSequence()
+ ) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Insight '{$insightId}' for the 'cursor' value not found.");
+ }
+
+ $cursor->setValue($cursorDocument);
+ }
+
+ $filterQueries = Query::groupByType($queries)['filters'];
+
+ try {
+ $insights = $dbForPlatform->find('insights', $queries);
+ $total = $includeTotal ? $dbForPlatform->count('insights', $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([
+ 'insights' => $insights,
+ 'total' => $total,
+ ]), Response::MODEL_INSIGHT_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Delete.php b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Delete.php
new file mode 100644
index 0000000000..6b1dfba31b
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Delete.php
@@ -0,0 +1,97 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/reports/:reportId')
+ ->desc('Delete report')
+ ->groups(['api', 'advisor'])
+ ->label('scope', 'reports.write')
+ ->label('event', 'reports.[reportId].delete')
+ ->label('resourceType', RESOURCE_TYPE_REPORTS)
+ ->label('audits.event', 'report.delete')
+ ->label('audits.resource', 'report/{request.reportId}')
+ ->label('abuse-key', 'projectId:{projectId},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', new Method(
+ namespace: 'advisor',
+ group: 'reports',
+ name: 'deleteReport',
+ description: '/docs/references/advisor/delete-report.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_NOCONTENT,
+ model: Response::MODEL_NONE,
+ ),
+ ],
+ contentType: ContentType::NONE
+ ))
+ ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Report ID.', false, ['dbForPlatform'])
+ ->inject('response')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->inject('queueForDeletes')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $reportId,
+ Response $response,
+ Document $project,
+ Database $dbForPlatform,
+ DeleteEvent $queueForDeletes,
+ Event $queueForEvents
+ ): void {
+ $report = $dbForPlatform->skipFilters(
+ fn () => $dbForPlatform->getDocument('reports', $reportId),
+ ['subQueryReportInsights'],
+ );
+
+ if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
+ throw new Exception(Exception::REPORT_NOT_FOUND);
+ }
+
+ if (!$dbForPlatform->deleteDocument('reports', $report->getId())) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove report from DB');
+ }
+
+ $queueForDeletes
+ ->setType(DELETE_TYPE_REPORT)
+ ->setDocument($report);
+
+ $queueForEvents
+ ->setParam('reportId', $report->getId())
+ ->setPayload($response->output($report, Response::MODEL_REPORT));
+
+ $response->noContent();
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Get.php b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Get.php
new file mode 100644
index 0000000000..78885a7c5d
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Get.php
@@ -0,0 +1,80 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/reports/:reportId')
+ ->desc('Get report')
+ ->groups(['api', 'advisor'])
+ ->label('scope', 'reports.read')
+ ->label('resourceType', RESOURCE_TYPE_REPORTS)
+ ->label('sdk', new Method(
+ namespace: 'advisor',
+ group: 'reports',
+ name: 'getReport',
+ description: '/docs/references/advisor/get-report.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_REPORT,
+ ),
+ ]
+ ))
+ ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Report ID.', false, ['dbForPlatform'])
+ ->inject('response')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $reportId,
+ Response $response,
+ Document $project,
+ Database $dbForPlatform
+ ) {
+ $report = $dbForPlatform->skipFilters(
+ fn () => $dbForPlatform->getDocument('reports', $reportId),
+ ['subQueryReportInsights'],
+ );
+
+ if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
+ throw new Exception(Exception::REPORT_NOT_FOUND);
+ }
+
+ $insights = $dbForPlatform->find('insights', [
+ Query::equal('projectInternalId', [$project->getSequence()]),
+ Query::equal('reportInternalId', [$report->getSequence()]),
+ Query::limit(APP_LIMIT_SUBQUERY),
+ ]);
+
+ $report->setAttribute('insights', $insights);
+
+ $response->dynamic($report, Response::MODEL_REPORT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Reports/XList.php b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/XList.php
new file mode 100644
index 0000000000..c5debb7f68
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/XList.php
@@ -0,0 +1,133 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/reports')
+ ->desc('List reports')
+ ->groups(['api', 'advisor'])
+ ->label('scope', 'reports.read')
+ ->label('resourceType', RESOURCE_TYPE_REPORTS)
+ ->label('sdk', new Method(
+ namespace: 'advisor',
+ group: 'reports',
+ name: 'listReports',
+ description: '/docs/references/advisor/list-reports.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_REPORT_LIST,
+ ),
+ ]
+ ))
+ ->param('queries', [], new Reports(), '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(', ', Reports::ALLOWED_ATTRIBUTES), true)
+ ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
+ ->inject('response')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ array $queries,
+ bool $includeTotal,
+ Response $response,
+ Document $project,
+ Database $dbForPlatform
+ ) {
+ 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());
+ }
+
+ $reportId = $cursor->getValue();
+ $cursorDocument = $dbForPlatform->skipFilters(
+ fn () => $dbForPlatform->getDocument('reports', $reportId),
+ ['subQueryReportInsights'],
+ );
+
+ if ($cursorDocument->isEmpty() || $cursorDocument->getAttribute('projectInternalId') !== $project->getSequence()) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Report '{$reportId}' for the 'cursor' value not found.");
+ }
+
+ $cursor->setValue($cursorDocument);
+ }
+
+ $filterQueries = Query::groupByType($queries)['filters'];
+
+ try {
+ $reports = $dbForPlatform->skipFilters(
+ fn () => $dbForPlatform->find('reports', $queries),
+ ['subQueryReportInsights'],
+ );
+ $total = $includeTotal ? $dbForPlatform->count('reports', $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.");
+ }
+
+ if (!empty($reports)) {
+ $reportSequences = \array_map(fn (Document $r) => $r->getSequence(), $reports);
+
+ $insights = $dbForPlatform->find('insights', [
+ Query::equal('projectInternalId', [$project->getSequence()]),
+ Query::equal('reportInternalId', $reportSequences),
+ Query::limit(APP_LIMIT_SUBQUERY),
+ ]);
+
+ $insightsByReport = [];
+ foreach ($insights as $insight) {
+ $insightsByReport[$insight->getAttribute('reportInternalId')][] = $insight;
+ }
+
+ foreach ($reports as $report) {
+ $report->setAttribute('insights', $insightsByReport[$report->getSequence()] ?? []);
+ }
+ }
+
+ $response->dynamic(new Document([
+ 'reports' => $reports,
+ 'total' => $total,
+ ]), Response::MODEL_REPORT_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Module.php b/src/Appwrite/Platform/Modules/Advisor/Module.php
new file mode 100644
index 0000000000..b28a2421c2
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Module.php
@@ -0,0 +1,14 @@
+addService('http', new Http());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Services/Http.php b/src/Appwrite/Platform/Modules/Advisor/Services/Http.php
new file mode 100644
index 0000000000..2558b00247
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Services/Http.php
@@ -0,0 +1,25 @@
+type = Service::TYPE_HTTP;
+
+ $this->addAction(GetReport::getName(), new GetReport());
+ $this->addAction(ListReports::getName(), new ListReports());
+ $this->addAction(DeleteReport::getName(), new DeleteReport());
+
+ $this->addAction(GetInsight::getName(), new GetInsight());
+ $this->addAction(ListInsights::getName(), new ListInsights());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Action.php b/src/Appwrite/Platform/Modules/Avatars/Http/Action.php
index bf7d01764f..d6b58b33dd 100644
--- a/src/Appwrite/Platform/Modules/Avatars/Http/Action.php
+++ b/src/Appwrite/Platform/Modules/Avatars/Http/Action.php
@@ -49,7 +49,6 @@ class Action extends PlatformAction
$image = new Image(\file_get_contents($path));
$image->crop((int) $width, (int) $height);
- $output = (empty($output)) ? $type : $output;
$data = $image->output($output, $quality);
$response
->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days
diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php
index f8e7a35b05..d0c600192b 100644
--- a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php
+++ b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php
@@ -86,10 +86,10 @@ class Get extends Action
}
if (!$isEmployee && !empty($githubName)) {
- $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub']) ?? '', $employees));
+ $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub'] ?? ''), $employees));
if (!empty($employeeGitHub)) {
$isEmployee = true;
- $employeeNumber = $isEmployee ? $employees[$employeeGitHub]['spot'] : '';
+ $employeeNumber = $employees[$employeeGitHub]['spot'];
$createdAt = new \DateTime($employees[$employeeGitHub]['memberSince'] ?? '');
}
}
diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php
index 37776a3466..ad74d6c192 100644
--- a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php
+++ b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php
@@ -90,10 +90,10 @@ class Get extends Action
}
if (!$isEmployee && !empty($githubName)) {
- $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub']) ?? '', $employees));
+ $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub'] ?? ''), $employees));
if (!empty($employeeGitHub)) {
$isEmployee = true;
- $employeeNumber = $isEmployee ? $employees[$employeeGitHub]['spot'] : '';
+ $employeeNumber = $employees[$employeeGitHub]['spot'];
$createdAt = new \DateTime($employees[$employeeGitHub]['memberSince'] ?? '');
}
}
diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php
index 0a4d652d0e..31ad572f18 100644
--- a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php
+++ b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php
@@ -94,11 +94,14 @@ class Get extends Action
throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED);
}
+ $body = $res->getBody();
$doc = new DOMDocument();
$doc->strictErrorChecking = false;
- @$doc->loadHTML($res->getBody());
+ if (!empty($body)) {
+ @$doc->loadHTML($body);
+ }
- $links = $doc->getElementsByTagName('link') ?? [];
+ $links = $doc->getElementsByTagName('link');
$outputHref = '';
$outputExt = '';
$space = 0;
@@ -128,7 +131,7 @@ class Get extends Action
case 'jpeg':
$size = \explode('x', \strtolower($sizes));
- $sizeWidth = (int) ($size[0] ?? 0);
+ $sizeWidth = (int) $size[0];
$sizeHeight = (int) ($size[1] ?? 0);
if (($sizeWidth * $sizeHeight) >= $space) {
@@ -204,7 +207,6 @@ class Get extends Action
$image = new Image($data);
$image->crop((int) $width, (int) $height);
- $output = (empty($output)) ? $type : $output;
$data = $image->output($output, $quality);
$response
diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Image/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Image/Get.php
index eb56ddf0b2..77ff9dd8ce 100644
--- a/src/Appwrite/Platform/Modules/Avatars/Http/Image/Get.php
+++ b/src/Appwrite/Platform/Modules/Avatars/Http/Image/Get.php
@@ -95,7 +95,6 @@ class Get extends Action
}
$image->crop((int) $width, (int) $height);
- $output = (empty($output)) ? $type : $output;
$data = $image->output($output, $quality);
$response
diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Initials/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Initials/Get.php
index 8278a43ea3..c73d20fb0c 100644
--- a/src/Appwrite/Platform/Modules/Avatars/Http/Initials/Get.php
+++ b/src/Appwrite/Platform/Modules/Avatars/Http/Initials/Get.php
@@ -90,7 +90,7 @@ class Get extends Action
}
}
- $rand = \substr($code, -1);
+ $rand = (int) \substr((string) $code, -1);
$rand = ($rand > \count($themes) - 1) ? $rand % \count($themes) : $rand;
diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php
index 27fd8708d9..f3448f5264 100644
--- a/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php
+++ b/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php
@@ -60,7 +60,6 @@ class Get extends Action
public function action(string $text, int $size, int $margin, bool $download, Response $response)
{
- $download = ($download === '1' || $download === 'true' || $download === 1 || $download === true);
$options = new QROptions([
'addQuietzone' => true,
'quietzoneSize' => $margin,
diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php
index b6fd354ee3..c43c0fc4bf 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');
@@ -105,7 +105,7 @@ class Get extends Action
$client->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON);
// Convert indexed array to empty array (should not happen due to Assoc validator)
- if (is_array($headers) && count($headers) > 0 && array_keys($headers) === range(0, count($headers) - 1)) {
+ if (count($headers) > 0 && array_keys($headers) === range(0, count($headers) - 1)) {
$headers = [];
}
@@ -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/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php
index f388e46f83..b0efac3829 100644
--- a/src/Appwrite/Platform/Modules/Compute/Base.php
+++ b/src/Appwrite/Platform/Modules/Compute/Base.php
@@ -2,7 +2,8 @@
namespace Appwrite\Platform\Modules\Compute;
-use Appwrite\Event\Build;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use Appwrite\Platform\Action;
@@ -57,7 +58,7 @@ class Base extends Action
return $allowedSpecifications[0] ?? APP_COMPUTE_SPECIFICATION_DEFAULT;
}
- public function redeployVcsFunction(Request $request, Document $function, Document $project, Document $installation, Database $dbForProject, Build $queueForBuilds, Document $template, GitHub $github, bool $activate, string $referenceType = 'branch', string $reference = ''): Document
+ public function redeployVcsFunction(Request $request, Document $function, Document $project, Document $installation, Database $dbForProject, BuildPublisher $publisherForBuilds, Document $template, GitHub $github, bool $activate, array $platform = [], string $referenceType = 'branch', string $reference = ''): Document
{
$deploymentId = ID::unique();
$entrypoint = $function->getAttribute('entrypoint', '');
@@ -68,7 +69,7 @@ class Base extends Action
$owner = $github->getOwnerName($providerInstallationId);
$providerRepositoryId = $function->getAttribute('providerRepositoryId', '');
try {
- $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
+ $repositoryName = $github->getRepositoryName($providerRepositoryId);
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
@@ -150,16 +151,19 @@ class Base extends Action
'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
]));
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
return $deployment;
}
- public function redeployVcsSite(Request $request, Document $site, Document $project, Document $installation, Database $dbForProject, Database $dbForPlatform, Build $queueForBuilds, Document $template, GitHub $github, bool $activate, Authorization $authorization, array $platform, string $referenceType = 'branch', string $reference = ''): Document
+ public function redeployVcsSite(Request $request, Document $site, Document $project, Document $installation, Database $dbForProject, Database $dbForPlatform, BuildPublisher $publisherForBuilds, Document $template, GitHub $github, bool $activate, Authorization $authorization, array $platform, string $referenceType = 'branch', string $reference = ''): Document
{
$deploymentId = ID::unique();
$providerInstallationId = $installation->getAttribute('providerInstallationId', '');
@@ -169,7 +173,7 @@ class Base extends Action
$owner = $github->getOwnerName($providerInstallationId);
$providerRepositoryId = $site->getAttribute('providerRepositoryId', '');
try {
- $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
+ $repositoryName = $github->getRepositoryName($providerRepositoryId);
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
@@ -358,11 +362,14 @@ class Base extends Action
$this->updateEmptyManualRule($project, $site, $deployment, $dbForPlatform, $authorization);
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($site)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $site,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
return $deployment;
}
diff --git a/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php b/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php
index 554456b041..8953f682d5 100644
--- a/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php
+++ b/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php
@@ -85,8 +85,6 @@ class Create extends Action
curl_exec($ch);
- curl_close($ch);
-
$response->chunk('', true);
}
}
diff --git a/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php b/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php
new file mode 100644
index 0000000000..e253292ca9
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php
@@ -0,0 +1,80 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/console/oauth2-providers')
+ ->desc('List OAuth2 providers')
+ ->groups(['api'])
+ ->label('scope', 'public')
+ ->label('sdk', new Method(
+ namespace: 'console',
+ group: 'console',
+ name: 'listOAuth2Providers',
+ description: 'List all OAuth2 providers supported by the Appwrite server, along with the parameters required to configure each provider. The response excludes mock providers but includes sandbox providers.',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_CONSOLE_OAUTH2_PROVIDER_LIST,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->inject('response')
+ ->callback($this->action(...));
+ }
+
+ public function action(Response $response): void
+ {
+ $providersConfig = Config::getParam('oAuthProviders', []);
+ $actions = OAuth2Base::getProviderActions();
+
+ $providers = [];
+ foreach ($providersConfig as $providerId => $config) {
+ $updateClass = $actions[$providerId] ?? null;
+ if ($updateClass === null) {
+ continue;
+ }
+ if (!($config['enabled'] ?? false)) {
+ continue;
+ }
+ if ($config['mock'] ?? false) {
+ continue;
+ }
+
+ $providers[] = new Document([
+ '$id' => $providerId,
+ 'parameters' => $updateClass::getParameters(),
+ ]);
+ }
+
+ $response->dynamic(new Document([
+ 'total' => \count($providers),
+ 'oAuth2Providers' => $providers,
+ ]), Response::MODEL_CONSOLE_OAUTH2_PROVIDER_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php
new file mode 100644
index 0000000000..d951e93886
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php
@@ -0,0 +1,69 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/console/scopes/project')
+ ->desc('List project scopes')
+ ->groups(['api'])
+ ->label('scope', 'public')
+ ->label('sdk', new Method(
+ namespace: 'console',
+ group: 'console',
+ name: 'listProjectScopes',
+ description: 'List all scopes available for project API keys, along with a description for each scope.',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_CONSOLE_KEY_SCOPE_LIST,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->inject('response')
+ ->callback($this->action(...));
+ }
+
+ public function action(Response $response): void
+ {
+ $scopesConfig = Config::getParam('projectScopes', []);
+
+ $scopes = [];
+ foreach ($scopesConfig as $scopeId => $scope) {
+ $scopes[] = new Document([
+ '$id' => $scopeId,
+ 'description' => $scope['description'] ?? '',
+ 'category' => $scope['category'] ?? '',
+ 'deprecated' => $scope['deprecated'] ?? false,
+ ]);
+ }
+
+ $response->dynamic(new Document([
+ 'total' => \count($scopes),
+ 'scopes' => $scopes,
+ ]), Response::MODEL_CONSOLE_KEY_SCOPE_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php
index 8368b272f1..d39049a409 100644
--- a/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php
+++ b/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php
@@ -36,7 +36,7 @@ class Get extends Action
namespace: 'console',
group: 'console',
name: 'variables',
- description: '/docs/references/console/variables.md',
+ description: 'Get all Environment Variables that are relevant for the console.',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
diff --git a/src/Appwrite/Platform/Modules/Console/Services/Http.php b/src/Appwrite/Platform/Modules/Console/Services/Http.php
index f3ca6218f2..2540ae8e01 100644
--- a/src/Appwrite/Platform/Modules/Console/Services/Http.php
+++ b/src/Appwrite/Platform/Modules/Console/Services/Http.php
@@ -5,6 +5,7 @@ namespace Appwrite\Platform\Modules\Console\Services;
use Appwrite\Platform\Modules\Console\Http\Assistant\Create as CreateAssistantQuery;
use Appwrite\Platform\Modules\Console\Http\Init\API;
use Appwrite\Platform\Modules\Console\Http\Init\Web;
+use Appwrite\Platform\Modules\Console\Http\OAuth2Providers\XList as ListOAuth2Providers;
use Appwrite\Platform\Modules\Console\Http\Redirects\Auth\Get as RedirectAuth;
use Appwrite\Platform\Modules\Console\Http\Redirects\Card\Get as RedirectCard;
use Appwrite\Platform\Modules\Console\Http\Redirects\Invite\Get as RedirectInvite;
@@ -14,6 +15,7 @@ use Appwrite\Platform\Modules\Console\Http\Redirects\Recover\Get as RedirectReco
use Appwrite\Platform\Modules\Console\Http\Redirects\Register\Get as RedirectRegister;
use Appwrite\Platform\Modules\Console\Http\Redirects\Root\Get as RedirectRoot;
use Appwrite\Platform\Modules\Console\Http\Resources\Get as GetResourceAvailability;
+use Appwrite\Platform\Modules\Console\Http\Scopes\Key\XList as ListKeyScopes;
use Appwrite\Platform\Modules\Console\Http\Variables\Get as GetVariables;
use Utopia\Platform\Service;
@@ -28,6 +30,8 @@ class Http extends Service
$this->addAction(Web::getName(), new Web());
$this->addAction(GetVariables::getName(), new GetVariables());
+ $this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers());
+ $this->addAction(ListKeyScopes::getName(), new ListKeyScopes());
$this->addAction(CreateAssistantQuery::getName(), new CreateAssistantQuery());
$this->addAction(GetResourceAvailability::getName(), new GetResourceAvailability());
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php
index b2417871ed..7893a70753 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php
@@ -7,9 +7,13 @@ use Appwrite\Platform\Action as AppwriteAction;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Operator;
+use Utopia\Database\Query;
class Action extends AppwriteAction
{
+ public const LIST_CACHE_FIELD_DOCUMENTS = 'documents';
+ public const LIST_CACHE_FIELD_TOTAL = 'total';
+
private string $context = DATABASE_TYPE_LEGACY;
public function getDatabaseType(): string
@@ -17,7 +21,7 @@ class Action extends AppwriteAction
return $this->context;
}
- public function setHttpPath(string $path): AppwriteAction
+ public function setHttpPath(string $path): self
{
if (\str_contains($path, '/tablesdb')) {
$this->context = DATABASE_TYPE_TABLESDB;
@@ -28,7 +32,8 @@ class Action extends AppwriteAction
if (\str_contains($path, '/vectorsdb')) {
$this->context = DATABASE_TYPE_VECTORSDB;
}
- return parent::setHttpPath($path);
+ parent::setHttpPath($path);
+ return $this;
}
/**
@@ -100,4 +105,67 @@ class Action extends AppwriteAction
return $data;
}
+
+ /**
+ * Stable Redis key for a collection's cached list responses.
+ *
+ * All variations (schema × roles × queries) for a single collection live as
+ * fields inside this one Redis hash, so purging every cached entry for a
+ * collection is a single O(1) DEL regardless of how many variations have
+ * been cached.
+ */
+ protected function getListCacheKey(Database $dbForProject, string $collectionId): string
+ {
+ return \sprintf(
+ '%s-cache:%s:%s:%s:collection:%s',
+ $dbForProject->getCacheName(),
+ $dbForProject->getAdapter()->getHostname(),
+ $dbForProject->getNamespace(),
+ $dbForProject->getTenant(),
+ $collectionId,
+ );
+ }
+
+ /**
+ * Hash field for a single variation of a cached list response.
+ *
+ * Scoped by the collection schema (attributes + indexes), the caller's
+ * authorization roles, the exact query set, and the field type — so users
+ * with different permissions never share entries.
+ *
+ * @param Document $collection Collection document (for schema hash)
+ * @param array $roles Caller authorization roles
+ * @param array $queries Queries for this list call
+ * @param string $type LIST_CACHE_FIELD_DOCUMENTS or LIST_CACHE_FIELD_TOTAL
+ */
+ protected function getListCacheField(Document $collection, array $roles, array $queries, string $type): string
+ {
+ $schemaHash = \md5(
+ \json_encode($collection->getAttribute('attributes', []))
+ . \json_encode($collection->getAttribute('indexes', []))
+ );
+
+ $serialized = \array_map(
+ static fn ($query) => $query instanceof Query ? $query->toArray() : $query,
+ $queries,
+ );
+
+ return \sprintf(
+ '%s:%s:%s:%s',
+ $schemaHash,
+ \md5(\json_encode($roles)),
+ \md5(\json_encode($serialized)),
+ $type,
+ );
+ }
+
+ /**
+ * Purge every cached list response for a collection.
+ *
+ * One DEL on the collection's Redis hash, clearing all variations at once.
+ */
+ protected function purgeListCache(Database $dbForProject, string $collectionId): bool
+ {
+ return $dbForProject->getCache()->purge($this->getListCacheKey($dbForProject, $collectionId));
+ }
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php
index 2f541936a8..1f730fa543 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php
@@ -3,34 +3,29 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections;
use Appwrite\Extend\Exception;
+use Appwrite\Platform\Modules\Databases\Http\Databases\Action as DatabasesAction;
use Utopia\Database\Database;
use Utopia\Database\Document;
-use Utopia\Platform\Action as UtopiaAction;
-use Utopia\Platform\Scope\HTTP;
-abstract class Action extends UtopiaAction
+abstract class Action extends DatabasesAction
{
/**
* The current API context (either 'table' or 'collection').
*/
- private ?string $context = COLLECTIONS;
-
- private ?string $databaseType = LEGACY;
+ private string $context = COLLECTIONS;
/**
* Get the response model used in the SDK and HTTP responses.
*/
abstract protected function getResponseModel(): string;
- public function setHttpPath(string $path): UtopiaAction
+ public function setHttpPath(string $path): self
{
if (\str_contains($path, '/tablesdb')) {
$this->context = TABLES;
- $this->databaseType = TABLESDB;
- } elseif (\str_contains($path, '/vectorsdb')) {
- $this->databaseType = VECTORSDB;
}
- return parent::setHttpPath($path);
+ parent::setHttpPath($path);
+ return $this;
}
/**
@@ -41,14 +36,6 @@ abstract class Action extends UtopiaAction
return $this->context;
}
- /**
- * Get the current API database type.
- */
- protected function getDatabaseType(): string
- {
- return $this->databaseType;
- }
-
/**
* Get the key used in event parameters (e.g., 'collectionId' or 'tableId').
*/
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php
index 0d562a2894..4e5203b13f 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php
@@ -26,9 +26,9 @@ use Utopia\Validator\Range;
abstract class Action extends UtopiaAction
{
/**
- * @var string|null The current context (either 'column' or 'attribute')
+ * @var string The current context (either 'column' or 'attribute')
*/
- private ?string $context = ATTRIBUTES;
+ private string $context = ATTRIBUTES;
/**
* Get the correct response model.
@@ -241,6 +241,10 @@ abstract class Action extends UtopiaAction
? UtopiaResponse::MODEL_ATTRIBUTE_INTEGER
: UtopiaResponse::MODEL_COLUMN_INTEGER,
+ Database::VAR_BIGINT => $isCollections
+ ? UtopiaResponse::MODEL_ATTRIBUTE_BIGINT
+ : UtopiaResponse::MODEL_COLUMN_BIGINT,
+
Database::VAR_FLOAT => $isCollections
? UtopiaResponse::MODEL_ATTRIBUTE_FLOAT
: UtopiaResponse::MODEL_COLUMN_FLOAT,
@@ -540,6 +544,7 @@ abstract class Action extends UtopiaAction
switch ($attribute->getAttribute('format')) {
case APP_DATABASE_ATTRIBUTE_INT_RANGE:
+ case APP_DATABASE_ATTRIBUTE_BIGINT_RANGE:
case APP_DATABASE_ATTRIBUTE_FLOAT_RANGE:
$min ??= $attribute->getAttribute('formatOptions')['min'];
$max ??= $attribute->getAttribute('formatOptions')['max'];
@@ -548,14 +553,15 @@ abstract class Action extends UtopiaAction
throw new Exception($this->getInvalidValueException(), 'Minimum value must be lesser than maximum value');
}
- if ($attribute->getAttribute('format') === APP_DATABASE_ATTRIBUTE_INT_RANGE) {
- $validator = new Range($min, $max, Database::VAR_INTEGER);
- } else {
+ if ($attribute->getAttribute('format') === APP_DATABASE_ATTRIBUTE_FLOAT_RANGE) {
$validator = new Range($min, $max, Database::VAR_FLOAT);
if (!is_null($default)) {
$default = \floatval($default);
}
+ } else {
+ // intRange and bigintRange share the same integer range semantics
+ $validator = new Range($min, $max, Range::TYPE_INTEGER);
}
if (!is_null($default) && !$validator->isValid($default)) {
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/BigInt/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/BigInt/Create.php
new file mode 100644
index 0000000000..4ea85b71e6
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/BigInt/Create.php
@@ -0,0 +1,117 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/databases/:databaseId/collections/:collectionId/attributes/bigint')
+ ->desc('Create bigint attribute')
+ ->groups(['api', 'database', 'schema'])
+ ->label('scope', 'collections.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].collections.[collectionId].attributes.[attributeId].create')
+ ->label('audits.event', 'attribute.create')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('sdk', new Method(
+ namespace: $this->getSDKNamespace(),
+ group: $this->getSDKGroup(),
+ name: self::getName(),
+ description: '/docs/references/databases/create-bigint-attribute.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_ACCEPTED,
+ model: $this->getResponseModel(),
+ )
+ ],
+ deprecated: new Deprecated(
+ since: '1.8.0',
+ replaceWith: 'tablesDB.createBigIntColumn',
+ ),
+ ))
+ ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
+ ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject'])
+ ->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Attribute Key.', false, ['dbForProject'])
+ ->param('required', null, new Boolean(), 'Is attribute required?')
+ ->param('min', null, new Nullable(new Integer(false, 64)), 'Minimum value', true)
+ ->param('max', null, new Nullable(new Integer(false, 64)), 'Maximum value', true)
+ ->param('default', null, new Nullable(new Integer(false, 64)), 'Default value. Cannot be set when attribute is required.', true)
+ ->param('array', false, new Boolean(), 'Is attribute an array?', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForDatabase')
+ ->inject('queueForEvents')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?int $min, ?int $max, ?int $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
+ {
+ $min ??= \PHP_INT_MIN;
+ $max ??= \PHP_INT_MAX;
+
+ if ($min > $max) {
+ throw new Exception($this->getInvalidValueException(), 'Minimum value must be lesser than maximum value');
+ }
+
+ $validator = new Range($min, $max, Range::TYPE_INTEGER);
+ if (!\is_null($default) && !$validator->isValid($default)) {
+ throw new Exception($this->getInvalidValueException(), $validator->getDescription());
+ }
+
+ $attribute = $this->createAttribute($databaseId, $collectionId, new Document([
+ 'key' => $key,
+ 'type' => Database::VAR_BIGINT,
+ 'size' => 8,
+ 'required' => $required,
+ 'default' => $default,
+ 'array' => $array,
+ 'format' => APP_DATABASE_ATTRIBUTE_BIGINT_RANGE,
+ 'formatOptions' => ['min' => $min, 'max' => $max],
+ ]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization);
+
+ $formatOptions = $attribute->getAttribute('formatOptions', []);
+ if (!empty($formatOptions)) {
+ $attribute->setAttribute('min', \intval($formatOptions['min']));
+ $attribute->setAttribute('max', \intval($formatOptions['max']));
+ }
+
+ $response
+ ->setStatusCode(SwooleResponse::STATUS_CODE_ACCEPTED)
+ ->dynamic($attribute, $this->getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/BigInt/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/BigInt/Update.php
new file mode 100644
index 0000000000..5d8e8bf3a5
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/BigInt/Update.php
@@ -0,0 +1,106 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/databases/:databaseId/collections/:collectionId/attributes/bigint/:key')
+ ->desc('Update bigint attribute')
+ ->groups(['api', 'database', 'schema'])
+ ->label('scope', 'collections.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].collections.[collectionId].attributes.[attributeId].update')
+ ->label('audits.event', 'attribute.update')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('sdk', new Method(
+ namespace: $this->getSDKNamespace(),
+ group: $this->getSDKGroup(),
+ name: self::getName(),
+ description: '/docs/references/databases/update-bigint-attribute.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: $this->getResponseModel(),
+ )
+ ],
+ contentType: ContentType::JSON,
+ deprecated: new Deprecated(
+ since: '1.8.0',
+ replaceWith: 'tablesDB.updateBigIntColumn',
+ ),
+ ))
+ ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
+ ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject'])
+ ->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Attribute Key.', false, ['dbForProject'])
+ ->param('required', null, new Boolean(), 'Is attribute required?')
+ ->param('min', null, new Nullable(new Integer(false, 64)), 'Minimum value', true)
+ ->param('max', null, new Nullable(new Integer(false, 64)), 'Maximum value', true)
+ ->param('default', null, new Nullable(new Integer(false, 64)), 'Default value. Cannot be set when attribute is required.')
+ ->param('newKey', null, fn (Database $dbForProject) => new Nullable(new Key(false, $dbForProject->getAdapter()->getMaxUIDLength())), 'New Attribute Key.', true, ['dbForProject'])
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForEvents')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?int $min, ?int $max, ?int $default, ?string $newKey, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, Authorization $authorization): void
+ {
+ $attribute = $this->updateAttribute(
+ databaseId: $databaseId,
+ collectionId: $collectionId,
+ key: $key,
+ dbForProject: $dbForProject,
+ queueForEvents: $queueForEvents,
+ authorization: $authorization,
+ type: Database::VAR_BIGINT,
+ default: $default,
+ required: $required,
+ min: $min,
+ max: $max,
+ newKey: $newKey
+ );
+
+ $formatOptions = $attribute->getAttribute('formatOptions', []);
+ if (!empty($formatOptions)) {
+ $attribute->setAttribute('min', \intval($formatOptions['min']));
+ $attribute->setAttribute('max', \intval($formatOptions['max']));
+ }
+
+ $response
+ ->setStatusCode(SwooleResponse::STATUS_CODE_OK)
+ ->dynamic($attribute, $this->getResponseModel());
+ }
+}
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/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php
index fd309a413c..3a53a49579 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php
@@ -290,13 +290,15 @@ class Create extends Action
}
if (isset($attribute['min']) || isset($attribute['max'])) {
- $format = $type === Database::VAR_INTEGER
- ? APP_DATABASE_ATTRIBUTE_INT_RANGE
- : APP_DATABASE_ATTRIBUTE_FLOAT_RANGE;
+ $format = match($type) {
+ Database::VAR_INTEGER => APP_DATABASE_ATTRIBUTE_INT_RANGE,
+ Database::VAR_BIGINT => APP_DATABASE_ATTRIBUTE_BIGINT_RANGE,
+ default => APP_DATABASE_ATTRIBUTE_FLOAT_RANGE,
+ };
$formatOptions = [
- 'min' => $attribute['min'] ?? ($type === Database::VAR_INTEGER ? \PHP_INT_MIN : -\PHP_FLOAT_MAX),
- 'max' => $attribute['max'] ?? ($type === Database::VAR_INTEGER ? \PHP_INT_MAX : \PHP_FLOAT_MAX),
+ 'min' => $attribute['min'] ?? ($type === Database::VAR_INTEGER || $type === Database::VAR_BIGINT ? \PHP_INT_MIN : -\PHP_FLOAT_MAX),
+ 'max' => $attribute['max'] ?? ($type === Database::VAR_INTEGER || $type === Database::VAR_BIGINT ? \PHP_INT_MAX : \PHP_FLOAT_MAX),
];
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php
index 0bd4a2e080..d62782f95e 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php
@@ -3,6 +3,8 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Functions\EventProcessor;
use Appwrite\Platform\Modules\Databases\Http\Databases\Action as DatabasesAction;
@@ -14,17 +16,17 @@ use Utopia\Database\Validator\Authorization;
abstract class Action extends DatabasesAction
{
/**
- * @var string|null The current context (either 'row' or 'document')
+ * @var string The current context (either 'row' or 'document')
*/
- private ?string $context = DOCUMENTS;
- private ?string $databaseType = DATABASE_TYPE_LEGACY;
+ private string $context = DOCUMENTS;
+ private string $databaseType = DATABASE_TYPE_LEGACY;
/**
* Get the response model used in the SDK and HTTP responses.
*/
abstract protected function getResponseModel(): string;
- public function setHttpPath(string $path): DatabasesAction
+ public function setHttpPath(string $path): self
{
if (str_contains($path, '/tablesdb/')) {
$this->context = ROWS;
@@ -47,7 +49,8 @@ abstract class Action extends DatabasesAction
],
];
- return parent::setHttpPath($path);
+ parent::setHttpPath($path);
+ return $this;
}
protected function getDatabasesOperationReadMetric(): string
@@ -406,8 +409,6 @@ abstract class Action extends DatabasesAction
if (\is_array($related)) {
$document->setAttribute($relationship->getAttribute('key'), \array_values($relations));
- } elseif (empty($relations)) {
- $document->setAttribute($relationship->getAttribute('key'), null);
}
}
@@ -422,7 +423,7 @@ abstract class Action extends DatabasesAction
* @param Document[] $documents
* @param Event $queueForEvents
* @param Event $queueForRealtime
- * @param Event $queueForFunctions
+ * @param FunctionPublisher $publisherForFunctions
* @param Event $queueForWebhooks
* @param Database $dbForProject
* @param EventProcessor $eventProcessor
@@ -435,7 +436,7 @@ abstract class Action extends DatabasesAction
array $documents,
Event $queueForEvents,
Event $queueForRealtime,
- Event $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Event $queueForWebhooks,
Database $dbForProject,
EventProcessor $eventProcessor
@@ -473,9 +474,15 @@ abstract class Action extends DatabasesAction
if (!empty($functionsEvents)) {
foreach ($generatedEvents as $event) {
if (isset($functionsEvents[$event])) {
- $queueForFunctions
- ->from($queueForEvents)
- ->trigger();
+ $publisherForFunctions->enqueue(FunctionMessage::fromEvent(
+ event: $queueForEvents->getEvent(),
+ params: $queueForEvents->getParams(),
+ project: $queueForEvents->getProject(),
+ user: $queueForEvents->getUser(),
+ userId: $queueForEvents->getUserId(),
+ payload: $queueForEvents->getPayload(),
+ platform: $queueForEvents->getPlatform(),
+ ));
break;
}
}
@@ -495,7 +502,6 @@ abstract class Action extends DatabasesAction
$queueForEvents->reset();
$queueForRealtime->reset();
- $queueForFunctions->reset();
$queueForWebhooks->reset();
}
}
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 33d5c39182..e0464f7e52 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;
@@ -84,16 +84,17 @@ class Decrement extends Action
->inject('dbForProject')
->inject('getDatabasesDB')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->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, callable $getDatabasesDB, 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, callable $getDatabasesDB, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization, User $user): void
{
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty()) {
@@ -202,10 +203,12 @@ class Decrement extends Action
)
);
- $queueForStatsUsage
+ $usage
->addMetric($this->getDatabasesOperationWriteMetric(), 1)
->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1);
+ $response->dynamic($document, $this->getResponseModel());
+
$queueForEvents
->setParam('databaseId', $databaseId)
->setParam('collectionId', $collectionId)
@@ -215,7 +218,5 @@ class Decrement extends Action
->setContext('database', $database)
->setContext($this->getCollectionsEventsContext(), $collection)
->setPayload($response->getPayload(), sensitive: $relationships);
-
- $response->dynamic($document, $this->getResponseModel());
}
}
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 a418fe6c36..de090f9882 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;
@@ -84,16 +84,17 @@ class Increment extends Action
->inject('dbForProject')
->inject('getDatabasesDB')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->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, callable $getDatabasesDB, 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, callable $getDatabasesDB, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization, User $user): void
{
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty()) {
@@ -202,10 +203,12 @@ class Increment extends Action
)
);
- $queueForStatsUsage
+ $usage
->addMetric($this->getDatabasesOperationWriteMetric(), 1)
->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1);
+ $response->dynamic($document, $this->getResponseModel());
+
$queueForEvents
->setParam('databaseId', $databaseId)
->setParam('collectionId', $collectionId)
@@ -215,7 +218,5 @@ class Increment extends Action
->setContext('database', $database)
->setContext($this->getCollectionsEventsContext(), $collection)
->setPayload($response->getPayload(), sensitive: $relationships);
-
- $response->dynamic($document, $this->getResponseModel());
}
}
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 4db99d4447..2dc3100046 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,7 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Bulk;
use Appwrite\Event\Event;
-use Appwrite\Event\StatsUsage;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Functions\EventProcessor;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Action;
@@ -12,6 +12,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;
@@ -77,17 +78,17 @@ class Delete extends Action
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, 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, callable $getDatabasesDB, Context $usage, Event $queueForEvents, Event $queueForRealtime, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void
{
$database = $dbForProject->getDocument('databases', $databaseId);
if ($database->isEmpty()) {
@@ -187,10 +188,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($this->getDatabasesOperationWriteMetric(), \max(1, $modified))
->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $modified));
@@ -206,7 +207,7 @@ class Delete extends Action
$documents,
$queueForEvents,
$queueForRealtime,
- $queueForFunctions,
+ $publisherForFunctions,
$queueForWebhooks,
$dbForProject,
$eventProcessor
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 fd3697bc76..393590d1e6 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,7 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Bulk;
use Appwrite\Event\Event;
-use Appwrite\Event\StatsUsage;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Functions\EventProcessor;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Action;
@@ -12,6 +12,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;
@@ -81,17 +82,17 @@ class Update extends Action
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string|array $data, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, 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, callable $getDatabasesDB, Context $usage, Event $queueForEvents, Event $queueForRealtime, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void
{
$data = \is_string($data)
? \json_decode($data, true)
@@ -218,10 +219,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($this->getDatabasesOperationWriteMetric(), \max(1, $modified))
->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $modified));
@@ -237,7 +238,7 @@ class Update extends Action
$documents,
$queueForEvents,
$queueForRealtime,
- $queueForFunctions,
+ $publisherForFunctions,
$queueForWebhooks,
$dbForProject,
$eventProcessor
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 13fb5e4c6a..d69298919b 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,7 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Bulk;
use Appwrite\Event\Event;
-use Appwrite\Event\StatsUsage;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Functions\EventProcessor;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Action;
@@ -12,6 +12,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;
@@ -58,7 +59,7 @@ class Upsert extends Action
group: $this->getSDKGroup(),
name: self::getName(),
description: '/docs/references/databases/upsert-documents.md',
- auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: SwooleResponse::STATUS_CODE_CREATED,
@@ -79,17 +80,17 @@ class Upsert extends Action
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, 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, callable $getDatabasesDB, Context $usage, Event $queueForEvents, Event $queueForRealtime, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void
{
$database = $dbForProject->getDocument('databases', $databaseId);
if ($database->isEmpty()) {
@@ -107,7 +108,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) {
@@ -193,10 +194,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($this->getDatabasesOperationWriteMetric(), \max(1, $modified))
->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $modified));
@@ -212,7 +213,7 @@ class Upsert extends Action
$upserted,
$queueForEvents,
$queueForRealtime,
- $queueForFunctions,
+ $publisherForFunctions,
$queueForWebhooks,
$dbForProject,
$eventProcessor
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 4960273631..2ade0b2b79 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,7 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents;
use Appwrite\Event\Event;
-use Appwrite\Event\StatsUsage;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Functions\EventProcessor;
use Appwrite\SDK\AuthType;
@@ -12,6 +12,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;
@@ -50,6 +51,11 @@ class Create extends Action
return UtopiaResponse::MODEL_DOCUMENT_LIST;
}
+ protected function getSupportForEmptyDocument()
+ {
+ return false;
+ }
+
public function __construct()
{
$this
@@ -130,39 +136,51 @@ class Create extends Action
->inject('getDatabasesDB')
->inject('user')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('authorization')
->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, callable $getDatabasesDB, 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, callable $getDatabasesDB, User $user, Event $queueForEvents, Context $usage, Event $queueForRealtime, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, array $plan, Authorization $authorization, EventProcessor $eventProcessor): void
{
$data = \is_string($data)
? \json_decode($data, true)
: $data;
+ $supportsEmptyDocument = $this->getSupportForEmptyDocument();
+ $hasData = !empty($data);
+ $hasDocuments = !empty($documents);
+
/**
* Determine which internal path to call, single or bulk
*/
- if (empty($data) && empty($documents)) {
+ if (!$supportsEmptyDocument && !$hasData && !$hasDocuments) {
// No single or bulk documents provided
throw new Exception($this->getMissingDataException());
}
- if (!empty($data) && !empty($documents)) {
+
+ // When empty documents are supported, an empty payload should still be treated as single create.
+ if ($supportsEmptyDocument && !$hasData && !$hasDocuments) {
+ $data = [];
+ $hasData = true;
+ }
+
+ if ($hasData && $hasDocuments) {
// Both single and bulk documents provided
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'You can only send one of the following parameters: data, ' . $this->getSDKGroup());
}
- if (!empty($data) && empty($documentId)) {
+ if ($hasData && empty($documentId)) {
// Single document provided without document ID
$document = $this->isCollectionsAPI() ? 'Document' : 'Row';
$message = "$document ID is required when creating a single " . strtolower($document) . '.';
throw new Exception($this->getMissingDataException(), $message);
}
- if (!empty($documents) && !empty($documentId)) {
+ if ($hasDocuments && !empty($documentId)) {
// Bulk documents provided with document ID
$documentId = $this->isCollectionsAPI() ? 'documentId' : 'rowId';
throw new Exception(
@@ -170,21 +188,21 @@ class Create extends Action
"Param \"$documentId\" is not allowed when creating multiple " . $this->getSDKGroup() . ', set "$id" on each instead.'
);
}
- if (!empty($documents) && !empty($permissions)) {
+ if ($hasDocuments && !empty($permissions)) {
// Bulk documents provided with permissions
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "permissions" is disallowed when creating multiple ' . $this->getSDKGroup() . ', set "$permissions" on each instead');
}
- $isBulk = true;
- if (!empty($data)) {
+ $isBulk = $hasDocuments;
+ if ($hasData) {
// Single document provided, convert to single item array
// But remember that it was single to respond with a single document
$isBulk = false;
$documents = [$data];
}
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($isBulk && !$isAPIKey && !$isPrivilegedUser) {
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE);
@@ -206,10 +224,10 @@ 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) {
+ $setPermissions = function (Document $document, ?array $permissions) use ($user, $isAPIKey, $isPrivilegedUser, $isBulk, $authorization) {
$allowedPermissions = [
Database::PERMISSION_READ,
Database::PERMISSION_UPDATE,
@@ -276,16 +294,6 @@ class Create extends Action
throw new Exception(Exception::USER_UNAUTHORIZED, $authorization->getDescription());
}
- if ($permission === Database::PERMISSION_UPDATE) {
- $validDocument = $authorization->isValid(
- new Input($permission, $document->getUpdate())
- );
- $valid = $validCollection || $validDocument;
- if ($documentSecurity && !$valid) {
- throw new Exception(Exception::USER_UNAUTHORIZED, $authorization->getDescription());
- }
- }
-
$relationships = \array_filter(
$collection->getAttribute('attributes', []),
fn ($attribute) => $attribute->getAttribute('type') === Database::VAR_RELATIONSHIP
@@ -491,7 +499,7 @@ class Create extends Action
);
}
- $queueForStatsUsage
+ $usage
->addMetric($this->getDatabasesOperationWriteMetric(), \max(1, $operations))
->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $operations)); // per collection
@@ -510,7 +518,7 @@ class Create extends Action
$created,
$queueForEvents,
$queueForRealtime,
- $queueForFunctions,
+ $publisherForFunctions,
$queueForWebhooks,
$dbForProject,
$eventProcessor
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 bfe83d1a6a..ecc5b152ec 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;
@@ -81,10 +81,11 @@ class Delete extends Action
->inject('dbForProject')
->inject('getDatabasesDB')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('transactionState')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
@@ -98,15 +99,16 @@ class Delete extends Action
Database $dbForProject,
callable $getDatabasesDB,
Event $queueForEvents,
- StatsUsage $queueForStatsUsage,
+ Context $usage,
TransactionState $transactionState,
array $plan,
- Authorization $authorization
+ Authorization $authorization,
+ User $user
): void {
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]);
@@ -213,9 +215,9 @@ class Delete extends Action
authorization: $authorization
);
- $queueForStatsUsage
- ->addMetric($this->getDatabasesOperationWriteMetric(), 1)
- ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1); // per collection
+ $usage
+ ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, 1)
+ ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), 1); // per collection
$response->addHeader('X-Debug-Operations', 1);
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 70a5c84462..06f0e9cf1c 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;
@@ -69,16 +69,17 @@ class Get extends Action
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('transactionState')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $documentId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, StatsUsage $queueForStatsUsage, TransactionState $transactionState, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, string $documentId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, User $user): void
{
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
@@ -99,7 +100,7 @@ class Get extends Action
}
try {
- $selects = Query::groupByType($queries)['selections'] ?? [];
+ $selects = Query::groupByType($queries)['selections'];
$collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence();
$collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence();
@@ -133,7 +134,7 @@ class Get extends Action
operations: $operations
);
- $queueForStatsUsage
+ $usage
->addMetric($this->getDatabasesOperationReadMetric(), max($operations, 1))
->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), $operations);
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php
index 4588e3666b..8aaac5fcb4 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php
@@ -131,6 +131,7 @@ class XList extends Action
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
+ 'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
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 e1cc2b03cd..b86d934ffb 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;
@@ -85,14 +85,15 @@ class Update extends Action
->inject('dbForProject')
->inject('getDatabasesDB')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('transactionState')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->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, callable $getDatabasesDB, 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, callable $getDatabasesDB, Event $queueForEvents, Context $usage, TransactionState $transactionState, array $plan, Authorization $authorization, User $user): void
{
$data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array
@@ -102,8 +103,8 @@ class Update extends Action
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]);
@@ -121,7 +122,6 @@ class Update extends Action
$dbForDatabases = $getDatabasesDB($database);
// Read permission should not be required for update
- /** @var Document $document */
$collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence();
if ($transactionId !== null) {
@@ -248,7 +248,7 @@ class Update extends Action
$setCollection($collection, $newDocument);
- $queueForStatsUsage
+ $usage
->addMetric($this->getDatabasesOperationWriteMetric(), max($operations, 1))
->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), $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 b819931f86..fb3d414097 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;
@@ -89,14 +89,14 @@ class Upsert extends Action
->inject('dbForProject')
->inject('getDatabasesDB')
->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, callable $getDatabasesDB, 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, User $user, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, TransactionState $transactionState, array $plan, Authorization $authorization): void
{
$data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array
@@ -108,8 +108,8 @@ class Upsert extends Action
throw new Exception($this->getMissingPayloadException());
}
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
@@ -260,7 +260,7 @@ class Upsert extends Action
$setCollection($collection, $newDocument);
- $queueForStatsUsage
+ $usage
->addMetric($this->getDatabasesOperationWriteMetric(), \max(1, $operations))
->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $operations));
@@ -353,12 +353,7 @@ class Upsert extends Action
$collectionsCache = [];
if (empty($upserted[0])) {
- if ($transactionId !== null) {
- // For transactions, get the document with transaction changes applied
- $upserted[0] = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId);
- } else {
- $upserted[0] = $dbForDatabases->getDocument($collectionTableId, $documentId);
- }
+ $upserted[0] = $dbForDatabases->getDocument($collectionTableId, $documentId);
}
$document = $upserted[0];
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 ef8de7ba57..3a49d6c665 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,24 +3,26 @@
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;
use Utopia\Database\Validator\UID;
use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
+use Utopia\Http\Http;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Nullable;
@@ -71,21 +73,22 @@ class XList extends Action
->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), '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.', true)
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject'])
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
- ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true)
+ ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for caching list responses. Responses are stored in an in-memory key-value cache, keyed per project, collection, schema version (attributes and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; document writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours).', true)
->inject('response')
->inject('dbForProject')
->inject('user')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('transactionState')
->inject('authorization')
+ ->inject('utopia')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, Document $user, callable $getDatabasesDB, 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, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, ?Http $utopia = null): void
{
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
@@ -125,85 +128,75 @@ class XList extends Action
$cursor->setValue($cursorDocument);
}
+ $dbStart = \microtime(true);
+
try {
- $selectQueries = Query::groupByType($queries)['selections'] ?? [];
+ $hasSelects = ! empty(Query::groupByType($queries)['selections']);
$collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence();
+ // When there are no select queries, relationship loading is skipped on the
+ // underlying find() to avoid pulling related documents the caller did not ask for.
+ $find = $hasSelects
+ ? fn () => $dbForDatabases->find($collectionTableId, $queries)
+ : fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries));
+
// Use transaction-aware document retrieval if transactionId is provided
if ($transactionId !== null) {
$documents = $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries);
$total = $includeTotal ? $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries) : 0;
- } elseif (! empty($selectQueries)) {
+ } elseif ((int)$ttl > 0) {
+ $cacheKey = $this->getListCacheKey($dbForProject, $collectionId);
+ $roles = $dbForProject->getAuthorization()->getRoles();
+ $documentsField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_DOCUMENTS);
- if ((int)$ttl > 0) {
- $serializedQueries = [];
- foreach ($queries as $query) {
- $serializedQueries[] = $query instanceof Query ? $query->toArray() : $query;
- }
-
- $hostname = $dbForProject->getAdapter()->getHostname();
- $roles = $dbForProject->getAuthorization()->getRoles();
- $schemaHash = \md5(\json_encode($collection->getAttribute('attributes', [])) . \json_encode($collection->getAttribute('indexes', [])));
- $cacheKeyBase = \sprintf(
- '%s-cache-%s:%s:%s:collection:%s:%s:user:%s:%s',
- $dbForProject->getCacheName(),
- $hostname ?? '',
- $dbForProject->getNamespace(),
- $dbForProject->getTenant(),
- $collectionId,
- $schemaHash,
- \md5(\json_encode($roles)),
- \md5(\json_encode($serializedQueries))
- );
-
- $documentsCacheKey = $cacheKeyBase . ':documents';
- $totalCacheKey = $cacheKeyBase . ':total';
-
- $documentsCacheHit = $totalDocumentsCacheHit = false;
-
- $cachedDocuments = $dbForProject->getCache()->load($documentsCacheKey, $ttl);
-
- if ($cachedDocuments !== null &&
- $cachedDocuments !== false &&
- \is_array($cachedDocuments)) {
- $documents = \array_map(function ($doc) {
- return new Document($doc);
- }, $cachedDocuments);
- $documentsCacheHit = true;
- } else {
- $documents = $dbForDatabases->find($collectionTableId, $queries);
-
- // Convert Document objects to arrays for caching
- $documentsArray = \array_map(function ($doc) {
- return $doc->getArrayCopy();
- }, $documents);
- $dbForProject->getCache()->save($documentsCacheKey, $documentsArray);
- }
-
- if ($includeTotal) {
- $cachedTotal = $dbForProject->getCache()->load($totalCacheKey, $ttl);
- if ($cachedTotal !== null && $cachedTotal !== false) {
- $total = $cachedTotal;
- $totalDocumentsCacheHit = true;
- } else {
- $total = $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT);
- $dbForProject->getCache()->save($totalCacheKey, $total);
- }
- } else {
- $total = 0;
- }
-
- $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss');
-
- } else {
- // has selects, allow relationship on documents
- $documents = $dbForDatabases->find($collectionTableId, $queries);
- $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0;
+ $documentsCacheHit = false;
+ try {
+ $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField);
+ } catch (\Throwable) {
+ $cachedDocuments = null;
}
+ if ($cachedDocuments !== null &&
+ $cachedDocuments !== false &&
+ \is_array($cachedDocuments)) {
+ $documents = \array_map(function ($doc) {
+ return new Document($doc);
+ }, $cachedDocuments);
+ $documentsCacheHit = true;
+ } else {
+ $documents = $find();
+
+ $documentsArray = \array_map(function ($doc) {
+ return $doc->getArrayCopy();
+ }, $documents);
+ try {
+ $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField);
+ } catch (\Throwable) {
+ }
+ }
+
+ if ($includeTotal) {
+ $totalField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_TOTAL);
+ try {
+ $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField);
+ } catch (\Throwable) {
+ $cachedTotal = null;
+ }
+ if ($cachedTotal !== null && $cachedTotal !== false) {
+ $total = (int) $cachedTotal;
+ } else {
+ $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT);
+ try {
+ $dbForProject->getCache()->save($cacheKey, $total, $totalField);
+ } catch (\Throwable) {
+ }
+ }
+ } else {
+ $total = 0;
+ }
+
+ $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss');
} else {
- // has no selects, disable relationship loading on documents
- /* @type Document[] $documents */
- $documents = $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries));
+ $documents = $find();
$total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0;
}
} catch (OrderException $e) {
@@ -213,8 +206,12 @@ 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);
}
+ $dbDurationMs = (\microtime(true) - $dbStart) * 1000;
+
$operations = 0;
$collectionsCache = [];
foreach ($documents as $document) {
@@ -229,7 +226,7 @@ class XList extends Action
);
}
- $queueForStatsUsage
+ $usage
->addMetric($this->getDatabasesOperationReadMetric(), max($operations, 1))
->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), $operations);
@@ -238,5 +235,20 @@ class XList extends Action
// rows or documents
$this->getSDKGroup() => $documents,
]), $this->getResponseModel());
+
+ try {
+ $this->afterQuery($dbDurationMs, $database, $collection, $queries, $utopia);
+ } catch (\Throwable) {
+ // Observers must never break the response.
+ }
+ }
+
+ /**
+ * After query hook.
+ *
+ * @param array $queries
+ */
+ protected function afterQuery(float $dbDurationMs, Document $database, Document $collection, array $queries, ?Http $utopia): void
+ {
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php
index 400d716e41..251e493cb6 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php
@@ -10,7 +10,7 @@ abstract class Action extends UtopiaAction
/**
* The current API context (either 'columnIndex' or 'index').
*/
- private ?string $context = INDEX;
+ private string $context = INDEX;
/**
* Get the response model used in the SDK and HTTP responses.
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Logs/XList.php
index 19b1cbbde1..cc082532f9 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Logs/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Logs/XList.php
@@ -123,6 +123,7 @@ class XList extends Action
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
+ 'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'] ?? null,
'time' => $log['time'] ?? null,
'osCode' => $os['osCode'] ?? null,
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php
index 5d9d425d71..800df6d044 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php
@@ -68,6 +68,7 @@ class Update extends Action
->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true)
->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true)
->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true)
+ ->param('purge', false, new Boolean(true), 'When true, purge all cached list responses for this collection as part of the update. Use this to force readers to see fresh data immediately instead of waiting for the cache TTL to expire.', true)
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
@@ -76,7 +77,7 @@ class Update extends Action
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, ?string $name, ?array $permissions, bool $documentSecurity, bool $enabled, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, ?string $name, ?array $permissions, bool $documentSecurity, bool $enabled, bool $purge, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void
{
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty()) {
@@ -99,8 +100,6 @@ class Update extends Action
// Map aggregate permissions into the multiple permissions they represent.
$permissions = Permission::aggregate($permissions);
- $enabled ??= $collection->getAttribute('enabled', true);
-
$collection = $dbForProject->updateDocument(
'database_' . $database->getSequence(),
$collectionId,
@@ -119,6 +118,10 @@ class Update extends Action
->setParam('databaseId', $databaseId)
->setParam($this->getEventsParamKey(), $collection->getId());
+ if ($purge) {
+ $this->purgeListCache($dbForProject, $collectionId);
+ }
+
$this->addRowBytesInfo($collection, $dbForProject);
$response->dynamic($collection, $this->getResponseModel());
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php
index 37213f1061..bea367af36 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php
@@ -119,6 +119,7 @@ class Get extends Action
$format = match ($days['period']) {
'1h' => 'Y-m-d\TH:00:00.000P',
'1d' => 'Y-m-d\T00:00:00.000P',
+ default => throw new \LogicException('Unexpected period: ' . $days['period']),
};
foreach ($metrics as $metric) {
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php
index 3585bc4477..294a6712a9 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php
@@ -49,11 +49,6 @@ class Create extends Action
$databaseOverride = '';
$dbScheme = '';
$databaseSharedTables = [];
- $databaseSharedTablesV1 = [];
- $databaseSharedTablesV2 = [];
- $projectSharedTables = [];
- $projectSharedTablesV1 = [];
- $projectSharedTablesV2 = [];
switch ($databasetype) {
case DOCUMENTSDB:
@@ -61,16 +56,14 @@ class Create extends Action
$databaseKeys = System::getEnv('_APP_DATABASE_DOCUMENTSDB_KEYS', '');
$databaseOverride = System::getEnv('_APP_DATABASE_DOCUMENTSDB_OVERRIDE');
$dbScheme = System::getEnv('_APP_DB_HOST_DOCUMENTSDB', 'mongodb');
- $databaseSharedTables = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', ''));
- $databaseSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', ''));
+ $databaseSharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', '')));
break;
case VECTORSDB:
$databases = Config::getParam('pools-vectorsdb', []);
$databaseKeys = System::getEnv('_APP_DATABASE_VECTORSDB_KEYS', '');
$databaseOverride = System::getEnv('_APP_DATABASE_VECTORSDB_OVERRIDE');
$dbScheme = System::getEnv('_APP_DB_HOST_VECTORSDB', 'postgresql');
- $databaseSharedTables = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''));
- $databaseSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', ''));
+ $databaseSharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', '')));
break;
default:
// legacy/tablesdb
@@ -78,8 +71,7 @@ class Create extends Action
return $dsn;
}
- $isSharedTablesV1 = false;
- $isSharedTablesV2 = false;
+ $isSharedTables = false;
if (!empty($dsn)) {
try {
@@ -90,10 +82,7 @@ class Create extends Action
}
$projectSharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
- $projectSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', ''));
- $projectSharedTablesV2 = \array_diff($projectSharedTables, $projectSharedTablesV1);
- $isSharedTablesV1 = \in_array($dsnHost, $projectSharedTablesV1);
- $isSharedTablesV2 = \in_array($dsnHost, $projectSharedTablesV2);
+ $isSharedTables = \in_array($dsnHost, $projectSharedTables);
}
if ($region !== 'default') {
@@ -102,23 +91,22 @@ class Create extends Action
return str_contains($value, $region);
});
}
- $databaseSharedTablesV2 = \array_diff($databaseSharedTables, $databaseSharedTablesV1);
$index = \array_search($databaseOverride, $databases);
if ($index !== false) {
$selectedDsn = $databases[$index];
} else {
- if (!empty($dsn)) {
- $beforeFilter = \array_values($databases);
- if ($isSharedTablesV1) {
- $databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTablesV1));
- } elseif ($isSharedTablesV2) {
- $databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTablesV2));
+ if (!empty($dsn) && !empty($databaseSharedTables)) {
+ if ($isSharedTables) {
+ $databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTables));
} else {
$databases = array_filter($databases, fn ($value) => !\in_array($value, $databaseSharedTables));
}
}
- $selectedDsn = !empty($databases) ? $databases[array_rand($databases)] : '';
+ if (empty($databases)) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, "No {$databasetype} database pool available for the current shared-tables mode");
+ }
+ $selectedDsn = $databases[array_rand($databases)];
}
if (\in_array($selectedDsn, $databaseSharedTables)) {
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/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php
index 0c0f5f1273..a13c6c4903 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Logs;
+use Appwrite\Detector\Detector;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -9,7 +10,6 @@ use Appwrite\SDK\Deprecated;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response as UtopiaResponse;
-use DeviceDetector\DeviceDetector as Detector;
use MaxMind\Db\Reader;
use Utopia\Audit\Audit;
use Utopia\Database\Database;
@@ -103,6 +103,9 @@ class XList extends Action
$os = $detector->getOS();
$client = $detector->getClient();
$device = $detector->getDevice();
+ $deviceName = $device['deviceName'] ?? '';
+ $deviceBrand = $device['deviceBrand'] ?? '';
+ $deviceModel = $device['deviceModel'] ?? '';
$output[$i] = new Document([
'event' => $log['event'],
@@ -110,6 +113,7 @@ class XList extends Action
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
+ 'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
@@ -121,9 +125,9 @@ class XList extends Action
'clientVersion' => $client['clientVersion'],
'clientEngine' => $client['clientEngine'],
'clientEngineVersion' => $client['clientEngineVersion'],
- 'deviceName' => $device['deviceName'],
- 'deviceBrand' => $device['deviceBrand'],
- 'deviceModel' => $device['deviceModel'],
+ 'deviceName' => $deviceName,
+ 'deviceBrand' => $deviceBrand,
+ 'deviceModel' => $deviceModel,
]);
$record = $geodb->get($log['ip']);
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php
index f3edf010d4..ccf9632fef 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php
@@ -9,8 +9,8 @@ abstract class Action extends DatabasesAction
/**
* The current API context (either 'table' or 'collection').
*/
- private ?string $context = COLLECTIONS;
- private ?string $databaseType = LEGACY;
+ private string $context = COLLECTIONS;
+ private string $databaseType = LEGACY;
public function getDatabaseType(): string
{
@@ -33,7 +33,7 @@ abstract class Action extends DatabasesAction
return $this->databaseType.'.'.METRIC_DATABASE_ID_OPERATIONS_WRITES;
}
- public function setHttpPath(string $path): DatabasesAction
+ public function setHttpPath(string $path): self
{
switch (true) {
case str_contains($path, '/tablesdb'):
@@ -50,7 +50,8 @@ abstract class Action extends DatabasesAction
$this->databaseType = VECTORSDB;
break;
}
- return parent::setHttpPath($path);
+ parent::setHttpPath($path);
+ return $this;
}
/**
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php
index 26457cc4a0..f06feccdee 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php
@@ -65,17 +65,18 @@ class Create extends Action
->inject('transactionState')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
- public function action(string $transactionId, array $operations, UtopiaResponse $response, Database $dbForProject, TransactionState $transactionState, array $plan, Authorization $authorization): void
+ public function action(string $transactionId, array $operations, UtopiaResponse $response, Database $dbForProject, TransactionState $transactionState, array $plan, Authorization $authorization, User $user): void
{
if (empty($operations)) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Operations array cannot be empty');
}
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
// API keys and admins can read any transaction, regular users need permissions
$transaction = ($isAPIKey || $isPrivilegedUser)
@@ -238,7 +239,7 @@ class Create extends Action
}
}
- $transaction = $authorization->skip(fn () => $dbForProject->withTransaction(function () use ($dbForProject, $transactionId, $staged, $existing, $operations) {
+ $transaction = $authorization->skip(fn () => $dbForProject->withTransaction(function () use ($dbForProject, $transactionId, $staged, $operations) {
$dbForProject->createDocuments('transactionLogs', $staged);
return $dbForProject->increaseDocumentAttribute(
'transactions',
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 d2f7124aa1..4f91ba3f94 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,15 @@ 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\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
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;
@@ -75,9 +77,9 @@ class Update extends Action
->inject('transactionState')
->inject('queueForDeletes')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('authorization')
->inject('eventProcessor')
@@ -91,13 +93,13 @@ class Update extends Action
* @param UtopiaResponse $response
* @param Database $dbForProject
* @param callable $getDatabasesDB
- * @param Document $user
+ * @param User $user
* @param TransactionState $transactionState
* @param Delete $queueForDeletes
* @param Event $queueForEvents
- * @param StatsUsage $queueForStatsUsage
+ * @param Context $usage
* @param Event $queueForRealtime
- * @param Event $queueForFunctions
+ * @param FunctionPublisher $publisherForFunctions
* @param Event $queueForWebhooks
* @param EventProcessor $eventProcessor
* @return void
@@ -105,11 +107,10 @@ class Update extends Action
* @throws Exception
* @throws \Throwable
* @throws \Utopia\Database\Exception
- * @throws Authorization
- * @throws Structure
+ * @throws StructureException
* @throws \Utopia\Http\Exception
*/
- public function action(string $transactionId, bool $commit, bool $rollback, Document $project, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, 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, Document $project, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, User $user, TransactionState $transactionState, Delete $queueForDeletes, Event $queueForEvents, Context $usage, Event $queueForRealtime, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, Authorization $authorization, EventProcessor $eventProcessor): void
{
if (!$commit && !$rollback) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Either commit or rollback must be true');
@@ -118,8 +119,8 @@ class Update extends Action
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Cannot commit and rollback at the same time');
}
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$transaction = ($isAPIKey || $isPrivilegedUser)
? $authorization->skip(fn () => $dbForProject->getDocument('transactions', $transactionId))
@@ -183,19 +184,33 @@ class Update extends Action
$dbForDatabases = $getDatabasesDB($databaseDoc);
try {
- $dbForDatabases->withTransaction(function () use ($dbForDatabases, $dbForProject, $transactionState, $queueForDeletes, $transactionId, &$transaction, &$operations, &$totalOperations, &$databaseOperations, &$currentDocumentId, $queueForEvents, $queueForStatsUsage, $queueForRealtime, $queueForFunctions, $queueForWebhooks, $authorization) {
- $authorization->skip(fn () => $dbForProject->updateDocument('transactions', $transactionId, new Document([
- 'status' => 'committing',
- ])));
+ $transaction = $authorization->skip(fn () => $dbForProject->updateDocument(
+ 'transactions',
+ $transactionId,
+ new Document(['status' => 'committing'])
+ ));
- $operations = $authorization->skip(fn () => $dbForProject->find('transactionLogs', [
- Query::equal('transactionInternalId', [$transaction->getSequence()]),
- Query::orderAsc(),
- Query::limit(PHP_INT_MAX),
- ]));
+ $operations = $authorization->skip(fn () => $dbForProject->find('transactionLogs', [
+ Query::equal('transactionInternalId', [$transaction->getSequence()]),
+ Query::orderAsc(),
+ Query::limit(PHP_INT_MAX),
+ ]));
+ $collections = [];
+ foreach ($operations as $operation) {
+ $databaseInternalId = $operation['databaseInternalId'];
+ $collectionInternalId = $operation['collectionInternalId'];
+ $collectionId = "database_{$databaseInternalId}_collection_{$collectionInternalId}";
+
+ if (!isset($collections[$collectionId])) {
+ $collections[$collectionId] = $authorization->skip(
+ fn () => $dbForProject->getCollection($collectionId)
+ );
+ }
+ }
+
+ $dbForDatabases->withTransaction(function () use ($dbForDatabases, $transactionState, &$operations, &$totalOperations, &$databaseOperations, &$currentDocumentId, $collections) {
$state = [];
- $collections = [];
foreach ($operations as $operation) {
$databaseInternalId = $operation['databaseInternalId'];
@@ -211,11 +226,6 @@ class Update extends Action
$data = $data->getArrayCopy();
}
- if (!isset($collections[$collectionId])) {
- $collections[$collectionId] = $authorization->skip(
- fn () => $dbForProject->getCollection($collectionId)
- );
- }
$collection = $collections[$collectionId];
if (\is_array($data) && !empty($data)) {
@@ -277,16 +287,17 @@ class Update extends Action
}
}
- $transaction = $authorization->skip(fn () => $dbForProject->updateDocument(
- 'transactions',
- $transactionId,
- new Document(['status' => 'committed'])
- ));
-
- $queueForDeletes
- ->setType(DELETE_TYPE_DOCUMENT)
- ->setDocument($transaction);
});
+
+ $transaction = $authorization->skip(fn () => $dbForProject->updateDocument(
+ 'transactions',
+ $transactionId,
+ new Document(['status' => 'committed'])
+ ));
+
+ $queueForDeletes
+ ->setType(DELETE_TYPE_DOCUMENT)
+ ->setDocument($transaction);
} catch (NotFoundException $e) {
$authorization->skip(fn () => $dbForProject->updateDocument('transactions', $transactionId, new Document([
'status' => 'failed',
@@ -320,11 +331,10 @@ class Update extends Action
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
- $queueForStatsUsage
- ->addMetric($this->getDatabasesOperationWriteMetric(), $totalOperations);
+ $usage->addMetric($this->getDatabasesOperationWriteMetric(), $totalOperations);
foreach ($databaseOperations as $sequence => $count) {
- $queueForStatsUsage->addMetric(
+ $usage->addMetric(
str_replace('{databaseInternalId}', $sequence, $this->getDatabasesIdOperationWriteMetric()),
$count
);
@@ -453,7 +463,15 @@ class Update extends Action
if (!empty($functionsEvents)) {
foreach ($generatedEvents as $event) {
if (isset($functionsEvents[$event])) {
- $queueForFunctions->from($queueForEvents)->trigger();
+ $publisherForFunctions->enqueue(FunctionMessage::fromEvent(
+ event: $queueForEvents->getEvent(),
+ params: $queueForEvents->getParams(),
+ project: $queueForEvents->getProject(),
+ user: $queueForEvents->getUser(),
+ userId: $queueForEvents->getUserId(),
+ payload: $queueForEvents->getPayload(),
+ platform: $queueForEvents->getPlatform(),
+ ));
break;
}
}
@@ -472,7 +490,6 @@ class Update extends Action
$queueForEvents->reset();
$queueForRealtime->reset();
- $queueForFunctions->reset();
$queueForWebhooks->reset();
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php
index 18e6fd7a8b..240e7d400c 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php
@@ -144,6 +144,7 @@ class Get extends Action
$format = match ($days['period']) {
'1h' => 'Y-m-d\TH:00:00.000P',
'1d' => 'Y-m-d\T00:00:00.000P',
+ default => throw new \LogicException('Unexpected period: ' . $days['period']),
};
foreach ($metrics as $metric) {
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php
index b8cb774a3e..db73954e7f 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php
@@ -133,6 +133,7 @@ class XList extends Action
$format = match ($days['period']) {
'1h' => 'Y-m-d\TH:00:00.000P',
'1d' => 'Y-m-d\T00:00:00.000P',
+ default => throw new \LogicException('Unexpected period: ' . $days['period']),
};
foreach ($metrics as $metric) {
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/XList.php
index e0ffeb8149..21dbc83edc 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/XList.php
@@ -27,6 +27,11 @@ class XList extends Action
return 'listDatabases';
}
+ protected function getDatabaseTypeQueryFilters(): array
+ {
+ return [Query::equal('type', [$this->getDatabaseType()])];
+ }
+
public function __construct()
{
$this
@@ -91,9 +96,8 @@ class XList extends Action
$cursor->setValue($cursorDocument);
}
- $queries[] = Query::equal('type', [$this->getDatabaseType()]);
-
try {
+ $queries = array_merge($queries, $this->getDatabaseTypeQueryFilters());
$databases = $dbForProject->find('databases', $queries);
$total = $includeTotal ? $dbForProject->count('databases', $queries, APP_LIMIT_COUNT) : 0;
} catch (OrderException $e) {
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Decrement.php
index 39a6eba228..6d986fc6b1 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Decrement.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Decrement.php
@@ -65,9 +65,10 @@ class Decrement extends DecrementDocumentAttribute
->inject('dbForProject')
->inject('getDatabasesDB')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Increment.php
index 4fbad6ea85..09def76941 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Increment.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Increment.php
@@ -65,9 +65,10 @@ class Increment extends IncrementDocumentAttribute
->inject('dbForProject')
->inject('getDatabasesDB')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Delete.php
index 094bba2b9d..6b2910aac4 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Delete.php
@@ -60,10 +60,10 @@ class Delete extends DocumentsDelete
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Update.php
index 8ada425fd7..f395d0b490 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Update.php
@@ -62,10 +62,10 @@ class Update extends DocumentsUpdate
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Upsert.php
index 2976ffae34..5acc4626af 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Upsert.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Upsert.php
@@ -62,10 +62,10 @@ class Upsert extends DocumentsUpsert
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php
index 8243f038d4..2df96958ad 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php
@@ -34,6 +34,12 @@ class Create extends DocumentCreate
return UtopiaResponse::MODEL_DOCUMENT_LIST;
}
+ protected function getSupportForEmptyDocument()
+ {
+ return true;
+ }
+
+
public function __construct()
{
$this
@@ -104,9 +110,9 @@ class Create extends DocumentCreate
->inject('getDatabasesDB')
->inject('user')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('authorization')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Delete.php
index 9b331d9448..0253c287aa 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Delete.php
@@ -67,10 +67,11 @@ class Delete extends DocumentDelete
->inject('dbForProject')
->inject('getDatabasesDB')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('transactionState')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Get.php
index f52ab78d61..47d352bf98 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Get.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Get.php
@@ -56,9 +56,10 @@ class Get extends DocumentGet
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('transactionState')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php
deleted file mode 100644
index cc7fe41555..0000000000
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php
+++ /dev/null
@@ -1,59 +0,0 @@
-setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
- ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId/logs')
- ->desc('List document logs')
- ->groups(['api', 'database'])
- ->label('scope', 'documents.read')
- ->label('resourceType', RESOURCE_TYPE_DATABASES)
- ->label('sdk', new Method(
- namespace: 'documentsDB',
- group: 'logs',
- name: 'listDocumentLogs',
- description: '/docs/references/documentsdb/get-document-logs.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: SwooleResponse::STATUS_CODE_OK,
- model: $this->getResponseModel(),
- )
- ],
- contentType: ContentType::JSON,
- ))
- ->param('databaseId', '', new UID(), 'Database ID.')
- ->param('collectionId', '', new UID(), 'Collection ID.')
- ->param('documentId', '', new UID(), 'Document ID.')
- ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true)
- ->inject('response')
- ->inject('dbForProject')
- ->inject('getDatabasesDB')
- ->inject('locale')
- ->inject('geodb')
- ->inject('authorization')
- ->inject('audit')
- ->callback($this->action(...));
- }
-}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Update.php
index 8cb14adf9b..9e79bb5464 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Update.php
@@ -66,10 +66,11 @@ class Update extends DocumentUpdate
->inject('dbForProject')
->inject('getDatabasesDB')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('transactionState')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Upsert.php
index 377f57ff4b..448c2d44bc 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Upsert.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Upsert.php
@@ -69,7 +69,7 @@ class Upsert extends DocumentUpsert
->inject('dbForProject')
->inject('getDatabasesDB')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('transactionState')
->inject('plan')
->inject('authorization')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php
index dac0047be2..51c0d67e8a 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php
@@ -60,9 +60,10 @@ class XList extends DocumentXList
->inject('dbForProject')
->inject('user')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('transactionState')
->inject('authorization')
+ ->inject('utopia')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php
index 3aee3ebcb1..dc3ce34605 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php
@@ -58,7 +58,7 @@ class Create extends IndexCreate
->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).', false, ['dbForProject'])
->param('key', null, fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Index Key.', false, ['dbForProject'])
- ->param('type', null, new WhiteList([Database::INDEX_KEY, Database::INDEX_FULLTEXT, Database::INDEX_UNIQUE, Database::INDEX_SPATIAL]), 'Index type.')
+ ->param('type', null, new WhiteList([Database::INDEX_KEY, Database::INDEX_FULLTEXT, Database::INDEX_UNIQUE]), 'Index type.')
->param('attributes', null, fn (Database $dbForProject) => new ArrayList(new Key(true, $dbForProject->getAdapter()->getMaxUIDLength()), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of attributes to index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' attributes are allowed, each 32 characters long.', false, ['dbForProject'])
->param('orders', [], new ArrayList(new WhiteList(['ASC', 'DESC'], false, Database::VAR_STRING), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of index orders. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' orders are allowed.', true)
->param('lengths', [], new ArrayList(new Nullable(new Integer()), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Length of index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE, optional: true)
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php
deleted file mode 100644
index 51695ea165..0000000000
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php
+++ /dev/null
@@ -1,58 +0,0 @@
-setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
- ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/logs')
- ->desc('List collection logs')
- ->groups(['api', 'database'])
- ->label('scope', 'collections.read')
- ->label('resourceType', RESOURCE_TYPE_DATABASES)
- ->label('sdk', new Method(
- namespace: 'documentsDB',
- group: $this->getSdkGroup(),
- name: 'listCollectionLogs',
- description: '/docs/references/documentsdb/get-collection-logs.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: SwooleResponse::STATUS_CODE_OK,
- model: $this->getResponseModel(),
- )
- ],
- contentType: ContentType::JSON
- ))
- ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
- ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject'])
- ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true)
- ->inject('response')
- ->inject('dbForProject')
- ->inject('locale')
- ->inject('geodb')
- ->inject('authorization')
- ->inject('audit')
- ->callback($this->action(...));
- }
-}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php
index 052970fec4..3acedc0379 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php
@@ -58,6 +58,7 @@ class Update extends CollectionUpdate
->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true)
->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true)
->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true)
+ ->param('purge', false, new Boolean(true), 'When true, purge all cached list responses for this collection as part of the update. Use this to force readers to see fresh data immediately instead of waiting for the cache TTL to expire.', true)
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php
index 5fff3131e0..1708656c98 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php
@@ -50,7 +50,7 @@ class Delete extends DatabaseDelete
->inject('dbForProject')
->inject('queueForDatabase')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Operations/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Operations/Create.php
index bc15d440d1..963af2f43e 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Operations/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Operations/Create.php
@@ -55,6 +55,7 @@ class Create extends OperationsCreate
->inject('transactionState')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Update.php
index 20e418c31f..97eff24508 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Update.php
@@ -58,9 +58,9 @@ class Update extends TransactionsUpdate
->inject('transactionState')
->inject('queueForDeletes')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('authorization')
->inject('eventProcessor')
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/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php
index 6754179425..ccb421b36d 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php
@@ -2,13 +2,13 @@
namespace Appwrite\Platform\Modules\Databases\Http\TablesDB\Logs;
+use Appwrite\Detector\Detector;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response as UtopiaResponse;
-use DeviceDetector\DeviceDetector as Detector;
use MaxMind\Db\Reader;
use Utopia\Audit\Audit;
use Utopia\Database\Database;
@@ -97,6 +97,9 @@ class XList extends Action
$os = $detector->getOS();
$client = $detector->getClient();
$device = $detector->getDevice();
+ $deviceName = $device['deviceName'] ?? '';
+ $deviceBrand = $device['deviceBrand'] ?? '';
+ $deviceModel = $device['deviceModel'] ?? '';
$output[$i] = new Document([
'event' => $log['event'],
@@ -104,6 +107,7 @@ class XList extends Action
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
+ 'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
@@ -115,9 +119,9 @@ class XList extends Action
'clientVersion' => $client['clientVersion'],
'clientEngine' => $client['clientEngine'],
'clientEngineVersion' => $client['clientEngineVersion'],
- 'deviceName' => $device['deviceName'],
- 'deviceBrand' => $device['deviceBrand'],
- 'deviceModel' => $device['deviceModel'],
+ 'deviceName' => $deviceName,
+ 'deviceBrand' => $deviceBrand,
+ 'deviceModel' => $deviceModel,
]);
$record = $geodb->get($log['ip']);
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/BigInt/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/BigInt/Create.php
new file mode 100644
index 0000000000..1d32c6bad9
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/BigInt/Create.php
@@ -0,0 +1,70 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/bigint')
+ ->desc('Create bigint column')
+ ->groups(['api', 'database', 'schema'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
+ ->label('audits.event', 'column.create')
+ ->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
+ ->label('sdk', new Method(
+ namespace: $this->getSDKNamespace(),
+ group: $this->getSDKGroup(),
+ name: self::getName(),
+ description: '/docs/references/tablesdb/create-bigint-column.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_ACCEPTED,
+ model: $this->getResponseModel(),
+ )
+ ]
+ ))
+ ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
+ ->param('tableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Table ID.', false, ['dbForProject'])
+ ->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Column Key.', false, ['dbForProject'])
+ ->param('required', null, new Boolean(), 'Is column required?')
+ ->param('min', null, new Nullable(new Integer(false, 64)), 'Minimum value', true)
+ ->param('max', null, new Nullable(new Integer(false, 64)), 'Maximum value', true)
+ ->param('default', null, new Nullable(new Integer(false, 64)), 'Default value. Cannot be set when column is required.', true)
+ ->param('array', false, new Boolean(), 'Is column an array?', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForDatabase')
+ ->inject('queueForEvents')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/BigInt/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/BigInt/Update.php
new file mode 100644
index 0000000000..b2754a2b7d
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/BigInt/Update.php
@@ -0,0 +1,71 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/bigint/:key')
+ ->desc('Update bigint column')
+ ->groups(['api', 'database', 'schema'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
+ ->label('audits.event', 'column.update')
+ ->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
+ ->label('sdk', new Method(
+ namespace: $this->getSDKNamespace(),
+ group: $this->getSDKGroup(),
+ name: self::getName(),
+ description: '/docs/references/tablesdb/update-bigint-column.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: $this->getResponseModel(),
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
+ ->param('tableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Table ID.', false, ['dbForProject'])
+ ->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Column Key.', false, ['dbForProject'])
+ ->param('required', null, new Boolean(), 'Is column required?')
+ ->param('min', null, new Nullable(new Integer(false, 64)), 'Minimum value', true)
+ ->param('max', null, new Nullable(new Integer(false, 64)), 'Maximum value', true)
+ ->param('default', null, new Nullable(new Integer(false, 64)), 'Default value. Cannot be set when column is required.')
+ ->param('newKey', null, fn (Database $dbForProject) => new Nullable(new Key(false, $dbForProject->getAdapter()->getMaxUIDLength())), 'New Column Key.', true, ['dbForProject'])
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForEvents')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Create.php
index ddfb023d25..10cd65bc98 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Create.php
@@ -34,7 +34,7 @@ class Create extends BooleanCreate
->desc('Create boolean column')
->groups(['api', 'database', 'schema'])
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'column.create')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Update.php
index c808021796..1e0fe04bdc 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Update.php
@@ -34,7 +34,7 @@ class Update extends BooleanUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/boolean/:key')
->desc('Update boolean column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Create.php
index 0698002f61..64e73e310e 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Create.php
@@ -34,7 +34,7 @@ class Create extends DatetimeCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/datetime')
->desc('Create datetime column')
->groups(['api', 'database'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Update.php
index 035893f33f..44c1a06da8 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Update.php
@@ -35,7 +35,7 @@ class Update extends DatetimeUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/datetime/:key')
->desc('Update dateTime column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Delete.php
index 81e71df07a..f4d606637d 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Delete.php
@@ -33,7 +33,7 @@ class Delete extends AttributesDelete
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key')
->desc('Delete column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.delete')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php
index 3da6a67be7..d0b2ed3e4b 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php
@@ -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;
@@ -34,7 +34,7 @@ class Create extends EmailCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/email')
->desc('Create email column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php
index 47921f9579..c116d8c5b1 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php
@@ -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;
@@ -35,7 +35,7 @@ class Update extends EmailUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/email/:key')
->desc('Update email column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php
index 9aeb9b2d4b..e58ae115fc 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php
@@ -35,7 +35,7 @@ class Create extends EnumCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/enum')
->desc('Create enum column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php
index 43503ee8ed..208fa9c8cf 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php
@@ -36,7 +36,7 @@ class Update extends EnumUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/enum/:key')
->desc('Update enum column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php
index 0dd0ef39e1..b8e81820aa 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php
@@ -34,7 +34,7 @@ class Create extends FloatCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/float')
->desc('Create float column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php
index 716923cc63..9ab61e642b 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php
@@ -35,7 +35,7 @@ class Update extends FloatUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/float/:key')
->desc('Update float column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php
index 0fe5fa062a..b0ef9e8a85 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php
@@ -42,7 +42,7 @@ class Get extends AttributesGet
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key')
->desc('Get column')
->groups(['api', 'database'])
- ->label('scope', ['tables.read', 'collections.read'])
+ ->label('scope', ['tables.read', 'collections.read', 'columns.read', 'attributes.read'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: $this->getSDKNamespace(),
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php
index c359feaab4..c2faec9aeb 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php
@@ -34,7 +34,7 @@ class Create extends IPCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/ip')
->desc('Create IP address column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php
index 0c7cc6644b..dcc4160580 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php
@@ -35,7 +35,7 @@ class Update extends IPUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/ip/:key')
->desc('Update IP address column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php
index bbb1710866..1a965c19dc 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php
@@ -34,7 +34,7 @@ class Create extends IntegerCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/integer')
->desc('Create integer column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php
index a9348f51e0..58dea7c848 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php
@@ -35,7 +35,7 @@ class Update extends IntegerUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/integer/:key')
->desc('Update integer column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php
index fb2c4fd1a8..c2f480d5d0 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php
@@ -35,7 +35,7 @@ class Create extends LineCreate
->desc('Create line column')
->groups(['api', 'database', 'schema'])
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'column.create')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php
index 564b743a2a..e2e8c59121 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php
@@ -35,7 +35,7 @@ class Update extends LineUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/line/:key')
->desc('Update line column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php
index da9471f37c..8e2dbd911d 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php
@@ -33,7 +33,7 @@ class Create extends LongtextCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/longtext')
->desc('Create longtext column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php
index fe93530cfb..9b90b745a2 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php
@@ -34,7 +34,7 @@ class Update extends LongtextUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/longtext/:key')
->desc('Update longtext column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php
index 585856cab9..f0b8099f02 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php
@@ -33,7 +33,7 @@ class Create extends MediumtextCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/mediumtext')
->desc('Create mediumtext column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php
index 733159d1d4..03009da25c 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php
@@ -34,7 +34,7 @@ class Update extends MediumtextUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/mediumtext/:key')
->desc('Update mediumtext column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php
index 9736e33158..138ee482c3 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php
@@ -35,7 +35,7 @@ class Create extends PointCreate
->desc('Create point column')
->groups(['api', 'database', 'schema'])
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'column.create')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php
index f104b170bd..66fb451a1f 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php
@@ -35,7 +35,7 @@ class Update extends PointUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/point/:key')
->desc('Update point column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php
index 177399396c..a03a34f310 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php
@@ -35,7 +35,7 @@ class Create extends PolygonCreate
->desc('Create polygon column')
->groups(['api', 'database', 'schema'])
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'column.create')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php
index e66e19a7b9..7a2fd8a5de 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php
@@ -35,7 +35,7 @@ class Update extends PolygonUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/polygon/:key')
->desc('Update polygon column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php
index 84ee3e6863..87544926fe 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php
@@ -34,7 +34,7 @@ class Create extends RelationshipCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/relationship')
->desc('Create relationship column')
->groups(['api', 'database'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php
index da5c8ca477..47884eda80 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php
@@ -34,7 +34,7 @@ class Update extends RelationshipUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key/relationship')
->desc('Update relationship column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php
index 122c8625f9..17f60f61c1 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php
@@ -37,7 +37,7 @@ class Create extends StringCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/string')
->desc('Create string column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php
index 0974a44d5d..2ec806d4fe 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php
@@ -37,7 +37,7 @@ class Update extends StringUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/string/:key')
->desc('Update string column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php
index 2c68431d8c..a8fde7d271 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php
@@ -33,7 +33,7 @@ class Create extends TextCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/text')
->desc('Create text column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php
index 599c93988d..4c1477fb9e 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php
@@ -34,7 +34,7 @@ class Update extends TextUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/text/:key')
->desc('Update text column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php
index 0b386c23f6..19b33594b7 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php
@@ -34,7 +34,7 @@ class Create extends URLCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/url')
->desc('Create URL column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php
index df6117ea77..d680389d9e 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php
@@ -35,7 +35,7 @@ class Update extends URLUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/url/:key')
->desc('Update URL column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php
index 0ee04f5f63..7595f16c45 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php
@@ -35,7 +35,7 @@ class Create extends VarcharCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/varchar')
->desc('Create varchar column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php
index 2b8eb9fbd7..dd170a0a19 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php
@@ -36,7 +36,7 @@ class Update extends VarcharUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/varchar/:key')
->desc('Update varchar column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php
index b38edf6218..56c436a13e 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php
@@ -33,7 +33,7 @@ class XList extends AttributesXList
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns')
->desc('List columns')
->groups(['api', 'database'])
- ->label('scope', ['tables.read', 'collections.read'])
+ ->label('scope', ['tables.read', 'collections.read', 'columns.read', 'attributes.read'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: $this->getSDKNamespace(),
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php
index e683aafba1..d377bed184 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php
@@ -37,7 +37,7 @@ class Create extends IndexCreate
->desc('Create index')
->groups(['api', 'database'])
->label('event', 'databases.[databaseId].tables.[tableId].indexes.[indexId].create')
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'indexes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'index.create')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Delete.php
index 7750408e29..ca7e4fc2da 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Delete.php
@@ -36,7 +36,7 @@ class Delete extends IndexDelete
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/indexes/:key')
->desc('Delete index')
->groups(['api', 'database'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'indexes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].indexes.[indexId].update')
->label('audits.event', 'index.delete')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Get.php
index 8f721abf0e..9918bcb2b8 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Get.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Get.php
@@ -32,7 +32,7 @@ class Get extends IndexGet
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/indexes/:key')
->desc('Get index')
->groups(['api', 'database'])
- ->label('scope', ['tables.read', 'collections.read'])
+ ->label('scope', ['tables.read', 'collections.read', 'indexes.read'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: $this->getSDKNamespace(),
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/XList.php
index ff1e736c31..5fe3be4c05 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/XList.php
@@ -33,7 +33,7 @@ class XList extends IndexXList
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/indexes')
->desc('List indexes')
->groups(['api', 'database'])
- ->label('scope', ['tables.read', 'collections.read'])
+ ->label('scope', ['tables.read', 'collections.read', 'indexes.read'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: $this->getSDKNamespace(),
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php
index 5433e83307..8315a8d04b 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
@@ -62,10 +62,10 @@ class Delete extends DocumentsDelete
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
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 060ae26676..a31ebc15e0 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
@@ -64,10 +64,10 @@ class Update extends DocumentsUpdate
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
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 533bbbf796..543de8c4bc 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
@@ -64,10 +64,10 @@ class Upsert extends DocumentsUpsert
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
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 842425b430..ea1bfa163d 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
@@ -67,9 +67,10 @@ class Decrement extends DecrementDocumentAttribute
->inject('dbForProject')
->inject('getDatabasesDB')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->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 115fbe6b3f..2f8be876d7 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
@@ -67,9 +67,10 @@ class Increment extends IncrementDocumentAttribute
->inject('dbForProject')
->inject('getDatabasesDB')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->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 5ab7c459bd..ea9e3e0b03 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
@@ -107,9 +107,9 @@ class Create extends DocumentCreate
->inject('getDatabasesDB')
->inject('user')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('authorization')
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 33c74f8eac..06aee2cb30 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
@@ -69,10 +69,11 @@ class Delete extends DocumentDelete
->inject('dbForProject')
->inject('getDatabasesDB')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('transactionState')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
}
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 2e56298b12..65ae238a1a 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
@@ -58,9 +58,10 @@ class Get extends DocumentGet
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('transactionState')
->inject('authorization')
+ ->inject('user')
->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 863eb7374a..93ec5d3b58 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
@@ -67,10 +67,11 @@ class Update extends DocumentUpdate
->inject('dbForProject')
->inject('getDatabasesDB')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('transactionState')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
}
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 81a3a788a2..472a49cf64 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
@@ -70,7 +70,7 @@ class Upsert extends DocumentUpsert
->inject('dbForProject')
->inject('getDatabasesDB')
->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 1ea9f1bf6f..87e276719e 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
@@ -57,14 +57,15 @@ class XList extends DocumentXList
->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), '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.', true)
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject'])
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
- ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true)
+ ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for caching list responses. Responses are stored in an in-memory key-value cache, keyed per project, table, schema version (columns and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; row writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours).', true)
->inject('response')
->inject('dbForProject')
->inject('user')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('transactionState')
->inject('authorization')
+ ->inject('utopia')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php
index 88b16d57f0..d10380a0e8 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php
@@ -60,6 +60,7 @@ class Update extends CollectionUpdate
->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true)
->param('rowSecurity', false, new Boolean(true), 'Enables configuring permissions for individual rows. A user needs one of row or table-level permissions to access a row. [Learn more about permissions](https://appwrite.io/docs/permissions).', true)
->param('enabled', true, new Boolean(), 'Is table enabled? When set to \'disabled\', users cannot access the table but Server SDKs with and API key can still read and write to the table. No data is lost when this is toggled.', true)
+ ->param('purge', false, new Boolean(true), 'When true, purge all cached list responses for this table as part of the update. Use this to force readers to see fresh data immediately instead of waiting for the cache TTL to expire.', true)
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Operations/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Operations/Create.php
index 818ed70cea..b00b75f270 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Operations/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Operations/Create.php
@@ -56,6 +56,7 @@ class Create extends OperationsCreate
->inject('transactionState')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->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 1b92312742..c41186b5c3 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php
@@ -59,9 +59,9 @@ class Update extends TransactionsUpdate
->inject('transactionState')
->inject('queueForDeletes')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('authorization')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/XList.php
index 80a9bd3686..8dc0f6521a 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/XList.php
@@ -9,6 +9,7 @@ use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Databases;
use Appwrite\Utopia\Response as UtopiaResponse;
+use Utopia\Database\Query;
use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
@@ -20,6 +21,16 @@ class XList extends DatabaseXList
return 'listTablesDatabases';
}
+ protected function getDatabaseTypeQueryFilters(): array
+ {
+ return [
+ Query::or([
+ Query::equal('type', [DATABASE_TYPE_TABLESDB, DATABASE_TYPE_LEGACY]),
+ Query::isNull('type'),
+ ]),
+ ];
+ }
+
public function __construct()
{
$this
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php
index b85a8b30b4..58433c7deb 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php
@@ -130,9 +130,26 @@ class Create extends CollectionAction
$indexes[] = new Document($index);
}
try {
- // passing null in creates only creates the metadata collection
- if (!$dbForDatabases->exists(null, Database::METADATA)) {
- $dbForDatabases->create();
+ // Bootstrap the database metadata without a separate existence
+ // check to avoid races when multiple first collections are created
+ // concurrently for the same VectorsDB database.
+ for ($attempt = 0; $attempt < 5; $attempt++) {
+ try {
+ $dbForDatabases->create();
+ break;
+ } catch (DuplicateException) {
+ break;
+ } catch (\Throwable $e) {
+ if ($dbForDatabases->exists(null, Database::METADATA)) {
+ break;
+ }
+
+ if ($attempt === 4) {
+ throw $e;
+ }
+
+ \usleep(100_000);
+ }
}
$dbForDatabases->createCollection(
id: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(),
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Delete.php
index c8726711f2..4c7d97aa55 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Delete.php
@@ -60,10 +60,10 @@ class Delete extends DocumentsDelete
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Update.php
index b469316405..18e441ede7 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Update.php
@@ -62,10 +62,10 @@ class Update extends DocumentsUpdate
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Upsert.php
index bb96a9675c..c26e61d716 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Upsert.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Upsert.php
@@ -62,10 +62,10 @@ class Upsert extends DocumentsUpsert
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Create.php
index 79ae43d087..dee8d8e85f 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Create.php
@@ -104,9 +104,9 @@ class Create extends DocumentCreate
->inject('getDatabasesDB')
->inject('user')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('plan')
->inject('authorization')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Delete.php
index d7d7cdee00..e81e34e1e5 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Delete.php
@@ -67,10 +67,11 @@ class Delete extends DocumentDelete
->inject('dbForProject')
->inject('getDatabasesDB')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('transactionState')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Get.php
index 9a50d9a1b9..73f7a55026 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Get.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Get.php
@@ -56,9 +56,10 @@ class Get extends DocumentGet
->inject('response')
->inject('dbForProject')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('transactionState')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php
deleted file mode 100644
index dea9d30119..0000000000
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php
+++ /dev/null
@@ -1,59 +0,0 @@
-setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
- ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId/logs')
- ->desc('List document logs')
- ->groups(['api', 'database'])
- ->label('scope', 'documents.read')
- ->label('resourceType', RESOURCE_TYPE_DATABASES)
- ->label('sdk', new Method(
- namespace: 'vectorsDB',
- group: 'logs',
- name: 'listDocumentLogs',
- description: '/docs/references/vectorsdb/get-document-logs.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: SwooleResponse::STATUS_CODE_OK,
- model: $this->getResponseModel(),
- )
- ],
- contentType: ContentType::JSON,
- ))
- ->param('databaseId', '', new UID(), 'Database ID.')
- ->param('collectionId', '', new UID(), 'Collection ID.')
- ->param('documentId', '', new UID(), 'Document ID.')
- ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true)
- ->inject('response')
- ->inject('dbForProject')
- ->inject('getDatabasesDB')
- ->inject('locale')
- ->inject('geodb')
- ->inject('authorization')
- ->inject('audit')
- ->callback($this->action(...));
- }
-}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Update.php
index c26b2a70b9..8a8b9d89ae 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Update.php
@@ -66,10 +66,11 @@ class Update extends DocumentUpdate
->inject('dbForProject')
->inject('getDatabasesDB')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('transactionState')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Upsert.php
index df4cb0f191..f8f17d33d9 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Upsert.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Upsert.php
@@ -70,7 +70,7 @@ class Upsert extends DocumentUpsert
->inject('dbForProject')
->inject('getDatabasesDB')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('transactionState')
->inject('plan')
->inject('authorization')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/XList.php
index d4a5d06269..c9ed05ac02 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/XList.php
@@ -60,7 +60,7 @@ class XList extends DocumentXList
->inject('dbForProject')
->inject('user')
->inject('getDatabasesDB')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('transactionState')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php
deleted file mode 100644
index cd0e45eb47..0000000000
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php
+++ /dev/null
@@ -1,57 +0,0 @@
-setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
- ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/logs')
- ->desc('List collection logs')
- ->groups(['api', 'database'])
- ->label('scope', 'collections.read')
- ->label('resourceType', RESOURCE_TYPE_DATABASES)
- ->label('sdk', new Method(
- namespace: 'vectorsDB',
- group: $this->getSdkGroup(),
- name: 'listCollectionLogs',
- description: '/docs/references/vectorsdb/get-collection-logs.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: SwooleResponse::STATUS_CODE_OK,
- model: $this->getResponseModel(),
- )
- ],
- contentType: ContentType::JSON
- ))
- ->param('databaseId', '', new UID(), 'Database ID.')
- ->param('collectionId', '', new UID(), 'Collection ID.')
- ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true)
- ->inject('response')
- ->inject('dbForProject')
- ->inject('locale')
- ->inject('geodb')
- ->inject('authorization')
- ->inject('audit')
- ->callback($this->action(...));
- }
-}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Update.php
index 2ec5f8991f..f8ba767e7e 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Update.php
@@ -73,7 +73,7 @@ class Update extends CollectionAction
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, ?string $name, ?int $dimensions, ?array $permissions, bool $documentSecurity, bool $enabled, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, ?string $name, ?int $dimensions, ?array $permissions, bool $documentSecurity, ?bool $enabled, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void
{
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty()) {
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php
index 2109e2bd68..c9d36904a9 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php
@@ -49,7 +49,7 @@ class Delete extends DatabaseDelete
->inject('dbForProject')
->inject('queueForDatabase')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php
index 98602efa3c..8a7137e38b 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php
@@ -2,13 +2,13 @@
namespace Appwrite\Platform\Modules\Databases\Http\VectorsDB\Embeddings\Text;
-use Appwrite\Event\StatsUsage;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Action as CreateDocumentAction;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Parameter;
use Appwrite\SDK\Response as SDKResponse;
+use Appwrite\Usage\Context;
use Appwrite\Utopia\Response as UtopiaResponse;
use Utopia\Agents\Adapters\Ollama;
use Utopia\Agents\Agent;
@@ -25,7 +25,7 @@ class Create extends CreateDocumentAction
{
public static function getName(): string
{
- return 'createTextEmbedding';
+ return 'createTextEmbeddings';
}
protected function getResponseModel(): string
@@ -68,9 +68,8 @@ class Create extends CreateDocumentAction
],
contentType: ContentType::JSON,
parameters: [
- new Parameter('databaseId', optional: false),
- new Parameter('collectionId', optional: false),
- new Parameter('documents', optional: false),
+ new Parameter('texts', optional: false),
+ new Parameter('model', optional: true),
]
)
])
@@ -79,13 +78,13 @@ class Create extends CreateDocumentAction
->inject('response')
->inject('project')
->inject('embeddingAgent')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('log')
->inject('logger')
->callback($this->action(...));
}
- public function action(array $texts, string $model, UtopiaResponse $response, Document $project, Agent $embeddingAgent, StatsUsage $queueForStatsUsage, Log $log, ?Logger $logger): void
+ public function action(array $texts, string $model, UtopiaResponse $response, Document $project, Agent $embeddingAgent, Context $usage, Log $log, ?Logger $logger): void
{
$results = [];
$embeddingAgent->getAdapter()->setModel($model);
@@ -99,7 +98,7 @@ class Create extends CreateDocumentAction
$error = '';
try {
$embedResult = $embeddingAgent->embed($text);
- $embedding = $embedResult['embedding'] ?? [];
+ $embedding = $embedResult['embedding'];
$totalDuration += $embedResult['totalDuration'] ?? 0;
$totalTokens += $embedResult['tokensProcessed'] ?? 0;
} catch (\Exception $e) {
@@ -140,8 +139,7 @@ class Create extends CreateDocumentAction
->setStatusCode(SwooleResponse::STATUS_CODE_OK)
->dynamic($embeddings, $this->getBulkResponseModel());
- $queueForStatsUsage
- ->setProject($project)
+ $usage
->addMetric(METRIC_EMBEDDINGS_TEXT, \count($texts))
->addMetric(\str_replace('{embeddingModel}', $model, METRIC_EMBEDDINGS_MODEL_TEXT), \count($texts))
->addMetric(METRIC_EMBEDDINGS_TEXT_TOTAL_TOKENS, $totalTokens)
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Operations/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Operations/Create.php
index 830c0c3fe1..27283cda49 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Operations/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Operations/Create.php
@@ -55,6 +55,7 @@ class Create extends OperationsCreate
->inject('transactionState')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Update.php
index babfe11a3a..d6399e6bc0 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Update.php
@@ -58,9 +58,9 @@ class Update extends TransactionsUpdate
->inject('transactionState')
->inject('queueForDeletes')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('queueForRealtime')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForWebhooks')
->inject('authorization')
->inject('eventProcessor')
diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php
index a1e3538cac..5d41ed3e2b 100644
--- a/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php
+++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php
@@ -12,7 +12,6 @@ use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\B
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Create as CreateRow;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Delete as DeleteRow;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Get as GetRow;
-use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Logs\XList as ListRowLogs;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Update as UpdateRow;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Upsert as UpsertRow;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\XList as ListRows;
@@ -21,7 +20,6 @@ use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\Cre
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\Delete as DeleteColumnIndex;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\Get as GetColumnIndex;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\XList as ListColumnIndexes;
-use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Logs\XList as ListTableLogs;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Update as UpdateTable;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Usage\Get as GetTableUsage;
use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\XList as ListTables;
@@ -69,7 +67,6 @@ class DocumentsDB extends Base
$service->addAction(UpdateTable::getName(), new UpdateTable());
$service->addAction(DeleteTable::getName(), new DeleteTable());
$service->addAction(ListTables::getName(), new ListTables());
- $service->addAction(ListTableLogs::getName(), new ListTableLogs());
$service->addAction(GetTableUsage::getName(), new GetTableUsage());
}
@@ -92,7 +89,6 @@ class DocumentsDB extends Base
$service->addAction(DeleteRow::getName(), new DeleteRow());
$service->addAction(DeleteRows::getName(), new DeleteRows());
$service->addAction(ListRows::getName(), new ListRows());
- $service->addAction(ListRowLogs::getName(), new ListRowLogs());
$service->addAction(IncrementRowColumn::getName(), new IncrementRowColumn());
$service->addAction(DecrementRowColumn::getName(), new DecrementRowColumn());
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/Legacy.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/Legacy.php
index a8d2205236..a2fba9efb3 100644
--- a/src/Appwrite/Platform/Modules/Databases/Services/Registry/Legacy.php
+++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/Legacy.php
@@ -2,6 +2,8 @@
namespace Appwrite\Platform\Modules\Databases\Services\Registry;
+use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\BigInt\Create as CreateBigIntAttribute;
+use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\BigInt\Update as UpdateBigIntAttribute;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Boolean\Create as CreateBooleanAttribute;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Boolean\Update as UpdateBooleanAttribute;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Datetime\Create as CreateDatetimeAttribute;
@@ -171,6 +173,10 @@ class Legacy extends Base
$service->addAction(CreateIntegerAttribute::getName(), new CreateIntegerAttribute());
$service->addAction(UpdateIntegerAttribute::getName(), new UpdateIntegerAttribute());
+ // Attribute: BigInt
+ $service->addAction(CreateBigIntAttribute::getName(), new CreateBigIntAttribute());
+ $service->addAction(UpdateBigIntAttribute::getName(), new UpdateBigIntAttribute());
+
// Attribute: IP
$service->addAction(CreateIPAttribute::getName(), new CreateIPAttribute());
$service->addAction(UpdateIPAttribute::getName(), new UpdateIPAttribute());
diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/TablesDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/TablesDB.php
index 965e0929fb..765fbd4421 100644
--- a/src/Appwrite/Platform/Modules/Databases/Services/Registry/TablesDB.php
+++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/TablesDB.php
@@ -5,6 +5,8 @@ namespace Appwrite\Platform\Modules\Databases\Services\Registry;
use Appwrite\Platform\Modules\Databases\Http\TablesDB\Create as CreateTablesDatabase;
use Appwrite\Platform\Modules\Databases\Http\TablesDB\Delete as DeleteTablesDatabase;
use Appwrite\Platform\Modules\Databases\Http\TablesDB\Get as GetTablesDatabase;
+use Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Columns\BigInt\Create as CreateBigInt;
+use Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Columns\BigInt\Update as UpdateBigInt;
use Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Columns\Boolean\Create as CreateBoolean;
use Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Columns\Boolean\Update as UpdateBoolean;
use Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Columns\Datetime\Create as CreateDatetime;
@@ -151,6 +153,10 @@ class TablesDB extends Base
$service->addAction(CreateInteger::getName(), new CreateInteger());
$service->addAction(UpdateInteger::getName(), new UpdateInteger());
+ // Column: BigInt
+ $service->addAction(CreateBigInt::getName(), new CreateBigInt());
+ $service->addAction(UpdateBigInt::getName(), new UpdateBigInt());
+
// Column: IP
$service->addAction(CreateIP::getName(), new CreateIP());
$service->addAction(UpdateIP::getName(), new UpdateIP());
diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php
index 9496b1781a..fe96d51d20 100644
--- a/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php
+++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php
@@ -18,7 +18,6 @@ use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\Creat
use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\Delete as DeleteIndex;
use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\Get as GetIndex;
use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\XList as ListIndexes;
-use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Logs\XList as ListCollectionLogs;
use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Update as UpdateCollection;
use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Usage\Get as GetCollectionUsage;
use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\XList as ListCollections;
@@ -68,7 +67,6 @@ class VectorsDB extends Base
$service->addAction(UpdateCollection::getName(), new UpdateCollection());
$service->addAction(DeleteCollection::getName(), new DeleteCollection());
$service->addAction(ListCollections::getName(), new ListCollections());
- $service->addAction(ListCollectionLogs::getName(), new ListCollectionLogs());
$service->addAction(GetCollectionUsage::getName(), new GetCollectionUsage());
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php
index 66ed3e0eab..39902aea53 100644
--- a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php
+++ b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php
@@ -54,7 +54,7 @@ class Databases extends Action
*/
public function action(Message $message, Document $project, Database $dbForPlatform, Database $dbForProject, callable $getDatabasesDB, Realtime $queueForRealtime, Log $log): void
{
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new Exception('Missing payload');
@@ -650,26 +650,30 @@ class Databases extends Action
Document|null $attribute = null,
Document|null $index = null,
): void {
- $queueForRealtime
- ->setProject($project)
- ->setSubscribers(['console'])
- ->setEvent($event)
- ->setParam('databaseId', $database->getId())
- ->setParam('tableId', $collection->getId())
- ->setParam('collectionId', $collection->getId());
+ try {
+ $queueForRealtime
+ ->setProject($project)
+ ->setSubscribers(['console'])
+ ->setEvent($event)
+ ->setParam('databaseId', $database->getId())
+ ->setParam('tableId', $collection->getId())
+ ->setParam('collectionId', $collection->getId());
- if (! empty($attribute)) {
- $queueForRealtime
- ->setParam('columnId', $attribute->getId())
- ->setParam('attributeId', $attribute->getId())
- ->setPayload($attribute->getArrayCopy());
- }
- if (! empty($index)) {
- $queueForRealtime
- ->setParam('indexId', $index->getId())
- ->setPayload($index->getArrayCopy());
+ if (! empty($attribute)) {
+ $queueForRealtime
+ ->setParam('columnId', $attribute->getId())
+ ->setParam('attributeId', $attribute->getId())
+ ->setPayload($attribute->getArrayCopy());
+ }
+ if (! empty($index)) {
+ $queueForRealtime
+ ->setParam('indexId', $index->getId())
+ ->setPayload($index->getArrayCopy());
+ }
+ $queueForRealtime->trigger();
+ } finally {
+ $queueForRealtime->reset();
}
- $queueForRealtime->trigger();
}
}
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php
index 65b6ffd5bb..57c465faef 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Functions\Http\Deployments;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -87,9 +88,10 @@ class Create extends Action
->inject('project')
->inject('deviceForFunctions')
->inject('deviceForLocal')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('plan')
->inject('authorization')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -106,9 +108,10 @@ class Create extends Action
Document $project,
Device $deviceForFunctions,
Device $deviceForLocal,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
array $plan,
- Authorization $authorization
+ Authorization $authorization,
+ array $platform
) {
$activate = \strval($activate) === 'true' || \strval($activate) === '1';
@@ -175,15 +178,8 @@ class Create extends Action
throw new Exception(Exception::STORAGE_INVALID_CONTENT_RANGE);
}
- // TODO remove the condition that checks `$end === $fileSize` in next breaking version
- if ($end === $fileSize - 1 || $end === $fileSize) {
- //if it's a last chunks the chunk size might differ, so we set the $chunks and $chunk to notify it's last chunk
- $chunks = $chunk = -1;
- } else {
- // Calculate total number of chunks based on the chunk size i.e ($rangeEnd - $rangeStart)
- $chunks = (int) ceil($fileSize / ($end + 1 - $start));
- $chunk = (int) ($start / ($end + 1 - $start)) + 1;
- }
+ $chunks = (int) ceil($fileSize / APP_LIMIT_UPLOAD_CHUNK_SIZE);
+ $chunk = (int) ($start / APP_LIMIT_UPLOAD_CHUNK_SIZE) + 1;
}
if (!$fileSizeValidator->isValid($fileSize) && $functionSizeLimit !== 0) { // Check if file size is exceeding allowed limit
@@ -202,9 +198,14 @@ class Create extends Action
$metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)];
if (!$deployment->isEmpty()) {
$chunks = $deployment->getAttribute('sourceChunksTotal', 1);
+ $uploaded = $deployment->getAttribute('sourceChunksUploaded', 0);
$metadata = $deployment->getAttribute('sourceMetadata', []);
- if ($chunk === -1) {
- $chunk = $chunks;
+
+ if ($uploaded === $chunks) {
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($deployment, Response::MODEL_DEPLOYMENT);
+ return;
}
}
@@ -252,6 +253,8 @@ class Create extends Action
'sourcePath' => $path,
'sourceSize' => $fileSize,
'totalSize' => $fileSize,
+ 'sourceChunksTotal' => $chunks,
+ 'sourceChunksUploaded' => $chunksUploaded,
'activate' => $activate,
'sourceMetadata' => $metadata,
'type' => $type
@@ -266,15 +269,19 @@ class Create extends Action
} else {
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
'sourceSize' => $fileSize,
+ 'sourceChunksUploaded' => $chunksUploaded,
'sourceMetadata' => $metadata,
]));
}
// Start the build
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ));
} else {
if ($deployment->isEmpty()) {
$deployment = $dbForProject->createDocument('deployments', new Document([
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php
index e9c665ea4b..d3e7155dc6 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php
@@ -31,7 +31,7 @@ class Get extends Action
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/functions/:functionId/deployments/:deploymentId/download')
- ->httpAlias('/v1/functions/:functionId/deployments/:deploymentId/build/download', ['type' => 'output'])
+ ->httpAlias('/v1/functions/:functionId/deployments/:deploymentId/build/download')
->groups(['api', 'functions'])
->desc('Get deployment download')
->label('scope', 'functions.read')
@@ -88,16 +88,11 @@ class Get extends Action
throw new Exception(Exception::DEPLOYMENT_NOT_FOUND);
}
- switch ($type) {
- case 'output':
- $path = $deployment->getAttribute('buildPath', '');
- $device = $deviceForBuilds;
- break;
- case 'source':
- $path = $deployment->getAttribute('sourcePath', '');
- $device = $deviceForFunctions;
- break;
- }
+ [$path, $device] = match ($type) {
+ 'output' => [$deployment->getAttribute('buildPath', ''), $deviceForBuilds],
+ 'source' => [$deployment->getAttribute('sourcePath', ''), $deviceForFunctions],
+ default => throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid deployment download type.'),
+ };
if (!$device->exists($path)) {
throw new Exception(Exception::DEPLOYMENT_NOT_FOUND);
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php
index 9884b12dba..76070c8bf5 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Duplicate;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
@@ -61,8 +62,10 @@ class Create extends Action
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('deviceForFunctions')
+ ->inject('project')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -73,8 +76,10 @@ class Create extends Action
Response $response,
Database $dbForProject,
Event $queueForEvents,
- Build $queueForBuilds,
- Device $deviceForFunctions
+ BuildPublisher $publisherForBuilds,
+ Device $deviceForFunctions,
+ Document $project,
+ array $platform
) {
$function = $dbForProject->getDocument('functions', $functionId);
@@ -127,10 +132,13 @@ class Create extends Action
'latestDeploymentStatus' => $function->getAttribute('latestDeploymentStatus'),
]));
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ));
$queueForEvents
->setParam('functionId', $function->getId())
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php
index 53af82e701..f18543c60e 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Template;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -76,9 +77,10 @@ class Create extends Base
->inject('dbForPlatform')
->inject('queueForEvents')
->inject('project')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('gitHub')
->inject('authorization')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -96,9 +98,10 @@ class Create extends Base
Database $dbForPlatform,
Event $queueForEvents,
Document $project,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
GitHub $github,
- Authorization $authorization
+ Authorization $authorization,
+ array $platform
) {
$function = $dbForProject->getDocument('functions', $functionId);
@@ -127,10 +130,11 @@ class Create extends Base
project: $project,
installation: $installation,
dbForProject: $dbForProject,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: $activate,
+ platform: $platform,
referenceType: $type,
reference: $reference
);
@@ -184,11 +188,14 @@ class Create extends Base
$this->updateEmptyManualRule($project, $function, $deployment, $dbForPlatform, $authorization);
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
$queueForEvents
->setParam('functionId', $function->getId())
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php
index 587c09beba..a74fc12593 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Vcs;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -70,8 +70,9 @@ class Create extends Base
->inject('dbForPlatform')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('gitHub')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -86,8 +87,9 @@ class Create extends Base
Database $dbForPlatform,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
GitHub $github,
+ array $platform,
) {
$function = $dbForProject->getDocument('functions', $functionId);
@@ -105,10 +107,11 @@ class Create extends Base
project: $project,
installation: $installation,
dbForProject: $dbForProject,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: $activate,
+ platform: $platform,
reference: $reference,
referenceType: $type
);
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php
index fef0708931..e8e9ea9a18 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php
@@ -116,7 +116,7 @@ class XList extends Base
$grouped = Query::groupByType($queries);
$filterQueries = $grouped['filters'];
- $selectQueries = $grouped['selections'] ?? [];
+ $selectQueries = $grouped['selections'];
try {
$results = $dbForProject->find('deployments', $queries);
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
index bc506c654a..f2f51a90e6 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
@@ -5,8 +5,8 @@ namespace Appwrite\Platform\Modules\Functions\Http\Executions;
use Ahc\Jwt\JWT;
use Appwrite\Event\Delete as DeleteEvent;
use Appwrite\Event\Event;
-use Appwrite\Event\Func;
-use Appwrite\Event\StatsUsage;
+use Appwrite\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Functions\Validator\Headers;
@@ -15,8 +15,10 @@ 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\Exception\Timeout as ExecutorTimeout;
use Executor\Executor;
use MaxMind\Db\Reader;
use Utopia\Auth\Proofs\Token;
@@ -60,7 +62,7 @@ class Create extends Base
->setHttpPath('/v1/functions/:functionId/executions')
->desc('Create execution')
->groups(['api', 'functions'])
- ->label('scope', 'execution.write')
+ ->label('scope', ['executions.write', 'execution.write'])
->label('resourceType', RESOURCE_TYPE_FUNCTIONS)
->label('event', 'functions.[functionId].executions.[executionId].create')
->label('sdk', new Method(
@@ -93,8 +95,8 @@ class Create extends Base
->inject('dbForPlatform')
->inject('user')
->inject('queueForEvents')
- ->inject('queueForStatsUsage')
- ->inject('queueForFunctions')
+ ->inject('usage')
+ ->inject('publisherForFunctions')
->inject('geodb')
->inject('store')
->inject('proofForToken')
@@ -119,10 +121,10 @@ class Create extends Base
Document $project,
Database $dbForProject,
Database $dbForPlatform,
- Document $user,
+ User $user,
Event $queueForEvents,
- StatsUsage $queueForStatsUsage,
- Func $queueForFunctions,
+ Context $usage,
+ FunctionPublisher $publisherForFunctions,
Reader $geodb,
Store $store,
Token $proofForToken,
@@ -145,21 +147,8 @@ class Create extends Base
}
}
- /**
- * @var array $headers
- */
- $assocParams = ['headers'];
- foreach ($assocParams as $assocParam) {
- if (!empty('headers') && !is_array($$assocParam)) {
- $$assocParam = \json_decode($$assocParam, true);
- }
- }
-
- $booleanParams = ['async'];
- foreach ($booleanParams as $booleamParam) {
- if (!empty($$booleamParam) && !is_bool($$booleamParam)) {
- $$booleamParam = $$booleamParam === "true" ? true : false;
- }
+ if (!is_array($headers)) {
+ $headers = \json_decode($headers, true);
}
// 'headers' validator
@@ -171,8 +160,8 @@ class Create extends Base
/* @var Document $function */
$function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId));
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::FUNCTION_NOT_FOUND);
@@ -213,7 +202,10 @@ class Create extends Base
$current = new Document();
foreach ($sessions as $session) {
- /** @var Utopia\Database\Document $session */
+ if (!$session instanceof Document) {
+ continue;
+ }
+
if ($proofForToken->verify($store->getProperty('secret', ''), $session->getAttribute('secret'))) { // Find most recent active session for user ID and JWT headers
$current = $session;
}
@@ -237,11 +229,11 @@ class Create extends Base
]);
$executionId = ID::unique();
- $headers['x-appwrite-execution-id'] = $executionId ?? '';
- $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey;
+ $headers['x-appwrite-execution-id'] = $executionId;
+ $headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $apiKey;
$headers['x-appwrite-trigger'] = 'http';
- $headers['x-appwrite-user-id'] = $user->getId() ?? '';
- $headers['x-appwrite-user-jwt'] = $jwt ?? '';
+ $headers['x-appwrite-user-id'] = $user->getId();
+ $headers['x-appwrite-user-jwt'] = $jwt;
$headers['x-appwrite-country-code'] = '';
$headers['x-appwrite-continent-code'] = '';
$headers['x-appwrite-continent-eu'] = 'false';
@@ -302,23 +294,20 @@ class Create extends Base
if ($async) {
if (is_null($scheduledAt)) {
- if ($project->getId() != '6862e6a6000cce69f9da') {
- $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution));
- }
- $queueForFunctions
- ->setType('http')
- ->setExecution($execution)
- ->setFunction($function)
- ->setBody($body)
- ->setHeaders($headers)
- ->setPath($path)
- ->setMethod($method)
- ->setJWT($jwt)
- ->setProject($project)
- ->setUser($user)
- ->setParam('functionId', $function->getId())
- ->setParam('executionId', $execution->getId())
- ->trigger();
+ $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution));
+ $publisherForFunctions->enqueue(new FunctionMessage(
+ project: $project,
+ user: $user,
+ function: $function,
+ functionId: $function->getId(),
+ execution: $execution,
+ type: 'http',
+ jwt: $jwt,
+ body: $body,
+ path: $path,
+ headers: $headers,
+ method: $method,
+ ));
} else {
$data = [
'headers' => $headers,
@@ -345,21 +334,21 @@ class Create extends Base
->setAttribute('scheduleInternalId', $schedule->getSequence())
->setAttribute('scheduledAt', $scheduledAt);
- if ($project->getId() != '6862e6a6000cce69f9da') {
- $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution));
- }
+ $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution));
}
- $this->enqueueDeletes(
- $project,
- $function->getSequence(),
- $executionsRetentionCount,
+ if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) {
$queueForDeletes
- );
+ ->setProject($project)
+ ->setResource($function->getSequence())
+ ->setResourceType(RESOURCE_TYPE_FUNCTIONS)
+ ->setType(DELETE_TYPE_EXECUTIONS_LIMIT)
+ ->trigger();
+ }
- return $response
- ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
- ->dynamic($execution, Response::MODEL_EXECUTION);
+ $response->setStatusCode(Response::STATUS_CODE_ACCEPTED);
+ $response->dynamic($execution, Response::MODEL_EXECUTION);
+ return;
}
$durationStart = \microtime(true);
@@ -369,10 +358,10 @@ class Create extends Base
// V2 vars
if ($version === 'v2') {
$vars = \array_merge($vars, [
- 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '',
- 'APPWRITE_FUNCTION_DATA' => $body ?? '',
- 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '',
- 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? ''
+ 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'],
+ 'APPWRITE_FUNCTION_DATA' => $body,
+ 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'],
+ 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt']
]);
}
@@ -429,25 +418,29 @@ class Create extends Base
$source = $deployment->getAttribute('buildPath', '');
$extension = str_ends_with($source, '.tar') ? 'tar' : 'tar.gz';
$command = $version === 'v2' ? '' : "cp /tmp/code.$extension /mnt/code/code.$extension && nohup helpers/start.sh \"$command\"";
- $executionResponse = $executor->createExecution(
- projectId: $project->getId(),
- deploymentId: $deployment->getId(),
- body: \strlen($body) > 0 ? $body : null,
- variables: $vars,
- timeout: $function->getAttribute('timeout', 0),
- image: $runtime['image'],
- source: $source,
- entrypoint: $deployment->getAttribute('entrypoint', ''),
- version: $version,
- path: $path,
- method: $method,
- headers: $headers,
- runtimeEntrypoint: $command,
- cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT,
- memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT,
- logging: $function->getAttribute('logging', true),
- requestTimeout: 30
- );
+ try {
+ $executionResponse = $executor->createExecution(
+ projectId: $project->getId(),
+ deploymentId: $deployment->getId(),
+ body: \strlen($body) > 0 ? $body : null,
+ variables: $vars,
+ timeout: $function->getAttribute('timeout', 0),
+ image: $runtime['image'],
+ source: $source,
+ entrypoint: $deployment->getAttribute('entrypoint', ''),
+ version: $version,
+ path: $path,
+ method: $method,
+ headers: $headers,
+ runtimeEntrypoint: $command,
+ cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT,
+ memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT,
+ logging: $function->getAttribute('logging', true),
+ requestTimeout: 30
+ );
+ } catch (ExecutorTimeout $th) {
+ throw new AppwriteException(AppwriteException::FUNCTION_SYNCHRONOUS_TIMEOUT, previous: $th);
+ }
$headersFiltered = [];
foreach ($executionResponse['headers'] as $key => $value) {
@@ -499,7 +492,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)
@@ -511,9 +504,7 @@ class Create extends Base
->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_FUNCTIONS, $function->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS), (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT)))
;
- if ($project->getId() != '6862e6a6000cce69f9da') {
- $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution));
- }
+ $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution));
}
$executionResponse['headers']['x-appwrite-execution-id'] = $execution->getId();
@@ -537,32 +528,18 @@ class Create extends Base
}
}
- $this->enqueueDeletes(
- $project,
- $function->getSequence(),
- $executionsRetentionCount,
+ if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) {
$queueForDeletes
- );
+ ->setProject($project)
+ ->setResource($function->getSequence())
+ ->setResourceType(RESOURCE_TYPE_FUNCTIONS)
+ ->setType(DELETE_TYPE_EXECUTIONS_LIMIT)
+ ->trigger();
+ }
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($execution, Response::MODEL_EXECUTION);
}
- private function enqueueDeletes(
- Document $project,
- string $resourceId,
- int $executionsRetentionCount,
- DeleteEvent $queueForDeletes
- ): void {
- /* cleanup */
- if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) {
- $queueForDeletes
- ->setProject($project)
- ->setResource($resourceId)
- ->setResourceType(RESOURCE_TYPE_FUNCTIONS)
- ->setType(DELETE_TYPE_EXECUTIONS_LIMIT)
- ->trigger();
- }
- }
}
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php
index 21ec3c66ce..9ecb5c0bf0 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php
@@ -35,7 +35,7 @@ class Delete extends Base
->setHttpPath('/v1/functions/:functionId/executions/:executionId')
->desc('Delete execution')
->groups(['api', 'functions'])
- ->label('scope', 'execution.write')
+ ->label('scope', ['executions.write', 'execution.write'])
->label('resourceType', RESOURCE_TYPE_FUNCTIONS)
->label('event', 'functions.[functionId].executions.[executionId].delete')
->label('audits.event', 'executions.delete')
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php
index 70912cf58c..0a9dd01b7e 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php
@@ -31,7 +31,7 @@ class Get extends Base
->setHttpPath('/v1/functions/:functionId/executions/:executionId')
->desc('Get execution')
->groups(['api', 'functions'])
- ->label('scope', 'execution.read')
+ ->label('scope', ['executions.read', 'execution.read'])
->label('resourceType', RESOURCE_TYPE_FUNCTIONS)
->label('sdk', new Method(
namespace: 'functions',
@@ -53,6 +53,7 @@ class Get extends Base
->inject('response')
->inject('dbForProject')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
@@ -61,12 +62,13 @@ class Get extends Base
string $executionId,
Response $response,
Database $dbForProject,
- Authorization $authorization
+ Authorization $authorization,
+ User $user
) {
$function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId));
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::FUNCTION_NOT_FOUND);
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php
index dcc3f6ee9c..6ad2a5ae55 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php
@@ -39,7 +39,7 @@ class XList extends Base
->setHttpPath('/v1/functions/:functionId/executions')
->desc('List executions')
->groups(['api', 'functions'])
- ->label('scope', 'execution.read')
+ ->label('scope', ['executions.read', 'execution.read'])
->label('resourceType', RESOURCE_TYPE_FUNCTIONS)
->label('sdk', new Method(
namespace: 'functions',
@@ -62,6 +62,7 @@ class XList extends Base
->inject('response')
->inject('dbForProject')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
@@ -71,12 +72,13 @@ class XList extends Base
bool $includeTotal,
Response $response,
Database $dbForProject,
- Authorization $authorization
+ Authorization $authorization,
+ User $user
) {
$function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId));
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::FUNCTION_NOT_FOUND);
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php
index d281c64414..148f0945ac 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php
@@ -2,9 +2,11 @@
namespace Appwrite\Platform\Modules\Functions\Http\Functions;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
-use Appwrite\Event\Func;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Validator\FunctionEvent;
use Appwrite\Event\Webhook;
@@ -115,10 +117,10 @@ class Create extends Base
->inject('timelimit')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('queueForRealtime')
->inject('queueForWebhooks')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('dbForPlatform')
->inject('request')
->inject('gitHub')
@@ -157,10 +159,10 @@ class Create extends Base
callable $timelimit,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
Realtime $queueForRealtime,
Webhook $queueForWebhooks,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Database $dbForPlatform,
Request $request,
GitHub $github,
@@ -326,10 +328,11 @@ class Create extends Base
project: $project,
installation: $installation,
dbForProject: $dbForProject,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: true,
+ platform: $platform,
reference: $providerBranch,
referenceType: 'branch'
);
@@ -367,15 +370,18 @@ class Create extends Base
'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
]));
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
}
$functionsDomain = $platform['functionsDomain'];
- if (!empty($functionsDomain)) {
+ if (!empty($functionsDomain) && isset($deployment) && !$deployment->isEmpty()) {
$routeSubdomain = ID::unique();
$domain = "{$routeSubdomain}.{$functionsDomain}";
// TODO: (@Meldiron) Remove after 1.7.x migration
@@ -391,8 +397,8 @@ class Create extends Base
'status' => 'verified',
'type' => 'deployment',
'trigger' => 'manual',
- 'deploymentId' => !isset($deployment) || $deployment->isEmpty() ? '' : $deployment->getId(),
- 'deploymentInternalId' => !isset($deployment) || $deployment->isEmpty() ? '' : $deployment->getSequence(),
+ 'deploymentId' => $deployment->getId(),
+ 'deploymentInternalId' => $deployment->getSequence(),
'deploymentResourceType' => 'function',
'deploymentResourceId' => $function->getId(),
'deploymentResourceInternalId' => $function->getSequence(),
@@ -418,14 +424,20 @@ class Create extends Base
->trigger();
/** Trigger Functions */
- $queueForFunctions
- ->from($ruleCreate)
- ->trigger();
+ $publisherForFunctions->enqueue(FunctionMessage::fromEvent(
+ event: $ruleCreate->getEvent(),
+ params: $ruleCreate->getParams(),
+ project: $ruleCreate->getProject(),
+ user: $ruleCreate->getUser(),
+ userId: $ruleCreate->getUserId(),
+ payload: $ruleCreate->getPayload(),
+ platform: $ruleCreate->getPlatform(),
+ ));
/** Trigger Realtime Events */
$queueForRealtime
- ->from($ruleCreate)
->setSubscribers(['console', $project->getId()])
+ ->from($ruleCreate)
->trigger();
}
}
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php
index a627bae9dd..e8713a179d 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Functions\Http\Functions;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Event\Validator\FunctionEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
@@ -105,11 +105,12 @@ class Update extends Base
->inject('dbForProject')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('dbForPlatform')
->inject('gitHub')
->inject('executor')
->inject('authorization')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -139,11 +140,12 @@ class Update extends Base
Database $dbForProject,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
Database $dbForPlatform,
GitHub $github,
Executor $executor,
- Authorization $authorization
+ Authorization $authorization,
+ array $platform
) {
// TODO: If only branch changes, re-deploy
$function = $dbForProject->getDocument('functions', $functionId);
@@ -162,16 +164,10 @@ class Update extends Base
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When connecting to VCS (Version Control System), you need to provide "installationId" and "providerBranch".');
}
- if ($function->isEmpty()) {
- throw new Exception(Exception::FUNCTION_NOT_FOUND);
- }
-
if (empty($runtime)) {
$runtime = $function->getAttribute('runtime');
}
- $enabled ??= $function->getAttribute('enabled', true);
-
$repositoryId = $function->getAttribute('repositoryId', '');
$repositoryInternalId = $function->getAttribute('repositoryInternalId', '');
@@ -287,11 +283,33 @@ class Update extends Base
// Redeploy logic
if (!$isConnected && !empty($providerRepositoryId)) {
- $this->redeployVcsFunction($request, $function, $project, $installation, $dbForProject, $queueForBuilds, new Document(), $github, true);
+ $this->redeployVcsFunction($request, $function, $project, $installation, $dbForProject, $publisherForBuilds, new Document(), $github, true, $platform);
}
// Inform scheduler if function is still active
- $schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId'));
+ $schedule = $authorization->skip(fn () => $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId')));
+
+ // Re-create schedule if missing
+ if ($schedule->isEmpty()) {
+ $schedule = $authorization->skip(
+ fn () => $dbForPlatform->createDocument('schedules', new Document([
+ 'region' => $project->getAttribute('region'),
+ 'resourceType' => SCHEDULE_RESOURCE_TYPE_FUNCTION,
+ 'resourceId' => $function->getId(),
+ 'resourceInternalId' => $function->getSequence(),
+ 'resourceUpdatedAt' => DateTime::now(),
+ 'projectId' => $project->getId(),
+ 'schedule' => $function->getAttribute('schedule'),
+ 'active' => false,
+ ]))
+ );
+
+ $function = $dbForProject->updateDocument('functions', $function->getId(), new Document([
+ 'scheduleId' => $schedule->getId(),
+ 'scheduleInternalId' => $schedule->getSequence(),
+ ]));
+ }
+
$schedule
->setAttribute('resourceUpdatedAt', DateTime::now())
->setAttribute('schedule', $function->getAttribute('schedule'))
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php
index 19476329bf..7016d600cb 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php
@@ -112,6 +112,7 @@ class Get extends Base
$format = match ($days['period']) {
'1h' => 'Y-m-d\TH:00:00.000P',
'1d' => 'Y-m-d\T00:00:00.000P',
+ default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period "' . $days['period'] . '".'),
};
foreach ($metrics as $metric) {
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php
index 38a95d4469..70b7b8e058 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Functions\Http\Usage;
+use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
@@ -104,6 +105,7 @@ class XList extends Base
$format = match ($days['period']) {
'1h' => 'Y-m-d\TH:00:00.000P',
'1d' => 'Y-m-d\T00:00:00.000P',
+ default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period "' . $days['period'] . '".'),
};
foreach ($metrics as $metric) {
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php
index fee5b0095d..de572cd41e 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php
@@ -2,11 +2,13 @@
namespace Appwrite\Platform\Modules\Functions\Http\Variables;
+use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
+use Appwrite\Utopia\Database\Validator\CustomId;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
@@ -38,6 +40,7 @@ class Create extends Base
->groups(['api', 'functions'])
->label('scope', 'functions.write')
->label('resourceType', RESOURCE_TYPE_FUNCTIONS)
+ ->label('event', 'variables.[variableId].create')
->label('audits.event', 'variable.create')
->label('audits.resource', 'function/{request.functionId}')
->label('sdk', new Method(
@@ -56,10 +59,12 @@ class Create extends Base
]
))
->param('functionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Function unique ID.', false, ['dbForProject'])
+ ->param('variableId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject'])
->param('key', null, new Text(Database::LENGTH_KEY), 'Variable key. Max length: ' . Database::LENGTH_KEY . ' chars.', false)
->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.', false)
->param('secret', true, new Boolean(), 'Secret variables can be updated or deleted, but only functions can read them during build and runtime.', true)
->inject('response')
+ ->inject('queueForEvents')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
@@ -69,10 +74,12 @@ class Create extends Base
public function action(
string $functionId,
+ string $variableId,
string $key,
string $value,
bool $secret,
Response $response,
+ QueueEvent $queueForEvents,
Database $dbForProject,
Database $dbForPlatform,
Document $project,
@@ -84,7 +91,7 @@ class Create extends Base
throw new Exception(Exception::FUNCTION_NOT_FOUND);
}
- $variableId = ID::unique();
+ $variableId = ($variableId === 'unique()') ? ID::unique() : $variableId;
$teamId = $project->getAttribute('teamId', '');
$variable = new Document([
@@ -120,6 +127,8 @@ class Create extends Base
'active' => $schedule->getAttribute('active'),
])));
+ $queueForEvents->setParam('variableId', $variable->getId());
+
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($variable, Response::MODEL_VARIABLE);
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php
index 5648596826..fa9f19ba8f 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Functions\Http\Variables;
+use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -35,6 +36,7 @@ class Delete extends Base
->groups(['api', 'functions'])
->label('scope', 'functions.write')
->label('resourceType', RESOURCE_TYPE_FUNCTIONS)
+ ->label('event', 'variables.[variableId].delete')
->label('audits.event', 'variable.delete')
->label('audits.resource', 'function/{request.functionId}')
->label('sdk', new Method(
@@ -56,6 +58,7 @@ class Delete extends Base
->param('functionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Function unique ID.', false, ['dbForProject'])
->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
->inject('response')
+ ->inject('queueForEvents')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('authorization')
@@ -66,6 +69,7 @@ class Delete extends Base
string $functionId,
string $variableId,
Response $response,
+ QueueEvent $queueForEvents,
Database $dbForProject,
Database $dbForPlatform,
Authorization $authorization
@@ -77,11 +81,7 @@ class Delete extends Base
}
$variable = $dbForProject->getDocument('variables', $variableId);
- if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') {
- throw new Exception(Exception::VARIABLE_NOT_FOUND);
- }
-
- if ($variable === false || $variable->isEmpty()) {
+ if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') {
throw new Exception(Exception::VARIABLE_NOT_FOUND);
}
@@ -102,6 +102,8 @@ class Delete extends Base
'active' => $schedule->getAttribute('active'),
])));
+ $queueForEvents->setParam('variableId', $variable->getId());
+
$response->noContent();
}
}
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php
index 19c345fbc2..13ce73e751 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php
@@ -66,7 +66,6 @@ class Get extends Base
$variable = $dbForProject->getDocument('variables', $variableId);
if (
- $variable === false ||
$variable->isEmpty() ||
$variable->getAttribute('resourceInternalId') !== $function->getSequence() ||
$variable->getAttribute('resourceType') !== 'function'
@@ -74,10 +73,6 @@ class Get extends Base
throw new Exception(Exception::VARIABLE_NOT_FOUND);
}
- if ($variable === false || $variable->isEmpty()) {
- throw new Exception(Exception::VARIABLE_NOT_FOUND);
- }
-
$response->dynamic($variable, Response::MODEL_VARIABLE);
}
}
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php
index acb066ca9c..6413b29f82 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Functions\Http\Variables;
+use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -38,6 +39,7 @@ class Update extends Base
->groups(['api', 'functions'])
->label('scope', 'functions.write')
->label('resourceType', RESOURCE_TYPE_FUNCTIONS)
+ ->label('event', 'variables.[variableId].update')
->label('audits.event', 'variable.update')
->label('audits.resource', 'function/{request.functionId}')
->label('sdk', new Method(
@@ -57,10 +59,11 @@ class Update extends Base
))
->param('functionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Function unique ID.', false, ['dbForProject'])
->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
- ->param('key', null, new Text(255), 'Variable key. Max length: 255 chars.', false)
+ ->param('key', null, new Nullable(new Text(255, 0)), 'Variable key. Max length: 255 chars.', true)
->param('value', null, new Nullable(new Text(8192, 0)), 'Variable value. Max length: 8192 chars.', true)
->param('secret', null, new Nullable(new Boolean()), 'Secret variables can be updated or deleted, but only functions can read them during build and runtime.', true)
->inject('response')
+ ->inject('queueForEvents')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('authorization')
@@ -70,10 +73,11 @@ class Update extends Base
public function action(
string $functionId,
string $variableId,
- string $key,
+ ?string $key,
?string $value,
?bool $secret,
Response $response,
+ QueueEvent $queueForEvents,
Database $dbForProject,
Database $dbForPlatform,
Authorization $authorization
@@ -85,7 +89,7 @@ class Update extends Base
}
$variable = $dbForProject->getDocument('variables', $variableId);
- if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') {
+ if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') {
throw new Exception(Exception::VARIABLE_NOT_FOUND);
}
@@ -93,19 +97,27 @@ class Update extends Base
throw new Exception(Exception::VARIABLE_CANNOT_UNSET_SECRET);
}
- $variable
- ->setAttribute('key', $key)
- ->setAttribute('value', $value ?? $variable->getAttribute('value'))
- ->setAttribute('secret', $secret ?? $variable->getAttribute('secret'))
- ->setAttribute('search', implode(' ', [$variableId, $function->getId(), $key, 'function']));
+ if (\is_null($key) && \is_null($value) && \is_null($secret)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID);
+ }
+
+ $updates = new Document();
+
+ if (!\is_null($key)) {
+ $updates->setAttribute('key', $key);
+ $updates->setAttribute('search', implode(' ', [$variableId, $function->getId(), $key, 'function']));
+ }
+
+ if (!\is_null($value)) {
+ $updates->setAttribute('value', $value);
+ }
+
+ if (!\is_null($secret)) {
+ $updates->setAttribute('secret', $secret);
+ }
try {
- $dbForProject->updateDocument('variables', $variable->getId(), new Document([
- 'key' => $key,
- 'value' => $value ?? $variable->getAttribute('value'),
- 'secret' => $secret ?? $variable->getAttribute('secret'),
- 'search' => implode(' ', [$variableId, $function->getId(), $key, 'function']),
- ]));
+ $variable = $dbForProject->updateDocument('variables', $variable->getId(), $updates);
} catch (DuplicateException $th) {
throw new Exception(Exception::VARIABLE_ALREADY_EXISTS);
}
@@ -125,6 +137,8 @@ class Update extends Base
'active' => $schedule->getAttribute('active'),
])));
+ $queueForEvents->setParam('variableId', $variable->getId());
+
$response->dynamic($variable, Response::MODEL_VARIABLE);
}
}
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/XList.php
index 55dea3be1e..b330812b96 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/XList.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/XList.php
@@ -7,12 +7,18 @@ use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
+use Appwrite\Utopia\Database\Validator\Queries\Variables;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
+use Utopia\Database\Exception\Order as OrderException;
+use Utopia\Database\Exception\Query as QueryException;
+use Utopia\Database\Query;
+use Utopia\Database\Validator\Query\Cursor;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
+use Utopia\Validator\Boolean;
class XList extends Base
{
@@ -51,22 +57,74 @@ class XList extends Base
)
)
->param('functionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Function unique ID.', false, ['dbForProject'])
+ ->param('queries', [], new Variables(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Variables::ALLOWED_ATTRIBUTES), true)
+ ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
- public function action(string $functionId, Response $response, Database $dbForProject)
- {
+ /**
+ * @param array $queries
+ */
+ public function action(
+ string $functionId,
+ array $queries,
+ bool $includeTotal,
+ Response $response,
+ Database $dbForProject
+ ) {
$function = $dbForProject->getDocument('functions', $functionId);
if ($function->isEmpty()) {
throw new Exception(Exception::FUNCTION_NOT_FOUND);
}
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $queries[] = Query::equal('resourceType', ['function']);
+ $queries[] = Query::equal('resourceInternalId', [$function->getSequence()]);
+ $queries[] = Query::orderAsc();
+
+ $cursor = Query::getCursorQueries($queries, false);
+ $cursor = \reset($cursor);
+
+ if ($cursor !== false) {
+ $validator = new Cursor();
+ if (!$validator->isValid($cursor)) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
+ }
+
+ $variableId = $cursor->getValue();
+ $cursorDocument = $dbForProject->findOne('variables', [
+ Query::equal('$id', [$variableId]),
+ Query::equal('resourceType', ['function']),
+ Query::equal('resourceInternalId', [$function->getSequence()]),
+ ]);
+
+ if ($cursorDocument->isEmpty()) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Variable '{$variableId}' for the 'cursor' value not found.");
+ }
+
+ $cursor->setValue($cursorDocument);
+ }
+
+ $filterQueries = Query::groupByType($queries)['filters'];
+
+ try {
+ $variables = $dbForProject->find('variables', $queries);
+ $total = $includeTotal ? $dbForProject->count('variables', $filterQueries, APP_LIMIT_COUNT) : 0;
+ } catch (OrderException $e) {
+ throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
+ }
+
$response->dynamic(new Document([
- 'variables' => $function->getAttribute('vars', []),
- 'total' => \count($function->getAttribute('vars', [])),
+ 'variables' => $variables,
+ 'total' => $total,
]), Response::MODEL_VARIABLE_LIST);
}
}
diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
index c080f5d3dd..5aa95d3bf2 100644
--- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
+++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
@@ -4,15 +4,20 @@ namespace Appwrite\Platform\Modules\Functions\Workers;
use Ahc\Jwt\JWT;
use Appwrite\Event\Event;
-use Appwrite\Event\Func;
+use Appwrite\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Message\Usage as UsageMessage;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
+use Appwrite\Event\Publisher\Screenshot;
+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\Extend\Exception as AppwriteException;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
+use Appwrite\Usage\Context;
use Appwrite\Utopia\Response\Model\Deployment;
use Appwrite\Vcs\Comment;
use Exception;
+use Executor\Exception\Timeout as ExecutorTimeout;
use Executor\Executor;
use Swoole\Coroutine as Co;
use Utopia\Cache\Cache;
@@ -32,6 +37,7 @@ use Utopia\Detector\Detector\Rendering;
use Utopia\Logger\Log;
use Utopia\Platform\Action;
use Utopia\Queue\Message;
+use Utopia\Span\Span;
use Utopia\Storage\Device;
use Utopia\Storage\Device\Local;
use Utopia\System\System;
@@ -56,11 +62,12 @@ class Builds extends Action
->inject('project')
->inject('dbForPlatform')
->inject('queueForEvents')
- ->inject('queueForScreenshots')
+ ->inject('publisherForScreenshots')
->inject('queueForWebhooks')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForRealtime')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
+ ->inject('publisherForUsage')
->inject('cache')
->inject('dbForProject')
->inject('deviceForFunctions')
@@ -74,24 +81,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(
@@ -99,11 +88,12 @@ class Builds extends Action
Document $project,
Database $dbForPlatform,
Event $queueForEvents,
- Screenshot $queueForScreenshots,
+ Screenshot $publisherForScreenshots,
Webhook $queueForWebhooks,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Realtime $queueForRealtime,
- StatsUsage $queueForStatsUsage,
+ Context $usage,
+ UsagePublisher $publisherForUsage,
Cache $cache,
Database $dbForProject,
Device $deviceForFunctions,
@@ -116,7 +106,7 @@ class Builds extends Action
): void {
Console::log('Build action started');
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new \Exception('Missing payload');
@@ -140,12 +130,13 @@ class Builds extends Action
$deviceForFunctions,
$deviceForSites,
$deviceForFiles,
- $queueForScreenshots,
+ $publisherForScreenshots,
$queueForWebhooks,
- $queueForFunctions,
+ $publisherForFunctions,
$queueForRealtime,
$queueForEvents,
- $queueForStatsUsage,
+ $usage,
+ $publisherForUsage,
$dbForPlatform,
$dbForProject,
$github,
@@ -157,7 +148,8 @@ class Builds extends Action
$log,
$executor,
$plan,
- $platform
+ $platform,
+ (int) ($payload['timeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900))
);
break;
@@ -167,40 +159,20 @@ 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(
Device $deviceForFunctions,
Device $deviceForSites,
Device $deviceForFiles,
- Screenshot $queueForScreenshots,
+ Screenshot $publisherForScreenshots,
Webhook $queueForWebhooks,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Realtime $queueForRealtime,
Event $queueForEvents,
- StatsUsage $queueForStatsUsage,
+ Context $usage,
+ UsagePublisher $publisherForUsage,
Database $dbForPlatform,
Database $dbForProject,
GitHub $github,
@@ -212,8 +184,15 @@ class Builds extends Action
Log $log,
Executor $executor,
array $plan,
- array $platform
+ array $platform,
+ int $timeout
): void {
+ Span::add('project.id', $project->getId());
+ Span::add('resource.id', $resource->getId());
+ Span::add('resource.type', $resource->getCollection());
+ Span::add('deployment.id', $deployment->getId());
+ Span::add('build.timeout', $timeout);
+
Console::info('Deployment action started');
$startTime = DateTime::now();
@@ -237,7 +216,7 @@ class Builds extends Action
throw new \Exception('Resource not found');
}
- if ($isResourceBlocked($project, $resourceKey === 'functions' ? RESOURCE_TYPE_FUNCTIONS : RESOURCE_TYPE_SITES, $resource->getId())) {
+ if ($isResourceBlocked($project, $resource->getCollection() === 'functions' ? RESOURCE_TYPE_FUNCTIONS : RESOURCE_TYPE_SITES, $resource->getId())) {
throw new \Exception('Resource is blocked');
}
@@ -254,12 +233,12 @@ class Builds extends Action
$version = $this->getVersion($resource);
$runtime = $this->getRuntime($resource, $version);
+ Span::add('build.runtime', $resource->getAttribute($resource->getCollection() === 'sites' ? 'buildRuntime' : 'runtime', ''));
+ Span::add('build.version', $version);
$spec = Config::getParam('specifications')[$resource->getAttribute('buildSpecification', APP_COMPUTE_SPECIFICATION_DEFAULT)];
-
- if ($resource->getCollection() === 'functions' && \is_null($runtime)) {
- throw new \Exception('Runtime "' . $resource->getAttribute('runtime', '') . '" is not supported');
- }
+ Span::add('build.cpus', (float) ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT));
+ Span::add('build.memory', (int) ($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT));
// Realtime preparation
$event = "{$resource->getCollection()}.[{$resourceKey}].deployments.[deploymentId].update";
@@ -272,6 +251,7 @@ class Builds extends Action
if ($deployment->getAttribute('status') === 'canceled') {
$this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime);
+
return;
}
@@ -298,7 +278,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 +292,7 @@ class Builds extends Action
}
try {
- if (!$isVcsEnabled) {
+ if (! $isVcsEnabled) {
// Non-VCS + Template
$templateRepositoryName = $template->getAttribute('repositoryName', '');
$templateOwnerName = $template->getAttribute('ownerName', '');
@@ -324,7 +304,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 +338,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 +380,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 +393,7 @@ class Builds extends Action
if ($dbForProject->getDocument('deployments', $deploymentId)->getAttribute('status') === 'canceled') {
$this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime);
+
return;
}
@@ -429,7 +410,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 +418,6 @@ class Builds extends Action
$rootDirectory = $rootDirectoryWithoutSpaces;
}
-
// Build from template
$templateRepositoryName = $template->getAttribute('repositoryName', '');
$templateOwnerName = $template->getAttribute('ownerName', '');
@@ -449,7 +429,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 +448,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);
@@ -482,7 +462,7 @@ class Builds extends Action
$providerCommitHash = \trim($stdout);
- $deployment->setAttribute('providerCommitHash', $providerCommitHash ?? '');
+ $deployment->setAttribute('providerCommitHash', $providerCommitHash);
$deployment->setAttribute('providerCommitAuthorUrl', APP_VCS_GITHUB_URL);
$deployment->setAttribute('providerCommitAuthor', APP_VCS_GITHUB_USERNAME);
$deployment->setAttribute('providerCommitMessage', "Create '" . $resource->getAttribute('name', '') . "' function");
@@ -511,7 +491,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 +509,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);
@@ -591,9 +571,15 @@ class Builds extends Action
->trigger();
/** Trigger Functions */
- $queueForFunctions
- ->from($deploymentUpdate)
- ->trigger();
+ $publisherForFunctions->enqueue(FunctionMessage::fromEvent(
+ event: $deploymentUpdate->getEvent(),
+ params: $deploymentUpdate->getParams(),
+ project: $deploymentUpdate->getProject(),
+ user: $deploymentUpdate->getUser(),
+ userId: $deploymentUpdate->getUserId(),
+ payload: $deploymentUpdate->getPayload(),
+ platform: $deploymentUpdate->getPlatform(),
+ ));
/** Trigger Realtime Event */
$queueForRealtime
@@ -623,16 +609,12 @@ class Builds extends Action
}
$cpus = $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT;
- $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);
- $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0);
+ $memory = max($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, $minMemory);
+ $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $timeout, 0);
$apiKey = $jwtObj->encode([
'projectId' => $project->getId(),
- 'scopes' => $resource->getAttribute('scopes', [])
+ 'scopes' => $resource->getAttribute('scopes', []),
]);
// Appwrite vars
@@ -662,7 +644,7 @@ class Builds extends Action
$vars = [
...$vars,
'APPWRITE_FUNCTION_API_ENDPOINT' => $endpoint,
- 'APPWRITE_FUNCTION_API_KEY' => API_KEY_DYNAMIC . '_' . $apiKey,
+ 'APPWRITE_FUNCTION_API_KEY' => API_KEY_EPHEMERAL . '_' . $apiKey,
'APPWRITE_FUNCTION_ID' => $resource->getId(),
'APPWRITE_FUNCTION_NAME' => $resource->getAttribute('name'),
'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(),
@@ -677,7 +659,7 @@ class Builds extends Action
$vars = [
...$vars,
'APPWRITE_SITE_API_ENDPOINT' => $endpoint,
- 'APPWRITE_SITE_API_KEY' => API_KEY_DYNAMIC . '_' . $apiKey,
+ 'APPWRITE_SITE_API_KEY' => API_KEY_EPHEMERAL . '_' . $apiKey,
'APPWRITE_SITE_ID' => $resource->getId(),
'APPWRITE_SITE_NAME' => $resource->getAttribute('name'),
'APPWRITE_SITE_DEPLOYMENT' => $deployment->getId(),
@@ -700,6 +682,7 @@ class Builds extends Action
if ($dbForProject->getDocument('deployments', $deploymentId)->getAttribute('status') === 'canceled') {
$this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime);
+
return;
}
@@ -721,7 +704,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 +731,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,
@@ -757,6 +740,9 @@ class Builds extends Action
);
Console::log('createRuntime finished');
+ } catch (ExecutorTimeout $error) {
+ Console::warning('createRuntime timed out');
+ $err = new AppwriteException(AppwriteException::BUILD_TIMEOUT, previous: $error);
} catch (\Throwable $error) {
Console::warning('createRuntime failed');
$err = $error;
@@ -782,6 +768,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 +776,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 +806,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;
}
}
@@ -861,8 +848,10 @@ class Builds extends Action
Console::log('Runtime creation finished');
- if ($dbForProject->getDocument('deployments', $deploymentId)->getAttribute('status') === 'canceled') {
+ $latestDeployment = $dbForProject->getDocument('deployments', $deploymentId);
+ if ($latestDeployment->getAttribute('status') === 'canceled') {
$this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime);
+
return;
}
@@ -870,7 +859,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;
}
@@ -892,13 +881,13 @@ class Builds extends Action
if (\str_contains($logs, '{APPWRITE_DETECTION_SEPARATOR_START}')) {
[$logsBefore, $detectionLogsStart] = \explode('{APPWRITE_DETECTION_SEPARATOR_START}', $logs, 2);
[$detectionLogs, $logsAfter] = \explode('{APPWRITE_DETECTION_SEPARATOR_END}', $detectionLogsStart, 2);
- $logs = ($logsBefore ?? '') . ($logsAfter ?? '');
+ $logs = $logsBefore . $logsAfter;
}
$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 +959,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 +1047,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 +1067,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 +1115,7 @@ class Builds extends Action
if ($dbForProject->getDocument('deployments', $deploymentId)->getAttribute('status') === 'canceled') {
$this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime);
+
return;
}
@@ -1139,7 +1129,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'),
@@ -1149,10 +1139,10 @@ class Builds extends Action
/** Screenshot site */
if ($resource->getCollection() === 'sites') {
- $queueForScreenshots
- ->setDeploymentId($deployment->getId())
- ->setProject($project)
- ->trigger();
+ $publisherForScreenshots->enqueue(new \Appwrite\Event\Message\Screenshot(
+ project: $project,
+ deploymentId: $deployment->getId(),
+ ));
Console::log('Site screenshot queued');
}
@@ -1167,25 +1157,24 @@ 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 = "[31m" . $message;
+ if (! \str_contains($message, '')) {
+ $message = '[31m' . $message;
}
$message = \str_replace('{APPWRITE_DETECTION_SEPARATOR_START}', '', $message);
$message = \str_replace('{APPWRITE_DETECTION_SEPARATOR_END}', '', $message);
- // Combine with previous logs if deployment got past build process
- $previousLogs = '';
- if (!is_null($deployment->getAttribute('buildSize', null))) {
- $previousLogs = $deployment->getAttribute('buildLogs', '');
- if (!empty($previousLogs)) {
- $message = $previousLogs . "\n" . $message;
- }
+ // Append error to whatever build logs were already streamed
+ $deployment = $dbForProject->getDocument('deployments', $deploymentId);
+ $previousLogs = $deployment->getAttribute('buildLogs', '');
+ if (! empty($previousLogs)) {
+ $message = $previousLogs . "\n" . $message;
}
$endTime = DateTime::now();
@@ -1219,110 +1208,90 @@ 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)];
+ $cpus = (int) ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT);
+ $memory = (int) ($spec['memory'] ?? APP_COMPUTE_MEMORY_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)) {
- throw new Exception('queueForRealtime must be an instance of Realtime');
- }
- if (!($dbForProject instanceof Database)) {
- throw new Exception('dbForProject must be an instance of Database');
- }
- if (!($deployment instanceof Document)) {
- throw new Exception('deployment must be an instance of Document');
- }
- if (!is_array($runtime)) {
- throw new Exception('runtime must be an array');
- }
- 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)) {
- throw new Exception('project must be an instance of Document');
- }
-
- if (!($deployment instanceof Document)) {
- throw new Exception('deployment must be an instance of Document');
- }
}
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,
@@ -1340,6 +1309,7 @@ class Builds extends Action
return match ($resource->getCollection()) {
'functions' => $resource->getAttribute('version', 'v2'),
'sites' => 'v5',
+ default => throw new \Exception('Unsupported resource type "' . $resource->getCollection() . '".'),
};
}
@@ -1355,7 +1325,7 @@ class Builds extends Action
$envCommand = '';
$bundleCommand = '';
- if (!is_null($framework)) {
+ if (! is_null($framework)) {
$envCommand = $framework['envCommand'] ?? '';
$bundleCommand = $framework['bundleCommand'] ?? '';
}
@@ -1364,7 +1334,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 +1343,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
@@ -1405,6 +1362,8 @@ class Builds extends Action
Realtime $queueForRealtime,
array $platform
): void {
+ $deployment = new Document();
+
try {
if ($resource->getAttribute('providerSilentMode', false) === true) {
return;
@@ -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,24 +1429,23 @@ 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()) {
- 'functions' => '',
- 'sites' => !empty($rule) ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '',
- default => throw new \Exception('Invalid resource type')
- };
+ $previewUrl = '';
+ if ($resource->getCollection() === 'sites' && !$rule->isEmpty()) {
+ $previewUrl = "{$protocol}://" . $rule->getAttribute('domain', '');
+ }
$comment = new Comment($platform);
$comment->parseComment($github->getComment($owner, $repositoryName, $commentId));
@@ -1498,7 +1456,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/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php
index 065fe477eb..c766f73929 100644
--- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php
+++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php
@@ -3,6 +3,7 @@
namespace Appwrite\Platform\Modules\Functions\Workers;
use Ahc\Jwt\JWT;
+use Appwrite\Event\Message\Screenshot;
use Appwrite\Event\Realtime;
use Appwrite\Permission;
use Appwrite\Role;
@@ -19,6 +20,8 @@ use Utopia\Platform\Action;
use Utopia\Queue\Message;
use Utopia\Storage\Device;
use Utopia\System\System;
+use Utopia\Telemetry\Adapter as Telemetry;
+use Utopia\Telemetry\Counter;
use function Swoole\Coroutine\batch;
@@ -43,6 +46,7 @@ class Screenshots extends Action
->inject('dbForProject')
->inject('project')
->inject('deviceForFiles')
+ ->inject('telemetry')
->callback($this->action(...));
}
@@ -52,19 +56,23 @@ class Screenshots extends Action
Database $dbForPlatform,
Database $dbForProject,
Document $project,
- Device $deviceForFiles
+ Device $deviceForFiles,
+ Telemetry $telemetry
): void {
Console::log('Screenshot action started');
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new \Exception('Missing payload');
}
+ $screenshotMessage = Screenshot::fromArray($payload);
+ $counter = $telemetry->createCounter('worker.screenshots.capture');
+
Console::log('Site screenshot started');
- $deploymentId = $payload['deploymentId'] ?? null;
+ $deploymentId = $screenshotMessage->deploymentId;
$deployment = $dbForProject->getDocument('deployments', $deploymentId);
if ($deployment->isEmpty()) {
@@ -101,9 +109,7 @@ class Screenshots extends Action
throw new \Exception("Rule for deployment not found");
}
- $client = new FetchClient();
- $client->setTimeout(\intval($site->getAttribute('timeout', '15')) * 1000);
- $client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON);
+ $timeout = \intval($site->getAttribute('timeout', '15')) * 1000;
$bucket = $dbForPlatform->getDocument('buckets', 'screenshots');
@@ -154,13 +160,13 @@ class Screenshots extends Action
]);
$screenshotError = null;
- $screenshots = batch(\array_map(function ($key) use ($configs, $apiKey, $site, $client, &$screenshotError) {
- return function () use ($key, $configs, $apiKey, $site, $client, &$screenshotError) {
+ $screenshots = batch(\array_map(function ($key) use ($configs, $apiKey, $site, $timeout, &$screenshotError) {
+ return function () use ($key, $configs, $apiKey, $site, $timeout, &$screenshotError) {
try {
$config = $configs[$key];
- $config['headers'] = \array_merge($config['headers'] ?? [], [
- 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey
+ $config['headers'] = \array_merge($config['headers'], [
+ 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey
]);
$config['sleep'] = 3000;
@@ -171,6 +177,10 @@ class Screenshots extends Action
}
$browserEndpoint = System::getEnv('_APP_BROWSER_HOST', 'http://appwrite-browser:3000/v1');
+ $client = new FetchClient();
+ $client->setTimeout($timeout);
+ $client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON);
+
$fetchResponse = $client->fetch(
url: $browserEndpoint . '/screenshots',
method: 'POST',
@@ -265,8 +275,24 @@ class Screenshots extends Action
$date = \date('H:i:s');
$this->appendToLogs($dbForProject, $deployment->getId(), $queueForRealtime, "[90m[$date] [90m[[0mappwrite[90m][33m Screenshot capturing failed. Deployment will continue. [0m\n");
+ $this->recordTelemetry($counter, 'failure');
+
throw $th;
}
+
+ $this->recordTelemetry($counter, 'success');
+ }
+
+ protected function recordTelemetry(Counter $counter, string $result): void
+ {
+ try {
+ $counter->add(1, [
+ 'resourceType' => RESOURCE_TYPE_SITES,
+ 'result' => $result,
+ ]);
+ } catch (\Throwable) {
+ // Telemetry should never affect screenshot processing.
+ }
}
protected function appendToLogs(Database $dbForProject, string $deploymentId, Realtime $queueForRealtime, string $logs)
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php
index 60cf5d00d4..728ffb8b71 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php
@@ -82,7 +82,7 @@ class Get extends Action
}
$certificatePayload = @openssl_x509_parse($peerCertificate);
- if ($certificatePayload === false || !\is_array($certificatePayload)) {
+ if ($certificatePayload === false) {
throw new Exception(Exception::HEALTH_INVALID_HOST, 'Failed to parse peer certificate for ' . $domain);
}
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php
index e01e89641d..76c34a0a2a 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Audits;
-use Appwrite\Event\Audit;
+use Appwrite\Event\Publisher\Audit;
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('queueForAudits')
+ ->inject('publisherForAudits')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Audit $queueForAudits, Response $response): void
+ public function action(int|string $threshold, Audit $publisherForAudits, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForAudits->getSize();
+ $size = $publisherForAudits->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php
index 8ae7c8687a..98e65e37e5 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Builds;
-use Appwrite\Event\Build;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -42,16 +42,16 @@ class Get extends Base
contentType: ContentType::JSON
))
->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true)
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Build $queueForBuilds, Response $response): void
+ public function action(int|string $threshold, BuildPublisher $publisherForBuilds, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForBuilds->getSize();
+ $size = $publisherForBuilds->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php
index 6724f25094..82c45db172 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Certificates;
-use Appwrite\Event\Certificate;
+use Appwrite\Event\Publisher\Certificate;
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('queueForCertificates')
+ ->inject('publisherForCertificates')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Certificate $queueForCertificates, Response $response): void
+ public function action(int|string $threshold, Certificate $publisherForCertificates, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForCertificates->getSize();
+ $size = $publisherForCertificates->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php
index dc84d0ee37..0429118e41 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php
@@ -2,20 +2,21 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Failed;
-use Appwrite\Event\Audit;
-use Appwrite\Event\Build;
-use Appwrite\Event\Certificate;
use Appwrite\Event\Database;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
-use Appwrite\Event\Func;
-use Appwrite\Event\Mail;
-use Appwrite\Event\Messaging;
-use Appwrite\Event\Migration;
-use Appwrite\Event\Screenshot;
-use Appwrite\Event\StatsResources;
-use Appwrite\Event\StatsUsage;
+use Appwrite\Event\Publisher\Audit;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
+use Appwrite\Event\Publisher\Certificate;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
+use Appwrite\Event\Publisher\Migration as MigrationPublisher;
+use Appwrite\Event\Publisher\Screenshot;
+use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher;
+use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Webhook;
+use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -75,17 +76,17 @@ class Get extends Base
->inject('response')
->inject('queueForDatabase')
->inject('queueForDeletes')
- ->inject('queueForAudits')
- ->inject('queueForMails')
- ->inject('queueForFunctions')
- ->inject('queueForStatsResources')
- ->inject('queueForStatsUsage')
+ ->inject('publisherForAudits')
+ ->inject('publisherForMails')
+ ->inject('publisherForFunctions')
+ ->inject('publisherForStatsResources')
+ ->inject('publisherForUsage')
->inject('queueForWebhooks')
- ->inject('queueForCertificates')
- ->inject('queueForBuilds')
- ->inject('queueForMessaging')
- ->inject('queueForMigrations')
- ->inject('queueForScreenshots')
+ ->inject('publisherForCertificates')
+ ->inject('publisherForBuilds')
+ ->inject('publisherForMessaging')
+ ->inject('publisherForMigrations')
+ ->inject('publisherForScreenshots')
->callback($this->action(...));
}
@@ -95,34 +96,35 @@ class Get extends Base
Response $response,
Database $queueForDatabase,
Delete $queueForDeletes,
- Audit $queueForAudits,
- Mail $queueForMails,
- Func $queueForFunctions,
- StatsResources $queueForStatsResources,
- StatsUsage $queueForStatsUsage,
+ Audit $publisherForAudits,
+ MailPublisher $publisherForMails,
+ FunctionPublisher $publisherForFunctions,
+ StatsResourcesPublisher $publisherForStatsResources,
+ UsagePublisher $publisherForUsage,
Webhook $queueForWebhooks,
- Certificate $queueForCertificates,
- Build $queueForBuilds,
- Messaging $queueForMessaging,
- Migration $queueForMigrations,
- Screenshot $queueForScreenshots,
+ Certificate $publisherForCertificates,
+ BuildPublisher $publisherForBuilds,
+ MessagingPublisher $publisherForMessaging,
+ MigrationPublisher $publisherForMigrations,
+ Screenshot $publisherForScreenshots,
): void {
$threshold = (int) $threshold;
$queue = match ($name) {
System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME) => $queueForDatabase,
System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME) => $queueForDeletes,
- System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME) => $queueForAudits,
- 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_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME) => $publisherForAudits,
+ System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME) => $publisherForMails,
+ System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME) => $publisherForFunctions,
+ System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME) => $publisherForStatsResources,
+ 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,
- System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $queueForScreenshots,
- System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $queueForMessaging,
- System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $queueForMigrations,
+ System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME) => $publisherForCertificates,
+ System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $publisherForBuilds,
+ System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $publisherForScreenshots,
+ System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $publisherForMessaging,
+ System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $publisherForMigrations,
+ default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unknown queue name: ' . $name),
};
$failed = $queue->getSize(failed: true);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Functions/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Functions/Get.php
index 1d10b8d1a0..29c7a7c859 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Functions/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Functions/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Functions;
-use Appwrite\Event\Func;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
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('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Func $queueForFunctions, Response $response): void
+ public function action(int|string $threshold, FunctionPublisher $publisherForFunctions, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForFunctions->getSize();
+ $size = $publisherForFunctions->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php
index dd05aebc39..0a655662de 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Logs;
-use Appwrite\Event\Audit;
+use Appwrite\Event\Publisher\Audit;
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('queueForAudits')
+ ->inject('publisherForAudits')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Audit $queueForAudits, Response $response): void
+ public function action(int|string $threshold, Audit $publisherForAudits, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForAudits->getSize();
+ $size = $publisherForAudits->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Mails/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Mails/Get.php
index 3b9c06b5f9..2dd36e8111 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Mails/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Mails/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Mails;
-use Appwrite\Event\Mail;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
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('queueForMails')
+ ->inject('publisherForMails')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Mail $queueForMails, Response $response): void
+ public function action(int|string $threshold, MailPublisher $publisherForMails, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForMails->getSize();
+ $size = $publisherForMails->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Messaging/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Messaging/Get.php
index db2d7d7172..a2a829b1d5 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Messaging/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Messaging/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Messaging;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
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('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Messaging $queueForMessaging, Response $response): void
+ public function action(int|string $threshold, MessagingPublisher $publisherForMessaging, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForMessaging->getSize();
+ $size = $publisherForMessaging->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Migrations/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Migrations/Get.php
index 4faca7d8a4..70bef3562b 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Migrations/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Migrations/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Migrations;
-use Appwrite\Event\Migration;
+use Appwrite\Event\Publisher\Migration as MigrationPublisher;
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('queueForMigrations')
+ ->inject('publisherForMigrations')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Migration $queueForMigrations, Response $response): void
+ public function action(int|string $threshold, MigrationPublisher $publisherForMigrations, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForMigrations->getSize();
+ $size = $publisherForMigrations->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsResources/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsResources/Get.php
index 57605298fd..5ab0aa2532 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsResources/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsResources/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\StatsResources;
-use Appwrite\Event\StatsResources;
+use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher;
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('queueForStatsResources')
+ ->inject('publisherForStatsResources')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, StatsResources $queueForStatsResources, Response $response): void
+ public function action(int|string $threshold, StatsResourcesPublisher $publisherForStatsResources, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForStatsResources->getSize();
+ $size = $publisherForStatsResources->getSize();
$this->assertQueueThreshold($size, $threshold);
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/Migrations/Http/Migrations/Appwrite/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php
new file mode 100644
index 0000000000..fa700877a1
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php
@@ -0,0 +1,116 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/migrations/appwrite')
+ ->desc('Create Appwrite migration')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.write')
+ ->label('event', 'migrations.[migrationId].create')
+ ->label('audits.event', 'migration.create')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'createAppwriteMigration',
+ description: '/docs/references/migrations/migration-appwrite.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_ACCEPTED,
+ model: Response::MODEL_MIGRATION,
+ )
+ ]
+ ))
+ ->param('resources', [], new ArrayList(new WhiteList(AppwriteSource::getSupportedResources())), 'List of resources to migrate')
+ ->param('endpoint', '', new URL(), 'Source Appwrite endpoint')
+ ->param('projectId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Source Project ID', false, ['dbForProject'])
+ ->param('apiKey', '', new Text(512), 'Source API Key')
+ ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('project')
+ ->inject('platform')
+ ->inject('queueForEvents')
+ ->inject('publisherForMigrations')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ array $resources,
+ string $endpoint,
+ string $projectId,
+ string $apiKey,
+ string $onDuplicate,
+ Response $response,
+ Database $dbForProject,
+ Document $project,
+ array $platform,
+ Event $queueForEvents,
+ MigrationPublisher $publisherForMigrations
+ ): void {
+ $migration = $dbForProject->createDocument('migrations', new Document([
+ '$id' => ID::unique(),
+ 'status' => 'pending',
+ 'stage' => 'init',
+ 'source' => AppwriteSource::getName(),
+ 'destination' => AppwriteSource::getName(),
+ 'credentials' => [
+ 'endpoint' => $endpoint,
+ 'projectId' => $projectId,
+ 'apiKey' => $apiKey,
+ ],
+ 'resources' => $resources,
+ 'statusCounters' => '{}',
+ 'resourceData' => '{}',
+ 'errors' => [],
+ 'options' => [
+ 'onDuplicate' => $onDuplicate,
+ ],
+ ]));
+
+ $queueForEvents->setParam('migrationId', $migration->getId());
+
+ $publisherForMigrations->enqueue(new MigrationMessage(
+ project: $project,
+ migration: $migration,
+ platform: $platform,
+ ));
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($migration, Response::MODEL_MIGRATION);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php
new file mode 100644
index 0000000000..32d8a62ec3
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php
@@ -0,0 +1,80 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/migrations/appwrite/report')
+ ->desc('Get Appwrite migration report')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.write')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'getAppwriteReport',
+ description: '/docs/references/migrations/migration-appwrite-report.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_MIGRATION_REPORT,
+ )
+ ]
+ ))
+ ->param('resources', [], new ArrayList(new WhiteList(AppwriteSource::getSupportedResources())), 'List of resources to migrate')
+ ->param('endpoint', '', new URL(), "Source's Appwrite Endpoint")
+ ->param('projectID', '', new Text(512), "Source's Project ID")
+ ->param('key', '', new Text(512), "Source's API Key")
+ ->inject('response')
+ ->inject('getDatabasesDB')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ array $resources,
+ string $endpoint,
+ string $projectID,
+ string $key,
+ Response $response,
+ callable $getDatabasesDB
+ ): void {
+ try {
+ $appwrite = new AppwriteSource($projectID, $endpoint, $key, $getDatabasesDB);
+ $report = $appwrite->report($resources);
+ } catch (\Throwable $e) {
+ throw new Exception(
+ Exception::MIGRATION_PROVIDER_ERROR,
+ 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
+ );
+ }
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_OK)
+ ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Exports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Exports/Create.php
new file mode 100644
index 0000000000..0ab3cecf1a
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Exports/Create.php
@@ -0,0 +1,213 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/migrations/csv/exports')
+ ->desc('Export documents to CSV')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.write')
+ ->label('event', 'migrations.[migrationId].create')
+ ->label('audits.event', 'migration.create')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'createCSVExport',
+ description: '/docs/references/migrations/migration-csv-export.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_ACCEPTED,
+ model: Response::MODEL_MIGRATION,
+ )
+ ]
+ ))
+ ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.')
+ ->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .csv extension.')
+ ->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true)
+ ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true)
+ ->param('delimiter', ',', new Text(1), 'The character that separates each column value. Default is comma.', true)
+ ->param('enclosure', '"', new Text(1), 'The character that encloses each column value. Default is double quotes.', true)
+ ->param('escape', '"', new Text(1), 'The escape character for the enclosure character. Default is double quotes.', true)
+ ->param('header', true, new Boolean(), 'Whether to include the header row with column names. Default is true.', true)
+ ->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true)
+ ->inject('user')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->inject('project')
+ ->inject('platform')
+ ->inject('queueForEvents')
+ ->inject('publisherForMigrations')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $resourceId,
+ string $filename,
+ array $columns,
+ array $queries,
+ string $delimiter,
+ string $enclosure,
+ string $escape,
+ bool $header,
+ bool $notify,
+ Document $user,
+ Response $response,
+ Database $dbForProject,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ Document $project,
+ array $platform,
+ Event $queueForEvents,
+ MigrationPublisher $publisherForMigrations
+ ): void {
+ try {
+ $parsedQueries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default'));
+ if ($bucket->isEmpty()) {
+ throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
+ }
+
+ [$databaseId, $collectionId] = \explode(':', $resourceId, 2);
+ if (empty($databaseId)) {
+ throw new Exception(Exception::DATABASE_NOT_FOUND);
+ }
+ if (empty($collectionId)) {
+ throw new Exception(Exception::COLLECTION_NOT_FOUND);
+ }
+
+ $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
+ if ($database->isEmpty()) {
+ throw new Exception(Exception::DATABASE_NOT_FOUND);
+ }
+
+ $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId));
+ if ($collection->isEmpty()) {
+ throw new Exception(Exception::COLLECTION_NOT_FOUND);
+ }
+
+ $databaseType = $database->getAttribute('type');
+ if (!\in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) {
+ throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv');
+ }
+
+ // Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields
+ $isSchemaless = \in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]);
+
+ $validator = new Documents(
+ attributes: $collection->getAttribute('attributes', []),
+ indexes: $collection->getAttribute('indexes', []),
+ idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(),
+ supportForAttributes: !$isSchemaless,
+ );
+
+ if (!$validator->isValid($parsedQueries)) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
+ }
+
+ $resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]);
+ $resourceType = self::resourceTypeForDatabaseType($databaseType);
+
+ $migration = $dbForProject->createDocument('migrations', new Document([
+ '$id' => ID::unique(),
+ 'status' => 'pending',
+ 'stage' => 'init',
+ 'source' => AppwriteSource::getName(),
+ 'destination' => CSV::getName(),
+ 'resources' => $resources,
+ 'resourceId' => $resourceId,
+ 'resourceType' => $resourceType,
+ 'statusCounters' => '{}',
+ 'resourceData' => '{}',
+ 'errors' => [],
+ 'options' => [
+ 'bucketId' => 'default', // Always use internal bucket
+ 'filename' => $filename,
+ 'columns' => $columns,
+ 'queries' => $queries,
+ 'delimiter' => $delimiter,
+ 'enclosure' => $enclosure,
+ 'escape' => $escape,
+ 'header' => $header,
+ 'notify' => $notify,
+ 'userInternalId' => $user->getSequence(),
+ ],
+ ]));
+
+ $queueForEvents->setParam('migrationId', $migration->getId());
+
+ $publisherForMigrations->enqueue(new MigrationMessage(
+ project: $project,
+ migration: $migration,
+ platform: $platform,
+ ));
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($migration, Response::MODEL_MIGRATION);
+ }
+
+ private static function transferGroupForDatabaseType(string $databaseType): string
+ {
+ return match ($databaseType) {
+ DATABASE_TYPE_LEGACY,
+ DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB,
+ DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB,
+ DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB,
+ default => throw new \LogicException('Unknown database type: ' . $databaseType),
+ };
+ }
+
+ private static function resourceTypeForDatabaseType(string $databaseType): string
+ {
+ return match ($databaseType) {
+ DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB,
+ DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB,
+ default => Resource::TYPE_DATABASE,
+ };
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php
new file mode 100644
index 0000000000..4b47ed7d58
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php
@@ -0,0 +1,225 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/migrations/csv/imports')
+ ->httpAlias('/v1/migrations/csv')
+ ->desc('Import documents from a CSV')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.write')
+ ->label('event', 'migrations.[migrationId].create')
+ ->label('audits.event', 'migration.create')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'createCSVImport',
+ description: '/docs/references/migrations/migration-csv-import.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_ACCEPTED,
+ model: Response::MODEL_MIGRATION,
+ )
+ ]
+ ))
+ ->param('bucketId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).', false, ['dbForProject'])
+ ->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File ID.', false, ['dbForProject'])
+ ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.')
+ ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true)
+ ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->inject('project')
+ ->inject('platform')
+ ->inject('deviceForFiles')
+ ->inject('deviceForMigrations')
+ ->inject('queueForEvents')
+ ->inject('publisherForMigrations')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $bucketId,
+ string $fileId,
+ string $resourceId,
+ bool $internalFile,
+ string $onDuplicate,
+ Response $response,
+ Database $dbForProject,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ Document $project,
+ array $platform,
+ Device $deviceForFiles,
+ Device $deviceForMigrations,
+ Event $queueForEvents,
+ MigrationPublisher $publisherForMigrations
+ ): void {
+ $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) {
+ if ($internalFile) {
+ return $dbForPlatform->getDocument('buckets', 'default');
+ }
+ return $dbForProject->getDocument('buckets', $bucketId);
+ });
+
+ if ($bucket->isEmpty()) {
+ throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
+ }
+
+ $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId));
+ if ($file->isEmpty()) {
+ throw new Exception(Exception::STORAGE_FILE_NOT_FOUND);
+ }
+
+ $path = $file->getAttribute('path', '');
+ if (!$deviceForFiles->exists($path)) {
+ throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path);
+ }
+
+ // No encryption or compression on files above 20MB.
+ $hasEncryption = !empty($file->getAttribute('openSSLCipher'));
+ $compression = $file->getAttribute('algorithm', Compression::NONE);
+ $hasCompression = $compression !== Compression::NONE;
+
+ $migrationId = ID::unique();
+ $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.csv');
+
+ if ($hasEncryption || $hasCompression) {
+ $source = $deviceForFiles->read($path);
+
+ if ($hasEncryption) {
+ $source = OpenSSL::decrypt(
+ $source,
+ $file->getAttribute('openSSLCipher'),
+ System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')),
+ 0,
+ hex2bin($file->getAttribute('openSSLIV')),
+ hex2bin($file->getAttribute('openSSLTag'))
+ );
+ }
+
+ if ($hasCompression) {
+ switch ($compression) {
+ case Compression::ZSTD:
+ $source = (new Zstd())->decompress($source);
+ break;
+ case Compression::GZIP:
+ $source = (new GZIP())->decompress($source);
+ break;
+ }
+ }
+
+ // Manual write after decryption and/or decompression
+ if (!$deviceForMigrations->write($newPath, $source, 'text/csv')) {
+ throw new \Exception('Unable to copy file');
+ }
+ } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) {
+ throw new \Exception('Unable to copy file');
+ }
+
+ [$databaseId] = \explode(':', $resourceId, 2);
+ $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
+ $databaseType = $database->getAttribute('type');
+ if (!\in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) {
+ throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv');
+ }
+ $fileSize = $deviceForMigrations->getFileSize($newPath);
+ $resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]);
+ $resourceType = self::resourceTypeForDatabaseType($databaseType);
+
+ $migration = $dbForProject->createDocument('migrations', new Document([
+ '$id' => $migrationId,
+ 'status' => 'pending',
+ 'stage' => 'init',
+ 'source' => CSV::getName(),
+ 'destination' => AppwriteSource::getName(),
+ 'resources' => $resources,
+ 'resourceId' => $resourceId,
+ 'resourceType' => $resourceType,
+ 'statusCounters' => '{}',
+ 'resourceData' => '{}',
+ 'errors' => [],
+ 'options' => [
+ 'path' => $newPath,
+ 'size' => $fileSize,
+ 'onDuplicate' => $onDuplicate,
+ ],
+ ]));
+
+ $queueForEvents->setParam('migrationId', $migration->getId());
+
+ $publisherForMigrations->enqueue(new MigrationMessage(
+ project: $project,
+ migration: $migration,
+ ));
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($migration, Response::MODEL_MIGRATION);
+ }
+
+ private static function transferGroupForDatabaseType(string $databaseType): string
+ {
+ return match ($databaseType) {
+ DATABASE_TYPE_LEGACY,
+ DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB,
+ DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB,
+ DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB,
+ default => throw new \LogicException('Unknown database type: ' . $databaseType),
+ };
+ }
+
+ private static function resourceTypeForDatabaseType(string $databaseType): string
+ {
+ return match ($databaseType) {
+ DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB,
+ DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB,
+ default => Resource::TYPE_DATABASE,
+ };
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Delete.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Delete.php
new file mode 100644
index 0000000000..f9c989b5bf
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Delete.php
@@ -0,0 +1,74 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/migrations/:migrationId')
+ ->desc('Delete migration')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.write')
+ ->label('event', 'migrations.[migrationId].delete')
+ ->label('audits.event', 'migrationId.delete')
+ ->label('audits.resource', 'migrations/{request.migrationId}')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'delete',
+ description: '/docs/references/migrations/delete-migration.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_NOCONTENT,
+ model: Response::MODEL_NONE,
+ )
+ ],
+ contentType: ContentType::NONE
+ ))
+ ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration ID.', false, ['dbForProject'])
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(string $migrationId, Response $response, Database $dbForProject, Event $queueForEvents): void
+ {
+ $migration = $dbForProject->getDocument('migrations', $migrationId);
+
+ if ($migration->isEmpty()) {
+ throw new Exception(Exception::MIGRATION_NOT_FOUND);
+ }
+
+ if (!$dbForProject->deleteDocument('migrations', $migration->getId())) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove migration from DB');
+ }
+
+ $queueForEvents->setParam('migrationId', $migration->getId());
+
+ $response->noContent();
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Create.php
new file mode 100644
index 0000000000..a8347858b4
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Create.php
@@ -0,0 +1,114 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/migrations/firebase')
+ ->desc('Create Firebase migration')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.write')
+ ->label('event', 'migrations.[migrationId].create')
+ ->label('audits.event', 'migration.create')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'createFirebaseMigration',
+ description: '/docs/references/migrations/migration-firebase.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_ACCEPTED,
+ model: Response::MODEL_MIGRATION,
+ )
+ ]
+ ))
+ ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate')
+ ->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('project')
+ ->inject('platform')
+ ->inject('queueForEvents')
+ ->inject('publisherForMigrations')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ array $resources,
+ string $serviceAccount,
+ Response $response,
+ Database $dbForProject,
+ Document $project,
+ array $platform,
+ Event $queueForEvents,
+ MigrationPublisher $publisherForMigrations
+ ): void {
+ $serviceAccountData = json_decode($serviceAccount, true);
+
+ if (empty($serviceAccountData)) {
+ throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON');
+ }
+
+ if (!isset($serviceAccountData['project_id']) || !isset($serviceAccountData['client_email']) || !isset($serviceAccountData['private_key'])) {
+ throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON');
+ }
+
+ $migration = $dbForProject->createDocument('migrations', new Document([
+ '$id' => ID::unique(),
+ 'status' => 'pending',
+ 'stage' => 'init',
+ 'source' => Firebase::getName(),
+ 'destination' => AppwriteSource::getName(),
+ 'credentials' => [
+ 'serviceAccount' => $serviceAccount,
+ ],
+ 'resources' => $resources,
+ 'statusCounters' => '{}',
+ 'resourceData' => '{}',
+ 'errors' => [],
+ ]));
+
+ $queueForEvents->setParam('migrationId', $migration->getId());
+
+ $publisherForMigrations->enqueue(new MigrationMessage(
+ project: $project,
+ migration: $migration,
+ platform: $platform,
+ ));
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($migration, Response::MODEL_MIGRATION);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Report/Get.php
new file mode 100644
index 0000000000..ef8084795e
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Report/Get.php
@@ -0,0 +1,80 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/migrations/firebase/report')
+ ->desc('Get Firebase migration report')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.write')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'getFirebaseReport',
+ description: '/docs/references/migrations/migration-firebase-report.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_MIGRATION_REPORT,
+ )
+ ]
+ ))
+ ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate')
+ ->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials')
+ ->inject('response')
+ ->callback($this->action(...));
+ }
+
+ public function action(array $resources, string $serviceAccount, Response $response): void
+ {
+ $serviceAccount = json_decode($serviceAccount, true);
+
+ if (empty($serviceAccount)) {
+ throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON');
+ }
+
+ if (!isset($serviceAccount['project_id']) || !isset($serviceAccount['client_email']) || !isset($serviceAccount['private_key'])) {
+ throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON');
+ }
+
+ try {
+ $firebase = new Firebase($serviceAccount);
+ $report = $firebase->report($resources);
+ } catch (\Throwable $e) {
+ throw new Exception(
+ Exception::MIGRATION_PROVIDER_ERROR,
+ 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
+ );
+ }
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_OK)
+ ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Get.php
new file mode 100644
index 0000000000..14b40e2306
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Get.php
@@ -0,0 +1,61 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/migrations/:migrationId')
+ ->desc('Get migration')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.read')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'get',
+ description: '/docs/references/migrations/get-migration.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_MIGRATION,
+ )
+ ]
+ ))
+ ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration unique ID.', false, ['dbForProject'])
+ ->inject('response')
+ ->inject('dbForProject')
+ ->callback($this->action(...));
+ }
+
+ public function action(string $migrationId, Response $response, Database $dbForProject): void
+ {
+ $migration = $dbForProject->getDocument('migrations', $migrationId);
+
+ if ($migration->isEmpty()) {
+ throw new Exception(Exception::MIGRATION_NOT_FOUND);
+ }
+
+ $response->dynamic($migration, Response::MODEL_MIGRATION);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Exports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Exports/Create.php
new file mode 100644
index 0000000000..d968bd91f6
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Exports/Create.php
@@ -0,0 +1,198 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/migrations/json/exports')
+ ->desc('Export documents to JSON')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.write')
+ ->label('event', 'migrations.[migrationId].create')
+ ->label('audits.event', 'migration.create')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'createJSONExport',
+ description: '/docs/references/migrations/migration-json-export.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_ACCEPTED,
+ model: Response::MODEL_MIGRATION,
+ )
+ ]
+ ))
+ ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.')
+ ->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .json extension.')
+ ->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true)
+ ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true)
+ ->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true)
+ ->inject('user')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->inject('project')
+ ->inject('platform')
+ ->inject('queueForEvents')
+ ->inject('publisherForMigrations')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $resourceId,
+ string $filename,
+ array $columns,
+ array $queries,
+ bool $notify,
+ Document $user,
+ Response $response,
+ Database $dbForProject,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ Document $project,
+ array $platform,
+ Event $queueForEvents,
+ MigrationPublisher $publisherForMigrations
+ ): void {
+ try {
+ $parsedQueries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default'));
+ if ($bucket->isEmpty()) {
+ throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
+ }
+
+ [$databaseId, $collectionId] = \explode(':', $resourceId, 2);
+ if (empty($databaseId)) {
+ throw new Exception(Exception::DATABASE_NOT_FOUND);
+ }
+ if (empty($collectionId)) {
+ throw new Exception(Exception::COLLECTION_NOT_FOUND);
+ }
+
+ $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
+ if ($database->isEmpty()) {
+ throw new Exception(Exception::DATABASE_NOT_FOUND);
+ }
+
+ $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId));
+ if ($collection->isEmpty()) {
+ throw new Exception(Exception::COLLECTION_NOT_FOUND);
+ }
+
+ $databaseType = $database->getAttribute('type');
+
+ // Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields
+ $isSchemaless = \in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]);
+
+ $validator = new Documents(
+ attributes: $collection->getAttribute('attributes', []),
+ indexes: $collection->getAttribute('indexes', []),
+ idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(),
+ supportForAttributes: !$isSchemaless,
+ );
+
+ if (!$validator->isValid($parsedQueries)) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
+ }
+
+ $resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]);
+ $resourceType = self::resourceTypeForDatabaseType($databaseType);
+
+ $migration = $dbForProject->createDocument('migrations', new Document([
+ '$id' => ID::unique(),
+ 'status' => 'pending',
+ 'stage' => 'init',
+ 'source' => AppwriteSource::getName(),
+ 'destination' => JSONSource::getName(),
+ 'resources' => $resources,
+ 'resourceId' => $resourceId,
+ 'resourceType' => $resourceType,
+ 'statusCounters' => '{}',
+ 'resourceData' => '{}',
+ 'errors' => [],
+ 'options' => [
+ 'bucketId' => 'default', // Always use internal bucket
+ 'filename' => $filename,
+ 'columns' => $columns,
+ 'queries' => $queries,
+ 'notify' => $notify,
+ 'userInternalId' => $user->getSequence(),
+ ],
+ ]));
+
+ $queueForEvents->setParam('migrationId', $migration->getId());
+
+ $publisherForMigrations->enqueue(new MigrationMessage(
+ project: $project,
+ migration: $migration,
+ platform: $platform,
+ ));
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($migration, Response::MODEL_MIGRATION);
+ }
+
+ private static function transferGroupForDatabaseType(string $databaseType): string
+ {
+ return match ($databaseType) {
+ DATABASE_TYPE_LEGACY,
+ DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB,
+ DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB,
+ DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB,
+ default => throw new \LogicException('Unknown database type: ' . $databaseType),
+ };
+ }
+
+ private static function resourceTypeForDatabaseType(string $databaseType): string
+ {
+ return match ($databaseType) {
+ DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB,
+ DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB,
+ default => Resource::TYPE_DATABASE,
+ };
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php
new file mode 100644
index 0000000000..c5d936711e
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php
@@ -0,0 +1,226 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/migrations/json/imports')
+ ->desc('Import documents from a JSON')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.write')
+ ->label('event', 'migrations.[migrationId].create')
+ ->label('audits.event', 'migration.create')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'createJSONImport',
+ description: '/docs/references/migrations/migration-json-import.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_ACCEPTED,
+ model: Response::MODEL_MIGRATION,
+ )
+ ]
+ ))
+ ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).')
+ ->param('fileId', '', new UID(), 'File ID.')
+ ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.')
+ ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true)
+ ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->inject('project')
+ ->inject('platform')
+ ->inject('deviceForFiles')
+ ->inject('deviceForMigrations')
+ ->inject('queueForEvents')
+ ->inject('publisherForMigrations')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $bucketId,
+ string $fileId,
+ string $resourceId,
+ bool $internalFile,
+ string $onDuplicate,
+ Response $response,
+ Database $dbForProject,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ Document $project,
+ array $platform,
+ Device $deviceForFiles,
+ Device $deviceForMigrations,
+ Event $queueForEvents,
+ MigrationPublisher $publisherForMigrations
+ ): void {
+ $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) {
+ if ($internalFile) {
+ return $dbForPlatform->getDocument('buckets', 'default');
+ }
+ return $dbForProject->getDocument('buckets', $bucketId);
+ });
+
+ if ($bucket->isEmpty()) {
+ throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
+ }
+
+ $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId));
+ if ($file->isEmpty()) {
+ throw new Exception(Exception::STORAGE_FILE_NOT_FOUND);
+ }
+
+ $path = $file->getAttribute('path', '');
+ if (!$deviceForFiles->exists($path)) {
+ throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path);
+ }
+
+ // No encryption or compression on files above 20MB.
+ $hasEncryption = !empty($file->getAttribute('openSSLCipher'));
+ $compression = $file->getAttribute('algorithm', Compression::NONE);
+ $hasCompression = $compression !== Compression::NONE;
+
+ $migrationId = ID::unique();
+ $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.json');
+
+ if ($hasEncryption || $hasCompression) {
+ $source = $deviceForFiles->read($path);
+
+ if ($hasEncryption) {
+ $source = OpenSSL::decrypt(
+ $source,
+ $file->getAttribute('openSSLCipher'),
+ System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')),
+ 0,
+ hex2bin($file->getAttribute('openSSLIV')),
+ hex2bin($file->getAttribute('openSSLTag'))
+ );
+ }
+
+ if ($hasCompression) {
+ switch ($compression) {
+ case Compression::ZSTD:
+ $source = (new Zstd())->decompress($source);
+ break;
+ case Compression::GZIP:
+ $source = (new GZIP())->decompress($source);
+ break;
+ }
+ }
+
+ // Manual write after decryption and/or decompression
+ if (!$deviceForMigrations->write($newPath, $source, 'application/json')) {
+ throw new \Exception('Unable to copy file');
+ }
+ } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) {
+ throw new \Exception('Unable to copy file');
+ }
+
+ $fileSize = $deviceForMigrations->getFileSize($newPath);
+
+ [$databaseId] = \explode(':', $resourceId, 2);
+ $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
+ if ($database->isEmpty()) {
+ throw new Exception(Exception::DATABASE_NOT_FOUND);
+ }
+ $databaseType = $database->getAttribute('type');
+ $resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]);
+ $resourceType = self::resourceTypeForDatabaseType($databaseType);
+
+ $migration = $dbForProject->createDocument('migrations', new Document([
+ '$id' => $migrationId,
+ 'status' => 'pending',
+ 'stage' => 'init',
+ 'source' => JSONSource::getName(),
+ 'destination' => AppwriteSource::getName(),
+ 'resources' => $resources,
+ 'resourceId' => $resourceId,
+ 'resourceType' => $resourceType,
+ 'statusCounters' => '{}',
+ 'resourceData' => '{}',
+ 'errors' => [],
+ 'options' => [
+ 'path' => $newPath,
+ 'size' => $fileSize,
+ 'onDuplicate' => $onDuplicate,
+ ],
+ ]));
+
+ $queueForEvents->setParam('migrationId', $migration->getId());
+
+ $publisherForMigrations->enqueue(new MigrationMessage(
+ project: $project,
+ migration: $migration,
+ platform: $platform,
+ ));
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($migration, Response::MODEL_MIGRATION);
+ }
+
+ private static function transferGroupForDatabaseType(string $databaseType): string
+ {
+ return match ($databaseType) {
+ DATABASE_TYPE_LEGACY,
+ DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB,
+ DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB,
+ DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB,
+ default => throw new \LogicException('Unknown database type: ' . $databaseType),
+ };
+ }
+
+ private static function resourceTypeForDatabaseType(string $databaseType): string
+ {
+ return match ($databaseType) {
+ DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB,
+ DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB,
+ default => Resource::TYPE_DATABASE,
+ };
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Create.php
new file mode 100644
index 0000000000..fb97b1c16c
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Create.php
@@ -0,0 +1,122 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/migrations/nhost')
+ ->desc('Create NHost migration')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.write')
+ ->label('event', 'migrations.[migrationId].create')
+ ->label('audits.event', 'migration.create')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'createNHostMigration',
+ description: '/docs/references/migrations/migration-nhost.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_ACCEPTED,
+ model: Response::MODEL_MIGRATION,
+ )
+ ]
+ ))
+ ->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate')
+ ->param('subdomain', '', new Text(512), 'Source\'s Subdomain')
+ ->param('region', '', new Text(512), 'Source\'s Region')
+ ->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret')
+ ->param('database', '', new Text(512), 'Source\'s Database Name')
+ ->param('username', '', new Text(512), 'Source\'s Database Username')
+ ->param('password', '', new Text(512), 'Source\'s Database Password')
+ ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('project')
+ ->inject('platform')
+ ->inject('queueForEvents')
+ ->inject('publisherForMigrations')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ array $resources,
+ string $subdomain,
+ string $region,
+ string $adminSecret,
+ string $database,
+ string $username,
+ string $password,
+ int $port,
+ Response $response,
+ Database $dbForProject,
+ Document $project,
+ array $platform,
+ Event $queueForEvents,
+ MigrationPublisher $publisherForMigrations
+ ): void {
+ $migration = $dbForProject->createDocument('migrations', new Document([
+ '$id' => ID::unique(),
+ 'status' => 'pending',
+ 'stage' => 'init',
+ 'source' => NHost::getName(),
+ 'destination' => AppwriteSource::getName(),
+ 'credentials' => [
+ 'subdomain' => $subdomain,
+ 'region' => $region,
+ 'adminSecret' => $adminSecret,
+ 'database' => $database,
+ 'username' => $username,
+ 'password' => $password,
+ 'port' => $port,
+ ],
+ 'resources' => $resources,
+ 'statusCounters' => '{}',
+ 'resourceData' => '{}',
+ 'errors' => [],
+ ]));
+
+ $queueForEvents->setParam('migrationId', $migration->getId());
+
+ $publisherForMigrations->enqueue(new MigrationMessage(
+ project: $project,
+ migration: $migration,
+ platform: $platform,
+ ));
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($migration, Response::MODEL_MIGRATION);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Report/Get.php
new file mode 100644
index 0000000000..964f2dc347
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Report/Get.php
@@ -0,0 +1,86 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/migrations/nhost/report')
+ ->desc('Get NHost migration report')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.write')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'getNHostReport',
+ description: '/docs/references/migrations/migration-nhost-report.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_MIGRATION_REPORT,
+ )
+ ]
+ ))
+ ->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate.')
+ ->param('subdomain', '', new Text(512), 'Source\'s Subdomain.')
+ ->param('region', '', new Text(512), 'Source\'s Region.')
+ ->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret.')
+ ->param('database', '', new Text(512), 'Source\'s Database Name.')
+ ->param('username', '', new Text(512), 'Source\'s Database Username.')
+ ->param('password', '', new Text(512), 'Source\'s Database Password.')
+ ->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true)
+ ->inject('response')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ array $resources,
+ string $subdomain,
+ string $region,
+ string $adminSecret,
+ string $database,
+ string $username,
+ string $password,
+ int $port,
+ Response $response
+ ): void {
+ try {
+ $nhost = new NHost($subdomain, $region, $adminSecret, $database, $username, $password, $port);
+ $report = $nhost->report($resources);
+ } catch (\Throwable $e) {
+ throw new Exception(
+ Exception::MIGRATION_PROVIDER_ERROR,
+ 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
+ );
+ }
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_OK)
+ ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Create.php
new file mode 100644
index 0000000000..98b33e379d
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Create.php
@@ -0,0 +1,120 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/migrations/supabase')
+ ->desc('Create Supabase migration')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.write')
+ ->label('event', 'migrations.[migrationId].create')
+ ->label('audits.event', 'migration.create')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'createSupabaseMigration',
+ description: '/docs/references/migrations/migration-supabase.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_ACCEPTED,
+ model: Response::MODEL_MIGRATION,
+ )
+ ]
+ ))
+ ->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate')
+ ->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint')
+ ->param('apiKey', '', new Text(512), 'Source\'s API Key')
+ ->param('databaseHost', '', new Text(512), 'Source\'s Database Host')
+ ->param('username', '', new Text(512), 'Source\'s Database Username')
+ ->param('password', '', new Text(512), 'Source\'s Database Password')
+ ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('project')
+ ->inject('platform')
+ ->inject('queueForEvents')
+ ->inject('publisherForMigrations')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ array $resources,
+ string $endpoint,
+ string $apiKey,
+ string $databaseHost,
+ string $username,
+ string $password,
+ int $port,
+ Response $response,
+ Database $dbForProject,
+ Document $project,
+ array $platform,
+ Event $queueForEvents,
+ MigrationPublisher $publisherForMigrations
+ ): void {
+ $migration = $dbForProject->createDocument('migrations', new Document([
+ '$id' => ID::unique(),
+ 'status' => 'pending',
+ 'stage' => 'init',
+ 'source' => Supabase::getName(),
+ 'destination' => AppwriteSource::getName(),
+ 'credentials' => [
+ 'endpoint' => $endpoint,
+ 'apiKey' => $apiKey,
+ 'databaseHost' => $databaseHost,
+ 'username' => $username,
+ 'password' => $password,
+ 'port' => $port,
+ ],
+ 'resources' => $resources,
+ 'statusCounters' => '{}',
+ 'resourceData' => '{}',
+ 'errors' => [],
+ ]));
+
+ $queueForEvents->setParam('migrationId', $migration->getId());
+
+ $publisherForMigrations->enqueue(new MigrationMessage(
+ project: $project,
+ migration: $migration,
+ platform: $platform,
+ ));
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($migration, Response::MODEL_MIGRATION);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Report/Get.php
new file mode 100644
index 0000000000..423e611430
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Report/Get.php
@@ -0,0 +1,85 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/migrations/supabase/report')
+ ->desc('Get Supabase migration report')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.write')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'getSupabaseReport',
+ description: '/docs/references/migrations/migration-supabase-report.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_MIGRATION_REPORT,
+ )
+ ]
+ ))
+ ->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate')
+ ->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint.')
+ ->param('apiKey', '', new Text(512), 'Source\'s API Key.')
+ ->param('databaseHost', '', new Text(512), 'Source\'s Database Host.')
+ ->param('username', '', new Text(512), 'Source\'s Database Username.')
+ ->param('password', '', new Text(512), 'Source\'s Database Password.')
+ ->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true)
+ ->inject('response')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ array $resources,
+ string $endpoint,
+ string $apiKey,
+ string $databaseHost,
+ string $username,
+ string $password,
+ int $port,
+ Response $response
+ ): void {
+ try {
+ $supabase = new Supabase($endpoint, $apiKey, $databaseHost, 'postgres', $username, $password, $port);
+ $report = $supabase->report($resources);
+ } catch (\Throwable $e) {
+ throw new Exception(
+ Exception::MIGRATION_PROVIDER_ERROR,
+ 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
+ );
+ }
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_OK)
+ ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Update.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Update.php
new file mode 100644
index 0000000000..8ecc53c2a3
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Update.php
@@ -0,0 +1,90 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/migrations/:migrationId')
+ ->desc('Update retry migration')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.write')
+ ->label('event', 'migrations.[migrationId].retry')
+ ->label('audits.event', 'migration.retry')
+ ->label('audits.resource', 'migrations/{request.migrationId}')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'retry',
+ description: '/docs/references/migrations/retry-migration.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_ACCEPTED,
+ model: Response::MODEL_MIGRATION,
+ )
+ ]
+ ))
+ ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration unique ID.', false, ['dbForProject'])
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('project')
+ ->inject('platform')
+ ->inject('publisherForMigrations')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $migrationId,
+ Response $response,
+ Database $dbForProject,
+ Document $project,
+ array $platform,
+ MigrationPublisher $publisherForMigrations
+ ): void {
+ $migration = $dbForProject->getDocument('migrations', $migrationId);
+
+ if ($migration->isEmpty()) {
+ throw new Exception(Exception::MIGRATION_NOT_FOUND);
+ }
+
+ if ($migration->getAttribute('status') !== 'failed') {
+ throw new Exception(Exception::MIGRATION_IN_PROGRESS, 'Migration not failed yet');
+ }
+
+ $migration
+ ->setAttribute('status', 'pending')
+ ->setAttribute('dateUpdated', \time());
+
+ $publisherForMigrations->enqueue(new MigrationMessage(
+ project: $project,
+ migration: $migration,
+ platform: $platform,
+ ));
+
+ $response->noContent();
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/XList.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/XList.php
new file mode 100644
index 0000000000..1a1252be79
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/XList.php
@@ -0,0 +1,104 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/migrations')
+ ->desc('List migrations')
+ ->groups(['api', 'migrations'])
+ ->label('scope', 'migrations.read')
+ ->label('sdk', new Method(
+ namespace: 'migrations',
+ group: null,
+ name: 'list',
+ description: '/docs/references/migrations/list-migrations.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_MIGRATION_LIST,
+ )
+ ]
+ ))
+ ->param('queries', [], new Migrations(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Migrations::ALLOWED_ATTRIBUTES), true)
+ ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
+ ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->callback($this->action(...));
+ }
+
+ public function action(array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject): void
+ {
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ if (!empty($search)) {
+ $queries[] = Query::search('search', $search);
+ }
+
+ $cursor = Query::getCursorQueries($queries, false);
+ $cursor = \reset($cursor);
+
+ if ($cursor !== false) {
+ $validator = new Cursor();
+ if (!$validator->isValid($cursor)) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
+ }
+
+ $migrationId = $cursor->getValue();
+ $cursorDocument = $dbForProject->getDocument('migrations', $migrationId);
+
+ if ($cursorDocument->isEmpty()) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Migration '{$migrationId}' for the 'cursor' value not found.");
+ }
+
+ $cursor->setValue($cursorDocument);
+ }
+
+ $filterQueries = Query::groupByType($queries)['filters'];
+ try {
+ $migrations = $dbForProject->find('migrations', $queries);
+ $total = $includeTotal ? $dbForProject->count('migrations', $filterQueries, APP_LIMIT_COUNT) : 0;
+ } catch (OrderException $e) {
+ throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
+ }
+
+ $response->dynamic(new Document([
+ 'migrations' => $migrations,
+ 'total' => $total,
+ ]), Response::MODEL_MIGRATION_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Module.php b/src/Appwrite/Platform/Modules/Migrations/Module.php
new file mode 100644
index 0000000000..6ec1e49a88
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Module.php
@@ -0,0 +1,14 @@
+addService('http', new Http());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Migrations/Services/Http.php b/src/Appwrite/Platform/Modules/Migrations/Services/Http.php
new file mode 100644
index 0000000000..1e2c95a78b
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Migrations/Services/Http.php
@@ -0,0 +1,59 @@
+type = Service::TYPE_HTTP;
+
+ // Migrations
+ $this->addAction(ListMigrations::getName(), new ListMigrations());
+ $this->addAction(GetMigration::getName(), new GetMigration());
+ $this->addAction(UpdateMigration::getName(), new UpdateMigration());
+ $this->addAction(DeleteMigration::getName(), new DeleteMigration());
+
+ // Appwrite source
+ $this->addAction(CreateAppwriteMigration::getName(), new CreateAppwriteMigration());
+ $this->addAction(GetAppwriteReport::getName(), new GetAppwriteReport());
+
+ // Firebase source
+ $this->addAction(CreateFirebaseMigration::getName(), new CreateFirebaseMigration());
+ $this->addAction(GetFirebaseReport::getName(), new GetFirebaseReport());
+
+ // Supabase source
+ $this->addAction(CreateSupabaseMigration::getName(), new CreateSupabaseMigration());
+ $this->addAction(GetSupabaseReport::getName(), new GetSupabaseReport());
+
+ // NHost source
+ $this->addAction(CreateNHostMigration::getName(), new CreateNHostMigration());
+ $this->addAction(GetNHostReport::getName(), new GetNHostReport());
+
+ // CSV import / export
+ $this->addAction(CreateCSVImport::getName(), new CreateCSVImport());
+ $this->addAction(CreateCSVExport::getName(), new CreateCSVExport());
+
+ // JSON import / export
+ $this->addAction(CreateJSONImport::getName(), new CreateJSONImport());
+ $this->addAction(CreateJSONExport::getName(), new CreateJSONExport());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Init.php b/src/Appwrite/Platform/Modules/Project/Http/Init.php
new file mode 100644
index 0000000000..ff191ade6c
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Init.php
@@ -0,0 +1,32 @@
+setType(Action::TYPE_INIT)
+ ->groups(['project'])
+ ->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/Project/Http/Project/AuthMethods/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php
new file mode 100644
index 0000000000..5335036cde
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php
@@ -0,0 +1,89 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/auth-methods/:methodId')
+ ->httpAlias('/v1/projects/:projectId/auth/:methodId')
+ ->desc('Update project auth method status')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.write')
+ ->label('event', 'authMethod.[methodId].update')
+ ->label('audits.event', 'project.authMethods.[methodId].update')
+ ->label('audits.resource', 'project.authMethods/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: null,
+ name: 'updateAuthMethod',
+ description: <<param('methodId', '', new WhiteList(\array_keys(Config::getParam('auth')), true), 'Auth Method ID. Possible values: ' . implode(',', \array_keys(Config::getParam('auth'))), false)
+ ->param('enabled', null, new Boolean(), 'Auth method status.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $methodId,
+ bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents
+ ): void {
+ $auth = Config::getParam('auth')[$methodId] ?? [];
+ $authKey = $auth['key'] ?? '';
+
+ $auths = $project->getAttribute('auths', []);
+ $auths[$authKey] = $enabled;
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
+ 'auths' => $auths,
+ ])));
+
+ $queueForEvents->setParam('methodId', $methodId);
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php
new file mode 100644
index 0000000000..4b26557ca9
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php
@@ -0,0 +1,80 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/project')
+ ->httpAlias('/v1/projects/:projectId')
+ ->desc('Delete project')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.write')
+ ->label('audits.event', 'project.delete')
+ ->label('audits.resource', 'project/{project.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: null,
+ name: 'delete',
+ description: <<inject('response')
+ ->inject('dbForPlatform')
+ ->inject('queueForDeletes')
+ ->inject('authorization')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ Response $response,
+ Database $dbForPlatform,
+ DeleteQueue $queueForDeletes,
+ Authorization $authorization,
+ Document $project,
+ ) {
+ $queueForDeletes
+ ->setProject($project)
+ ->setType(DELETE_TYPE_DOCUMENT)
+ ->setDocument($project);
+
+ if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('projects', $project->getId()))) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove project from DB');
+ }
+
+ $response->noContent();
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Get.php
new file mode 100644
index 0000000000..197d82ef58
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Get.php
@@ -0,0 +1,64 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project')
+ ->httpAlias('/v1/projects/:projectId')
+ ->desc('Get project')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: null,
+ name: 'get',
+ description: <<inject('response')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ Response $response,
+ Document $project,
+ ) {
+ if ($project->isEmpty()) {
+ throw new Exception(Exception::PROJECT_NOT_FOUND);
+ }
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php
new file mode 100644
index 0000000000..eebc0a7067
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php
@@ -0,0 +1,118 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/project/keys')
+ ->httpAlias('/v1/projects/:projectId/keys')
+ ->desc('Create project key')
+ ->groups(['api', 'project'])
+ ->label('scope', 'keys.write')
+ ->label('event', 'keys.[keyId].create')
+ ->label('audits.event', 'project.key.create')
+ ->label('audits.resource', 'project.key/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'keys',
+ name: 'createKey',
+ description: <<param('keyId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key 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('name', null, new Text(128), 'Key name. Max length: 128 chars.')
+ ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', optional: false)
+ ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true)
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $keyId,
+ string $name,
+ array $scopes,
+ ?string $expire,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ ) {
+ $keyId = ($keyId == 'unique()') ? ID::unique() : $keyId;
+
+ $key = new Document([
+ '$id' => $keyId,
+ '$permissions' => [],
+ 'resourceInternalId' => $project->getSequence(),
+ 'resourceId' => $project->getId(),
+ 'resourceType' => 'projects',
+ 'name' => $name,
+ 'scopes' => $scopes,
+ 'expire' => $expire,
+ 'sdks' => [],
+ 'accessedAt' => null,
+ 'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)),
+ ]);
+
+ try {
+ $key = $authorization->skip(fn () => $dbForPlatform->createDocument('keys', $key));
+ } catch (DuplicateException) {
+ throw new Exception(Exception::KEY_ALREADY_EXISTS);
+ }
+
+ $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
+
+ $queueForEvents->setParam('keyId', $key->getId());
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_CREATED)
+ ->dynamic($key, Response::MODEL_KEY);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php
new file mode 100644
index 0000000000..c5da673e22
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php
@@ -0,0 +1,90 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/project/keys/:keyId')
+ ->httpAlias('/v1/projects/:projectId/keys/:keyId')
+ ->desc('Delete project key')
+ ->groups(['api', 'project'])
+ ->label('scope', 'keys.write')
+ ->label('event', 'keys.[keyId].delete')
+ ->label('audits.event', 'project.key.delete')
+ ->label('audits.resource', 'project.key/{request.keyId}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'keys',
+ name: 'deleteKey',
+ description: <<param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform'])
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('queueForEvents')
+ ->inject('project')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $keyId,
+ Response $response,
+ Database $dbForPlatform,
+ Event $queueForEvents,
+ Document $project,
+ Authorization $authorization,
+ ) {
+ $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId));
+
+ if ($key->isEmpty() || $key->getAttribute('resourceType', '') !== 'projects' || $key->getAttribute('resourceInternalId', '') !== $project->getSequence()) {
+ throw new Exception(Exception::KEY_NOT_FOUND);
+ }
+
+ if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('keys', $key->getId()))) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove document from DB');
+ };
+
+ $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
+
+ $queueForEvents->setParam('keyId', $key->getId());
+
+ $response->noContent();
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Ephemeral/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Ephemeral/Create.php
new file mode 100644
index 0000000000..4130effe69
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Ephemeral/Create.php
@@ -0,0 +1,106 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/project/keys/ephemeral')
+ ->httpAlias('/v1/projects/:projectId/jwts')
+ ->desc('Create ephemeral project key')
+ ->groups(['api', 'project'])
+ ->label('scope', 'keys.write')
+ ->label('event', 'keys.[keyId].create')
+ ->label('audits.event', 'project.key.create')
+ ->label('audits.resource', 'project.key/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'keys',
+ name: 'createEphemeralKey',
+ description: <<param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', optional: false)
+ ->param('duration', null, new Range(1, 3600), 'Time in seconds before ephemeral key expires. Maximum duration is 3600 seconds.', optional: false, example: 600)
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ array $scopes,
+ int $duration,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Document $project,
+ ) {
+ $keyId = ID::unique();
+
+ $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0);
+
+ $secret = $jwt->encode([
+ 'projectId' => $project->getId(),
+ 'scopes' => $scopes
+ ]);
+
+ $now = new \DateTime();
+ $expire = $now->add(new \DateInterval('PT' . $duration . 'S'))->format('Y-m-d\TH:i:s.u\Z');
+
+ $key = new Document([
+ '$id' => $keyId,
+ '$createdAt' => DatabaseDateTime::now(),
+ '$updatedAt' => DatabaseDateTime::now(),
+ 'name' => '',
+ 'scopes' => $scopes,
+ 'expire' => $expire,
+ 'sdks' => [],
+ 'accessedAt' => null,
+ 'secret' => API_KEY_EPHEMERAL . '_' . $secret,
+ ]);
+
+ $queueForEvents->setParam('keyId', $key->getId());
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_CREATED)
+ ->dynamic($key, Response::MODEL_EPHEMERAL_KEY);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php
new file mode 100644
index 0000000000..e43c669e4f
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php
@@ -0,0 +1,74 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/keys/:keyId')
+ ->httpAlias('/v1/projects/:projectId/keys/:keyId')
+ ->desc('Get project key')
+ ->groups(['api', 'project'])
+ ->label('scope', 'keys.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'keys',
+ name: 'getKey',
+ description: <<param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform'])
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $keyId,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ ) {
+ $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId));
+
+ if ($key->isEmpty() || $key->getAttribute('resourceType', '') !== 'projects' || $key->getAttribute('resourceInternalId', '') !== $project->getSequence()) {
+ throw new Exception(Exception::KEY_NOT_FOUND);
+ }
+
+ $response->dynamic($key, Response::MODEL_KEY);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php
new file mode 100644
index 0000000000..9193bdbfdf
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php
@@ -0,0 +1,108 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
+ ->setHttpPath('/v1/project/keys/:keyId')
+ ->httpAlias('/v1/projects/:projectId/keys/:keyId')
+ ->desc('Update project key')
+ ->groups(['api', 'project'])
+ ->label('scope', 'keys.write')
+ ->label('event', 'keys.[keyId].update')
+ ->label('audits.event', 'project.key.update')
+ ->label('audits.resource', 'project.key/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'keys',
+ name: 'updateKey',
+ description: <<param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform'])
+ ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.')
+ ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', optional: false)
+ ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true)
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $keyId,
+ string $name,
+ array $scopes,
+ ?string $expire,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ ) {
+ $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId));
+
+ if ($key->isEmpty() || $key->getAttribute('resourceType', '') !== 'projects' || $key->getAttribute('resourceInternalId', '') !== $project->getSequence()) {
+ throw new Exception(Exception::KEY_NOT_FOUND);
+ }
+
+ $updates = new Document([
+ 'name' => $name,
+ 'scopes' => $scopes,
+ 'expire' => $expire,
+ ]);
+
+ try {
+ $key = $authorization->skip(fn () => $dbForPlatform->updateDocument('keys', $key->getId(), $updates));
+ } catch (Duplicate) {
+ throw new Exception(Exception::KEY_ALREADY_EXISTS);
+ }
+
+ $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
+
+ $queueForEvents->setParam('keyId', $key->getId());
+
+ $response->dynamic($key, Response::MODEL_KEY);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php
new file mode 100644
index 0000000000..d243e6f2c3
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php
@@ -0,0 +1,127 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/keys')
+ ->httpAlias('/v1/projects/:projectId/keys')
+ ->desc('List project keys')
+ ->groups(['api', 'project'])
+ ->label('scope', 'keys.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'keys',
+ name: 'listKeys',
+ description: <<param('queries', [], new Keys(), '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(', ', Keys::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());
+ }
+
+ // Backwards compatibility
+ if (\count(Query::getByType($queries, [Query::TYPE_LIMIT])) === 0) {
+ $queries[] = Query::limit(5000);
+ }
+
+ $queries[] = Query::equal('resourceType', ['projects']);
+ $queries[] = Query::equal('resourceInternalId', [$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());
+ }
+
+ $keyId = $cursor->getValue();
+ $cursorDocument = $authorization->skip(fn () => $dbForPlatform->findOne('keys', [
+ Query::equal('$id', [$keyId]),
+ Query::equal('resourceType', ['projects']),
+ Query::equal('resourceInternalId', [$project->getSequence()]),
+ ]));
+
+ if ($cursorDocument->isEmpty()) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Key '{$keyId}' for the 'cursor' value not found.");
+ }
+
+ $cursor->setValue($cursorDocument);
+ }
+
+ $filterQueries = Query::groupByType($queries)['filters'];
+
+ try {
+ $keys = $authorization->skip(fn () => $dbForPlatform->find('keys', $queries));
+ $total = $includeTotal ? $authorization->skip(fn () => $dbForPlatform->count('keys', $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([
+ 'keys' => $keys,
+ 'total' => $total,
+ ]), Response::MODEL_KEY_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php
similarity index 56%
rename from src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php
rename to src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php
index de11bb0091..8a3506eb13 100644
--- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php
@@ -1,20 +1,16 @@
setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
- ->setHttpPath('/v1/projects/:projectId/labels')
+ ->setHttpPath('/v1/project/labels')
+ ->httpAlias('/v1/projects/:projectId/labels')
->desc('Update project labels')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.write')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.write')
+ // ->label('event', 'project.labels.update')
+ ->label('audits.event', 'project.labels.update')
+ ->label('audits.resource', 'project.labels/{response.$id}')
->label('sdk', new Method(
- namespace: 'projects',
- group: 'projects',
+ namespace: 'project',
+ group: null,
name: 'updateLabels',
description: <<param('projectId', '', new UID(), 'Project unique ID.')
->param('labels', [], new ArrayList(new Text(36, allowList: [...Text::NUMBERS, ...Text::ALPHABET_UPPER, ...Text::ALPHABET_LOWER]), APP_LIMIT_ARRAY_LABELS_SIZE), 'Array of project labels. Replaces the previous labels. Maximum of ' . APP_LIMIT_ARRAY_LABELS_SIZE . ' labels are allowed, each up to 36 alphanumeric characters long.')
->inject('response')
->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
->callback($this->action(...));
}
@@ -67,20 +62,15 @@ class Update extends Action
* @param array $labels
*/
public function action(
- string $projectId,
array $labels,
Response $response,
- Database $dbForPlatform
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization
): void {
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
$labels = (array) \array_values(\array_unique($labels));
- $project = $dbForPlatform->updateDocument('projects', $project->getId(), new Document(['labels' => $labels]));
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document(['labels' => $labels])));
$response->dynamic($project, Response::MODEL_PROJECT);
}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php
new file mode 100644
index 0000000000..f4002c60ef
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php
@@ -0,0 +1,111 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/project/mock-phones')
+ ->desc('Create project mock phone')
+ ->groups(['api', 'project'])
+ ->label('scope', 'mocks.write')
+ ->label('event', 'mock-phones.[number].create')
+ ->label('audits.event', 'project.mock-phone.create')
+ ->label('audits.resource', 'project.mock-phone/{response.number}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'mocks',
+ name: 'createMockPhone',
+ description: <<param('number', null, new Phone(), 'Phone number to associate with the mock phone. Must be a valid E.164 formatted phone number.')
+ ->param('otp', '', new Text(6, 6, Text::NUMBERS), 'One-time password (OTP) to associate with the mock phone. Must be a 6-digit numeric code.')
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $number,
+ string $otp,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Document $project,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ ) {
+ $auths = $project->getAttribute('auths', []);
+
+ $mockNumbers = $auths['mockNumbers'] ?? [];
+
+ if (\count($mockNumbers) >= APP_LIMIT_COUNT) {
+ throw new Exception(Exception::MOCK_NUMBER_LIMIT_EXCEEDED);
+ }
+
+ foreach ($mockNumbers as $mockNumber) {
+ if ($mockNumber['phone'] === $number) {
+ throw new Exception(Exception::MOCK_NUMBER_ALREADY_EXISTS);
+ }
+ }
+
+ // Set to now date
+ $mockNumber = [
+ 'phone' => $number,
+ 'otp' => $otp,
+ '$createdAt' => DateTime::now(),
+ '$updatedAt' => DateTime::now(),
+ ];
+
+ $mockNumbers[] = $mockNumber;
+ $auths['mockNumbers'] = $mockNumbers;
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents->setParam('number', $number);
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_CREATED)
+ ->dynamic(new Document($mockNumber), Response::MODEL_MOCK_NUMBER);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php
new file mode 100644
index 0000000000..0fb23e1764
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php
@@ -0,0 +1,103 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/project/mock-phones/:number')
+ ->desc('Delete project mock phone')
+ ->groups(['api', 'project'])
+ ->label('scope', 'mocks.write')
+ ->label('event', 'mock-phones.[number].delete')
+ ->label('audits.event', 'project.mock-phone.delete')
+ ->label('audits.resource', 'project.mock-phone/{request.number}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'mocks',
+ name: 'deleteMockPhone',
+ description: <<param('number', null, new Phone(), 'Phone number associated with the mock phone. Must be a valid E.164 formatted phone number.')
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $number,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Document $project,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ ) {
+ $auths = $project->getAttribute('auths', []);
+
+ $mockNumbers = $auths['mockNumbers'] ?? [];
+
+ $mockNumberIndex = null;
+ foreach ($mockNumbers as $index => $mock) {
+ if ($mock['phone'] === $number) {
+ $mockNumberIndex = $index;
+ break;
+ }
+ }
+
+ if (\is_null($mockNumberIndex)) {
+ throw new Exception(Exception::MOCK_NUMBER_NOT_FOUND);
+ }
+
+ unset($mockNumbers[$mockNumberIndex]);
+ $mockNumbers = array_values($mockNumbers);
+
+ $auths['mockNumbers'] = $mockNumbers;
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents->setParam('number', $number);
+
+ $response->noContent();
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php
new file mode 100644
index 0000000000..a51095b368
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php
@@ -0,0 +1,78 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/mock-phones/:number')
+ ->desc('Get project mock phone')
+ ->groups(['api', 'project'])
+ ->label('scope', 'mocks.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'mocks',
+ name: 'getMockPhone',
+ description: <<param('number', null, new Phone(), 'Phone number associated with the mock phone. Must be a valid E.164 formatted phone number.')
+ ->inject('response')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $number,
+ Response $response,
+ Document $project
+ ) {
+ $auths = $project->getAttribute('auths', []);
+
+ $mockNumbers = $auths['mockNumbers'] ?? [];
+
+ $mockNumberIndex = null;
+ foreach ($mockNumbers as $index => $mock) {
+ if ($mock['phone'] === $number) {
+ $mockNumberIndex = $index;
+ break;
+ }
+ }
+
+ if (\is_null($mockNumberIndex)) {
+ throw new Exception(Exception::MOCK_NUMBER_NOT_FOUND);
+ }
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_OK)
+ ->dynamic(new Document($mockNumbers[$mockNumberIndex]), Response::MODEL_MOCK_NUMBER);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php
new file mode 100644
index 0000000000..48b90a1b97
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php
@@ -0,0 +1,107 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
+ ->setHttpPath('/v1/project/mock-phones/:number')
+ ->desc('Update project mock phone')
+ ->groups(['api', 'project'])
+ ->label('scope', 'mocks.write')
+ ->label('event', 'mock-phones.[number].update')
+ ->label('audits.event', 'project.mock-phone.update')
+ ->label('audits.resource', 'project.mock-phone/{response.number}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'mocks',
+ name: 'updateMockPhone',
+ description: <<param('number', null, new Phone(), 'Phone number associated with the mock phone. Must be a valid E.164 formatted phone number.')
+ ->param('otp', '', new Text(6, 6, Text::NUMBERS), 'One-time password (OTP) to associate with the mock phone. Must be a 6-digit numeric code.')
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $number,
+ string $otp,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Document $project,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ ) {
+ $auths = $project->getAttribute('auths', []);
+
+ $mockNumbers = $auths['mockNumbers'] ?? [];
+
+ $mockNumberIndex = null;
+ foreach ($mockNumbers as $index => $mock) {
+ if ($mock['phone'] === $number) {
+ $mockNumberIndex = $index;
+ break;
+ }
+ }
+
+ if (\is_null($mockNumberIndex)) {
+ throw new Exception(Exception::MOCK_NUMBER_NOT_FOUND);
+ }
+
+ $mockNumbers[$mockNumberIndex]['otp'] = $otp;
+ $mockNumbers[$mockNumberIndex]['$updatedAt'] = DateTime::now();
+
+ $auths['mockNumbers'] = $mockNumbers;
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents->setParam('number', $number);
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_OK)
+ ->dynamic(new Document($mockNumbers[$mockNumberIndex]), Response::MODEL_MOCK_NUMBER);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php
new file mode 100644
index 0000000000..82aa7f1446
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php
@@ -0,0 +1,87 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/mock-phones')
+ ->desc('List project mock phones')
+ ->groups(['api', 'project'])
+ ->label('scope', 'mocks.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'mocks',
+ name: 'listMockPhones',
+ description: <<param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true)
+ ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
+ ->inject('response')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ array $queries,
+ bool $includeTotal,
+ Response $response,
+ Document $project,
+ ) {
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $auths = $project->getAttribute('auths', []);
+ $mockNumbers = $auths['mockNumbers'] ?? [];
+ $grouped = Query::groupByType($queries);
+ $limit = $grouped['limit'] ?? null;
+ $offset = $grouped['offset'] ?? 0;
+
+ $total = $includeTotal ? \count($mockNumbers) : 0;
+ $mockNumbers = \array_slice($mockNumbers, $offset, $limit);
+
+ $mockNumbers = \array_map(fn ($mockNumber) => new Document($mockNumber), $mockNumbers);
+
+ $response->dynamic(new Document([
+ 'mockNumbers' => $mockNumbers,
+ 'total' => $total,
+ ]), Response::MODEL_MOCK_NUMBER_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php
new file mode 100644
index 0000000000..7c68ff4032
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php
@@ -0,0 +1,55 @@
+ static::getClientIdParamName(),
+ 'name' => static::getClientIdName(),
+ 'example' => static::getClientIdExample(),
+ 'hint' => '',
+ ],
+ [
+ '$id' => 'keyId',
+ 'name' => 'Key ID',
+ 'example' => 'P4000000N8',
+ 'hint' => '',
+ ],
+ [
+ '$id' => 'teamId',
+ 'name' => 'Team ID',
+ 'example' => 'D4000000R6',
+ 'hint' => '',
+ ],
+ [
+ '$id' => 'p8File',
+ 'name' => 'P8 File',
+ 'example' => '-----BEGIN PRIVATE KEY-----MIGTAg...jy2Xbna-----END PRIVATE KEY-----',
+ 'hint' => '',
+ ],
+ ];
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param('keyId', null, new Nullable(new Text(256, 0)), '\'Key ID\' of Apple OAuth2 app. For example: P4000000N8', optional: true)
+ ->param('teamId', null, new Nullable(new Text(256, 0)), '\'Team ID\' of Apple OAuth2 app. For example: D4000000R6', optional: true)
+ ->param('p8File', null, new Nullable(new Text(4096, 0)), 'Contents of the Apple OAuth2 app .p8 private key file. The secret key wrapped by the PEM markers is 200 characters long. For example: -----BEGIN PRIVATE KEY-----MIGTAg...jy2Xbna-----END PRIVATE KEY-----', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $storedSecret = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ 'keyId' => $storedSecret['keyID'] ?? '',
+ 'teamId' => $storedSecret['teamID'] ?? '',
+ 'p8File' => '',
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because Apple's
+ * client secret is composed of three fields (.p8 file contents, Key ID and
+ * Team ID) that must be JSON-encoded to match the shape Apple's OAuth2
+ * adapter expects in getAppSecret(). The method is named differently to
+ * avoid an LSP-incompatible override of Base::action().
+ */
+ public function handle(
+ ?string $serviceId,
+ ?string $keyId,
+ ?string $teamId,
+ ?string $p8File,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ // The secret is stored as JSON `{"p8": "...", "keyID": "...", "teamID": "..."}`
+ // to match the shape Apple's OAuth2 adapter expects in getAppSecret().
+ // Merge new values with what's already stored so that submitting only
+ // some of the fields leaves the rest untouched.
+ $encodedSecret = null;
+ if (!\is_null($keyId) || !\is_null($teamId) || !\is_null($p8File)) {
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = [];
+ if (!empty($storedRaw)) {
+ $existing = \json_decode($storedRaw, true) ?: [];
+ }
+ $encodedSecret = \json_encode([
+ 'p8' => $p8File ?? ($existing['p8'] ?? ''),
+ 'keyID' => $keyId ?? ($existing['keyID'] ?? ''),
+ 'teamID' => $teamId ?? ($existing['teamID'] ?? ''),
+ ]);
+ }
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $serviceId, $encodedSecret, $enabled);
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee keyId/teamId/p8File are write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php
new file mode 100644
index 0000000000..aa5f39b213
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php
@@ -0,0 +1,175 @@
+ 'endpoint',
+ 'name' => 'Domain',
+ 'example' => 'example.us.auth0.com',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of Auth0 instance. For example: example.us.auth0.com', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'endpoint' => $decoded['auth0Domain'] ?? '',
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because Auth0
+ * takes an additional optional `endpoint` parameter. The method is named
+ * differently to avoid an LSP-incompatible override of Base::action().
+ */
+ public function handle(
+ ?string $clientId,
+ ?string $clientSecret,
+ ?string $endpoint,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ // The secret is stored as JSON `{"clientSecret": "...", "auth0Domain": "..."}`
+ // to match the shape Auth0's OAuth2 adapter expects (getAuth0Domain()).
+ // Merge new values with existing storage so that submitting only one of
+ // `clientSecret`/`endpoint` leaves the other untouched.
+ $encodedSecret = null;
+ if (!\is_null($clientSecret) || !\is_null($endpoint)) {
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = [];
+ if (!empty($storedRaw)) {
+ $existing = \json_decode($storedRaw, true) ?: [];
+ }
+ $encodedSecret = \json_encode([
+ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
+ 'auth0Domain' => $endpoint ?? ($existing['auth0Domain'] ?? ''),
+ ]);
+ }
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee the clientSecret is write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php
new file mode 100644
index 0000000000..af6b12618a
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php
@@ -0,0 +1,172 @@
+ 'endpoint',
+ 'name' => 'Domain',
+ 'example' => 'example.authentik.com',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of Authentik instance. For example: example.authentik.com', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'endpoint' => $decoded['authentikDomain'] ?? '',
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because Authentik
+ * takes an additional required `endpoint` parameter. The method is named
+ * differently to avoid an LSP-incompatible override of Base::action().
+ */
+ public function handle(
+ ?string $clientId,
+ ?string $clientSecret,
+ ?string $endpoint,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ // The secret is stored as JSON `{"clientSecret": "...", "authentikDomain": "..."}`
+ // to match the shape Authentik's OAuth2 adapter expects (getAuthentikDomain()).
+ // The `endpoint` param is optional; if omitted, the existing stored endpoint is preserved.
+ // `clientSecret` is optional; if omitted, the existing stored secret is preserved.
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = [];
+ if (!empty($storedRaw)) {
+ $existing = \json_decode($storedRaw, true) ?: [];
+ }
+ $encodedSecret = \json_encode([
+ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
+ 'authentikDomain' => $endpoint ?? ($existing['authentikDomain'] ?? ''),
+ ]);
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee the clientSecret is write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php
new file mode 100644
index 0000000000..dd4f4f6faa
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php
@@ -0,0 +1,55 @@
+' of OAuth2 app. For example: [. ]".
+ * Returns an empty string when the name is empty.
+ */
+ private static function buildParamDescription(string $name, string $example, string $hint): string
+ {
+ if ($name === '') {
+ return '';
+ }
+
+ $description = '\'' . $name . '\' of ' . static::getProviderLabel() . ' OAuth2 app. For example: ' . $example;
+ if ($hint !== '') {
+ $description .= '. ' . $hint;
+ }
+
+ return $description;
+ }
+
+ /**
+ * Verbose, user-facing name of the clientId param. Includes alternate
+ * names when the provider exposes more than one (e.g. "Client ID or App
+ * ID", "Application ID (also known as Client ID)").
+ *
+ * @return string
+ */
+ abstract public static function getClientIdName(): string;
+
+ /**
+ * Example value of the clientId param. Used to build the public OAuth2
+ * providers metadata response.
+ *
+ * @return string
+ */
+ abstract public static function getClientIdExample(): string;
+
+ /**
+ * Optional hint for the clientId param. Typically used to call out a
+ * common wrong value (e.g. "Example of wrong value: 370006"). Defaults
+ * to an empty string.
+ */
+ public static function getClientIdHint(): string
+ {
+ return '';
+ }
+
+ /**
+ * Verbose, user-facing name of the clientSecret param. Returns an empty
+ * string for providers that don't have a single clientSecret param
+ * (e.g. Apple uses keyId/teamId/p8File instead).
+ *
+ * @return string
+ */
+ abstract public static function getClientSecretName(): string;
+
+ /**
+ * Example value of the clientSecret param. Returns an empty string for
+ * providers without a clientSecret param.
+ *
+ * @return string
+ */
+ abstract public static function getClientSecretExample(): string;
+
+ /**
+ * Optional hint for the clientSecret param. Defaults to an empty string.
+ */
+ public static function getClientSecretHint(): string
+ {
+ return '';
+ }
+
+ /**
+ * Public-facing parameter metadata for this provider. Used by the public
+ * console OAuth2 providers endpoint to describe the form fields a project
+ * owner must fill in to configure the provider.
+ *
+ * Default shape: clientId + clientSecret. Providers that take additional
+ * fields (Apple, Auth0, Authentik, Gitlab, Microsoft, Oidc, Okta)
+ * override this method to add or replace entries. Each parameter is an
+ * associative array with keys `$id`, `name`, `example`, `hint`.
+ *
+ * @return array>
+ */
+ public static function getParameters(): array
+ {
+ $parameters = [];
+
+ $clientIdName = static::getClientIdName();
+ if ($clientIdName !== '') {
+ $parameters[] = [
+ '$id' => static::getClientIdParamName(),
+ 'name' => $clientIdName,
+ 'example' => static::getClientIdExample(),
+ 'hint' => static::getClientIdHint(),
+ ];
+ }
+
+ $clientSecretName = static::getClientSecretName();
+ if ($clientSecretName !== '') {
+ $parameters[] = [
+ '$id' => static::getClientSecretParamName(),
+ 'name' => $clientSecretName,
+ 'example' => static::getClientSecretExample(),
+ 'hint' => static::getClientSecretHint(),
+ ];
+ }
+
+ return $parameters;
+ }
+
+ /**
+ * Public-facing name of the clientId param. Some providers use a different
+ * terminology (e.g. Dropbox calls it "App key"), so the param name and the
+ * corresponding response field can be customized by overriding this method.
+ *
+ * @return string e.g. 'clientId' (default), 'appKey'
+ */
+ public static function getClientIdParamName(): string
+ {
+ return 'clientId';
+ }
+
+ /**
+ * Public-facing name of the clientSecret param. Some providers use a
+ * different terminology (e.g. Dropbox calls it "App secret"), so the param
+ * name and the corresponding response field can be customized by
+ * overriding this method.
+ *
+ * @return string e.g. 'clientSecret' (default), 'appSecret'
+ */
+ public static function getClientSecretParamName(): string
+ {
+ return 'clientSecret';
+ }
+
+ /**
+ * SDK method name exposed to clients.
+ *
+ * @return string e.g. 'updateOAuth2GitHub'
+ */
+ abstract public static function getProviderSDKMethod(): string;
+
+ public static function getName()
+ {
+ return 'updateProjectOAuth2' . static::getProviderLabel();
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ /**
+ * Registry of provider ID -> Update action class. Mirrors the OAuth2
+ * actions registered in Project\Services\Http. Used by the Get and XList
+ * read endpoints to dispatch per-provider response shaping.
+ *
+ * @return array>
+ */
+ public static function getProviderActions(): array
+ {
+ return [
+ 'github' => GitHub\Update::class,
+ 'discord' => Discord\Update::class,
+ 'figma' => Figma\Update::class,
+ 'dropbox' => Dropbox\Update::class,
+ 'dailymotion' => Dailymotion\Update::class,
+ 'bitbucket' => Bitbucket\Update::class,
+ 'bitly' => Bitly\Update::class,
+ 'box' => Box\Update::class,
+ 'autodesk' => Autodesk\Update::class,
+ 'google' => Google\Update::class,
+ 'zoom' => Zoom\Update::class,
+ 'zoho' => Zoho\Update::class,
+ 'yandex' => Yandex\Update::class,
+ 'x' => X\Update::class,
+ 'wordpress' => WordPress\Update::class,
+ 'twitch' => Twitch\Update::class,
+ 'stripe' => Stripe\Update::class,
+ 'spotify' => Spotify\Update::class,
+ 'slack' => Slack\Update::class,
+ 'podio' => Podio\Update::class,
+ 'notion' => Notion\Update::class,
+ 'salesforce' => Salesforce\Update::class,
+ 'yahoo' => Yahoo\Update::class,
+ 'linkedin' => Linkedin\Update::class,
+ 'disqus' => Disqus\Update::class,
+ 'amazon' => Amazon\Update::class,
+ 'etsy' => Etsy\Update::class,
+ 'facebook' => Facebook\Update::class,
+ 'tradeshift' => Tradeshift\Update::class,
+ 'tradeshiftBox' => TradeshiftSandbox\Update::class,
+ 'paypal' => Paypal\Update::class,
+ 'paypalSandbox' => PaypalSandbox\Update::class,
+ 'gitlab' => Gitlab\Update::class,
+ 'authentik' => Authentik\Update::class,
+ 'auth0' => Auth0\Update::class,
+ 'fusionauth' => FusionAuth\Update::class,
+ 'keycloak' => Keycloak\Update::class,
+ 'oidc' => Oidc\Update::class,
+ 'okta' => Okta\Update::class,
+ 'kick' => Kick\Update::class,
+ 'apple' => Apple\Update::class,
+ 'microsoft' => Microsoft\Update::class,
+ ];
+ }
+
+ /**
+ * Build the read-only response document for this provider, with credential
+ * fields zeroed out (write-only). Default implementation handles providers
+ * that store a plain client ID + client secret. Special providers (Apple,
+ * Gitlab, Auth0, Authentik, Oidc, Okta) override to expose their
+ * non-secret extras (endpoint, domain, discovery URLs, ...) decoded from
+ * the JSON-encoded secret blob.
+ */
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ ]);
+ }
+
+ /**
+ * Decode the JSON-encoded secret blob stored under `{providerId}Secret`.
+ * Returns an empty array when the value is empty or not valid JSON.
+ */
+ protected function decodeStoredSecret(Document $project): array
+ {
+ $providerId = static::getProviderId();
+ $stored = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+
+ if (empty($stored)) {
+ return [];
+ }
+
+ $decoded = \json_decode($stored, true);
+ return \is_array($decoded) ? $decoded : [];
+ }
+
+ /**
+ * Apply the provided credential changes to the project's oAuthProviders map,
+ * run the optional credential verification hook, persist the project, and
+ * return the updated project document.
+ *
+ * Providers that need to serialize multiple values into a single secret
+ * (e.g. GitLab, which stores `{clientSecret, endpoint}` as JSON) should
+ * encode those values into `$clientSecret` before calling this method.
+ */
+ protected function persistCredentials(
+ Document $project,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ ?string $clientId,
+ ?string $clientSecret,
+ ?bool $enabled
+ ): Document {
+ $providerId = static::getProviderId();
+ if (!(\in_array($providerId, \array_keys(Config::getParam('oAuthProviders'))))) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Provider ' . $providerId . ' is not supported by server configuration.');
+ }
+
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+
+ $appIdKey = $providerId . 'Appid';
+ $appSecretKey = $providerId . 'Secret';
+ $enabledKey = $providerId . 'Enabled';
+
+ if (!\is_null($clientId)) {
+ $oAuthProviders[$appIdKey] = $clientId;
+ }
+
+ if (!\is_null($clientSecret)) {
+ $oAuthProviders[$appSecretKey] = $clientSecret;
+ }
+
+ if (!\is_null($enabled)) {
+ $oAuthProviders[$enabledKey] = $enabled;
+ }
+
+ if ($enabled === true || \is_null($enabled)) {
+ try {
+ if (empty($oAuthProviders[$appIdKey]) || empty($oAuthProviders[$appSecretKey])) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Client ID and Client Secret are required when enabling OAuth2 provider.');
+ }
+
+ $providerClass = static::getProviderClass();
+ $providerInstance = new $providerClass(appId: $oAuthProviders[$appIdKey], appSecret: $oAuthProviders[$appSecretKey], callback: '', state: [], scopes: []);
+
+ // E2E integration check
+ if (\method_exists($providerInstance, 'verifyCredentials')) {
+ $providerInstance->verifyCredentials();
+ }
+
+ $oAuthProviders[$enabledKey] = true;
+ } catch (\Throwable $err) {
+ if ($enabled === true) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Could not enable OAuth2 provider: ' . $err->getMessage());
+ }
+ }
+ }
+
+ $updates = new Document([
+ 'oAuthProviders' => $oAuthProviders
+ ]);
+
+ return $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+ }
+
+ public function action(
+ ?string $clientId,
+ ?string $clientSecret,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $clientSecret, $enabled);
+
+ $queueForEvents->setParam('providerId', static::getProviderId());
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee the clientSecret is write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php
new file mode 100644
index 0000000000..a477bfbefb
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php
@@ -0,0 +1,65 @@
+ 'endpoint',
+ 'name' => 'Domain',
+ 'example' => 'example.fusionauth.io',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of FusionAuth instance. For example: example.fusionauth.io', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'endpoint' => $decoded['fusionAuthDomain'] ?? '',
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because FusionAuth
+ * takes an additional required `endpoint` parameter. The method is named
+ * differently to avoid an LSP-incompatible override of Base::action().
+ */
+ public function handle(
+ ?string $clientId,
+ ?string $clientSecret,
+ ?string $endpoint,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ // The secret is stored as JSON `{"clientSecret": "...", "fusionAuthDomain": "..."}`
+ // to match the shape FusionAuth's OAuth2 adapter expects (getFusionAuthDomain()).
+ // The `endpoint` param is optional; if omitted, the existing stored endpoint is preserved.
+ // `clientSecret` is optional; if omitted, the existing stored secret is preserved.
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = [];
+ if (!empty($storedRaw)) {
+ $existing = \json_decode($storedRaw, true) ?: [];
+ }
+ $encodedSecret = \json_encode([
+ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
+ 'fusionAuthDomain' => $endpoint ?? ($existing['fusionAuthDomain'] ?? ''),
+ ]);
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee the clientSecret is write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php
new file mode 100644
index 0000000000..250a3e5df1
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php
@@ -0,0 +1,115 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/oauth2/:provider')
+ ->desc('Get project OAuth2 provider')
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: 'getOAuth2Provider',
+ description: <<param('providerId', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders', [])), true), 'OAuth2 provider key. For example: github, google, apple.', aliases: ['provider'])
+ ->inject('response')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $providerId,
+ Response $response,
+ Document $project,
+ ): void {
+ $providers = Config::getParam('oAuthProviders', []);
+ if (!\array_key_exists($providerId, $providers) || !($providers[$providerId]['enabled'] ?? false)) {
+ throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED);
+ }
+
+ $actions = Base::getProviderActions();
+ if (!isset($actions[$providerId])) {
+ throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED);
+ }
+
+ $updateClass = $actions[$providerId];
+ $action = new $updateClass();
+
+ $response->dynamic($action->buildReadResponse($project), $updateClass::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php
new file mode 100644
index 0000000000..7c680e5141
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php
@@ -0,0 +1,60 @@
+ 'endpoint',
+ 'name' => 'Endpoint',
+ 'example' => 'https://gitlab.com',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('endpoint', null, new Nullable(new URL(allowEmpty: true)), 'Endpoint URL of self-hosted GitLab instance. For example: https://gitlab.com', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'endpoint' => $decoded['endpoint'] ?? '',
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because Gitlab
+ * takes an additional `endpoint` parameter. The method is named
+ * differently to avoid an LSP-incompatible override of Base::action().
+ */
+ public function handle(
+ ?string $applicationId,
+ ?string $secret,
+ ?string $endpoint,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ // The secret is stored as JSON `{"clientSecret": "...", "endpoint": "..."}`
+ // so that the Gitlab OAuth2 adapter can extract the endpoint via getEndpoint().
+ // Merge the new values with what's already stored so that submitting only
+ // one of `secret`/`endpoint` leaves the other untouched.
+ $encodedSecret = null;
+ if (!\is_null($secret) || !\is_null($endpoint)) {
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = [];
+ if (!empty($storedRaw)) {
+ $existing = \json_decode($storedRaw, true) ?: [];
+ }
+ $encodedSecret = \json_encode([
+ 'clientSecret' => $secret ?? ($existing['clientSecret'] ?? ''),
+ 'endpoint' => $endpoint ?? ($existing['endpoint'] ?? ''),
+ ]);
+ }
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $applicationId, $encodedSecret, $enabled);
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee the secret is write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php
new file mode 100644
index 0000000000..2a061d09ce
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php
@@ -0,0 +1,183 @@
+ 'prompt',
+ 'name' => 'Prompt',
+ 'example' => '["consent"]',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('prompt', null, new Nullable(new ArrayList(new WhiteList(['none', 'consent', 'select_account'], true), 3)), 'Array of Google OAuth2 prompt values. If "none" is included, it must be the only element. "none" means: don\'t display any authentication or consent screens. Must not be specified with other values. "consent" means: prompt the user for consent. "select_account" means: prompt the user to select an account.', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'prompt' => $decoded['prompt'] ?? ['consent'],
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because Google
+ * takes an additional optional `prompt` parameter. The method is named
+ * differently to avoid an LSP-incompatible override of Base::action().
+ */
+ public function handle(
+ ?string $clientId,
+ ?string $clientSecret,
+ ?array $prompt,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ if ($prompt !== null) {
+ if (empty($prompt)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Prompt array cannot be empty.');
+ }
+
+ if (\in_array('none', $prompt) && \count($prompt) > 1) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When "none" is used as a prompt value, it must be the only element in the array.');
+ }
+ }
+
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = $this->decodeStoredSecret($project);
+
+ // Backwards compatibility: secrets stored before the prompt feature
+ // were saved as plain strings. Treat the raw value as clientSecret.
+ if (!empty($storedRaw) && empty($existing)) {
+ $existing = ['clientSecret' => $storedRaw];
+ }
+
+ $encodedSecret = \json_encode([
+ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
+ 'prompt' => $prompt ?? ($existing['prompt'] ?? ['consent']),
+ ]);
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
+
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php
new file mode 100644
index 0000000000..aa41e8a5e9
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php
@@ -0,0 +1,183 @@
+ 'endpoint',
+ 'name' => 'Domain',
+ 'example' => 'keycloak.example.com',
+ 'hint' => '',
+ ],
+ [
+ '$id' => 'realmName',
+ 'name' => 'Realm name',
+ 'example' => 'appwrite-realm',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of Keycloak instance. For example: keycloak.example.com', optional: true)
+ ->param('realmName', null, new Nullable(new Text(256, 0)), 'Keycloak realm name. For example: appwrite-realm', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'endpoint' => $decoded['keycloakDomain'] ?? '',
+ 'realmName' => $decoded['keycloakRealm'] ?? '',
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because Keycloak
+ * takes additional required `endpoint` and `realmName` parameters. The
+ * method is named differently to avoid an LSP-incompatible override of
+ * Base::action().
+ */
+ public function handle(
+ ?string $clientId,
+ ?string $clientSecret,
+ ?string $endpoint,
+ ?string $realmName,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ // The secret is stored as JSON `{"clientSecret": "...", "keycloakDomain": "...", "keycloakRealm": "..."}`
+ // to match the shape Keycloak's OAuth2 adapter expects (getKeycloakDomain(), getKeycloakRealm()).
+ // The `endpoint` and `realmName` params are optional; if omitted, existing stored values are preserved.
+ // `clientSecret` is optional; if omitted, the existing stored secret is preserved.
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = [];
+ if (!empty($storedRaw)) {
+ $existing = \json_decode($storedRaw, true) ?: [];
+ }
+ $encodedSecret = \json_encode([
+ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
+ 'keycloakDomain' => $endpoint ?? ($existing['keycloakDomain'] ?? ''),
+ 'keycloakRealm' => $realmName ?? ($existing['keycloakRealm'] ?? ''),
+ ]);
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee the clientSecret is write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php
new file mode 100644
index 0000000000..db4a20174f
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php
@@ -0,0 +1,55 @@
+ 'tenant',
+ 'name' => 'Tenant',
+ 'example' => 'common',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('tenant', null, new Nullable(new Text(256, 0)), 'Microsoft Entra ID tenant identifier. Use \'common\', \'organizations\', \'consumers\' or a specific tenant ID. For example: common', true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'tenant' => $decoded['tenantID'] ?? '',
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because Microsoft
+ * takes an additional required `tenant` parameter. The method is named
+ * differently to avoid an LSP-incompatible override of Base::action().
+ */
+ public function handle(
+ ?string $applicationId,
+ ?string $applicationSecret,
+ ?string $tenant,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ // The secret is stored as JSON `{"clientSecret": "...", "tenantID": "..."}`
+ // to match the shape Microsoft's OAuth2 adapter expects (getTenantID()).
+ // The `tenant` param is optional; if omitted, the existing stored tenant is preserved.
+ // `applicationSecret` is optional; if omitted, the existing stored secret is preserved.
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = [];
+ if (!empty($storedRaw)) {
+ $existing = \json_decode($storedRaw, true) ?: [];
+ }
+ $encodedSecret = \json_encode([
+ 'clientSecret' => $applicationSecret ?? ($existing['clientSecret'] ?? ''),
+ 'tenantID' => $tenant ?? ($existing['tenantID'] ?? ''),
+ ]);
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $applicationId, $encodedSecret, $enabled);
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee the applicationSecret is write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php
new file mode 100644
index 0000000000..4b048b0c0b
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php
@@ -0,0 +1,65 @@
+ 'wellKnownURL',
+ 'name' => 'Well-known URL',
+ 'example' => 'https://myoauth.com/.well-known/openid-configuration',
+ 'hint' => '',
+ ],
+ [
+ '$id' => 'authorizationURL',
+ 'name' => 'Authorization URL',
+ 'example' => 'https://myoauth.com/oauth2/authorize',
+ 'hint' => '',
+ ],
+ [
+ '$id' => 'tokenURL',
+ 'name' => 'Token URL',
+ 'example' => 'https://myoauth.com/oauth2/token',
+ 'hint' => '',
+ ],
+ [
+ '$id' => 'userInfoURL',
+ 'name' => 'User Info URL',
+ 'example' => 'https://myoauth.com/oauth2/userinfo',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('wellKnownURL', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect well-known configuration URL. When provided, authorization, token, and user info endpoints can be discovered automatically. For example: https://myoauth.com/.well-known/openid-configuration', optional: true)
+ ->param('authorizationURL', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect authorization endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/authorize', optional: true)
+ ->param('tokenURL', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect token endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/token', optional: true, aliases: ['tokenUrl'])
+ ->param('userInfoURL', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect user info endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/userinfo', optional: true, aliases: ['userInfoUrl'])
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'wellKnownURL' => $decoded['wellKnownEndpoint'] ?? '',
+ 'authorizationURL' => $decoded['authorizationEndpoint'] ?? '',
+ 'tokenURL' => $decoded['tokenEndpoint'] ?? '',
+ 'userInfoURL' => $decoded['userInfoEndpoint'] ?? '',
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because OIDC takes
+ * a well-known URL plus three discovery URLs (authorization, token, user
+ * info), all stored together with the client secret as JSON. The method is
+ * named differently to avoid an LSP-incompatible override of Base::action().
+ *
+ * Enabling the provider requires either a non-empty `wellKnownEndpoint`,
+ * or all three of `authorizationEndpoint`, `tokenEndpoint`, and
+ * `userInfoEndpoint` to be set. The check considers the merged state of
+ * existing stored values plus the new values from the request, so callers
+ * can enable the provider in a single request without re-sending fields
+ * that were configured previously.
+ */
+ public function handle(
+ ?string $clientId,
+ ?string $clientSecret,
+ ?string $wellKnownURL,
+ ?string $authorizationURL,
+ ?string $tokenURL,
+ ?string $userInfoURL,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ // The secret is stored as JSON
+ // `{"clientSecret": "...", "wellKnownEndpoint": "...", "authorizationEndpoint": "...", "tokenEndpoint": "...", "userInfoEndpoint": "..."}`
+ // so that the OIDC OAuth2 adapter can extract each endpoint individually.
+ // Merge new values with what's already stored so that submitting only a
+ // subset of fields leaves the others untouched.
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = [];
+ if (!empty($storedRaw)) {
+ $existing = \json_decode($storedRaw, true) ?: [];
+ }
+
+ $merged = [
+ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
+ 'wellKnownEndpoint' => $wellKnownURL ?? ($existing['wellKnownEndpoint'] ?? ''),
+ 'authorizationEndpoint' => $authorizationURL ?? ($existing['authorizationEndpoint'] ?? ''),
+ 'tokenEndpoint' => $tokenURL ?? ($existing['tokenEndpoint'] ?? ''),
+ 'userInfoEndpoint' => $userInfoURL ?? ($existing['userInfoEndpoint'] ?? ''),
+ ];
+
+ // When enabling, require either wellKnownEndpoint alone, or all three
+ // discovery URLs (authorization, token, user info). Skip this check
+ // when disabling or when leaving the enabled flag unchanged.
+ if ($enabled === true) {
+ $hasWellKnown = !empty($merged['wellKnownEndpoint']);
+ $hasAllDiscovery = !empty($merged['authorizationEndpoint'])
+ && !empty($merged['tokenEndpoint'])
+ && !empty($merged['userInfoEndpoint']);
+
+ if (!$hasWellKnown && !$hasAllDiscovery) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Enabling OpenID Connect requires either wellKnownURL, or all of authorizationURL, tokenURL, and userInfoURL.');
+ }
+ }
+
+ $encodedSecret = \json_encode($merged);
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee the clientSecret is write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php
new file mode 100644
index 0000000000..0344b6a14a
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php
@@ -0,0 +1,198 @@
+ 'domain',
+ 'name' => 'Domain',
+ 'example' => 'trial-6400025.okta.com',
+ 'hint' => 'Example of wrong value: trial-6400025-admin.okta.com, or https://trial-6400025.okta.com/',
+ ],
+ [
+ '$id' => 'authorizationServerId',
+ 'name' => 'Authorization Server ID',
+ 'example' => 'aus000000000000000h7z',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('domain', null, new Nullable(new ValidatorDomain(allowEmpty: true)), 'Okta company domain. Required when enabling the provider. For example: trial-6400025.okta.com. Example of wrong value: trial-6400025-admin.okta.com, or https://trial-6400025.okta.com/', optional: true)
+ ->param('authorizationServerId', null, new Nullable(new Text(256, 0)), 'Custom Authorization Servers. Optional, can be left empty or unconfigured. For example: aus000000000000000h7z', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'domain' => $decoded['oktaDomain'] ?? '',
+ 'authorizationServerId' => $decoded['authorizationServerId'] ?? '',
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because Okta
+ * takes additional optional `domain` and `authorizationServerId` parameters.
+ * The method is named differently to avoid an LSP-incompatible override of
+ * Base::action().
+ */
+ public function handle(
+ ?string $clientId,
+ ?string $clientSecret,
+ ?string $domain,
+ ?string $authorizationServerId,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ // The secret is stored as JSON `{"clientSecret": "...", "oktaDomain": "...", "authorizationServerId": "..."}`
+ // to match the shape Okta's OAuth2 adapter expects.
+ // Merge new values with existing storage so that submitting only some of
+ // the parameters leaves the others untouched.
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = [];
+ if (!empty($storedRaw)) {
+ $existing = \json_decode($storedRaw, true) ?: [];
+ }
+
+ $encodedSecret = null;
+ if (!\is_null($clientSecret) || !\is_null($domain) || !\is_null($authorizationServerId)) {
+ $encodedSecret = \json_encode([
+ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
+ 'oktaDomain' => $domain ?? ($existing['oktaDomain'] ?? ''),
+ 'authorizationServerId' => $authorizationServerId ?? ($existing['authorizationServerId'] ?? ''),
+ ]);
+ }
+
+ // Domain is required when enabling the provider, since Okta builds its
+ // authorization, token and userinfo URLs from it.
+ if ($enabled === true) {
+ $effectiveDomain = $domain ?? ($existing['oktaDomain'] ?? '');
+ if (empty($effectiveDomain)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Domain is required when enabling Okta OAuth2 provider.');
+ }
+ }
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
+
+ // Reuse buildReadResponse to keep PATCH/GET shapes identical and
+ // guarantee the clientSecret is write-only on every response path.
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php
new file mode 100644
index 0000000000..87b4e1576b
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php
@@ -0,0 +1,60 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/oauth2')
+ ->desc('List project OAuth2 providers')
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: 'listOAuth2Providers',
+ description: <<param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true)
+ ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
+ ->inject('response')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ /**
+ * @param array $queries
+ */
+ public function action(
+ array $queries,
+ bool $includeTotal,
+ Response $response,
+ Document $project,
+ ): void {
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $providers = Config::getParam('oAuthProviders', []);
+ $actions = Base::getProviderActions();
+
+ $documents = [];
+ foreach ($actions as $providerId => $updateClass) {
+ if (!($providers[$providerId]['enabled'] ?? false)) {
+ // Disabled by Appwrite configuration, exclude from response
+ continue;
+ }
+
+ $action = new $updateClass();
+ $documents[] = $action->buildReadResponse($project);
+ }
+
+ $total = $includeTotal ? \count($documents) : 0;
+
+ $grouped = Query::groupByType($queries);
+ $offset = $grouped['offset'] ?? 0;
+ $limit = $grouped['limit'] ?? null;
+
+ $documents = \array_slice($documents, $offset, $limit);
+
+ $response->dynamic(new Document([
+ 'total' => $total,
+ 'providers' => $documents,
+ ]), Response::MODEL_OAUTH2_PROVIDER_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php
new file mode 100644
index 0000000000..45cf1f5a66
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php
@@ -0,0 +1,55 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/project/platforms/android')
+ ->desc('Create project Android platform')
+ ->groups(['api', 'project'])
+ ->label('scope', 'platforms.write')
+ ->label('event', 'platforms.[platformId].create')
+ ->label('audits.event', 'project.platform.create')
+ ->label('audits.resource', 'project.platform/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'platforms',
+ name: 'createAndroidPlatform',
+ description: <<param('platformId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform 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('name', null, new Text(128), 'Platform name. Max length: 128 chars.')
+ ->param('applicationId', '', new Text(256), 'Android application ID. Max length: 256 chars.')
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $platformId,
+ string $name,
+ string $applicationId,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Document $project,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ ) {
+ $platformId = ($platformId == 'unique()') ? ID::unique() : $platformId;
+
+ $platform = new Document([
+ '$id' => $platformId,
+ '$permissions' => [],
+ 'projectInternalId' => $project->getSequence(),
+ 'projectId' => $project->getId(),
+ 'type' => Platform::TYPE_ANDROID,
+ 'name' => $name,
+ 'key' => $applicationId,
+ 'hostname' => '',
+ ]);
+
+ try {
+ $platform = $authorization->skip(fn () => $dbForPlatform->createDocument('platforms', $platform));
+ } catch (DuplicateException) {
+ throw new Exception(Exception::PLATFORM_ALREADY_EXISTS);
+ }
+
+ $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
+
+ $queueForEvents->setParam('platformId', $platform->getId());
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_CREATED)
+ ->dynamic($platform, Response::MODEL_PLATFORM_ANDROID);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php
new file mode 100644
index 0000000000..3ff958e814
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php
@@ -0,0 +1,103 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
+ ->setHttpPath('/v1/project/platforms/android/:platformId')
+ ->desc('Update project Android platform')
+ ->groups(['api', 'project'])
+ ->label('scope', 'platforms.write')
+ ->label('event', 'platforms.[platformId].update')
+ ->label('audits.event', 'project.platform.update')
+ ->label('audits.resource', 'project.platform/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'platforms',
+ name: 'updateAndroidPlatform',
+ description: <<param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform'])
+ ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.')
+ ->param('applicationId', '', new Text(256), 'Android application ID. Max length: 256 chars.')
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $platformId,
+ string $name,
+ string $applicationId,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ Document $project,
+ ) {
+ $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId));
+
+ if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) {
+ throw new Exception(Exception::PLATFORM_NOT_FOUND);
+ }
+
+ if ($platform->getAttribute('type', '') !== Platform::TYPE_ANDROID) {
+ throw new Exception(Exception::PLATFORM_METHOD_UNSUPPORTED);
+ }
+
+ $updates = new Document([
+ 'name' => $name,
+ 'key' => $applicationId,
+ ]);
+
+ try {
+ $platform = $authorization->skip(fn () => $dbForPlatform->updateDocument('platforms', $platform->getId(), $updates));
+ } catch (Duplicate) {
+ throw new Exception(Exception::PLATFORM_ALREADY_EXISTS);
+ }
+
+ $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
+
+ $queueForEvents->setParam('platformId', $platform->getId());
+
+ $response->dynamic($platform, Response::MODEL_PLATFORM_ANDROID);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php
new file mode 100644
index 0000000000..0843bf9a0c
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php
@@ -0,0 +1,105 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/project/platforms/apple')
+ ->desc('Create project Apple platform')
+ ->groups(['api', 'project'])
+ ->label('scope', 'platforms.write')
+ ->label('event', 'platforms.[platformId].create')
+ ->label('audits.event', 'project.platform.create')
+ ->label('audits.resource', 'project.platform/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'platforms',
+ name: 'createApplePlatform',
+ description: <<param('platformId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform 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('name', null, new Text(128), 'Platform name. Max length: 128 chars.')
+ ->param('bundleIdentifier', '', new Text(256), 'Apple bundle identifier. Max length: 256 chars.')
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $platformId,
+ string $name,
+ string $bundleIdentifier,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Document $project,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ ) {
+ $platformId = ($platformId == 'unique()') ? ID::unique() : $platformId;
+
+ $platform = new Document([
+ '$id' => $platformId,
+ '$permissions' => [],
+ 'projectInternalId' => $project->getSequence(),
+ 'projectId' => $project->getId(),
+ 'type' => Platform::TYPE_APPLE,
+ 'name' => $name,
+ 'key' => $bundleIdentifier,
+ 'hostname' => '',
+ ]);
+
+ try {
+ $platform = $authorization->skip(fn () => $dbForPlatform->createDocument('platforms', $platform));
+ } catch (DuplicateException) {
+ throw new Exception(Exception::PLATFORM_ALREADY_EXISTS);
+ }
+
+ $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
+
+ $queueForEvents->setParam('platformId', $platform->getId());
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_CREATED)
+ ->dynamic($platform, Response::MODEL_PLATFORM_APPLE);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php
new file mode 100644
index 0000000000..0295075f19
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php
@@ -0,0 +1,103 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
+ ->setHttpPath('/v1/project/platforms/apple/:platformId')
+ ->desc('Update project Apple platform')
+ ->groups(['api', 'project'])
+ ->label('scope', 'platforms.write')
+ ->label('event', 'platforms.[platformId].update')
+ ->label('audits.event', 'project.platform.update')
+ ->label('audits.resource', 'project.platform/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'platforms',
+ name: 'updateApplePlatform',
+ description: <<param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform'])
+ ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.')
+ ->param('bundleIdentifier', '', new Text(256), 'Apple bundle identifier. Max length: 256 chars.')
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $platformId,
+ string $name,
+ string $bundleIdentifier,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ Document $project,
+ ) {
+ $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId));
+
+ if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) {
+ throw new Exception(Exception::PLATFORM_NOT_FOUND);
+ }
+
+ if ($platform->getAttribute('type', '') !== Platform::TYPE_APPLE) {
+ throw new Exception(Exception::PLATFORM_METHOD_UNSUPPORTED);
+ }
+
+ $updates = new Document([
+ 'name' => $name,
+ 'key' => $bundleIdentifier,
+ ]);
+
+ try {
+ $platform = $authorization->skip(fn () => $dbForPlatform->updateDocument('platforms', $platform->getId(), $updates));
+ } catch (Duplicate) {
+ throw new Exception(Exception::PLATFORM_ALREADY_EXISTS);
+ }
+
+ $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
+
+ $queueForEvents->setParam('platformId', $platform->getId());
+
+ $response->dynamic($platform, Response::MODEL_PLATFORM_APPLE);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php
new file mode 100644
index 0000000000..24669b02b2
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php
@@ -0,0 +1,89 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/project/platforms/:platformId')
+ ->httpAlias('/v1/projects/:projectId/platforms/:platformId')
+ ->desc('Delete project platform')
+ ->groups(['api', 'project'])
+ ->label('scope', 'platforms.write')
+ ->label('event', 'platforms.[platformId].delete')
+ ->label('audits.event', 'project.platform.delete')
+ ->label('audits.resource', 'project.platform/{request.platformId}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'platforms',
+ name: 'deletePlatform',
+ description: <<param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform'])
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->inject('project')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $platformId,
+ Response $response,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ Document $project,
+ Event $queueForEvents,
+ ) {
+ $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId));
+
+ if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) {
+ throw new Exception(Exception::PLATFORM_NOT_FOUND);
+ }
+
+ if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('platforms', $platform->getId()))) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove document from DB');
+ };
+
+ $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
+
+ $queueForEvents->setParam('platformId', $platform->getId());
+
+ $response->noContent();
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php
new file mode 100644
index 0000000000..4bbbc2fc8c
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php
@@ -0,0 +1,92 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/platforms/:platformId')
+ ->httpAlias('/v1/projects/:projectId/platforms/:platformId')
+ ->desc('Get project platform')
+ ->groups(['api', 'project'])
+ ->label('scope', 'platforms.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'platforms',
+ name: 'getPlatform',
+ description: <<param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform'])
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $platformId,
+ Response $response,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ Document $project
+ ) {
+ $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId));
+
+ if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) {
+ throw new Exception(Exception::PLATFORM_NOT_FOUND);
+ }
+
+ $type = Platform::mapDeprecatedType($platform->getAttribute('type'));
+ $platform->setAttribute('type', $type);
+
+ $model = match($type) {
+ Platform::TYPE_WEB => Response::MODEL_PLATFORM_WEB,
+ Platform::TYPE_APPLE => Response::MODEL_PLATFORM_APPLE,
+ Platform::TYPE_ANDROID => Response::MODEL_PLATFORM_ANDROID,
+ Platform::TYPE_WINDOWS => Response::MODEL_PLATFORM_WINDOWS,
+ Platform::TYPE_LINUX => Response::MODEL_PLATFORM_LINUX,
+ default => Response::MODEL_PLATFORM_WEB // Backwards compatibility
+ };
+
+ $response->dynamic($platform, $model);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php
new file mode 100644
index 0000000000..472b41cace
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php
@@ -0,0 +1,105 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/project/platforms/linux')
+ ->desc('Create project Linux platform')
+ ->groups(['api', 'project'])
+ ->label('scope', 'platforms.write')
+ ->label('event', 'platforms.[platformId].create')
+ ->label('audits.event', 'project.platform.create')
+ ->label('audits.resource', 'project.platform/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'platforms',
+ name: 'createLinuxPlatform',
+ description: <<param('platformId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform 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('name', null, new Text(128), 'Platform name. Max length: 128 chars.')
+ ->param('packageName', '', new Text(256), 'Linux package name. Max length: 256 chars.')
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $platformId,
+ string $name,
+ string $packageName,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Document $project,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ ) {
+ $platformId = ($platformId == 'unique()') ? ID::unique() : $platformId;
+
+ $platform = new Document([
+ '$id' => $platformId,
+ '$permissions' => [],
+ 'projectInternalId' => $project->getSequence(),
+ 'projectId' => $project->getId(),
+ 'type' => Platform::TYPE_LINUX,
+ 'name' => $name,
+ 'key' => $packageName,
+ 'hostname' => '', // Web platform attribute
+ ]);
+
+ try {
+ $platform = $authorization->skip(fn () => $dbForPlatform->createDocument('platforms', $platform));
+ } catch (DuplicateException) {
+ throw new Exception(Exception::PLATFORM_ALREADY_EXISTS);
+ }
+
+ $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
+
+ $queueForEvents->setParam('platformId', $platform->getId());
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_CREATED)
+ ->dynamic($platform, Response::MODEL_PLATFORM_LINUX);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php
new file mode 100644
index 0000000000..9c1f715c33
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php
@@ -0,0 +1,103 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
+ ->setHttpPath('/v1/project/platforms/linux/:platformId')
+ ->desc('Update project Linux platform')
+ ->groups(['api', 'project'])
+ ->label('scope', 'platforms.write')
+ ->label('event', 'platforms.[platformId].update')
+ ->label('audits.event', 'project.platform.update')
+ ->label('audits.resource', 'project.platform/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'platforms',
+ name: 'updateLinuxPlatform',
+ description: <<param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform'])
+ ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.')
+ ->param('packageName', '', new Text(256), 'Linux package name. Max length: 256 chars.')
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $platformId,
+ string $name,
+ string $packageName,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ Document $project,
+ ) {
+ $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId));
+
+ if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) {
+ throw new Exception(Exception::PLATFORM_NOT_FOUND);
+ }
+
+ if ($platform->getAttribute('type', '') !== Platform::TYPE_LINUX) {
+ throw new Exception(Exception::PLATFORM_METHOD_UNSUPPORTED);
+ }
+
+ $updates = new Document([
+ 'name' => $name,
+ 'key' => $packageName,
+ ]);
+
+ try {
+ $platform = $authorization->skip(fn () => $dbForPlatform->updateDocument('platforms', $platform->getId(), $updates));
+ } catch (Duplicate) {
+ throw new Exception(Exception::PLATFORM_ALREADY_EXISTS);
+ }
+
+ $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
+
+ $queueForEvents->setParam('platformId', $platform->getId());
+
+ $response->dynamic($platform, Response::MODEL_PLATFORM_LINUX);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php
new file mode 100644
index 0000000000..6c07727150
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php
@@ -0,0 +1,173 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/project/platforms/web')
+ ->httpAlias('/v1/projects/:projectId/platforms')
+ ->desc('Create project web platform')
+ ->groups(['api', 'project'])
+ ->label('scope', 'platforms.write')
+ ->label('event', 'platforms.[platformId].create')
+ ->label('audits.event', 'project.platform.create')
+ ->label('audits.resource', 'project.platform/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'platforms',
+ name: 'createWebPlatform',
+ description: <<param('platformId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform 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('name', null, new Text(128), 'Platform name. Max length: 128 chars.')
+ ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true, example: 'app.example.com') // Optional for backwards compatibility
+ ->param('key', '', new Text(256), 'Deprecated: Package name for Android or bundle ID for iOS or macOS. Max length: 256 chars.', optional: true, deprecated: true) // Exists for backwards compatibility
+ ->param('type', '', new Text(256), 'Deprecated: Platform type. Max length: 256 chars.', optional: true, deprecated: true) // Exists for backwards compatibility
+ ->inject('request')
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $platformId,
+ string $name,
+ string $hostname,
+ ?string $key, // For backwards compatibility
+ ?string $type, // For backwards compatibility
+ Request $request,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Document $project,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ ) {
+ $key = $key ?? ''; // App platform attribute, backwards compatibility
+ $type = $type ?? ''; // App platform attribute, backwards compatibility
+
+ // Backwards compatibility
+ // Used to have: type, name, key, hostname
+ if (!empty($type)) {
+ // Validate deprecated type, and rename to new type
+ $deprecatedTypeMapping = [
+ // Web
+ 'web' => Platform::TYPE_WEB,
+ 'flutter-web' => Platform::TYPE_WEB,
+ 'unity' => Platform::TYPE_WEB, // Was not officially supported anyway
+
+ // Apple
+ 'flutter-macos' => Platform::TYPE_APPLE,
+ 'flutter-ios' => Platform::TYPE_APPLE,
+ 'react-native-ios' => Platform::TYPE_APPLE,
+ 'apple-ios' => Platform::TYPE_APPLE,
+ 'apple-macos' => Platform::TYPE_APPLE,
+ 'apple-watchos' => Platform::TYPE_APPLE,
+ 'apple-tvos' => Platform::TYPE_APPLE,
+
+ // Android
+ 'flutter-android' => Platform::TYPE_ANDROID,
+ 'android' => Platform::TYPE_ANDROID,
+ 'react-native-android' => Platform::TYPE_ANDROID,
+
+ 'flutter-linux' => Platform::TYPE_LINUX,
+ 'flutter-windows' => Platform::TYPE_WINDOWS,
+ ];
+
+ $typeValidator = new WhiteList(\array_keys($deprecatedTypeMapping));
+ if (!$typeValidator->isValid($request->getParam('type', ''))) {
+ throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "type" is invalid: ' . $typeValidator->getDescription());
+ }
+
+ $type = $deprecatedTypeMapping[$request->getParam('type', '')] ?? '';
+ }
+
+ if (!empty($key)) {
+ // Validate deprecated app id (key)
+ $keyValidator = new Text(256);
+ if (!$keyValidator->isValid($key)) {
+ throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "key" is invalid: ' . $keyValidator->getDescription());
+ }
+ }
+
+ if (empty($key) && empty($type)) {
+ // Modern request, validate hostname
+ if (empty($hostname)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "hostname" is not optional.');
+ }
+ }
+
+ $platformId = ($platformId == 'unique()') ? ID::unique() : $platformId;
+
+ $platform = new Document([
+ '$id' => $platformId,
+ '$permissions' => [],
+ 'projectInternalId' => $project->getSequence(),
+ 'projectId' => $project->getId(),
+ 'type' => $type ?: Platform::TYPE_WEB, // Preserve type for backwards compatibility
+ 'name' => $name,
+ 'key' => $key,
+ 'hostname' => $hostname
+ ]);
+
+ try {
+ $platform = $authorization->skip(fn () => $dbForPlatform->createDocument('platforms', $platform));
+ } catch (DuplicateException) {
+ throw new Exception(Exception::PLATFORM_ALREADY_EXISTS);
+ }
+
+ $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
+
+ $queueForEvents->setParam('platformId', $platform->getId());
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_CREATED)
+ ->dynamic($platform, Response::MODEL_PLATFORM_WEB);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php
new file mode 100644
index 0000000000..62d209ea25
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php
@@ -0,0 +1,151 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
+ ->setHttpPath('/v1/project/platforms/web/:platformId')
+ ->httpAlias('/v1/projects/:projectId/platforms/:platformId')
+ ->desc('Update project web platform')
+ ->groups(['api', 'project'])
+ ->label('scope', 'platforms.write')
+ ->label('event', 'platforms.[platformId].update')
+ ->label('audits.event', 'project.platform.update')
+ ->label('audits.resource', 'project.platform/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'platforms',
+ name: 'updateWebPlatform',
+ description: <<param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform'])
+ ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.')
+ ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true, example: 'app.example.com') // Optional for backwards compatibility
+ ->param('key', '', new Text(256), 'Package name for Android or bundle ID for iOS or macOS. Max length: 256 chars.', optional: true, deprecated: true) // Exists for backwards compatibility
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $platformId,
+ string $name,
+ string $hostname,
+ ?string $key, // For backwards compatibility
+ Response $response,
+ QueueEvent $queueForEvents,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ Document $project,
+ ) {
+ $key = $key ?? ''; // App platform attribute, backwards compatibility
+
+ // Backwards compatibility
+ // Used to have: type, name, key, hostname
+ if (!empty($key)) {
+ // Validate deprecated app id (key)
+ $keyValidator = new Text(256);
+ if (!$keyValidator->isValid($key)) {
+ throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "key" is invalid: ' . $keyValidator->getDescription());
+ }
+ }
+
+ // One day, ideally, we ensure hostname is not empty
+ // But for backwards compatibility backend must threat it as optional for now
+
+ $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId));
+
+ if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) {
+ throw new Exception(Exception::PLATFORM_NOT_FOUND);
+ }
+
+ // Wrapped in if, for backwards compatibility
+ if (!empty($hostname)) {
+ $supportedTypes = [
+ Platform::TYPE_WEB,
+ // Backwards compatibility
+ 'flutter-web',
+ 'unity',
+ 'flutter-macos',
+ 'flutter-ios',
+ 'react-native-ios',
+ 'apple-ios',
+ 'apple-macos',
+ 'apple-watchos',
+ 'apple-tvos',
+ 'flutter-android',
+ 'react-native-android',
+ 'flutter-windows',
+ 'flutter-linux',
+ ];
+ if (!in_array($platform->getAttribute('type', ''), $supportedTypes)) {
+ throw new Exception(Exception::PLATFORM_METHOD_UNSUPPORTED);
+ }
+ }
+
+ $updates = new Document([
+ 'name' => $name,
+ ]);
+
+ // Wrapped in if, for backwards compatibility
+ if (!empty($hostname)) {
+ $updates->setAttribute('hostname', $hostname);
+ }
+
+ // Backwards compatibility
+ if (!empty($key)) {
+ $updates->setAttribute('key', $key);
+ }
+
+ try {
+ $platform = $authorization->skip(fn () => $dbForPlatform->updateDocument('platforms', $platform->getId(), $updates));
+ } catch (Duplicate) {
+ throw new Exception(Exception::PLATFORM_ALREADY_EXISTS);
+ }
+
+ $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
+
+ $queueForEvents->setParam('platformId', $platform->getId());
+
+ $response->dynamic($platform, Response::MODEL_PLATFORM_WEB);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php
new file mode 100644
index 0000000000..58be45d03b
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php
@@ -0,0 +1,105 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/project/platforms/windows')
+ ->desc('Create project Windows platform')
+ ->groups(['api', 'project'])
+ ->label('scope', 'platforms.write')
+ ->label('event', 'platforms.[platformId].create')
+ ->label('audits.event', 'project.platform.create')
+ ->label('audits.resource', 'project.platform/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'platforms',
+ name: 'createWindowsPlatform',
+ description: <<param('platformId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform 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('name', null, new Text(128), 'Platform name. Max length: 128 chars.')
+ ->param('packageIdentifierName', '', new Text(256), 'Windows package identifier name. Max length: 256 chars.')
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $platformId,
+ string $name,
+ string $packageIdentifierName,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Document $project,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ ) {
+ $platformId = ($platformId == 'unique()') ? ID::unique() : $platformId;
+
+ $platform = new Document([
+ '$id' => $platformId,
+ '$permissions' => [],
+ 'projectInternalId' => $project->getSequence(),
+ 'projectId' => $project->getId(),
+ 'type' => Platform::TYPE_WINDOWS,
+ 'name' => $name,
+ 'key' => $packageIdentifierName,
+ 'hostname' => '',
+ ]);
+
+ try {
+ $platform = $authorization->skip(fn () => $dbForPlatform->createDocument('platforms', $platform));
+ } catch (DuplicateException) {
+ throw new Exception(Exception::PLATFORM_ALREADY_EXISTS);
+ }
+
+ $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
+
+ $queueForEvents->setParam('platformId', $platform->getId());
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_CREATED)
+ ->dynamic($platform, Response::MODEL_PLATFORM_WINDOWS);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php
new file mode 100644
index 0000000000..5cfb6ee7ea
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php
@@ -0,0 +1,103 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
+ ->setHttpPath('/v1/project/platforms/windows/:platformId')
+ ->desc('Update project Windows platform')
+ ->groups(['api', 'project'])
+ ->label('scope', 'platforms.write')
+ ->label('event', 'platforms.[platformId].update')
+ ->label('audits.event', 'project.platform.update')
+ ->label('audits.resource', 'project.platform/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'platforms',
+ name: 'updateWindowsPlatform',
+ description: <<param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform'])
+ ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.')
+ ->param('packageIdentifierName', '', new Text(256), 'Windows package identifier name. Max length: 256 chars.')
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $platformId,
+ string $name,
+ string $packageIdentifierName,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ Document $project,
+ ) {
+ $platform = $authorization->skip(fn () => $dbForPlatform->getDocument('platforms', $platformId));
+
+ if ($platform->isEmpty() || $platform->getAttribute('projectInternalId', '') !== $project->getSequence()) {
+ throw new Exception(Exception::PLATFORM_NOT_FOUND);
+ }
+
+ if ($platform->getAttribute('type', '') !== Platform::TYPE_WINDOWS) {
+ throw new Exception(Exception::PLATFORM_METHOD_UNSUPPORTED);
+ }
+
+ $updates = new Document([
+ 'name' => $name,
+ 'key' => $packageIdentifierName,
+ ]);
+
+ try {
+ $platform = $authorization->skip(fn () => $dbForPlatform->updateDocument('platforms', $platform->getId(), $updates));
+ } catch (Duplicate) {
+ throw new Exception(Exception::PLATFORM_ALREADY_EXISTS);
+ }
+
+ $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
+
+ $queueForEvents->setParam('platformId', $platform->getId());
+
+ $response->dynamic($platform, Response::MODEL_PLATFORM_WINDOWS);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php
new file mode 100644
index 0000000000..3913f08e75
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php
@@ -0,0 +1,130 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/platforms')
+ ->httpAlias('/v1/projects/:projectId/platforms')
+ ->desc('List project platforms')
+ ->groups(['api', 'project'])
+ ->label('scope', 'platforms.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'platforms',
+ name: 'listPlatforms',
+ description: <<param('queries', [], new Platforms(), '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(', ', Platforms::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());
+ }
+
+ foreach ($queries as $query) {
+ if (\in_array($query->getAttribute(), ['bundleIdentifier', 'applicationId', 'packageIdentifierName', 'packageName'])) {
+ $query->setAttribute('key');
+ }
+ }
+
+ $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());
+ }
+
+ $platformId = $cursor->getValue();
+ $cursorDocument = $authorization->skip(fn () => $dbForPlatform->findOne('platforms', [
+ Query::equal('$id', [$platformId]),
+ Query::equal('projectInternalId', [$project->getSequence()]),
+ ]));
+
+ if ($cursorDocument->isEmpty()) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Platform '{$platformId}' for the 'cursor' value not found.");
+ }
+
+ $cursor->setValue($cursorDocument);
+ }
+
+ $filterQueries = Query::groupByType($queries)['filters'];
+
+ try {
+ $platforms = $authorization->skip(fn () => $dbForPlatform->find('platforms', $queries));
+ $total = $includeTotal ? $authorization->skip(fn () => $dbForPlatform->count('platforms', $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.");
+ }
+
+ foreach ($platforms as $platform) {
+ $platform->setAttribute('type', Platform::mapDeprecatedType($platform->getAttribute('type')));
+ }
+
+ $response->dynamic(new Document([
+ 'platforms' => $platforms,
+ 'total' => $total,
+ ]), Response::MODEL_PLATFORM_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php
new file mode 100644
index 0000000000..21342332d9
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php
@@ -0,0 +1,152 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/policies/:policyId')
+ ->desc('Get project policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.read', 'project.policies.read'])
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'getPolicy',
+ description: <<param('policyId', '', new WhiteList([
+ 'password-dictionary',
+ 'password-history',
+ 'password-personal-data',
+ 'session-alert',
+ 'session-duration',
+ 'session-invalidation',
+ 'session-limit',
+ 'user-limit',
+ 'membership-privacy',
+ ], true), 'Policy ID. Can be one of: password-dictionary, password-history, password-personal-data, session-alert, session-duration, session-invalidation, session-limit, user-limit, membership-privacy.')
+ ->inject('response')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $policyId,
+ Response $response,
+ Document $project,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+
+ [$policy, $model] = match ($policyId) {
+ 'password-dictionary' => [
+ new Document([
+ '$id' => 'password-dictionary',
+ 'enabled' => $auths['passwordDictionary'] ?? false,
+ ]),
+ Response::MODEL_POLICY_PASSWORD_DICTIONARY,
+ ],
+ 'password-history' => [
+ new Document([
+ '$id' => 'password-history',
+ 'total' => $auths['passwordHistory'] ?? 0,
+ ]),
+ Response::MODEL_POLICY_PASSWORD_HISTORY,
+ ],
+ 'password-personal-data' => [
+ new Document([
+ '$id' => 'password-personal-data',
+ 'enabled' => $auths['personalDataCheck'] ?? false,
+ ]),
+ Response::MODEL_POLICY_PASSWORD_PERSONAL_DATA,
+ ],
+ 'session-alert' => [
+ new Document([
+ '$id' => 'session-alert',
+ 'enabled' => $auths['sessionAlerts'] ?? false,
+ ]),
+ Response::MODEL_POLICY_SESSION_ALERT,
+ ],
+ 'session-duration' => [
+ new Document([
+ '$id' => 'session-duration',
+ 'duration' => $auths['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG,
+ ]),
+ Response::MODEL_POLICY_SESSION_DURATION,
+ ],
+ 'session-invalidation' => [
+ new Document([
+ '$id' => 'session-invalidation',
+ 'enabled' => $auths['invalidateSessions'] ?? true,
+ ]),
+ Response::MODEL_POLICY_SESSION_INVALIDATION,
+ ],
+ 'session-limit' => [
+ new Document([
+ '$id' => 'session-limit',
+ 'total' => $auths['maxSessions'] ?? 0,
+ ]),
+ Response::MODEL_POLICY_SESSION_LIMIT,
+ ],
+ 'user-limit' => [
+ new Document([
+ '$id' => 'user-limit',
+ 'total' => $auths['limit'] ?? 0,
+ ]),
+ Response::MODEL_POLICY_USER_LIMIT,
+ ],
+ 'membership-privacy' => [
+ new Document([
+ '$id' => 'membership-privacy',
+ 'userId' => $auths['membershipsUserId'] ?? false,
+ 'userEmail' => $auths['membershipsUserEmail'] ?? false,
+ 'userPhone' => $auths['membershipsUserPhone'] ?? false,
+ 'userName' => $auths['membershipsUserName'] ?? false,
+ 'userMFA' => $auths['membershipsMfa'] ?? false,
+ ]),
+ Response::MODEL_POLICY_MEMBERSHIP_PRIVACY,
+ ],
+ default => throw new \LogicException('Unknown policy ID: ' . $policyId),
+ };
+
+ $response->dynamic($policy, $model);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php
new file mode 100644
index 0000000000..41a6168b07
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php
@@ -0,0 +1,108 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/policies/membership-privacy')
+ ->httpAlias('/v1/projects/:projectId/auth/memberships-privacy')
+ ->desc('Update membership privacy policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.write', 'project.policies.write'])
+ ->label('event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'updateMembershipPrivacyPolicy',
+ description: <<param('userId', null, new Boolean(), 'Set to true if you want make user ID visible to all team members, or false to hide it.', optional: true)
+ ->param('userEmail', null, new Boolean(), 'Set to true if you want make user email visible to all team members, or false to hide it.', optional: true)
+ ->param('userPhone', null, new Boolean(), 'Set to true if you want make user phone number visible to all team members, or false to hide it.', optional: true)
+ ->param('userName', null, new Boolean(), 'Set to true if you want make user name visible to all team members, or false to hide it.', optional: true)
+ ->param('userMFA', null, new Boolean(), 'Set to true if you want make user MFA status visible to all team members, or false to hide it.', optional: true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ ?bool $userId,
+ ?bool $userEmail,
+ ?bool $userPhone,
+ ?bool $userName,
+ ?bool $userMFA,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+
+ if ($userId !== null) {
+ $auths['membershipsUserId'] = $userId;
+ }
+ if ($userEmail !== null) {
+ $auths['membershipsUserEmail'] = $userEmail;
+ }
+ if ($userPhone !== null) {
+ $auths['membershipsUserPhone'] = $userPhone;
+ }
+ if ($userName !== null) {
+ $auths['membershipsUserName'] = $userName;
+ }
+ if ($userMFA !== null) {
+ $auths['membershipsMfa'] = $userMFA;
+ }
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents
+ ->setParam('projectId', $project->getId())
+ ->setParam('policy', 'membership-privacy');
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php
new file mode 100644
index 0000000000..d7ee99fbfe
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php
@@ -0,0 +1,85 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/policies/password-dictionary')
+ ->httpAlias('/v1/projects/:projectId/auth/password-dictionary')
+ ->desc('Update password dictionary policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.write', 'project.policies.write'])
+ ->label('event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'updatePasswordDictionaryPolicy',
+ description: <<param('enabled', null, new Boolean(), 'Toggle password dictionary policy. Set to true if you want password change to block passwords in the dictionary, or false to allow them. When changing this policy, existing passwords remain valid.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+ $auths['passwordDictionary'] = $enabled;
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents
+ ->setParam('projectId', $project->getId())
+ ->setParam('policy', 'password-dictionary');
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php
new file mode 100644
index 0000000000..84861a19e1
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php
@@ -0,0 +1,93 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/policies/password-history')
+ ->httpAlias('/v1/projects/:projectId/auth/password-history')
+ ->desc('Update password history policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.write', 'project.policies.write'])
+ ->label('event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'updatePasswordHistoryPolicy',
+ description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the password history length per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ ?int $total,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+
+ if (\is_null($total)) {
+ $auths['passwordHistory'] = 0;
+ } else {
+ $auths['passwordHistory'] = $total;
+ }
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents
+ ->setParam('projectId', $project->getId())
+ ->setParam('policy', 'password-history');
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php
new file mode 100644
index 0000000000..435f00fc39
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php
@@ -0,0 +1,86 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/policies/password-personal-data')
+ ->httpAlias('/v1/projects/:projectId/auth/personal-data')
+ ->desc('Update password personal data policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.write', 'project.policies.write'])
+ ->label('event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'updatePasswordPersonalDataPolicy',
+ description: <<param('enabled', null, new Boolean(), 'Toggle password personal data policy. Set to true if you want to block passwords including user\'s personal data, or false to allow it. When changing this policy, existing passwords remain valid.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+ $auths['personalDataCheck'] = $enabled;
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents
+ ->setParam('projectId', $project->getId())
+ ->setParam('policy', 'password-personal-data');
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php
new file mode 100644
index 0000000000..79653d46ad
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php
@@ -0,0 +1,85 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/policies/session-alert')
+ ->httpAlias('/v1/projects/:projectId/auth/session-alerts')
+ ->desc('Update session alert policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.write', 'project.policies.write'])
+ ->label('event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'updateSessionAlertPolicy',
+ description: <<param('enabled', null, new Boolean(), 'Toggle session alert policy. Set to true if you want users to receive email notifications when a sessions are created for their users, or false to not send email alerts.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+ $auths['sessionAlerts'] = $enabled;
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents
+ ->setParam('projectId', $project->getId())
+ ->setParam('policy', 'session-alert');
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php
new file mode 100644
index 0000000000..0a7f33218a
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php
@@ -0,0 +1,85 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/policies/session-duration')
+ ->httpAlias('/v1/projects/:projectId/auth/duration')
+ ->desc('Update session duration policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.write', 'project.policies.write'])
+ ->label('event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'updateSessionDurationPolicy',
+ description: <<param('duration', null, new Range(5, 31536000), 'Maximum session length in seconds. Minium allowed value is 5 second, and maximum is 1 year, which is 31536000 seconds.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ int $duration,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+ $auths['duration'] = $duration;
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents
+ ->setParam('projectId', $project->getId())
+ ->setParam('policy', 'session-duration');
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php
new file mode 100644
index 0000000000..a1feb67346
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php
@@ -0,0 +1,85 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/policies/session-invalidation')
+ ->httpAlias('/v1/projects/:projectId/auth/session-invalidation')
+ ->desc('Update session invalidation policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.write', 'project.policies.write'])
+ ->label('event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'updateSessionInvalidationPolicy',
+ description: <<param('enabled', null, new Boolean(), 'Toggle session invalidation policy. Set to true if you want password change to invalidate all sessions of an user, or false to keep sessions active.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+ $auths['invalidateSessions'] = $enabled;
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents
+ ->setParam('projectId', $project->getId())
+ ->setParam('policy', 'session-invalidation');
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php
new file mode 100644
index 0000000000..936a541249
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php
@@ -0,0 +1,91 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/policies/session-limit')
+ ->httpAlias('/v1/projects/:projectId/auth/max-sessions')
+ ->desc('Update session limit policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.write', 'project.policies.write'])
+ ->label('event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'updateSessionLimitPolicy',
+ description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the maximum number of sessions allowed per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ ?int $total,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+
+ if (\is_null($total)) {
+ $auths['maxSessions'] = 0;
+ } else {
+ $auths['maxSessions'] = $total;
+ }
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents
+ ->setParam('projectId', $project->getId())
+ ->setParam('policy', 'session-limit');
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php
new file mode 100644
index 0000000000..2b7e704853
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php
@@ -0,0 +1,91 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/policies/user-limit')
+ ->httpAlias('/v1/projects/:projectId/auth/limit')
+ ->desc('Update user limit policy')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.write', 'project.policies.write'])
+ ->label('event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.event', 'projects.[projectId].policies.[policy].update')
+ ->label('audits.resource', 'project/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'updateUserLimitPolicy',
+ description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the maximum number of users allowed in the project. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ ?int $total,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $auths = $project->getAttribute('auths', []);
+
+ if (\is_null($total)) {
+ $auths['limit'] = 0;
+ } else {
+ $auths['limit'] = $total;
+ }
+
+ $updates = new Document([
+ 'auths' => $auths,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents
+ ->setParam('projectId', $project->getId())
+ ->setParam('policy', 'user-limit');
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php
new file mode 100644
index 0000000000..3020fa79dd
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php
@@ -0,0 +1,132 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/policies')
+ ->desc('List project policies')
+ ->groups(['api', 'project'])
+ ->label('scope', ['policies.read', 'project.policies.read'])
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'policies',
+ name: 'listPolicies',
+ description: <<param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true)
+ ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
+ ->inject('response')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ /**
+ * @param array $queries
+ */
+ public function action(
+ array $queries,
+ bool $includeTotal,
+ Response $response,
+ Document $project,
+ ) {
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $auths = $project->getAttribute('auths', []);
+
+ $policies = [
+ new Document([
+ '$id' => 'password-dictionary',
+ 'enabled' => $auths['passwordDictionary'] ?? false,
+ ]),
+ new Document([
+ '$id' => 'password-history',
+ 'total' => $auths['passwordHistory'] ?? 0,
+ ]),
+ new Document([
+ '$id' => 'password-personal-data',
+ 'enabled' => $auths['personalDataCheck'] ?? false,
+ ]),
+ new Document([
+ '$id' => 'session-alert',
+ 'enabled' => $auths['sessionAlerts'] ?? false,
+ ]),
+ new Document([
+ '$id' => 'session-duration',
+ 'duration' => $auths['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG,
+ ]),
+ new Document([
+ '$id' => 'session-invalidation',
+ 'enabled' => $auths['invalidateSessions'] ?? true,
+ ]),
+ new Document([
+ '$id' => 'session-limit',
+ 'total' => $auths['maxSessions'] ?? 0,
+ ]),
+ new Document([
+ '$id' => 'user-limit',
+ 'total' => $auths['limit'] ?? 0,
+ ]),
+ new Document([
+ '$id' => 'membership-privacy',
+ 'userId' => $auths['membershipsUserId'] ?? false,
+ 'userEmail' => $auths['membershipsUserEmail'] ?? false,
+ 'userPhone' => $auths['membershipsUserPhone'] ?? false,
+ 'userName' => $auths['membershipsUserName'] ?? false,
+ 'userMFA' => $auths['membershipsMfa'] ?? false,
+ ]),
+ ];
+
+ $total = $includeTotal ? \count($policies) : 0;
+
+ $grouped = Query::groupByType($queries);
+ $offset = $grouped['offset'] ?? 0;
+ $limit = $grouped['limit'] ?? null;
+
+ $policies = \array_slice($policies, $offset, $limit);
+
+ $response->dynamic(new Document([
+ 'policies' => $policies,
+ 'total' => $total,
+ ]), Response::MODEL_POLICY_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Update.php
new file mode 100644
index 0000000000..ad5691c1e0
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Update.php
@@ -0,0 +1,86 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/protocols/:protocolId')
+ ->httpAlias('/v1/project/protocols/:protocolId/status')
+ ->httpAlias('/v1/projects/:projectId/api')
+ ->desc('Update project protocol')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.write')
+ ->label('event', 'protocols.[protocolId].update')
+ ->label('audits.event', 'project.protocols.[protocolId].update')
+ ->label('audits.resource', 'project.protocols/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: null,
+ name: 'updateProtocol',
+ description: <<param('protocolId', '', new WhiteList(array_keys(Config::getParam('protocols')), true), 'Protocol name. Can be one of: ' . \implode(', ', array_keys(Config::getParam('protocols'))))
+ ->param('enabled', null, new Boolean(), 'Protocol status.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $protocolId,
+ bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents,
+ ): void {
+ $protocols = $project->getAttribute('apis', []);
+ $protocols[$protocolId] = $enabled;
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
+ 'apis' => $protocols,
+ ])));
+
+ $queueForEvents->setParam('protocolId', $protocolId);
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php
new file mode 100644
index 0000000000..8c87a41475
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php
@@ -0,0 +1,179 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/project/smtp/tests')
+ ->httpAlias('/v1/projects/:projectId/smtp/tests')
+ ->desc('Create project SMTP test')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.write')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'smtp',
+ name: 'createSMTPTest',
+ description: <<param('emails', [], new ArrayList(new Email(), 10), 'Array of emails to send test email to. Maximum of 10 emails are allowed.')
+ ->param('senderName', '', new Text(256), 'Name of the email sender', optional: true, deprecated: true) // Backwards compatibility
+ ->param('senderEmail', '', new Email(), 'Email of the sender', optional: true, deprecated: true) // Backwards compatibility
+ ->param('replyTo', '', new Email(), 'Reply to email', optional: true, deprecated: true) // Backwards compatibility
+ ->param('host', '', new Hostname(), 'SMTP server host name', optional: true, deprecated: true) // Backwards compatibility
+ ->param('port', null, new Integer(), 'SMTP server port', optional: true, deprecated: true) // Backwards compatibility
+ ->param('username', '', new Text(256), 'SMTP server username', optional: true, deprecated: true) // Backwards compatibility
+ ->param('password', '', new Text(256), 'SMTP server password', optional: true, deprecated: true) // Backwards compatibility
+ ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', optional: true, deprecated: true) // Backwards compatibility
+ ->inject('response')
+ ->inject('project')
+ ->inject('publisherForMails')
+ ->inject('plan')
+ ->callback($this->action(...));
+ }
+
+ /**
+ * @param array $emails
+ */
+ public function action(
+ array $emails,
+ string $paramSenderName, // Backwards compatibility
+ string $paramSenderEmail, // Backwards compatibility
+ string $paramReplyTo, // Backwards compatibility
+ string $paramHost, // Backwards compatibility
+ ?int $paramPort, // Backwards compatibility
+ string $paramUsername, // Backwards compatibility
+ string $paramPassword, // Backwards compatibility
+ string $paramSecure, // Backwards compatibility
+ Response $response,
+ Document $project,
+ MailPublisher $publisherForMails,
+ array $plan
+ ): void {
+ // Backwards compatibility: use inline params if provided, otherwise fall back to project SMTP config.
+ // When inline params are provided they are treated as self-contained — project config is ignored
+ // so legacy (1.9.1) callers do not get project state (e.g. replyToName) leaked into their request.
+ $hasInlineParams = !empty($paramHost);
+
+ $smtp = $project->getAttribute('smtp', []);
+
+ if (!$hasInlineParams && ($smtp['enabled'] ?? false) !== true) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to send a test email.');
+ }
+
+ if ($hasInlineParams) {
+ $senderName = $paramSenderName;
+ $senderEmail = $paramSenderEmail;
+ $replyToEmail = $paramReplyTo;
+ $replyToName = ''; // 1.9.1 inline params did not include replyToName
+ $host = $paramHost;
+ $port = $paramPort ?? 0;
+ $username = $paramUsername;
+ $password = $paramPassword;
+ $secure = $paramSecure;
+ } else {
+ $senderName = $smtp['senderName'] ?? '';
+ $senderEmail = $smtp['senderEmail'] ?? '';
+ $replyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; // Includes backwards compatibility
+ $replyToName = $smtp['replyToName'] ?? '';
+ $host = $smtp['host'] ?? '';
+ $port = $smtp['port'] ?? 0;
+ $username = $smtp['username'] ?? '';
+ $password = $smtp['password'] ?? '';
+ $secure = $smtp['secure'] ?? '';
+ }
+
+ if (empty($senderEmail)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender email must be configured on the project to send a test email.');
+ }
+
+ if (empty($host)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP host must be configured on the project to send a test email.');
+ }
+
+ if (empty($port)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP port must be configured on the project to send a test email.');
+ }
+
+ // Fallback to sender details when reply-to is not explicitly configured
+ $replyToEmailDisplay = !empty($replyToEmail) ? $replyToEmail : $senderEmail;
+ $replyToNameDisplay = !empty($replyToName) ? $replyToName : $senderName;
+
+ $subject = 'Custom SMTP email sample';
+ $template = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-smtp-test.tpl');
+ $template
+ ->setParam('{{from}}', "{$senderName} ({$senderEmail})")
+ ->setParam('{{replyTo}}', "{$replyToNameDisplay} ({$replyToEmailDisplay})")
+ ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL)
+ ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR)
+ ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER)
+ ->setParam('{{discordUrl}}', $plan['discordUrl'] ?? APP_SOCIAL_DISCORD)
+ ->setParam('{{githubUrl}}', $plan['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE)
+ ->setParam('{{termsUrl}}', $plan['termsUrl'] ?? APP_EMAIL_TERMS_URL)
+ ->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL);
+
+ foreach ($emails as $email) {
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $email,
+ subject: $subject,
+ bodyTemplate: APP_CE_CONFIG_DIR . '/locale/templates/email-base-styled.tpl',
+ body: $template->render(),
+ smtp: [
+ 'host' => $host,
+ 'port' => $port,
+ 'username' => $username,
+ 'password' => $password,
+ 'secure' => $secure,
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ],
+ ));
+ }
+
+ $response->noContent();
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php
new file mode 100644
index 0000000000..97e723f52c
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php
@@ -0,0 +1,173 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/smtp')
+ ->httpAlias('/v1/projects/:projectId/smtp')
+ ->desc('Update project SMTP configuration')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.write')
+ // ->label('event', 'project.smtp.update')
+ ->label('audits.event', 'project.smtp.update')
+ ->label('audits.resource', 'project.smtp/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'smtp',
+ name: 'updateSMTP',
+ description: <<param('host', null, new Nullable(new Hostname()), 'SMTP server hostname (domain)', optional: true)
+ ->param('port', null, new Nullable(new Integer()), 'SMTP server port', optional: true)
+ ->param('username', null, new Nullable(new Text(256)), 'SMTP server username. Leave empty for no authorization.', optional: true)
+ ->param('password', null, new Nullable(new Text(256)), 'SMTP server password. Leave empty for no authorization. This property is stored securely and cannot be read in future (write-only).', optional: true)
+ ->param('senderEmail', null, new Nullable(new Email()), 'Email address shown in inbox as the sender of the email.', optional: true)
+ ->param('senderName', null, new Nullable(new Text(256)), 'Name shown in inbox as the sender of the email.', optional: true)
+ ->param('replyToEmail', null, new Nullable(new Email()), 'Email used when user replies to the email.', optional: true)
+ ->param('replyToName', null, new Nullable(new Text(256)), 'Name used when user replies to the email.', optional: true)
+ ->param('secure', null, new Nullable(new WhiteList(['tls', 'ssl'], true)), 'Configures if communication with SMTP server is encrypted. Allowed values are: tls, ssl. Leave empty for no encryption.', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'Enable or disable custom SMTP. Custom SMTP is useful for branding purposes, but also allows use of custom email templates.', optional: true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+
+ public function action(
+ ?string $host,
+ ?int $port,
+ ?string $username,
+ ?string $password,
+ ?string $senderEmail,
+ ?string $senderName,
+ ?string $replyToEmail,
+ ?string $replyToName,
+ ?string $secure,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization
+ ): void {
+ // Fetch current configuration
+ $smtp = $project->getAttribute('smtp', []);
+
+ // Apply changes
+ $keys = ['host', 'port', 'username', 'password', 'senderEmail', 'senderName', 'replyToEmail', 'replyToName', 'secure', 'enabled'];
+ foreach ($keys as $key) {
+ if (!\is_null(${$key})) {
+ $smtp[$key] = ${$key};
+ }
+ }
+
+ // Backwards compatibility
+ $smtp['replyToEmail'] = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
+
+ if (($smtp['enabled'] ?? false) === true) {
+ // Ensure required fields are set
+ $requiredKeys = ['host', 'port', 'senderEmail'];
+ foreach ($requiredKeys as $key) {
+ if (empty($smtp[$key])) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "' . $key . '" is not optional.');
+ }
+ }
+ }
+
+ // Validate SMTP credentials
+ // Validate when the caller is explicitly enabling or hasn't expressed a preference
+ // (so a credentials-only PATCH can auto-enable). Skip only when the caller is
+ // explicitly keeping/turning SMTP off.
+ if (\is_null($enabled) || $enabled === true) {
+ $mail = new PHPMailer(true);
+ $mail->isSMTP();
+
+ $mail->Host = $smtp['host'] ?? '';
+ $mail->Port = $smtp['port'] ?? '';
+ $mail->SMTPSecure = $smtp['secure'] ?? '';
+ $mail->setFrom($smtp['senderEmail'], $smtp['senderName'] ?? '');
+
+ if (!empty($smtp['username'] ?? '')) {
+ $mail->SMTPAuth = true;
+ $mail->Username = $smtp['username'];
+ $mail->Password = $smtp['password'] ?? '';
+ }
+
+ if (!empty($smtp['replyToEmail'] ?? '')) {
+ $mail->addReplyTo($smtp['replyToEmail'], $smtp['replyToName'] ?? '');
+ }
+
+ $mail->SMTPAutoTLS = false;
+ $mail->Timeout = 5;
+
+ try {
+ $valid = $mail->SmtpConnect();
+
+ if (!$valid) {
+ throw new \Exception('Connection is not valid.');
+ }
+
+ // Auto-enable if configuration is valid
+ // Dont do this if specifically request to mark disabled
+ if (\is_null($enabled)) {
+ $smtp['enabled'] = true;
+ }
+ } catch (Throwable $error) {
+ if (($smtp['enabled'] ?? null) === true) {
+ throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage());
+ }
+ }
+ }
+
+ // Save configuration
+ $updates = new Document([
+ 'smtp' => $smtp,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Update.php
new file mode 100644
index 0000000000..7aab6f5ad0
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Update.php
@@ -0,0 +1,86 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/services/:serviceId')
+ ->httpAlias('/v1/project/services/:serviceId/status')
+ ->httpAlias('/v1/projects/:projectId/service')
+ ->desc('Update project service')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.write')
+ ->label('event', 'services.[serviceId].update')
+ ->label('audits.event', 'project.services.[serviceId].update')
+ ->label('audits.resource', 'project.services/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: null,
+ name: 'updateService',
+ description: <<param('serviceId', '', new WhiteList(array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional'])), true), 'Service name. Can be one of: '.\implode(', ', array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional']))))
+ ->param('enabled', null, new Boolean(), 'Service status.')
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $serviceId,
+ bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ Event $queueForEvents
+ ): void {
+ $services = $project->getAttribute('services', []);
+ $services[$serviceId] = $enabled;
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
+ 'services' => $services,
+ ])));
+
+ $queueForEvents->setParam('serviceId', $serviceId);
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php
new file mode 100644
index 0000000000..02ba431775
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php
@@ -0,0 +1,142 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/templates/email/:templateId')
+ ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale')
+ ->desc('Get project email template')
+ ->groups(['api', 'project'])
+ ->label('scope', 'templates.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'templates',
+ name: 'getEmailTemplate',
+ description: <<param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? []))
+ ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes'])
+ ->inject('response')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $templateId,
+ string $locale,
+ Response $response,
+ Document $project,
+ ) {
+ $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en');
+
+ // Get custom template if available
+ $templates = $project->getAttribute('templates', []);
+ $template = $templates['email.' . $templateId . '-' . $locale] ?? [];
+
+ // Enforced params
+ $template['templateId'] = $templateId;
+ $template['locale'] = $locale;
+
+ // Prepare default tempaltes
+ $localeObj = new Locale($locale);
+ $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en'));
+
+ $defaultSubject = $localeObj->getText('emails.' . $templateId . '.subject');
+ $defaultMessage = $this->getDefaultMessage($templateId, $localeObj);
+
+ // Apply defaults if needed
+ if (\is_null($template['message'] ?? null)) {
+ $template['message'] = $defaultMessage;
+ }
+
+ if (\is_null($template['subject'] ?? null)) {
+ $template['subject'] = $defaultSubject;
+ }
+
+ // Backwards compatibility
+ if (!\is_null($template['replyTo'] ?? null)) {
+ $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? '';
+ }
+
+ $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE);
+ }
+
+ protected function getDefaultMessage(string $templateId, Locale $localeObj): string
+ {
+ $templateConfigs = [
+ 'magicSession' => [
+ 'file' => 'email-magic-url.tpl',
+ 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase']
+ ],
+ 'mfaChallenge' => [
+ 'file' => 'email-mfa-challenge.tpl',
+ 'placeholders' => ['description', 'clientInfo']
+ ],
+ 'otpSession' => [
+ 'file' => 'email-otp.tpl',
+ 'placeholders' => ['description', 'clientInfo', 'securityPhrase']
+ ],
+ 'sessionAlert' => [
+ 'file' => 'email-session-alert.tpl',
+ 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer']
+ ],
+ ];
+
+ // fallback to the base template.
+ $config = $templateConfigs[$templateId] ?? [
+ 'file' => 'email-inner-base.tpl',
+ 'placeholders' => ['buttonText', 'body', 'footer']
+ ];
+
+ $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']);
+ $message = Template::fromString($templateString);
+
+ // Set type-specific parameters
+ foreach ($config['placeholders'] as $param) {
+ $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']);
+ $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml);
+ }
+
+ $message
+ ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello"))
+ ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks"))
+ ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature"));
+
+ $message = $message->render(useContent: true);
+
+ return $message;
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php
new file mode 100644
index 0000000000..ef93abf683
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php
@@ -0,0 +1,144 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/templates/email')
+ ->httpAlias('/v1/projects/:projectId/templates/email')
+ ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale')
+ ->desc('Update project email template')
+ ->groups(['api', 'project'])
+ ->label('scope', 'templates.write')
+ ->label('event', 'templates.[templateId].update')
+ ->label('audits.event', 'project.template.update')
+ ->label('audits.resource', 'project.template/{response.templateId}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'templates',
+ name: 'updateEmailTemplate',
+ description: <<param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? []))
+ ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes'])
+ ->param('subject', null, new Nullable(new Text(255)), 'Subject of the email template. Can be up to 255 characters.', optional: true)
+ ->param('message', null, new Nullable(new Text(10485760)), 'Plain or HTML body of the email template message. Can be up to 10MB of content.', optional: true)
+ ->param('senderName', null, new Nullable(new Text(255, 0)), 'Name of the email sender.', optional: true)
+ ->param('senderEmail', null, new Nullable(new Email()), 'Email of the sender.', optional: true)
+ ->param('replyToEmail', null, new Nullable(new Email()), 'Reply to email.', optional: true)
+ ->param('replyToName', null, new Nullable(new Text(255, 0)), 'Reply to name.', optional: true)
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $templateId,
+ string $locale,
+ ?string $subject,
+ ?string $message,
+ ?string $senderName,
+ ?string $senderEmail,
+ ?string $replyToEmail,
+ ?string $replyToName,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Database $dbForPlatform,
+ Authorization $authorization,
+ Document $project,
+ ) {
+ $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en');
+
+ // Prevent template update if custom SMTP is not configured
+ $smtp = $project->getAttribute('smtp', []);
+ if (($smtp['enabled'] ?? false) !== true) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to configure custom email templates.');
+ }
+
+ // Fetch current configuration
+ $templates = $project->getAttribute('templates', []);
+ $template = $templates['email.' . $templateId . '-' . $locale] ?? [];
+
+ // Apply changes
+ $keys = ['senderName', 'senderEmail', 'replyToEmail', 'replyToName', 'message', 'subject'];
+ foreach ($keys as $key) {
+ if (!\is_null(${$key})) {
+ $template[$key] = ${$key};
+ }
+ }
+
+ // Backwards compatibility
+ if (!\is_null($template['replyTo'] ?? null)) {
+ $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? '';
+ }
+
+ // Ensure required fields are set
+ $requiredKeys = ['subject', 'message'];
+ foreach ($requiredKeys as $key) {
+ if (empty($template[$key])) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "' . $key . '" is not optional.');
+ }
+ }
+
+ // Save configuration
+ $templates['email.' . $templateId . '-' . $locale] = $template;
+ $updates = new Document([
+ 'templates' => $templates,
+ ]);
+
+ $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
+
+ $queueForEvents->setParam('templateId', $templateId);
+
+ $response->dynamic(new Document([
+ 'templateId' => $templateId,
+ 'locale' => $locale,
+ 'subject' => $template['subject'],
+ 'message' => $template['message'],
+ 'senderName' => $template['senderName'] ?? '',
+ 'senderEmail' => $template['senderEmail'] ?? '',
+ 'replyToEmail' => $template['replyToEmail'] ?? '',
+ 'replyToName' => $template['replyToName'] ?? '',
+ ]), Response::MODEL_EMAIL_TEMPLATE);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php
new file mode 100644
index 0000000000..d15f2f856c
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php
@@ -0,0 +1,114 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/templates/email')
+ ->desc('List project email templates')
+ ->groups(['api', 'project'])
+ ->label('scope', 'templates.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'templates',
+ name: 'listEmailTemplates',
+ description: <<param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true)
+ ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
+ ->inject('response')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ /**
+ * @param array $queries
+ */
+ public function action(
+ array $queries,
+ bool $includeTotal,
+ Response $response,
+ Document $project,
+ ) {
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $templates = $project->getAttribute('templates', []);
+
+ $emailTemplates = [];
+ foreach ($templates as $key => $template) {
+ if (!\str_starts_with($key, 'email.')) {
+ continue;
+ }
+
+ $suffix = \substr($key, \strlen('email.'));
+ $parts = \explode('-', $suffix, 2);
+ if (\count($parts) !== 2) {
+ continue;
+ }
+
+ [$templateId, $locale] = $parts;
+
+ $template['templateId'] = $templateId;
+ $template['locale'] = $locale;
+
+ // Backwards compatibility
+ if (!\is_null($template['replyTo'] ?? null)) {
+ $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? '';
+ }
+
+ $emailTemplates[] = new Document($template);
+ }
+
+ $total = $includeTotal ? \count($emailTemplates) : 0;
+
+ $grouped = Query::groupByType($queries);
+ $offset = $grouped['offset'] ?? 0;
+ $limit = $grouped['limit'] ?? null;
+
+ $emailTemplates = \array_slice($emailTemplates, $offset, $limit);
+
+ $response->dynamic(new Document([
+ 'templates' => $emailTemplates,
+ 'total' => $total,
+ ]), Response::MODEL_EMAIL_TEMPLATE_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php
new file mode 100644
index 0000000000..8c76ed2a8e
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php
@@ -0,0 +1,107 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/project/variables')
+ ->desc('Create project variable')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.write')
+ ->label('event', 'variables.[variableId].create')
+ ->label('audits.event', 'project.variable.create')
+ ->label('audits.resource', 'project.variable/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'variables',
+ name: 'createVariable',
+ description: <<param('variableId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject'])
+ ->param('key', null, new Text(Database::LENGTH_KEY), 'Variable key. Max length: ' . Database::LENGTH_KEY . ' chars.')
+ ->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.')
+ ->param('secret', true, new Boolean(), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true)
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('dbForProject')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $variableId,
+ string $key,
+ string $value,
+ bool $secret,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Database $dbForProject,
+ ) {
+ $variableId = ($variableId === 'unique()') ? ID::unique() : $variableId;
+
+ $variable = new Document([
+ '$id' => $variableId,
+ '$permissions' => [],
+ 'resourceInternalId' => '', // Already in project DB anyway
+ 'resourceId' => '', // Already in project DB anyway
+ 'resourceType' => 'project',
+ 'key' => $key,
+ 'value' => $value,
+ 'secret' => $secret,
+ 'search' => implode(' ', [$variableId, $key, 'project']),
+ ]);
+
+ try {
+ $variable = $dbForProject->createDocument('variables', $variable);
+ } catch (DuplicateException $th) {
+ throw new Exception(Exception::VARIABLE_ALREADY_EXISTS);
+ }
+
+ foreach (['functions', 'sites'] as $collection) {
+ $dbForProject->updateDocuments($collection, new Document([
+ 'live' => false
+ ]));
+ }
+
+ $queueForEvents->setParam('variableId', $variable->getId());
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_CREATED)
+ ->dynamic($variable, Response::MODEL_VARIABLE);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php
new file mode 100644
index 0000000000..553fb09e54
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php
@@ -0,0 +1,87 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/project/variables/:variableId')
+ ->desc('Delete project variable')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.write')
+ ->label('event', 'variables.[variableId].delete')
+ ->label('audits.event', 'project.variable.delete')
+ ->label('audits.resource', 'project.variable/{request.variableId}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'variables',
+ name: 'deleteVariable',
+ description: <<param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $variableId,
+ Response $response,
+ Database $dbForProject,
+ Event $queueForEvents,
+ ) {
+ $variable = $dbForProject->getDocument('variables', $variableId);
+
+ if ($variable->isEmpty() || $variable->getAttribute('resourceType', '') !== 'project') {
+ throw new Exception(Exception::VARIABLE_NOT_FOUND);
+ }
+
+ if (!$dbForProject->deleteDocument('variables', $variable->getId())) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove document from DB');
+ };
+
+ foreach (['functions', 'sites'] as $collection) {
+ $dbForProject->updateDocuments($collection, new Document([
+ 'live' => false
+ ]));
+ }
+
+ $queueForEvents->setParam('variableId', $variable->getId());
+
+ $response->noContent();
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php
new file mode 100644
index 0000000000..d9030421d7
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php
@@ -0,0 +1,66 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/variables/:variableId')
+ ->desc('Get project variable')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'variables',
+ name: 'getVariable',
+ description: <<param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
+ ->inject('response')
+ ->inject('dbForProject')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $variableId,
+ Response $response,
+ Database $dbForProject,
+ ) {
+ $variable = $dbForProject->getDocument('variables', $variableId);
+
+ if ($variable->isEmpty() || $variable->getAttribute('resourceType', '') !== 'project') {
+ throw new Exception(Exception::VARIABLE_NOT_FOUND);
+ }
+
+ $response->dynamic($variable, Response::MODEL_VARIABLE);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php
new file mode 100644
index 0000000000..6b05e19a78
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php
@@ -0,0 +1,120 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
+ ->setHttpPath('/v1/project/variables/:variableId')
+ ->desc('Update project variable')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.write')
+ ->label('event', 'variables.[variableId].update')
+ ->label('audits.event', 'project.variable.update')
+ ->label('audits.resource', 'project.variable/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'variables',
+ name: 'updateVariable',
+ description: <<param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
+ ->param('key', null, new Nullable(new Text(255, 0)), 'Variable key. Max length: 255 chars.', true)
+ ->param('value', null, new Nullable(new Text(8192, 0)), 'Variable value. Max length: 8192 chars.', true)
+ ->param('secret', null, new Nullable(new Boolean()), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true)
+ ->inject('response')
+ ->inject('queueForEvents')
+ ->inject('dbForProject')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $variableId,
+ ?string $key,
+ ?string $value,
+ ?bool $secret,
+ Response $response,
+ QueueEvent $queueForEvents,
+ Database $dbForProject,
+ ) {
+ $variable = $dbForProject->getDocument('variables', $variableId);
+
+ if ($variable->isEmpty() || $variable->getAttribute('resourceType', '') !== 'project') {
+ throw new Exception(Exception::VARIABLE_NOT_FOUND);
+ }
+
+ $isSecretVariable = $variable->getAttribute('secret', false) === true;
+ if ($isSecretVariable && $secret === false) {
+ throw new Exception(Exception::VARIABLE_CANNOT_UNSET_SECRET);
+ }
+
+ if (\is_null($key) && \is_null($value) && \is_null($secret)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID);
+ }
+
+ $updates = new Document();
+
+ if (!\is_null($key)) {
+ $updates->setAttribute('key', $key);
+ $updates->setAttribute('search', implode(' ', [$variableId, $key, 'project']));
+ }
+
+ if (!\is_null($value)) {
+ $updates->setAttribute('value', $value);
+ }
+
+ if (!\is_null($secret)) {
+ $updates->setAttribute('secret', $secret);
+ }
+
+ try {
+ $variable = $dbForProject->updateDocument('variables', $variable->getId(), $updates);
+ } catch (Duplicate $th) {
+ throw new Exception(Exception::VARIABLE_ALREADY_EXISTS);
+ }
+
+ foreach (['functions', 'sites'] as $collection) {
+ $dbForProject->updateDocuments($collection, new Document([
+ 'live' => false
+ ]));
+ }
+
+ $queueForEvents->setParam('variableId', $variable->getId());
+
+ $response->dynamic($variable, Response::MODEL_VARIABLE);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php
new file mode 100644
index 0000000000..bd391ea3b4
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php
@@ -0,0 +1,115 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project/variables')
+ ->desc('List project variables')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'variables',
+ name: 'listVariables',
+ description: <<param('queries', [], new Variables(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Variables::ALLOWED_ATTRIBUTES), true)
+ ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
+ ->inject('project')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->callback($this->action(...));
+ }
+
+ /**
+ * @param array $queries
+ */
+ public function action(
+ array $queries,
+ bool $includeTotal,
+ Document $project,
+ Response $response,
+ Database $dbForProject,
+ ) {
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $queries[] = Query::equal('resourceType', ['project']);
+
+ $cursor = Query::getCursorQueries($queries, false);
+ $cursor = \reset($cursor);
+
+ if ($cursor !== false) {
+ $validator = new Cursor();
+ if (!$validator->isValid($cursor)) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
+ }
+
+ $variableId = $cursor->getValue();
+ $cursorDocument = $dbForProject->findOne('variables', [
+ Query::equal('$id', [$variableId]),
+ Query::equal('resourceType', ['project']),
+ ]);
+
+ if ($cursorDocument->isEmpty()) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Variable '{$variableId}' for the 'cursor' value not found.");
+ }
+
+ $cursor->setValue($cursorDocument);
+ }
+
+ $filterQueries = Query::groupByType($queries)['filters'];
+
+ try {
+ $variables = $dbForProject->find('variables', $queries);
+ $total = $includeTotal ? $dbForProject->count('variables', $filterQueries, APP_LIMIT_COUNT) : 0;
+ } catch (OrderException $e) {
+ throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
+ }
+
+ $response->dynamic(new Document([
+ 'variables' => $variables,
+ 'total' => $total,
+ ]), Response::MODEL_VARIABLE_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Module.php b/src/Appwrite/Platform/Modules/Project/Module.php
new file mode 100644
index 0000000000..ab6f445853
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Module.php
@@ -0,0 +1,14 @@
+addService('http', new Http());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php
new file mode 100644
index 0000000000..3fe9f63d9e
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php
@@ -0,0 +1,227 @@
+type = Service::TYPE_HTTP;
+
+ // Hooks
+ $this->addAction(Init::getName(), new Init());
+
+ // Project
+ $this->addAction(DeleteProject::getName(), new DeleteProject());
+ $this->addAction(GetProject::getName(), new GetProject());
+ $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels());
+ $this->addAction(UpdateProjectProtocol::getName(), new UpdateProjectProtocol());
+ $this->addAction(UpdateProjectService::getName(), new UpdateProjectService());
+
+ // SMTP
+ $this->addAction(UpdateSMTP::getName(), new UpdateSMTP());
+ $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest());
+
+ // Templates
+ $this->addAction(ListTemplates::getName(), new ListTemplates());
+ $this->addAction(GetTemplate::getName(), new GetTemplate());
+ $this->addAction(UpdateTemplate::getName(), new UpdateTemplate());
+
+ // Variables
+ $this->addAction(CreateVariable::getName(), new CreateVariable());
+ $this->addAction(ListVariables::getName(), new ListVariables());
+ $this->addAction(GetVariable::getName(), new GetVariable());
+ $this->addAction(DeleteVariable::getName(), new DeleteVariable());
+ $this->addAction(UpdateVariable::getName(), new UpdateVariable());
+
+ // Keys
+ $this->addAction(CreateKey::getName(), new CreateKey());
+ $this->addAction(CreateEphemeralKey::getName(), new CreateEphemeralKey());
+ $this->addAction(ListKeys::getName(), new ListKeys());
+ $this->addAction(GetKey::getName(), new GetKey());
+ $this->addAction(DeleteKey::getName(), new DeleteKey());
+ $this->addAction(UpdateKey::getName(), new UpdateKey());
+
+ // Platforms
+ $this->addAction(ListPlatforms::getName(), new ListPlatforms());
+ $this->addAction(GetPlatform::getName(), new GetPlatform());
+ $this->addAction(CreateWebPlatform::getName(), new CreateWebPlatform());
+ $this->addAction(CreateApplePlatform::getName(), new CreateApplePlatform());
+ $this->addAction(CreateAndroidPlatform::getName(), new CreateAndroidPlatform());
+ $this->addAction(CreateWindowsPlatform::getName(), new CreateWindowsPlatform());
+ $this->addAction(CreateLinuxPlatform::getName(), new CreateLinuxPlatform());
+ $this->addAction(UpdateWebPlatform::getName(), new UpdateWebPlatform());
+ $this->addAction(UpdateApplePlatform::getName(), new UpdateApplePlatform());
+ $this->addAction(UpdateAndroidPlatform::getName(), new UpdateAndroidPlatform());
+ $this->addAction(UpdateWindowsPlatform::getName(), new UpdateWindowsPlatform());
+ $this->addAction(UpdateLinuxPlatform::getName(), new UpdateLinuxPlatform());
+ $this->addAction(DeletePlatform::getName(), new DeletePlatform());
+
+ // Mock Phones
+ $this->addAction(CreateMockPhone::getName(), new CreateMockPhone());
+ $this->addAction(ListMockPhones::getName(), new ListMockPhones());
+ $this->addAction(GetMockPhone::getName(), new GetMockPhone());
+ $this->addAction(UpdateMockPhone::getName(), new UpdateMockPhone());
+ $this->addAction(DeleteMockPhone::getName(), new DeleteMockPhone());
+
+ // Policies
+ $this->addAction(ListPolicies::getName(), new ListPolicies());
+ $this->addAction(GetPolicy::getName(), new GetPolicy());
+ $this->addAction(UpdateMembershipPrivacyPolicy::getName(), new UpdateMembershipPrivacyPolicy());
+ $this->addAction(UpdatePasswordDictionaryPolicy::getName(), new UpdatePasswordDictionaryPolicy());
+ $this->addAction(UpdatePasswordHistoryPolicy::getName(), new UpdatePasswordHistoryPolicy());
+ $this->addAction(UpdatePasswordPersonalDataPolicy::getName(), new UpdatePasswordPersonalDataPolicy());
+ $this->addAction(UpdateSessionAlertPolicy::getName(), new UpdateSessionAlertPolicy());
+ $this->addAction(UpdateSessionDurationPolicy::getName(), new UpdateSessionDurationPolicy());
+ $this->addAction(UpdateSessionInvalidationPolicy::getName(), new UpdateSessionInvalidationPolicy());
+ $this->addAction(UpdateSessionLimitPolicy::getName(), new UpdateSessionLimitPolicy());
+ $this->addAction(UpdateUserLimitPolicy::getName(), new UpdateUserLimitPolicy());
+
+ // Auth Methods
+ $this->addAction(UpdateAuthMethod::getName(), new UpdateAuthMethod());
+
+ // OAuth2
+ $this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers());
+ $this->addAction(GetOAuth2Provider::getName(), new GetOAuth2Provider());
+ $this->addAction(UpdateOAuth2GitHub::getName(), new UpdateOAuth2GitHub());
+ $this->addAction(UpdateOAuth2Discord::getName(), new UpdateOAuth2Discord());
+ $this->addAction(UpdateOAuth2Figma::getName(), new UpdateOAuth2Figma());
+ $this->addAction(UpdateOAuth2Dropbox::getName(), new UpdateOAuth2Dropbox());
+ $this->addAction(UpdateOAuth2Dailymotion::getName(), new UpdateOAuth2Dailymotion());
+ $this->addAction(UpdateOAuth2Bitbucket::getName(), new UpdateOAuth2Bitbucket());
+ $this->addAction(UpdateOAuth2Bitly::getName(), new UpdateOAuth2Bitly());
+ $this->addAction(UpdateOAuth2Box::getName(), new UpdateOAuth2Box());
+ $this->addAction(UpdateOAuth2Autodesk::getName(), new UpdateOAuth2Autodesk());
+ $this->addAction(UpdateOAuth2Google::getName(), new UpdateOAuth2Google());
+ $this->addAction(UpdateOAuth2Zoom::getName(), new UpdateOAuth2Zoom());
+ $this->addAction(UpdateOAuth2Zoho::getName(), new UpdateOAuth2Zoho());
+ $this->addAction(UpdateOAuth2Yandex::getName(), new UpdateOAuth2Yandex());
+ $this->addAction(UpdateOAuth2X::getName(), new UpdateOAuth2X());
+ $this->addAction(UpdateOAuth2WordPress::getName(), new UpdateOAuth2WordPress());
+ $this->addAction(UpdateOAuth2Twitch::getName(), new UpdateOAuth2Twitch());
+ $this->addAction(UpdateOAuth2Stripe::getName(), new UpdateOAuth2Stripe());
+ $this->addAction(UpdateOAuth2Spotify::getName(), new UpdateOAuth2Spotify());
+ $this->addAction(UpdateOAuth2Slack::getName(), new UpdateOAuth2Slack());
+ $this->addAction(UpdateOAuth2Podio::getName(), new UpdateOAuth2Podio());
+ $this->addAction(UpdateOAuth2Notion::getName(), new UpdateOAuth2Notion());
+ $this->addAction(UpdateOAuth2Salesforce::getName(), new UpdateOAuth2Salesforce());
+ $this->addAction(UpdateOAuth2Yahoo::getName(), new UpdateOAuth2Yahoo());
+ $this->addAction(UpdateOAuth2Linkedin::getName(), new UpdateOAuth2Linkedin());
+ $this->addAction(UpdateOAuth2Disqus::getName(), new UpdateOAuth2Disqus());
+ $this->addAction(UpdateOAuth2Amazon::getName(), new UpdateOAuth2Amazon());
+ $this->addAction(UpdateOAuth2Etsy::getName(), new UpdateOAuth2Etsy());
+ $this->addAction(UpdateOAuth2Facebook::getName(), new UpdateOAuth2Facebook());
+ $this->addAction(UpdateOAuth2Tradeshift::getName(), new UpdateOAuth2Tradeshift());
+ $this->addAction(UpdateOAuth2TradeshiftSandbox::getName(), new UpdateOAuth2TradeshiftSandbox());
+ $this->addAction(UpdateOAuth2Paypal::getName(), new UpdateOAuth2Paypal());
+ $this->addAction(UpdateOAuth2PaypalSandbox::getName(), new UpdateOAuth2PaypalSandbox());
+ $this->addAction(UpdateOAuth2Gitlab::getName(), new UpdateOAuth2Gitlab());
+ $this->addAction(UpdateOAuth2Authentik::getName(), new UpdateOAuth2Authentik());
+ $this->addAction(UpdateOAuth2Auth0::getName(), new UpdateOAuth2Auth0());
+ $this->addAction(UpdateOAuth2FusionAuth::getName(), new UpdateOAuth2FusionAuth());
+ $this->addAction(UpdateOAuth2Keycloak::getName(), new UpdateOAuth2Keycloak());
+ $this->addAction(UpdateOAuth2Oidc::getName(), new UpdateOAuth2Oidc());
+ $this->addAction(UpdateOAuth2Okta::getName(), new UpdateOAuth2Okta());
+ $this->addAction(UpdateOAuth2Kick::getName(), new UpdateOAuth2Kick());
+ $this->addAction(UpdateOAuth2Apple::getName(), new UpdateOAuth2Apple());
+ $this->addAction(UpdateOAuth2Microsoft::getName(), new UpdateOAuth2Microsoft());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php
index 5329585be3..76df8c2b45 100644
--- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php
+++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php
@@ -63,7 +63,7 @@ class Delete extends Action
$key = $dbForPlatform->getDocument('devKeys', $keyId);
- if ($key === false || $key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) {
+ if ($key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::KEY_NOT_FOUND);
}
diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php
index 5cb3b0545f..ff4e348c8e 100644
--- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php
+++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php
@@ -63,7 +63,7 @@ class Get extends Action
$key = $dbForPlatform->getDocument('devKeys', $keyId);
- if ($key === false || $key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) {
+ if ($key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::KEY_NOT_FOUND);
}
diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php
index f3e47f80ba..9704740bc4 100644
--- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php
+++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php
@@ -66,7 +66,7 @@ class Update extends Action
$key = $dbForPlatform->getDocument('devKeys', $keyId);
- if ($key === false || $key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) {
+ if ($key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::KEY_NOT_FOUND);
}
diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php
index 1dd9487ec7..d2c92fc65c 100644
--- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php
+++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php
@@ -21,8 +21,6 @@ use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
-use Utopia\Database\Helpers\Permission;
-use Utopia\Database\Helpers\Role;
use Utopia\Database\Validator\UID;
use Utopia\DSN\DSN;
use Utopia\Platform\Scope\HTTP;
@@ -30,7 +28,6 @@ use Utopia\Pools\Group;
use Utopia\System\System;
use Utopia\Validator;
use Utopia\Validator\Text;
-use Utopia\Validator\URL;
use Utopia\Validator\WhiteList;
class Create extends Action
@@ -74,15 +71,6 @@ class Create extends Action
->param('name', null, new Text(128), 'Project name. Max length: 128 chars.')
->param('teamId', '', new UID(), 'Team unique ID.')
->param('region', System::getEnv('_APP_REGION', 'default'), new WhiteList(array_keys(array_filter(Config::getParam('regions'), fn ($config) => !$config['disabled']))), 'Project Region.', true)
- ->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true)
- ->param('logo', '', new Text(1024), 'Project logo.', true)
- ->param('url', '', new URL(), 'Project URL.', true)
- ->param('legalName', '', new Text(256), 'Project legal Name. Max length: 256 chars.', true)
- ->param('legalCountry', '', new Text(256), 'Project legal Country. Max length: 256 chars.', true)
- ->param('legalState', '', new Text(256), 'Project legal State. Max length: 256 chars.', true)
- ->param('legalCity', '', new Text(256), 'Project legal City. Max length: 256 chars.', true)
- ->param('legalAddress', '', new Text(256), 'Project legal Address. Max length: 256 chars.', true)
- ->param('legalTaxId', '', new Text(256), 'Project legal Tax ID. Max length: 256 chars.', true)
->inject('request')
->inject('response')
->inject('dbForPlatform')
@@ -92,7 +80,7 @@ class Create extends Action
->callback($this->action(...));
}
- public function action(string $projectId, string $name, string $teamId, string $region, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks)
+ public function action(string $projectId, string $name, string $teamId, string $region, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks)
{
$team = $dbForPlatform->getDocument('teams', $teamId);
@@ -109,16 +97,21 @@ class Create extends Action
$auth = Config::getParam('auth', []);
$auths = [
'limit' => 0,
- 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT,
+ 'maxSessions' => 0,
'passwordHistory' => 0,
'passwordDictionary' => false,
'duration' => TOKEN_EXPIRATION_LOGIN_LONG,
'personalDataCheck' => false,
+ 'disposableEmails' => false,
+ 'canonicalEmails' => false,
+ 'freeEmails' => false,
'mockNumbers' => [],
'sessionAlerts' => false,
'membershipsUserName' => false,
'membershipsUserEmail' => false,
'membershipsMfa' => false,
+ 'membershipsUserId' => false,
+ 'membershipsUserPhone' => false,
'invalidateSessions' => true
];
@@ -172,16 +165,7 @@ class Create extends Action
'teamInternalId' => $team->getSequence(),
'teamId' => $team->getId(),
'region' => $region,
- 'description' => $description,
- 'logo' => $logo,
- 'url' => $url,
'version' => APP_VERSION_STABLE,
- 'legalName' => $legalName,
- 'legalCountry' => $legalCountry,
- 'legalState' => $legalState,
- 'legalCity' => $legalCity,
- 'legalAddress' => $legalAddress,
- 'legalTaxId' => ID::custom($legalTaxId),
'services' => new \stdClass(),
'platforms' => null,
'oAuthProviders' => [],
@@ -206,28 +190,16 @@ class Create extends Action
}
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
- $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', ''));
$projectTables = !\in_array($dsn->getHost(), $sharedTables);
- $sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1);
- $sharedTablesV2 = !$projectTables && !$sharedTablesV1;
- $sharedTables = $sharedTablesV1 || $sharedTablesV2;
- if (!$sharedTablesV2) {
+ if ($projectTables) {
$adapter = new DatabasePool($pools->get($dsn->getHost()));
$dbForProject = new Database($adapter, $cache);
- $dbForProject->setDatabase(APP_DATABASE);
-
- if ($sharedTables) {
- $dbForProject
- ->setSharedTables(true)
- ->setTenant($sharedTablesV1 ? (int)$project->getSequence() : null)
- ->setNamespace($dsn->getParam('namespace'));
- } else {
- $dbForProject
- ->setSharedTables(false)
- ->setTenant(null)
- ->setNamespace('_' . $project->getSequence());
- }
+ $dbForProject
+ ->setDatabase(APP_DATABASE)
+ ->setSharedTables(false)
+ ->setTenant(null)
+ ->setNamespace('_' . $project->getSequence());
$create = true;
@@ -237,27 +209,11 @@ class Create extends Action
$create = false;
}
- if ($create || $projectTables) {
- $adapter = new AdapterDatabase($dbForProject);
- $audit = new Audit($adapter);
- $audit->setup();
- }
+ $adapter = new AdapterDatabase($dbForProject);
+ $audit = new Audit($adapter);
+ $audit->setup();
- if (!$create && $sharedTablesV1) {
- $adapter = new AdapterDatabase($dbForProject);
- $attributes = $adapter->getAttributeDocuments();
- $indexes = $adapter->getIndexDocuments();
- $dbForProject->createDocument(Database::METADATA, new Document([
- '$id' => ID::custom('audit'),
- '$permissions' => [Permission::create(Role::any())],
- 'name' => 'audit',
- 'attributes' => $attributes,
- 'indexes' => $indexes,
- 'documentSecurity' => true
- ]));
- }
-
- if ($create || $sharedTablesV1) {
+ if ($create) {
/** @var array $collections */
$collections = Config::getParam('collections', [])['projects'] ?? [];
@@ -272,14 +228,7 @@ 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
- ]));
+ // Collection already exists
}
}
}
diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php
index 8e420e87f2..0d2a951388 100644
--- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php
+++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php
@@ -109,7 +109,7 @@ class XList extends Action
}
try {
- $selectQueries = Query::groupByType($queries)['selections'] ?? [];
+ $selectQueries = Query::groupByType($queries)['selections'];
$filterQueries = Query::groupByType($queries)['filters'];
$projects = $this->find($dbForPlatform, $queries, $selectQueries);
diff --git a/src/Appwrite/Platform/Modules/Projects/Services/Http.php b/src/Appwrite/Platform/Modules/Projects/Services/Http.php
index 8b0d6f87c8..8275e664d5 100644
--- a/src/Appwrite/Platform/Modules/Projects/Services/Http.php
+++ b/src/Appwrite/Platform/Modules/Projects/Services/Http.php
@@ -8,7 +8,6 @@ use Appwrite\Platform\Modules\Projects\Http\DevKeys\Get as GetDevKey;
use Appwrite\Platform\Modules\Projects\Http\DevKeys\Update as UpdateDevKey;
use Appwrite\Platform\Modules\Projects\Http\DevKeys\XList as ListDevKeys;
use Appwrite\Platform\Modules\Projects\Http\Projects\Create as CreateProject;
-use Appwrite\Platform\Modules\Projects\Http\Projects\Labels\Update as UpdateProjectLabels;
use Appwrite\Platform\Modules\Projects\Http\Projects\Team\Update as UpdateProjectTeam;
use Appwrite\Platform\Modules\Projects\Http\Projects\Update as UpdateProject;
use Appwrite\Platform\Modules\Projects\Http\Projects\XList as ListProjects;
@@ -31,7 +30,6 @@ class Http extends Service
$this->addAction(CreateProject::getName(), new CreateProject());
$this->addAction(UpdateProject::getName(), new UpdateProject());
$this->addAction(ListProjects::getName(), new ListProjects());
- $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels());
$this->addAction(UpdateProjectTeam::getName(), new UpdateProjectTeam());
$this->addAction(CreateSchedule::getName(), new CreateSchedule());
diff --git a/src/Appwrite/Platform/Modules/Proxy/Action.php b/src/Appwrite/Platform/Modules/Proxy/Action.php
index 30ad140530..8baf54c790 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Action.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Action.php
@@ -164,9 +164,7 @@ class Action extends PlatformAction
$validator = new AnyOf($cnameValidators);
$validators[] = $validator;
- if (\is_null($mainValidator)) {
- $mainValidator = $validator;
- }
+ $mainValidator = $validator;
}
// Ensure at least one of CNAME/A/AAAA record points to our servers properly
diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php
index bfa62ef920..6f2e40d13f 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Proxy\Http\Rules\API;
-use Appwrite\Event\Certificate;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Certificate;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Proxy\Action;
use Appwrite\SDK\AuthType;
@@ -14,6 +14,7 @@ use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
+use Utopia\Database\Validator\Authorization;
use Utopia\Logger\Log;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
@@ -43,12 +44,14 @@ class Create extends Action
->label('audits.resource', 'rule/{response.$id}')
->label('sdk', new Method(
namespace: 'proxy',
- group: null,
+ group: 'rules',
name: 'createAPIRule',
description: <<param('domain', null, new ValidatorDomain(), 'Domain name.')
->inject('response')
->inject('project')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->inject('queueForEvents')
->inject('dbForPlatform')
->inject('platform')
->inject('log')
+ ->inject('authorization')
->callback($this->action(...));
}
- public function action(string $domain, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, array $platform, Log $log)
- {
+ public function action(
+ string $domain,
+ Response $response,
+ Document $project,
+ Certificate $publisherForCertificates,
+ Event $queueForEvents,
+ Database $dbForPlatform,
+ array $platform,
+ Log $log,
+ Authorization $authorization,
+ ) {
$this->validateDomainRestrictions($domain, $platform);
// TODO: (@Meldiron) Remove after 1.7.x migration
@@ -108,23 +121,31 @@ class Create extends Action
}
try {
- $rule = $dbForPlatform->createDocument('rules', $rule);
+ $rule = $authorization->skip(fn () => $dbForPlatform->createDocument('rules', $rule));
} catch (Duplicate $e) {
throw new Exception(Exception::RULE_ALREADY_EXISTS);
}
if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) {
- $queueForCertificates
- ->setDomain(new Document([
+ $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: $project,
+ domain: new Document([
'domain' => $rule->getAttribute('domain'),
'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')),
- ]))
- ->setAction(Certificate::ACTION_GENERATION)
- ->trigger();
+ ]),
+ action: \Appwrite\Event\Certificate::ACTION_GENERATION,
+ ));
}
$queueForEvents->setParam('ruleId', $rule->getId());
+ // Rename 'created' status to 'unverified' for consistency.
+ // 'verifying' and 'verified' statuses stay as is.
+ // 'unverified' in the meaning of failed certificate generation stays as is.
+ if ($rule->getAttribute('status') === 'created') {
+ $rule->setAttribute('status', 'unverified');
+ }
+
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($rule, Response::MODEL_PROXY_RULE);
diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Delete.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Delete.php
index 1d5b770496..29751ff20a 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Delete.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Delete.php
@@ -12,6 +12,7 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
+use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
@@ -38,12 +39,12 @@ class Delete extends Action
->label('audits.resource', 'rule/{request.ruleId}')
->label('sdk', new Method(
namespace: 'proxy',
- group: null,
+ group: 'rules',
name: 'deleteRule',
description: <<inject('dbForPlatform')
->inject('queueForDeletes')
->inject('queueForEvents')
+ ->inject('authorization')
->callback($this->action(...));
}
@@ -67,15 +69,16 @@ class Delete extends Action
Document $project,
Database $dbForPlatform,
DeleteEvent $queueForDeletes,
- Event $queueForEvents
+ Event $queueForEvents,
+ Authorization $authorization,
) {
- $rule = $dbForPlatform->getDocument('rules', $ruleId);
+ $rule = $authorization->skip(fn () => $dbForPlatform->getDocument('rules', $ruleId));
if ($rule->isEmpty() || $rule->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::RULE_NOT_FOUND);
}
- $dbForPlatform->deleteDocument('rules', $rule->getId());
+ $authorization->skip(fn () => $dbForPlatform->deleteDocument('rules', $rule->getId()));
$queueForDeletes
->setType(DELETE_TYPE_DOCUMENT)
diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php
index a61ce80c4b..c68574fefe 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Function;
-use Appwrite\Event\Certificate;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Certificate;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Proxy\Action;
use Appwrite\SDK\AuthType;
@@ -14,6 +14,7 @@ use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
+use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Logger\Log;
use Utopia\Platform\Scope\HTTP;
@@ -45,12 +46,14 @@ class Create extends Action
->label('audits.resource', 'rule/{response.$id}')
->label('sdk', new Method(
namespace: 'proxy',
- group: null,
+ group: 'rules',
name: 'createFunctionRule',
description: <<param('branch', '', new Text(255, 0), 'Name of VCS branch to deploy changes automatically', true)
->inject('response')
->inject('project')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->inject('queueForEvents')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('platform')
->inject('log')
+ ->inject('authorization')
->callback($this->action(...));
}
- public function action(string $domain, string $functionId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log)
- {
+ public function action(
+ string $domain,
+ string $functionId,
+ string $branch,
+ Response $response,
+ Document $project,
+ Certificate $publisherForCertificates,
+ Event $queueForEvents,
+ Database $dbForPlatform,
+ Database $dbForProject,
+ array $platform,
+ Log $log,
+ Authorization $authorization,
+ ) {
+
$this->validateDomainRestrictions($domain, $platform);
$function = $dbForProject->getDocument('functions', $functionId);
@@ -126,23 +143,31 @@ class Create extends Action
}
try {
- $rule = $dbForPlatform->createDocument('rules', $rule);
+ $rule = $authorization->skip(fn () => $dbForPlatform->createDocument('rules', $rule));
} catch (Duplicate $e) {
throw new Exception(Exception::RULE_ALREADY_EXISTS);
}
if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) {
- $queueForCertificates
- ->setDomain(new Document([
+ $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: $project,
+ domain: new Document([
'domain' => $rule->getAttribute('domain'),
'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')),
- ]))
- ->setAction(Certificate::ACTION_GENERATION)
- ->trigger();
+ ]),
+ action: \Appwrite\Event\Certificate::ACTION_GENERATION,
+ ));
}
$queueForEvents->setParam('ruleId', $rule->getId());
+ // Rename 'created' status to 'unverified' for consistency.
+ // 'verifying' and 'verified' statuses stay as is.
+ // 'unverified' in the meaning of failed certificate generation stays as is.
+ if ($rule->getAttribute('status') === 'created') {
+ $rule->setAttribute('status', 'unverified');
+ }
+
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($rule, Response::MODEL_PROXY_RULE);
diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Get.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Get.php
index b88a4ffc06..103ab1fddc 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Get.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Get.php
@@ -10,6 +10,7 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
+use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
@@ -34,12 +35,12 @@ class Get extends Action
->label('scope', 'rules.read')
->label('sdk', new Method(
namespace: 'proxy',
- group: null,
+ group: 'rules',
name: 'getRule',
description: <<inject('response')
->inject('project')
->inject('dbForPlatform')
+ ->inject('authorization')
->callback($this->action(...));
}
@@ -58,15 +60,16 @@ class Get extends Action
string $ruleId,
Response $response,
Document $project,
- Database $dbForPlatform
+ Database $dbForPlatform,
+ Authorization $authorization,
) {
- $rule = $dbForPlatform->getDocument('rules', $ruleId);
+ $rule = $authorization->skip(fn () => $dbForPlatform->getDocument('rules', $ruleId));
if ($rule->isEmpty() || $rule->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::RULE_NOT_FOUND);
}
- $certificate = $dbForPlatform->getDocument('certificates', $rule->getAttribute('certificateId', ''));
+ $certificate = $authorization->skip(fn () => $dbForPlatform->getDocument('certificates', $rule->getAttribute('certificateId', '')));
// Give priority to certificate generation logs if present
if (!empty($certificate->getAttribute('logs', ''))) {
@@ -75,6 +78,13 @@ class Get extends Action
$rule->setAttribute('renewAt', $certificate->getAttribute('renewDate', ''));
+ // Rename 'created' status to 'unverified' for consistency.
+ // 'verifying' and 'verified' statuses stay as is.
+ // 'unverified' in the meaning of failed certificate generation stays as is.
+ if ($rule->getAttribute('status') === 'created') {
+ $rule->setAttribute('status', 'unverified');
+ }
+
$response->dynamic($rule, Response::MODEL_PROXY_RULE);
}
}
diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php
index 95c29f48e8..f55405bb48 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Redirect;
-use Appwrite\Event\Certificate;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Certificate;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Proxy\Action;
use Appwrite\SDK\AuthType;
@@ -14,6 +14,7 @@ use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
+use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Logger\Log;
use Utopia\Platform\Scope\HTTP;
@@ -46,12 +47,14 @@ class Create extends Action
->label('audits.resource', 'rule/{response.$id}')
->label('sdk', new Method(
namespace: 'proxy',
- group: null,
+ group: 'rules',
name: 'createRedirectRule',
description: <<param('resourceType', '', new WhiteList(['site', 'function']), 'Type of parent resource.')
->inject('response')
->inject('project')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->inject('queueForEvents')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('platform')
->inject('log')
+ ->inject('authorization')
->callback($this->action(...));
}
- public function action(string $domain, string $url, int $statusCode, string $resourceId, string $resourceType, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log)
- {
+ public function action(
+ string $domain,
+ string $url,
+ int $statusCode,
+ string $resourceId,
+ string $resourceType,
+ Response $response,
+ Document $project,
+ Certificate $publisherForCertificates,
+ Event $queueForEvents,
+ Database $dbForPlatform,
+ Database $dbForProject,
+ array $platform,
+ Log $log,
+ Authorization $authorization,
+ ) {
+
$this->validateDomainRestrictions($domain, $platform);
$collection = match ($resourceType) {
'site' => 'sites',
- 'function' => 'functions'
+ 'function' => 'functions',
+ default => throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid resource type: ' . $resourceType),
};
$resource = $dbForProject->getDocument($collection, $resourceId);
if ($resource->isEmpty()) {
@@ -130,23 +150,31 @@ class Create extends Action
}
try {
- $rule = $dbForPlatform->createDocument('rules', $rule);
+ $rule = $authorization->skip(fn () => $dbForPlatform->createDocument('rules', $rule));
} catch (Duplicate $e) {
throw new Exception(Exception::RULE_ALREADY_EXISTS);
}
if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) {
- $queueForCertificates
- ->setDomain(new Document([
+ $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: $project,
+ domain: new Document([
'domain' => $rule->getAttribute('domain'),
'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')),
- ]))
- ->setAction(Certificate::ACTION_GENERATION)
- ->trigger();
+ ]),
+ action: \Appwrite\Event\Certificate::ACTION_GENERATION,
+ ));
}
$queueForEvents->setParam('ruleId', $rule->getId());
+ // Rename 'created' status to 'unverified' for consistency.
+ // 'verifying' and 'verified' statuses stay as is.
+ // 'unverified' in the meaning of failed certificate generation stays as is.
+ if ($rule->getAttribute('status') === 'created') {
+ $rule->setAttribute('status', 'unverified');
+ }
+
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($rule, Response::MODEL_PROXY_RULE);
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..7da9a11636 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Site;
-use Appwrite\Event\Certificate;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Certificate;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Proxy\Action;
use Appwrite\SDK\AuthType;
@@ -14,6 +14,7 @@ use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
+use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Logger\Log;
use Utopia\Platform\Scope\HTTP;
@@ -45,12 +46,14 @@ class Create extends Action
->label('audits.resource', 'rule/{response.$id}')
->label('sdk', new Method(
namespace: 'proxy',
- group: null,
+ group: 'rules',
name: 'createSiteRule',
description: <<param('branch', '', new Text(255, 0), 'Name of VCS branch to deploy changes automatically', true)
->inject('response')
->inject('project')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->inject('queueForEvents')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('platform')
->inject('log')
+ ->inject('authorization')
->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 $publisherForCertificates,
+ Event $queueForEvents,
+ Database $dbForPlatform,
+ Database $dbForProject,
+ array $platform,
+ Log $log,
+ Authorization $authorization,
+ ) {
+
$this->validateDomainRestrictions($domain, $platform);
$site = $dbForProject->getDocument('sites', $siteId);
@@ -126,23 +143,31 @@ class Create extends Action
}
try {
- $rule = $dbForPlatform->createDocument('rules', $rule);
+ $rule = $authorization->skip(fn () => $dbForPlatform->createDocument('rules', $rule));
} catch (Duplicate $e) {
throw new Exception(Exception::RULE_ALREADY_EXISTS);
}
if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) {
- $queueForCertificates
- ->setDomain(new Document([
+ $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: $project,
+ domain: new Document([
'domain' => $rule->getAttribute('domain'),
'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')),
- ]))
- ->setAction(Certificate::ACTION_GENERATION)
- ->trigger();
+ ]),
+ action: \Appwrite\Event\Certificate::ACTION_GENERATION,
+ ));
}
$queueForEvents->setParam('ruleId', $rule->getId());
+ // Rename 'created' status to 'unverified' for consistency.
+ // 'verifying' and 'verified' statuses stay as is.
+ // 'unverified' in the meaning of failed certificate generation stays as is.
+ if ($rule->getAttribute('status') === 'created') {
+ $rule->setAttribute('status', 'unverified');
+ }
+
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($rule, Response::MODEL_PROXY_RULE);
diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Status/Update.php
similarity index 66%
rename from src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php
rename to src/Appwrite/Platform/Modules/Proxy/Http/Rules/Status/Update.php
index fb83cb34c5..1ad6f730b3 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Status/Update.php
@@ -1,9 +1,9 @@
setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
- ->setHttpPath('/v1/proxy/rules/:ruleId/verification')
- ->desc('Update rule verification status')
+ ->setHttpPath('/v1/proxy/rules/:ruleId/status')
+ ->httpAlias('/v1/proxy/rules/:ruleId/verification')
+ ->desc('Update rule status')
->groups(['api', 'proxy'])
->label('scope', 'rules.write')
->label('event', 'rules.[ruleId].update')
@@ -41,12 +43,12 @@ class Update extends Action
->label('audits.resource', 'rule/{response.$id}')
->label('sdk', new Method(
namespace: 'proxy',
- group: null,
- name: 'updateRuleVerification',
+ group: 'rules',
+ name: 'updateRuleStatus',
description: <<param('ruleId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Rule ID.', false, ['dbForProject'])
->inject('response')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->inject('queueForEvents')
->inject('project')
->inject('dbForPlatform')
->inject('log')
+ ->inject('authorization')
->callback($this->action(...));
}
public function action(
string $ruleId,
Response $response,
- Certificate $queueForCertificates,
+ Certificate $publisherForCertificates,
Event $queueForEvents,
Document $project,
Database $dbForPlatform,
- Log $log
+ Log $log,
+ Authorization $authorization,
) {
- $rule = $dbForPlatform->getDocument('rules', $ruleId);
+ $rule = $authorization->skip(fn () => $dbForPlatform->getDocument('rules', $ruleId));
if ($rule->isEmpty() || $rule->getAttribute('projectInternalId') !== $project->getSequence()) {
throw new Exception(Exception::RULE_NOT_FOUND);
@@ -83,38 +87,40 @@ class Update extends Action
// If rule is already verified or in certificate generation state, don't queue for verification again
if ($rule->getAttribute('status') === RULE_STATUS_VERIFIED || $rule->getAttribute('status') === RULE_STATUS_CERTIFICATE_GENERATING) {
- return $response->dynamic($rule, Response::MODEL_PROXY_RULE);
+ $response->dynamic($rule, Response::MODEL_PROXY_RULE);
+ return;
}
try {
$this->verifyRule($rule, $log);
// Reset logs and status for the rule
- $rule = $dbForPlatform->updateDocument('rules', $rule->getId(), new Document([
+ $rule = $authorization->skip(fn () => $dbForPlatform->updateDocument('rules', $rule->getId(), new Document([
'logs' => '',
'status' => RULE_STATUS_CERTIFICATE_GENERATING,
- ]));
+ ])));
$certificateId = $rule->getAttribute('certificateId', '');
// Reset logs for the associated certificate.
if (!empty($certificateId)) {
- $certificate = $dbForPlatform->updateDocument('certificates', $certificateId, new Document([
+ $certificate = $authorization->skip(fn () => $dbForPlatform->updateDocument('certificates', $certificateId, new Document([
'logs' => '',
- ]));
+ ])));
}
} catch (Exception $err) {
- $dbForPlatform->updateDocument('rules', $rule->getId(), new Document([
+ $authorization->skip(fn () => $dbForPlatform->updateDocument('rules', $rule->getId(), new Document([
'$updatedAt' => DateTime::now(),
- ]));
+ ])));
throw $err;
}
// Issue a TLS certificate when DNS verification is successful
- $queueForCertificates
- ->setDomain(new Document([
+ $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: $project,
+ domain: new Document([
'domain' => $rule->getAttribute('domain'),
'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')),
- ]))
- ->trigger();
+ ]),
+ ));
if (!empty($certificate)) {
$rule->setAttribute('renewAt', $certificate->getAttribute('renewDate', ''));
diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/XList.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/XList.php
index 19daf8c8d2..999b4c8d74 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/XList.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/XList.php
@@ -13,6 +13,7 @@ use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
+use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
@@ -39,12 +40,12 @@ class XList extends Action
->label('scope', 'rules.read')
->label('sdk', new Method(
namespace: 'proxy',
- group: null,
+ group: 'rules',
name: 'listRules',
description: <<param('queries', [], new Rules(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Rules::ALLOWED_ATTRIBUTES), true)
- ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
+ ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true, deprecated: true)
->inject('response')
->inject('project')
->inject('dbForPlatform')
+ ->inject('authorization')
->callback($this->action(...));
}
public function action(
array $queries,
+ bool $total,
string $search,
- bool $includeTotal,
Response $response,
Document $project,
- Database $dbForPlatform
+ Database $dbForPlatform,
+ Authorization $authorization,
) {
try {
$queries = Query::parseQueries($queries);
@@ -91,7 +94,7 @@ class XList extends Action
}
$ruleId = $cursor->getValue();
- $cursorDocument = $dbForPlatform->getDocument('rules', $ruleId);
+ $cursorDocument = $authorization->skip(fn () => $dbForPlatform->getDocument('rules', $ruleId));
if ($cursorDocument->isEmpty()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Rule '{$ruleId}' for the 'cursor' value not found.");
@@ -102,9 +105,9 @@ class XList extends Action
$filterQueries = Query::groupByType($queries)['filters'];
- $rules = $dbForPlatform->find('rules', $queries);
+ $rules = $authorization->skip(fn () => $dbForPlatform->find('rules', $queries));
foreach ($rules as $rule) {
- $certificate = $dbForPlatform->getDocument('certificates', $rule->getAttribute('certificateId', ''));
+ $certificate = $authorization->skip(fn () => $dbForPlatform->getDocument('certificates', $rule->getAttribute('certificateId', '')));
// Give priority to certificate generation logs if present
if (!empty($certificate->getAttribute('logs', ''))) {
@@ -112,11 +115,18 @@ class XList extends Action
}
$rule->setAttribute('renewAt', $certificate->getAttribute('renewDate', ''));
+
+ // Rename 'created' status to 'unverified' for consistency.
+ // 'verifying' and 'verified' statuses stay as is.
+ // 'unverified' in the meaning of failed certificate generation stays as is.
+ if ($rule->getAttribute('status') === 'created') {
+ $rule->setAttribute('status', 'unverified');
+ }
}
$response->dynamic(new Document([
'rules' => $rules,
- 'total' => $includeTotal ? $dbForPlatform->count('rules', $filterQueries, APP_LIMIT_COUNT) : 0,
+ 'total' => $total ? $authorization->skip(fn () => $dbForPlatform->count('rules', $filterQueries, APP_LIMIT_COUNT)) : 0,
]), Response::MODEL_PROXY_RULE_LIST);
}
}
diff --git a/src/Appwrite/Platform/Modules/Proxy/Services/Http.php b/src/Appwrite/Platform/Modules/Proxy/Services/Http.php
index 980c64cc54..b2a9de1933 100644
--- a/src/Appwrite/Platform/Modules/Proxy/Services/Http.php
+++ b/src/Appwrite/Platform/Modules/Proxy/Services/Http.php
@@ -8,7 +8,7 @@ use Appwrite\Platform\Modules\Proxy\Http\Rules\Function\Create as CreateFunction
use Appwrite\Platform\Modules\Proxy\Http\Rules\Get as GetRule;
use Appwrite\Platform\Modules\Proxy\Http\Rules\Redirect\Create as CreateRedirectRule;
use Appwrite\Platform\Modules\Proxy\Http\Rules\Site\Create as CreateSiteRule;
-use Appwrite\Platform\Modules\Proxy\Http\Rules\Verification\Update as UpdateRuleVerification;
+use Appwrite\Platform\Modules\Proxy\Http\Rules\Status\Update as UpdateRuleStatus;
use Appwrite\Platform\Modules\Proxy\Http\Rules\XList as ListRules;
use Utopia\Platform\Service;
@@ -26,6 +26,6 @@ class Http extends Service
$this->addAction(GetRule::getName(), new GetRule());
$this->addAction(ListRules::getName(), new ListRules());
$this->addAction(DeleteRule::getName(), new DeleteRule());
- $this->addAction(UpdateRuleVerification::getName(), new UpdateRuleVerification());
+ $this->addAction(UpdateRuleStatus::getName(), new UpdateRuleStatus());
}
}
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php
index 8a6964209f..63ed776709 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Sites\Http\Deployments;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -85,7 +86,7 @@ class Create extends Action
->inject('queueForEvents')
->inject('deviceForSites')
->inject('deviceForLocal')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('plan')
->inject('authorization')
->inject('platform')
@@ -107,7 +108,7 @@ class Create extends Action
Event $queueForEvents,
Device $deviceForSites,
Device $deviceForLocal,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
array $plan,
Authorization $authorization,
array $platform,
@@ -177,15 +178,8 @@ class Create extends Action
throw new Exception(Exception::STORAGE_INVALID_CONTENT_RANGE);
}
- // TODO remove the condition that checks `$end === $fileSize` in next breaking version
- if ($end === $fileSize - 1 || $end === $fileSize) {
- //if it's a last chunks the chunk size might differ, so we set the $chunks and $chunk to notify it's last chunk
- $chunks = $chunk = -1;
- } else {
- // Calculate total number of chunks based on the chunk size i.e ($rangeEnd - $rangeStart)
- $chunks = (int) ceil($fileSize / ($end + 1 - $start));
- $chunk = (int) ($start / ($end + 1 - $start)) + 1;
- }
+ $chunks = (int) ceil($fileSize / APP_LIMIT_UPLOAD_CHUNK_SIZE);
+ $chunk = (int) ($start / APP_LIMIT_UPLOAD_CHUNK_SIZE) + 1;
}
if (!$fileSizeValidator->isValid($fileSize) && $siteSizeLimit !== 0) { // Check if file size is exceeding allowed limit
@@ -204,9 +198,14 @@ class Create extends Action
$metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)];
if (!$deployment->isEmpty()) {
$chunks = $deployment->getAttribute('sourceChunksTotal', 1);
+ $uploaded = $deployment->getAttribute('sourceChunksUploaded', 0);
$metadata = $deployment->getAttribute('sourceMetadata', []);
- if ($chunk === -1) {
- $chunk = $chunks;
+
+ if ($uploaded === $chunks) {
+ $response
+ ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
+ ->dynamic($deployment, Response::MODEL_DEPLOYMENT);
+ return;
}
}
@@ -262,6 +261,8 @@ class Create extends Action
'sourcePath' => $path,
'sourceSize' => $fileSize,
'totalSize' => $fileSize,
+ 'sourceChunksTotal' => $chunks,
+ 'sourceChunksUploaded' => $chunksUploaded,
'activate' => $activate,
'sourceMetadata' => $metadata,
'type' => $type,
@@ -309,15 +310,19 @@ class Create extends Action
} else {
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
'sourceSize' => $fileSize,
+ 'sourceChunksUploaded' => $chunksUploaded,
'sourceMetadata' => $metadata,
]));
}
// Start the build
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($site)
- ->setDeployment($deployment);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $site,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ));
} else {
if ($deployment->isEmpty()) {
$deployment = $dbForProject->createDocument('deployments', new Document([
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Download/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Download/Get.php
index 600135e152..339d059365 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Download/Get.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Download/Get.php
@@ -87,16 +87,11 @@ class Get extends Action
throw new Exception(Exception::DEPLOYMENT_NOT_FOUND);
}
- switch ($type) {
- case 'output':
- $path = $deployment->getAttribute('buildPath', '');
- $device = $deviceForBuilds;
- break;
- case 'source':
- $path = $deployment->getAttribute('sourcePath', '');
- $device = $deviceForSites;
- break;
- }
+ [$path, $device] = match ($type) {
+ 'output' => [$deployment->getAttribute('buildPath', ''), $deviceForBuilds],
+ 'source' => [$deployment->getAttribute('sourcePath', ''), $deviceForSites],
+ default => throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid deployment download type.'),
+ };
if (!$device->exists($path)) {
throw new Exception(Exception::DEPLOYMENT_NOT_FOUND);
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php
index 546549604b..b3619c6017 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Sites\Http\Deployments\Duplicate;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
@@ -63,7 +64,7 @@ class Create extends Action
->inject('dbForProject')
->inject('dbForPlatform')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('deviceForSites')
->inject('authorization')
->inject('platform')
@@ -79,7 +80,7 @@ class Create extends Action
Database $dbForProject,
Database $dbForPlatform,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
Device $deviceForSites,
Authorization $authorization,
array $platform
@@ -177,10 +178,13 @@ class Create extends Action
]))
);
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($site)
- ->setDeployment($deployment);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $site,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ));
$queueForEvents
->setParam('siteId', $site->getId())
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php
index f648c57a83..29854d473b 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Sites\Http\Deployments\Template;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -77,7 +78,7 @@ class Create extends Base
->inject('dbForPlatform')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('gitHub')
->inject('authorization')
->inject('platform')
@@ -98,7 +99,7 @@ class Create extends Base
Database $dbForPlatform,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
GitHub $github,
Authorization $authorization,
array $platform
@@ -130,7 +131,7 @@ class Create extends Base
installation: $installation,
dbForProject: $dbForProject,
dbForPlatform: $dbForPlatform,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: $activate,
@@ -223,11 +224,14 @@ class Create extends Base
$this->updateEmptyManualRule($project, $site, $deployment, $dbForPlatform, $authorization);
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($site)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $site,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
$queueForEvents
->setParam('siteId', $site->getId())
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php
index 4351dd8dd9..d34b8c4055 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Sites\Http\Deployments\Vcs;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -71,7 +71,7 @@ class Create extends Base
->inject('dbForPlatform')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('gitHub')
->inject('authorization')
->inject('platform')
@@ -89,7 +89,7 @@ class Create extends Base
Database $dbForPlatform,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
GitHub $github,
Authorization $authorization,
array $platform
@@ -111,7 +111,7 @@ class Create extends Base
installation: $installation,
dbForProject: $dbForProject,
dbForPlatform: $dbForPlatform,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: $activate,
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php
index a9198f937b..3dccd687ea 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php
@@ -116,7 +116,7 @@ class XList extends Base
$grouped = Query::groupByType($queries);
$filterQueries = $grouped['filters'];
- $selectQueries = $grouped['selections'] ?? [];
+ $selectQueries = $grouped['selections'];
try {
$results = $dbForProject->find('deployments', $queries);
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php
index 6510e505ae..2aee03265e 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Sites\Http\Sites;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\Platform\Modules\Compute\Validator\Specification;
@@ -99,10 +99,11 @@ class Update extends Base
->inject('dbForProject')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('dbForPlatform')
->inject('gitHub')
->inject('executor')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -133,10 +134,11 @@ class Update extends Base
Database $dbForProject,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
Database $dbForPlatform,
GitHub $github,
- Executor $executor
+ Executor $executor,
+ array $platform
) {
if (!empty($adapter)) {
$configFramework = Config::getParam('frameworks')[$framework] ?? [];
@@ -164,16 +166,10 @@ class Update extends Base
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When connecting to VCS (Version Control System), you need to provide "installationId" and "providerBranch".');
}
- if ($site->isEmpty()) {
- throw new Exception(Exception::SITE_NOT_FOUND);
- }
-
if (empty($framework)) {
$framework = $site->getAttribute('framework');
}
- $enabled ??= $site->getAttribute('enabled', true);
-
$repositoryId = $site->getAttribute('repositoryId', '');
$repositoryInternalId = $site->getAttribute('repositoryInternalId', '');
@@ -285,7 +281,7 @@ class Update extends Base
// Redeploy logic
if (!$isConnected && !empty($providerRepositoryId)) {
- $this->redeployVcsFunction($request, $site, $project, $installation, $dbForProject, $queueForBuilds, new Document(), $github, true);
+ $this->redeployVcsFunction($request, $site, $project, $installation, $dbForProject, $publisherForBuilds, new Document(), $github, true, $platform);
}
$queueForEvents->setParam('siteId', $site->getId());
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php
index a6768462d1..85968c7550 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php
@@ -121,6 +121,7 @@ class Get extends Base
$format = match ($days['period']) {
'1h' => 'Y-m-d\TH:00:00.000P',
'1d' => 'Y-m-d\T00:00:00.000P',
+ default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']),
};
foreach ($metrics as $metric) {
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php
index a90cb0cab9..636889f6c0 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Sites\Http\Usage;
+use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
@@ -107,6 +108,7 @@ class XList extends Base
$format = match ($days['period']) {
'1h' => 'Y-m-d\TH:00:00.000P',
'1d' => 'Y-m-d\T00:00:00.000P',
+ default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']),
};
foreach ($metrics as $metric) {
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php
index 04b30fbc9c..edd3412b8f 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php
@@ -2,11 +2,13 @@
namespace Appwrite\Platform\Modules\Sites\Http\Variables;
+use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
+use Appwrite\Utopia\Database\Validator\CustomId;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
@@ -36,6 +38,7 @@ class Create extends Base
->groups(['api', 'sites'])
->label('scope', 'sites.write')
->label('resourceType', RESOURCE_TYPE_SITES)
+ ->label('event', 'variables.[variableId].create')
->label('audits.event', 'variable.create')
->label('audits.resource', 'site/{request.siteId}')
->label('sdk', new Method(
@@ -54,16 +57,18 @@ class Create extends Base
]
))
->param('siteId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Site unique ID.', false, ['dbForProject'])
+ ->param('variableId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject'])
->param('key', null, new Text(Database::LENGTH_KEY), 'Variable key. Max length: ' . Database::LENGTH_KEY . ' chars.', false)
->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.', false)
->param('secret', true, new Boolean(), 'Secret variables can be updated or deleted, but only sites can read them during build and runtime.', true)
->inject('response')
+ ->inject('queueForEvents')
->inject('dbForProject')
->inject('project')
->callback($this->action(...));
}
- public function action(string $siteId, string $key, string $value, bool $secret, Response $response, Database $dbForProject, Document $project)
+ public function action(string $siteId, string $variableId, string $key, string $value, bool $secret, Response $response, QueueEvent $queueForEvents, Database $dbForProject, Document $project)
{
$site = $dbForProject->getDocument('sites', $siteId);
@@ -71,7 +76,7 @@ class Create extends Base
throw new Exception(Exception::SITE_NOT_FOUND);
}
- $variableId = ID::unique();
+ $variableId = ($variableId === 'unique()') ? ID::unique() : $variableId;
$teamId = $project->getAttribute('teamId', '');
$variable = new Document([
@@ -96,6 +101,8 @@ class Create extends Base
'live' => false,
]));
+ $queueForEvents->setParam('variableId', $variable->getId());
+
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($variable, Response::MODEL_VARIABLE);
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php
index 703806f1aa..74c638bddc 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Sites\Http\Variables;
+use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -33,6 +34,7 @@ class Delete extends Base
->groups(['api', 'sites'])
->label('scope', 'sites.write')
->label('resourceType', RESOURCE_TYPE_SITES)
+ ->label('event', 'variables.[variableId].delete')
->label('audits.event', 'variable.delete')
->label('audits.resource', 'site/{request.siteId}')
->label('sdk', new Method(
@@ -54,11 +56,12 @@ class Delete extends Base
->param('siteId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Site unique ID.', false, ['dbForProject'])
->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
->inject('response')
+ ->inject('queueForEvents')
->inject('dbForProject')
->callback($this->action(...));
}
- public function action(string $siteId, string $variableId, Response $response, Database $dbForProject)
+ public function action(string $siteId, string $variableId, Response $response, QueueEvent $queueForEvents, Database $dbForProject)
{
$site = $dbForProject->getDocument('sites', $siteId);
@@ -67,11 +70,7 @@ class Delete extends Base
}
$variable = $dbForProject->getDocument('variables', $variableId);
- if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') {
- throw new Exception(Exception::VARIABLE_NOT_FOUND);
- }
-
- if ($variable === false || $variable->isEmpty()) {
+ if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') {
throw new Exception(Exception::VARIABLE_NOT_FOUND);
}
@@ -81,6 +80,8 @@ class Delete extends Base
'live' => false,
]));
+ $queueForEvents->setParam('variableId', $variable->getId());
+
$response->noContent();
}
}
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php
index 54522c0ec7..2fcb051996 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php
@@ -66,7 +66,6 @@ class Get extends Base
$variable = $dbForProject->getDocument('variables', $variableId);
if (
- $variable === false ||
$variable->isEmpty() ||
$variable->getAttribute('resourceInternalId') !== $site->getSequence() ||
$variable->getAttribute('resourceType') !== 'site'
@@ -74,10 +73,6 @@ class Get extends Base
throw new Exception(Exception::VARIABLE_NOT_FOUND);
}
- if ($variable === false || $variable->isEmpty()) {
- throw new Exception(Exception::VARIABLE_NOT_FOUND);
- }
-
$response->dynamic($variable, Response::MODEL_VARIABLE);
}
}
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php
index 99f68a45df..0ed7414b9d 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Sites\Http\Variables;
+use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -35,6 +36,7 @@ class Update extends Base
->desc('Update variable')
->groups(['api', 'sites'])
->label('scope', 'sites.write')
+ ->label('event', 'variables.[variableId].update')
->label('audits.event', 'variable.update')
->label('audits.resource', 'site/{request.siteId}')
->label('resourceType', RESOURCE_TYPE_SITES)
@@ -55,10 +57,11 @@ class Update extends Base
))
->param('siteId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Site unique ID.', false, ['dbForProject'])
->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
- ->param('key', null, new Text(255), 'Variable key. Max length: 255 chars.', false)
+ ->param('key', null, new Nullable(new Text(255, 0)), 'Variable key. Max length: 255 chars.', true)
->param('value', null, new Nullable(new Text(8192, 0)), 'Variable value. Max length: 8192 chars.', true)
->param('secret', null, new Nullable(new Boolean()), 'Secret variables can be updated or deleted, but only sites can read them during build and runtime.', true)
->inject('response')
+ ->inject('queueForEvents')
->inject('dbForProject')
->callback($this->action(...));
}
@@ -66,10 +69,11 @@ class Update extends Base
public function action(
string $siteId,
string $variableId,
- string $key,
+ ?string $key,
?string $value,
?bool $secret,
Response $response,
+ QueueEvent $queueForEvents,
Database $dbForProject
) {
$site = $dbForProject->getDocument('sites', $siteId);
@@ -79,7 +83,7 @@ class Update extends Base
}
$variable = $dbForProject->getDocument('variables', $variableId);
- if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') {
+ if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') {
throw new Exception(Exception::VARIABLE_NOT_FOUND);
}
@@ -87,19 +91,27 @@ class Update extends Base
throw new Exception(Exception::VARIABLE_CANNOT_UNSET_SECRET);
}
- $variable
- ->setAttribute('key', $key)
- ->setAttribute('value', $value ?? $variable->getAttribute('value'))
- ->setAttribute('secret', $secret ?? $variable->getAttribute('secret'))
- ->setAttribute('search', implode(' ', [$variableId, $site->getId(), $key, 'site']));
+ if (\is_null($key) && \is_null($value) && \is_null($secret)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID);
+ }
+
+ $updates = new Document();
+
+ if (!\is_null($key)) {
+ $updates->setAttribute('key', $key);
+ $updates->setAttribute('search', implode(' ', [$variableId, $site->getId(), $key, 'site']));
+ }
+
+ if (!\is_null($value)) {
+ $updates->setAttribute('value', $value);
+ }
+
+ if (!\is_null($secret)) {
+ $updates->setAttribute('secret', $secret);
+ }
try {
- $dbForProject->updateDocument('variables', $variable->getId(), new Document([
- 'key' => $variable->getAttribute('key'),
- 'value' => $variable->getAttribute('value'),
- 'secret' => $variable->getAttribute('secret'),
- 'search' => $variable->getAttribute('search'),
- ]));
+ $variable = $dbForProject->updateDocument('variables', $variable->getId(), $updates);
} catch (DuplicateException $th) {
throw new Exception(Exception::VARIABLE_ALREADY_EXISTS);
}
@@ -108,6 +120,8 @@ class Update extends Base
'live' => false,
]));
+ $queueForEvents->setParam('variableId', $variable->getId());
+
$response->dynamic($variable, Response::MODEL_VARIABLE);
}
}
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/XList.php
index 669aa8be98..1270fe4925 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/XList.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/XList.php
@@ -7,12 +7,18 @@ use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
+use Appwrite\Utopia\Database\Validator\Queries\Variables;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
+use Utopia\Database\Exception\Order as OrderException;
+use Utopia\Database\Exception\Query as QueryException;
+use Utopia\Database\Query;
+use Utopia\Database\Validator\Query\Cursor;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
+use Utopia\Validator\Boolean;
class XList extends Base
{
@@ -51,13 +57,20 @@ class XList extends Base
)
)
->param('siteId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Site unique ID.', false, ['dbForProject'])
+ ->param('queries', [], new Variables(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Variables::ALLOWED_ATTRIBUTES), true)
+ ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
+ /**
+ * @param array $queries
+ */
public function action(
string $siteId,
+ array $queries,
+ bool $includeTotal,
Response $response,
Database $dbForProject
) {
@@ -67,9 +80,51 @@ class XList extends Base
throw new Exception(Exception::SITE_NOT_FOUND);
}
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $queries[] = Query::equal('resourceType', ['site']);
+ $queries[] = Query::equal('resourceInternalId', [$site->getSequence()]);
+ $queries[] = Query::orderAsc();
+
+ $cursor = Query::getCursorQueries($queries, false);
+ $cursor = \reset($cursor);
+
+ if ($cursor !== false) {
+ $validator = new Cursor();
+ if (!$validator->isValid($cursor)) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
+ }
+
+ $variableId = $cursor->getValue();
+ $cursorDocument = $dbForProject->findOne('variables', [
+ Query::equal('$id', [$variableId]),
+ Query::equal('resourceType', ['site']),
+ Query::equal('resourceInternalId', [$site->getSequence()]),
+ ]);
+
+ if ($cursorDocument->isEmpty()) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Variable '{$variableId}' for the 'cursor' value not found.");
+ }
+
+ $cursor->setValue($cursorDocument);
+ }
+
+ $filterQueries = Query::groupByType($queries)['filters'];
+
+ try {
+ $variables = $dbForProject->find('variables', $queries);
+ $total = $includeTotal ? $dbForProject->count('variables', $filterQueries, APP_LIMIT_COUNT) : 0;
+ } catch (OrderException $e) {
+ throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
+ }
+
$response->dynamic(new Document([
- 'variables' => $site->getAttribute('vars', []),
- 'total' => \count($site->getAttribute('vars', [])),
+ 'variables' => $variables,
+ 'total' => $total,
]), Response::MODEL_VARIABLE_LIST);
}
}
diff --git a/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php b/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php
new file mode 100644
index 0000000000..ef2ace34ff
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php
@@ -0,0 +1,20 @@
+skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
@@ -204,15 +204,8 @@ class Create extends Action
throw new Exception(Exception::STORAGE_INVALID_APPWRITE_ID);
}
- // TODO remove the condition that checks `$end === $fileSize` in next breaking version
- if ($end === $fileSize - 1 || $end === $fileSize) {
- //if it's a last chunks the chunk size might differ, so we set the $chunks and $chunk to -1 notify it's last chunk
- $chunks = $chunk = -1;
- } else {
- // Calculate total number of chunks based on the chunk size i.e ($rangeEnd - $rangeStart)
- $chunks = (int) ceil($fileSize / ($end + 1 - $start));
- $chunk = (int) ($start / ($end + 1 - $start)) + 1;
- }
+ $chunks = (int) ceil($fileSize / APP_LIMIT_UPLOAD_CHUNK_SIZE);
+ $chunk = (int) ($start / APP_LIMIT_UPLOAD_CHUNK_SIZE) + 1;
}
/**
@@ -249,18 +242,15 @@ class Create extends Action
$uploaded = $file->getAttribute('chunksUploaded', 0);
$metadata = $file->getAttribute('metadata', []);
- if ($chunk === -1) {
- $chunk = $chunks;
- }
-
if ($uploaded === $chunks) {
- throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS);
- }
- } else {
- // Guard against manually setting range header for single chunk upload
- if ($chunks === -1) {
- $chunks = 1;
- $chunk = 1;
+ if (empty($contentRange)) {
+ throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS);
+ }
+
+ $response
+ ->setStatusCode(Response::STATUS_CODE_OK)
+ ->dynamic($file, Response::MODEL_FILE);
+ return;
}
}
@@ -286,6 +276,8 @@ class Create extends Action
$mimeType = $deviceForFiles->getFileMimeType($path); // Get mime-type before compression and encryption
$fileHash = $deviceForFiles->getFileHash($path); // Get file hash before compression and encryption
$data = '';
+ $iv = '';
+ $tag = null;
// Compression
$algorithm = $bucket->getAttribute('compression', Compression::NONE);
if ($fileSize <= APP_STORAGE_READ_BUFFER && $algorithm != Compression::NONE) {
@@ -382,14 +374,11 @@ class Create extends Action
->setAttribute('chunksUploaded', $chunksUploaded);
/**
- * Validate create permission and skip authorization in updateDocument
- * Without this, the file creation will fail when user doesn't have update permission
+ * Skip authorization in updateDocument.
+ * Without this, the file creation will fail when user doesn't have update permission.
* However as with chunk upload even if we are updating, we are essentially creating a file
- * adding it's new chunk so we validate create permission instead of update
+ * adding it's new chunk so we rely on the create-permission check performed earlier.
*/
- if (!$authorization->isValid(new Input(Database::PERMISSION_CREATE, $bucket->getCreate()))) {
- throw new Exception(Exception::USER_UNAUTHORIZED);
- }
$file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file));
}
@@ -429,15 +418,11 @@ class Create extends Action
->setAttribute('metadata', $metadata);
/**
- * Validate create permission and skip authorization in updateDocument
- * Without this, the file creation will fail when user doesn't have update permission
+ * Skip authorization in updateDocument.
+ * Without this, the file creation will fail when user doesn't have update permission.
* However as with chunk upload even if we are updating, we are essentially creating a file
- * adding it's new chunk so we validate create permission instead of update
+ * adding it's new chunk so we rely on the create-permission check performed earlier.
*/
- if (!$authorization->isValid(new Input(Database::PERMISSION_CREATE, $bucket->getCreate()))) {
- throw new Exception(Exception::USER_UNAUTHORIZED);
- }
-
try {
$file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file));
} catch (NotFoundException) {
@@ -466,8 +451,5 @@ class Create extends Action
*/
protected function afterCreateSuccess(Document $file)
{
- if (!($file instanceof Document)) {
- throw new Exception('file must be an instance of document');
- }
}
}
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php
index ca376842e2..5b44c61d18 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php
@@ -66,6 +66,7 @@ class Delete extends Action
->inject('deviceForFiles')
->inject('queueForDeletes')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
@@ -77,12 +78,13 @@ class Delete extends Action
Event $queueForEvents,
Device $deviceForFiles,
DeleteEvent $queueForDeletes,
- Authorization $authorization
+ Authorization $authorization,
+ User $user,
) {
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
@@ -108,10 +110,17 @@ class Delete extends Action
$deviceDeleted = false;
if ($file->getAttribute('chunksTotal') !== $file->getAttribute('chunksUploaded')) {
- $deviceDeleted = $deviceForFiles->abort(
- $file->getAttribute('path'),
- ($file->getAttribute('metadata', [])['uploadId'] ?? '')
- );
+ try {
+ $deviceDeleted = $deviceForFiles->abort(
+ $file->getAttribute('path'),
+ ($file->getAttribute('metadata', [])['uploadId'] ?? '')
+ );
+ } catch (\Exception $e) {
+ // If the partial upload chunks are already gone from the device
+ // (e.g. the upload never wrote anything to disk), treat it as deleted
+ // so the pending file document can still be removed from the database.
+ $deviceDeleted = true;
+ }
} else {
$deviceDeleted = $deviceForFiles->delete($file->getAttribute('path'));
}
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php
index 042ae76565..c876004319 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php
@@ -70,6 +70,7 @@ class Get extends Action
->inject('resourceToken')
->inject('deviceForFiles')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
@@ -84,12 +85,13 @@ class Get extends Action
Document $resourceToken,
Device $deviceForFiles,
Authorization $authorization,
+ User $user,
) {
/* @type Document $bucket */
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php
index caaab29efc..c9ce5796eb 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php
@@ -51,6 +51,7 @@ class Get extends Action
->inject('response')
->inject('dbForProject')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
@@ -59,12 +60,13 @@ class Get extends Action
string $fileId,
Response $response,
Database $dbForProject,
- Authorization $authorization
+ Authorization $authorization,
+ User $user
) {
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php
index 63a72fc683..cb511d5231 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php
@@ -4,6 +4,8 @@ namespace Appwrite\Platform\Modules\Storage\Http\Buckets\Files\Preview;
use Appwrite\Extend\Exception;
use Appwrite\OpenSSL\OpenSSL;
+use Appwrite\Platform\Modules\Storage\Config\CacheControl;
+use Appwrite\Platform\Modules\Storage\Config\StorageCacheControl;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
@@ -15,7 +17,6 @@ use Utopia\Compression\Algorithms\GZIP;
use Utopia\Compression\Algorithms\Zstd;
use Utopia\Compression\Compression;
use Utopia\Config\Config;
-use Utopia\Console;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
@@ -26,6 +27,7 @@ use Utopia\Http\Adapter\Swoole\Request;
use Utopia\Image\Image;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
+use Utopia\Span\Span;
use Utopia\Storage\Device;
use Utopia\System\System;
use Utopia\Validator\HexColor;
@@ -54,6 +56,7 @@ class Get extends Action
->label('cache', true)
->label('cache.resourceType', 'bucket/{request.bucketId}')
->label('cache.resource', 'file/{request.fileId}')
+ ->label('cache.params', ['width', 'height', 'gravity', 'quality', 'borderWidth', 'borderColor', 'borderRadius', 'opacity', 'rotation', 'background', 'output', 'project'])
->label('sdk', new Method(
namespace: 'storage',
group: 'files',
@@ -92,6 +95,8 @@ class Get extends Action
->inject('deviceForLocal')
->inject('project')
->inject('authorization')
+ ->inject('user')
+ ->inject('cacheControlForStorage')
->callback($this->action(...));
}
@@ -117,7 +122,9 @@ class Get extends Action
Device $deviceForFiles,
Device $deviceForLocal,
Document $project,
- Authorization $authorization
+ Authorization $authorization,
+ User $user,
+ callable $cacheControlForStorage
) {
if (!\extension_loaded('imagick')) {
@@ -127,8 +134,8 @@ class Get extends Action
/* @type Document $bucket */
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
@@ -197,7 +204,7 @@ class Get extends Action
// when file extension is not provided and the mime type is not one of our supported outputs
// we fallback to `jpg` output format
- $output = empty($type) ? (array_search($mime, $outputs) ?? 'jpg') : $type;
+ $output = empty($type) ? (array_search($mime, $outputs) ?: 'jpg') : $type;
}
$startTime = \microtime(true);
@@ -238,49 +245,88 @@ class Get extends Action
throw new Exception(Exception::STORAGE_FILE_TYPE_UNSUPPORTED, $e->getMessage());
}
- $image->crop((int) $width, (int) $height, $gravity);
+ if ($width > 0 || $height > 0 || $gravity !== Image::GRAVITY_CENTER) {
+ Span::add('storage.transform.crop.width', $width);
+ Span::add('storage.transform.crop.height', $height);
+ Span::add('storage.transform.crop.gravity', $gravity);
+ $image->crop($width, $height, $gravity);
+ }
- if (!empty($opacity) || $opacity === 0) {
+ if ($opacity !== 1.0) {
+ Span::add('storage.transform.opacity', $opacity);
$image->setOpacity($opacity);
}
if (!empty($background)) {
+ Span::add('storage.transform.background', $background);
$image->setBackground('#' . $background);
}
- if (!empty($borderWidth)) {
+ if ($borderWidth > 0) {
+ Span::add('storage.transform.border.width', $borderWidth);
+ Span::add('storage.transform.border.color', $borderColor);
$image->setBorder($borderWidth, '#' . $borderColor);
}
- if (!empty($borderRadius)) {
+ if ($borderRadius > 0) {
+ Span::add('storage.transform.borderRadius', $borderRadius);
$image->setBorderRadius($borderRadius);
}
- if (!empty($rotation)) {
+ if ($rotation !== 0) {
+ Span::add('storage.transform.rotation', $rotation);
$image->setRotation(($rotation + 360) % 360);
}
+ if ($quality !== -1) {
+ Span::add('storage.transform.quality', $quality);
+ }
+
$data = $image->output($output, $quality);
$renderingTime = \microtime(true) - $startTime - $downloadTime - $decryptionTime - $decompressionTime;
$totalTime = \microtime(true) - $startTime;
- Console::info("File preview rendered,project=" . $project->getId() . ",bucket=" . $bucketId . ",file=" . $file->getId() . ",uri=" . $request->getURI() . ",total=" . $totalTime . ",rendering=" . $renderingTime . ",decryption=" . $decryptionTime . ",decompression=" . $decompressionTime . ",download=" . $downloadTime);
+ Span::add('storage.file.id', $file->getId());
+ Span::add('storage.bucket.id', $bucketId);
+ Span::add('storage.file.size_bytes', $file->getAttribute('sizeActual'));
+ if (!empty($type)) {
+ Span::add('storage.file.extension', $type);
+ }
+ Span::add('storage.timing.download_seconds', $downloadTime);
+ Span::add('storage.timing.decryption_seconds', $decryptionTime);
+ Span::add('storage.timing.decompression_seconds', $decompressionTime);
+ Span::add('storage.timing.rendering_seconds', $renderingTime);
+ Span::add('storage.timing.total_seconds', $totalTime);
$contentType = (\array_key_exists($output, $outputs)) ? $outputs[$output] : $outputs['jpg'];
//Do not update transformedAt if it's a console user
- if (!User::isPrivileged($authorization->getRoles())) {
+ 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());
- $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $file->getAttribute('bucketInternalId'), $file->getId(), $file));
+ $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $file->getAttribute('bucketInternalId'), $file->getId(), new Document([
+ 'transformedAt' => $file->getAttribute('transformedAt'),
+ ])));
}
}
+ $maxAge = 2592000; // 30 days
+ $cacheControl = $cacheControlForStorage(new StorageCacheControl(
+ source: CacheControl::SOURCE_ACTION,
+ user: $user,
+ maxAge: $maxAge,
+ project: $project,
+ bucket: $bucket,
+ file: $file,
+ resourceToken: $resourceToken,
+ fileSecurity: $fileSecurity,
+ ));
+
$response
- ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days
+ ->addHeader('Cache-Control', $cacheControl)
->setContentType($contentType)
->file($data);
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php
index c475c53d24..5b3fd02370 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php
@@ -52,6 +52,7 @@ class Get extends Action
->inject('mode')
->inject('deviceForFiles')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
@@ -66,7 +67,8 @@ class Get extends Action
Document $project,
string $mode,
Device $deviceForFiles,
- Authorization $authorization
+ Authorization $authorization,
+ User $user
) {
$decoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0);
@@ -88,8 +90,8 @@ class Get extends Action
$disposition = $decoded['disposition'] ?? 'inline';
$dbForProject = $isInternal ? $dbForPlatform : $dbForProject;
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php
index 57856c1564..407f3766df 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php
@@ -64,6 +64,7 @@ class Update extends Action
->inject('dbForProject')
->inject('queueForEvents')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
@@ -75,12 +76,13 @@ class Update extends Action
Response $response,
Database $dbForProject,
Event $queueForEvents,
- Authorization $authorization
+ Authorization $authorization,
+ User $user
) {
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
@@ -108,7 +110,7 @@ class Update extends Action
// Users can only manage their own roles, API keys and Admin users can manage any
$roles = $authorization->getRoles();
- if (!User::isApp($roles) && !User::isPrivileged($roles) && !\is_null($permissions)) {
+ if (!$user->isApp($roles) && !$user->isPrivileged($roles) && !\is_null($permissions)) {
foreach (Database::PERMISSIONS as $type) {
foreach ($permissions as $permission) {
$permission = Permission::parse($permission);
@@ -128,7 +130,7 @@ class Update extends Action
}
if (\is_null($permissions)) {
- $permissions = $file->getPermissions() ?? [];
+ $permissions = $file->getPermissions();
}
$file->setAttribute('$permissions', $permissions);
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php
index cba6c2fa13..b2f00da6d2 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php
@@ -71,6 +71,7 @@ class Get extends Action
->inject('resourceToken')
->inject('deviceForFiles')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
@@ -84,13 +85,14 @@ class Get extends Action
string $mode,
Document $resourceToken,
Device $deviceForFiles,
- Authorization $authorization
+ Authorization $authorization,
+ User $user
) {
/* @type Document $bucket */
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php
index 6de360ae0e..945c4bfd7c 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php
@@ -63,6 +63,7 @@ class XList extends Action
->inject('dbForProject')
->inject('mode')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
@@ -74,12 +75,13 @@ class XList extends Action
Response $response,
Database $dbForProject,
string $mode,
- Authorization $authorization
+ Authorization $authorization,
+ User $user
) {
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php
index e4827a6354..406f8e4f49 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php
@@ -99,12 +99,8 @@ class Update extends Action
$permissions ??= $bucket->getPermissions();
$maximumFileSize ??= $bucket->getAttribute('maximumFileSize', (int) System::getEnv('_APP_STORAGE_LIMIT', 0));
- $allowedFileExtensions ??= $bucket->getAttribute('allowedFileExtensions', []);
- $enabled ??= $bucket->getAttribute('enabled', true);
$encryption ??= $bucket->getAttribute('encryption', true);
- $antivirus ??= $bucket->getAttribute('antivirus', true);
$compression ??= $bucket->getAttribute('compression', Compression::NONE);
- $transformations ??= $bucket->getAttribute('transformations', true);
// Map aggregate permissions into the multiple permissions they represent.
$permissions = Permission::aggregate($permissions);
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php
index 8f2cd9bbac..d8e5cd5ad2 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php
@@ -143,11 +143,12 @@ class XList extends Action
});
foreach ($stats as $stat) {
- $bucket = $bucketByStatsId[$stat->getId()];
-
- if ($bucket) {
- $bucket->setAttribute('totalSize', $stat->getAttribute('value', 0));
+ if (!isset($bucketByStatsId[$stat->getId()])) {
+ continue;
}
+
+ $bucket = $bucketByStatsId[$stat->getId()];
+ $bucket->setAttribute('totalSize', $stat->getAttribute('value', 0));
}
} catch (\Throwable) {
// Stats may not be available, default to 0
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php
index a7bda355da..10a603f5df 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php
@@ -109,6 +109,7 @@ class Get extends Action
$format = match ($days['period']) {
'1h' => 'Y-m-d\\TH:00:00.000P',
'1d' => 'Y-m-d\\T00:00:00.000P',
+ default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']),
};
foreach ($metrics as $metric) {
diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php
index 44fdd54e8c..04eac21754 100644
--- a/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php
+++ b/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Modules\Storage\Http\Usage;
+use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
@@ -92,6 +93,7 @@ class XList extends Action
$format = match ($days['period']) {
'1h' => 'Y-m-d\\TH:00:00.000P',
'1d' => 'Y-m-d\\T00:00:00.000P',
+ default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']),
};
foreach ($metrics as $metric) {
diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Logs/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Logs/XList.php
index 486807d5f9..acdb6defc7 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Logs/XList.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Logs/XList.php
@@ -103,6 +103,7 @@ class XList extends Action
'userEmail' => $log['data']['userEmail'] ?? null,
'userName' => $log['data']['userName'] ?? null,
'mode' => $log['data']['mode'] ?? null,
+ 'userType' => $log['data']['userType'] ?? null,
'ip' => $log['ip'],
'time' => $log['time'],
'osCode' => $os['osCode'],
diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
index 3bf597eaca..5500a56cbc 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
@@ -4,16 +4,17 @@ namespace Appwrite\Platform\Modules\Teams\Http\Memberships;
use Appwrite\Auth\Validator\Phone;
use Appwrite\Event\Event;
-use Appwrite\Event\Mail;
-use Appwrite\Event\Messaging;
-use Appwrite\Event\StatsUsage;
+use Appwrite\Event\Message\Mail as MailMessage;
+use Appwrite\Event\Message\Messaging as MessagingMessage;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
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 +33,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 +72,7 @@ class Create extends Action
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_MEMBERSHIP,
- )
+ ),
]
))
->label('abuse-limit', 10)
@@ -87,24 +89,27 @@ class Create extends Action
->inject('dbForProject')
->inject('authorization')
->inject('locale')
- ->inject('queueForMails')
- ->inject('queueForMessaging')
+ ->inject('publisherForMails')
+ ->inject('publisherForMessaging')
->inject('queueForEvents')
->inject('timelimit')
- ->inject('queueForStatsUsage')
+ ->inject('usage')
->inject('plan')
+ ->inject('platform')
->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, User $user, Database $dbForProject, Authorization $authorization, Locale $locale, MailPublisher $publisherForMails, MessagingPublisher $publisherForMessaging, Event $queueForEvents, callable $timelimit, Context $usage, array $plan, array $platform, Password $proofForPassword, Token $proofForToken)
{
- $isAppUser = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAppUser = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
+ $invitee = new Document();
+ $hash = '';
if (empty($url)) {
- if (!$isAppUser && !$isPrivilegedUser) {
+ if (! $isAppUser && ! $isPrivilegedUser) {
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'URL is required');
}
}
@@ -113,7 +118,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,36 +129,33 @@ 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) {
- throw new Exception(Exception::USER_ALREADY_EXISTS, 'Given phone and email doesn\'t match', 409);
- }
}
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,18 +167,45 @@ 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);
}
+ $emailMetadata = [
+ 'emailCanonical' => null,
+ 'emailIsCanonical' => null,
+ 'emailIsCorporate' => null,
+ 'emailIsDisposable' => null,
+ 'emailIsFree' => null,
+ ];
+
try {
- $userId = ID::unique();
- $hash = $proofForPassword->hash($proofForPassword->generate());
- $emailCanonical = new Email($email);
- } catch (Throwable) {
- $emailCanonical = null;
+ $parsedEmail = new Email($email);
+ $canonical = $parsedEmail->getCanonical();
+ $emailMetadata = [
+ 'emailCanonical' => $canonical,
+ 'emailIsCanonical' => $parsedEmail->get() === $canonical,
+ 'emailIsCorporate' => $parsedEmail->isCorporate(),
+ 'emailIsDisposable' => $parsedEmail->isDisposable(),
+ 'emailIsFree' => $parsedEmail->isFree(),
+ ];
+ } catch (\Throwable) {
}
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
+ }
+
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ throw new Exception(Exception::USER_EMAIL_FREE);
+ }
+
+ $hash = $proofForPassword->hash($proofForPassword->generate());
+
$userId = ID::unique();
$userDocument = new Document([
@@ -209,11 +238,11 @@ class Create extends Action
'tokens' => null,
'memberships' => null,
'search' => implode(' ', [$userId, $email, $name]),
- 'emailCanonical' => $emailCanonical?->getCanonical(),
- 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(),
- 'emailIsCorporate' => $emailCanonical?->isCorporate(),
- 'emailIsDisposable' => $emailCanonical?->isDisposable(),
- 'emailIsFree' => $emailCanonical?->isFree(),
+ 'emailCanonical' => $emailMetadata['emailCanonical'],
+ 'emailIsCanonical' => $emailMetadata['emailIsCanonical'],
+ 'emailIsCorporate' => $emailMetadata['emailIsCorporate'],
+ 'emailIsDisposable' => $emailMetadata['emailIsDisposable'],
+ 'emailIsFree' => $emailMetadata['emailIsFree'],
]);
try {
@@ -225,7 +254,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 +284,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 +321,24 @@ 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");
- $customTemplate = $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ?? [];
+ $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] ??
+ $project->getAttribute('templates', [])['email.invitation-' . $locale->fallback] ?? [];
$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,45 +346,57 @@ 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 = "";
+ $replyToEmail = '';
+ $replyToName = '';
+ $smtpConfig = [];
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'])) {
- $replyTo = $smtp['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '';
+ if (! empty($smtpReplyToEmail)) {
+ $replyToEmail = $smtpReplyToEmail;
+ }
+ if (! empty($smtp['replyToName'])) {
+ $replyToName = $smtp['replyToName'];
}
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->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'])) {
- $replyTo = $customTemplate['replyTo'];
+ // Includes backwards compatibility: fall back to legacy `replyTo` key
+ $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? '';
+ if (! empty($customReplyToEmail)) {
+ $replyToEmail = $customReplyToEmail;
+ }
+ if (! empty($customTemplate['replyToName'])) {
+ $replyToName = $customTemplate['replyToName'];
}
$body = $customTemplate['message'] ?? '';
$subject = $customTemplate['subject'] ?? $subject;
}
- $queueForMails
- ->setSmtpReplyTo($replyTo)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ];
}
$emailVariables = [
@@ -363,29 +406,27 @@ class Create extends Action
'user' => $name,
'team' => $team->getAttribute('name'),
'redirect' => $url,
- 'project' => $projectName
+ 'project' => $projectName,
];
- $queueForMails
- ->setSubject($subject)
- ->setBody($body)
- ->setPreview($preview)
- ->setRecipient($invitee->getAttribute('email'))
- ->setName($invitee->getAttribute('name', ''))
- ->appendVariables($emailVariables)
- ->trigger();
- } elseif (!empty($phone)) {
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $invitee->getAttribute('email'),
+ name: $invitee->getAttribute('name', ''),
+ subject: $subject,
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ platform: $platform,
+ ));
+ } elseif (! empty($phone)) {
if (empty(System::getEnv('_APP_SMS_PROVIDER'))) {
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
}
$message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/sms-base.tpl');
- $customTemplate = $project->getAttribute('templates', [])['sms.invitation-' . $locale->default] ?? [];
- if (!empty($customTemplate)) {
- $message = $customTemplate['message'];
- }
-
$message = $message->setParam('{{token}}', $url);
$message = $message->render();
@@ -396,35 +437,32 @@ class Create extends Action
],
]);
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_INTERNAL)
- ->setMessage($messageDoc)
- ->setRecipients([$phone])
- ->setProviderType('SMS');
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_INTERNAL,
+ project: $project,
+ message: $messageDoc,
+ recipients: [$phone],
+ providerType: 'SMS',
+ ));
$helper = PhoneNumberUtil::getInstance();
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/Teams/Http/Memberships/Delete.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php
index 3b516c2d60..d055ecb23f 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php
@@ -126,6 +126,7 @@ class Delete extends Action
if ($team->getAttribute('userInternalId') === $membership->getAttribute('userInternalId')) {
$membership = $dbForProject->findOne('memberships', [
Query::equal('teamInternalId', [$team->getSequence()]),
+ Query::equal('confirm', [true]),
]);
if (!$membership->isEmpty()) {
diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php
index 9bfbd8528e..ef8d130855 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php
@@ -52,10 +52,11 @@ class Get extends Action
->inject('project')
->inject('dbForProject')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
- public function action(string $teamId, string $membershipId, Response $response, Document $project, Database $dbForProject, Authorization $authorization)
+ public function action(string $teamId, string $membershipId, Response $response, Document $project, Database $dbForProject, Authorization $authorization, User $user)
{
$team = $dbForProject->getDocument('teams', $teamId);
@@ -69,32 +70,35 @@ class Get extends Action
throw new Exception(Exception::MEMBERSHIP_NOT_FOUND);
}
+ // Default should be "false", but existing projects already rely on this being "true"
$membershipsPrivacy = [
'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true,
'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true,
'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true,
+ 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? true,
+ 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? true,
];
$roles = $authorization->getRoles();
- $isPrivilegedUser = User::isPrivileged($roles);
- $isAppUser = User::isApp($roles);
+ $isPrivilegedUser = $user->isPrivileged($roles);
+ $isAppUser = $user->isApp($roles);
$membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) {
return $privacy || $isPrivilegedUser || $isAppUser;
}, $membershipsPrivacy);
- $user = !empty(array_filter($membershipsPrivacy))
+ $memberUser = !empty(array_filter($membershipsPrivacy))
? $dbForProject->getDocument('users', $membership->getAttribute('userId'))
: new Document();
if ($membershipsPrivacy['mfa']) {
- $mfa = $user->getAttribute('mfa', false);
+ $mfa = $memberUser->getAttribute('mfa', false);
if ($mfa) {
- $totp = TOTP::getAuthenticatorFromUser($user);
+ $totp = TOTP::getAuthenticatorFromUser($memberUser);
$totpEnabled = $totp && $totp->getAttribute('verified', false);
- $emailEnabled = $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false);
- $phoneEnabled = $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false);
+ $emailEnabled = $memberUser->getAttribute('email', false) && $memberUser->getAttribute('emailVerification', false);
+ $phoneEnabled = $memberUser->getAttribute('phone', false) && $memberUser->getAttribute('phoneVerification', false);
if (!$totpEnabled && !$emailEnabled && !$phoneEnabled) {
$mfa = false;
@@ -105,11 +109,21 @@ class Get extends Action
}
if ($membershipsPrivacy['userName']) {
- $membership->setAttribute('userName', $user->getAttribute('name'));
+ $membership->setAttribute('userName', $memberUser->getAttribute('name'));
}
if ($membershipsPrivacy['userEmail']) {
- $membership->setAttribute('userEmail', $user->getAttribute('email'));
+ $membership->setAttribute('userEmail', $memberUser->getAttribute('email'));
+ }
+
+ if ($membershipsPrivacy['userId']) {
+ $membership->setAttribute('userId', $memberUser->getId());
+ } else {
+ $membership->removeAttribute('userId');
+ }
+
+ if ($membershipsPrivacy['userPhone']) {
+ $membership->setAttribute('userPhone', $memberUser->getAttribute('phone'));
}
$membership->setAttribute('teamName', $team->getAttribute('name'));
diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php
index 46b6c3cacf..28bfa769ee 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Status/Update.php
@@ -74,10 +74,12 @@ class Update extends Action
->inject('queueForEvents')
->inject('store')
->inject('proofForToken')
+ ->inject('domainVerification')
+ ->inject('cookieDomain')
->callback($this->action(...));
}
- public function action(string $teamId, string $membershipId, string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Authorization $authorization, $project, Reader $geodb, Event $queueForEvents, Store $store, Token $proofForToken)
+ public function action(string $teamId, string $membershipId, string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Authorization $authorization, $project, Reader $geodb, Event $queueForEvents, Store $store, Token $proofForToken, bool $domainVerification, ?string $cookieDomain)
{
$protocol = $request->getProtocol();
@@ -162,7 +164,7 @@ class Update extends Action
->setProperty('secret', $secret)
->encode();
- if (!Config::getParam('domainVerification')) {
+ if (!$domainVerification) {
$response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded]));
}
@@ -172,7 +174,7 @@ class Update extends Action
value: $encoded,
expire: (new \DateTime($expire))->getTimestamp(),
path: '/',
- domain: Config::getParam('cookieDomain'),
+ domain: $cookieDomain,
secure: ('https' === $protocol),
httponly: true
)
@@ -181,7 +183,7 @@ class Update extends Action
value: $encoded,
expire: (new \DateTime($expire))->getTimestamp(),
path: '/',
- domain: Config::getParam('cookieDomain'),
+ domain: $cookieDomain,
secure: ('https' === $protocol),
httponly: true,
sameSite: Config::getParam('cookieSamesite')
diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php
index a935055163..540dc8a871 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php
@@ -66,7 +66,7 @@ class Update extends Action
->callback($this->action(...));
}
- public function action(string $teamId, string $membershipId, array $roles, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Authorization $authorization, Event $queueForEvents)
+ public function action(string $teamId, string $membershipId, array $roles, Request $request, Response $response, User $user, Document $project, Database $dbForProject, Authorization $authorization, Event $queueForEvents)
{
$team = $dbForProject->getDocument('teams', $teamId);
if ($team->isEmpty()) {
@@ -83,8 +83,8 @@ class Update extends Action
throw new Exception(Exception::USER_NOT_FOUND);
}
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
- $isAppUser = User::isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
+ $isAppUser = $user->isApp($authorization->getRoles());
$isOwner = $authorization->hasRole('team:' . $team->getId() . '/owner');
if ($project->getId() === 'console') {
diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php
index ba59f48b43..7835c8051f 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php
@@ -61,10 +61,11 @@ class XList extends Action
->inject('project')
->inject('dbForProject')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
- public function action(string $teamId, array $queries, string $search, bool $includeTotal, Response $response, Document $project, Database $dbForProject, Authorization $authorization)
+ public function action(string $teamId, array $queries, string $search, bool $includeTotal, Response $response, Document $project, Database $dbForProject, Authorization $authorization, User $user)
{
$team = $dbForProject->getDocument('teams', $teamId);
@@ -122,33 +123,36 @@ class XList extends Action
$memberships = array_filter($memberships, fn (Document $membership) => !empty($membership->getAttribute('userId')));
+ // Default should be "false", but existing projects already rely on this being "true"
$membershipsPrivacy = [
'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true,
'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true,
'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true,
+ 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? true,
+ 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? true,
];
$roles = $authorization->getRoles();
- $isPrivilegedUser = User::isPrivileged($roles);
- $isAppUser = User::isApp($roles);
+ $isPrivilegedUser = $user->isPrivileged($roles);
+ $isAppUser = $user->isApp($roles);
$membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) {
return $privacy || $isPrivilegedUser || $isAppUser;
}, $membershipsPrivacy);
$memberships = array_map(function ($membership) use ($dbForProject, $team, $membershipsPrivacy) {
- $user = !empty(array_filter($membershipsPrivacy))
+ $memberUser = !empty(array_filter($membershipsPrivacy))
? $dbForProject->getDocument('users', $membership->getAttribute('userId'))
: new Document();
if ($membershipsPrivacy['mfa']) {
- $mfa = $user->getAttribute('mfa', false);
+ $mfa = $memberUser->getAttribute('mfa', false);
if ($mfa) {
- $totp = TOTP::getAuthenticatorFromUser($user);
+ $totp = TOTP::getAuthenticatorFromUser($memberUser);
$totpEnabled = $totp && $totp->getAttribute('verified', false);
- $emailEnabled = $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false);
- $phoneEnabled = $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false);
+ $emailEnabled = $memberUser->getAttribute('email', false) && $memberUser->getAttribute('emailVerification', false);
+ $phoneEnabled = $memberUser->getAttribute('phone', false) && $memberUser->getAttribute('phoneVerification', false);
if (!$totpEnabled && !$emailEnabled && !$phoneEnabled) {
$mfa = false;
@@ -159,11 +163,21 @@ class XList extends Action
}
if ($membershipsPrivacy['userName']) {
- $membership->setAttribute('userName', $user->getAttribute('name'));
+ $membership->setAttribute('userName', $memberUser->getAttribute('name'));
}
if ($membershipsPrivacy['userEmail']) {
- $membership->setAttribute('userEmail', $user->getAttribute('email'));
+ $membership->setAttribute('userEmail', $memberUser->getAttribute('email'));
+ }
+
+ if ($membershipsPrivacy['userId']) {
+ $membership->setAttribute('userId', $memberUser->getId());
+ } else {
+ $membership->removeAttribute('userId');
+ }
+
+ if ($membershipsPrivacy['userPhone']) {
+ $membership->setAttribute('userPhone', $memberUser->getAttribute('phone'));
}
$membership->setAttribute('teamName', $team->getAttribute('name'));
diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php
index ae20017e76..0d20a58b6b 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php
@@ -68,10 +68,10 @@ class Create extends Action
->callback($this->action(...));
}
- public function action(string $teamId, string $name, array $roles, Response $response, Document $user, Database $dbForProject, Authorization $authorization, Event $queueForEvents)
+ public function action(string $teamId, string $name, array $roles, Response $response, User $user, Database $dbForProject, Authorization $authorization, Event $queueForEvents)
{
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
- $isAppUser = User::isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
+ $isAppUser = $user->isApp($authorization->getRoles());
$teamId = $teamId == 'unique()' ? ID::unique() : $teamId;
diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php
index 5f1bd55788..934074d3c2 100644
--- a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php
+++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php
@@ -11,12 +11,12 @@ use Utopia\Platform\Action as UtopiaAction;
class Action extends UtopiaAction
{
- protected function getFileAndBucket(Database $dbForProject, Authorization $authorization, string $bucketId, string $fileId): array
+ protected function getFileAndBucket(Database $dbForProject, Authorization $authorization, User $user, string $bucketId, string $fileId): array
{
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
- $isAPIKey = User::isApp($authorization->getRoles());
- $isPrivilegedUser = User::isPrivileged($authorization->getRoles());
+ $isAPIKey = $user->isApp($authorization->getRoles());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php
index 10257d3603..1c2bd692ef 100644
--- a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php
+++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php
@@ -8,6 +8,7 @@ use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
+use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Response;
use Utopia\Auth\Proofs\Token;
use Utopia\Database\Database;
@@ -64,19 +65,20 @@ class Create extends Action
->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File unique ID.', false, ['dbForProject'])
->param('expire', null, new Nullable(new DatetimeValidator(requireDateInFuture: true)), 'Token expiry date', true)
->inject('response')
+ ->inject('user')
->inject('dbForProject')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $bucketId, string $fileId, ?string $expire, Response $response, Database $dbForProject, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $bucketId, string $fileId, ?string $expire, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Authorization $authorization): void
{
/**
* @var Document $bucket
* @var Document $file
*/
- ['bucket' => $bucket, 'file' => $file] = $this->getFileAndBucket($dbForProject, $authorization, $bucketId, $fileId);
+ ['bucket' => $bucket, 'file' => $file] = $this->getFileAndBucket($dbForProject, $authorization, $user, $bucketId, $fileId);
$fileSecurity = $bucket->getAttribute('fileSecurity', false);
$bucketPermission = $authorization->isValid(new Input(Database::PERMISSION_UPDATE, $bucket->getUpdate()));
diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/XList.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/XList.php
index 3d7be9bf81..5a93a06f26 100644
--- a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/XList.php
+++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/XList.php
@@ -7,6 +7,7 @@ use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
+use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Database\Validator\Queries\FileTokens;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
@@ -57,14 +58,15 @@ class XList extends Action
->param('queries', [], new FileTokens(), '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(', ', FileTokens::ALLOWED_ATTRIBUTES), true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
+ ->inject('user')
->inject('dbForProject')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $bucketId, string $fileId, array $queries, bool $includeTotal, Response $response, Database $dbForProject, Authorization $authorization)
+ public function action(string $bucketId, string $fileId, array $queries, bool $includeTotal, Response $response, User $user, Database $dbForProject, Authorization $authorization)
{
- ['bucket' => $bucket, 'file' => $file] = $this->getFileAndBucket($dbForProject, $authorization, $bucketId, $fileId);
+ ['bucket' => $bucket, 'file' => $file] = $this->getFileAndBucket($dbForProject, $authorization, $user, $bucketId, $fileId);
$queries = Query::parseQueries($queries);
$queries[] = Query::equal('resourceType', [TOKENS_RESOURCE_TYPE_FILES]);
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php
index 4a34ffd36a..a40d7fc6b9 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Authorize\External;
-use Appwrite\Event\Build;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\Platform\Modules\VCS\Http\GitHub\Deployment;
@@ -60,7 +60,7 @@ class Update extends Action
->inject('dbForPlatform')
->inject('authorization')
->inject('getProjectDB')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('platform')
->callback($this->action(...));
}
@@ -75,7 +75,7 @@ class Update extends Action
Database $dbForPlatform,
Authorization $authorization,
callable $getProjectDB,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
array $platform
) {
$installation = $dbForPlatform->getDocument('installations', $installationId);
@@ -110,10 +110,7 @@ class Update extends Action
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
try {
- $providerRepositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
- if (empty($providerRepositoryName)) {
- throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
- }
+ $providerRepositoryName = $github->getRepositoryName($providerRepositoryId);
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
@@ -133,7 +130,7 @@ class Update extends Action
$providerCommitAuthor = $commitDetails["commitAuthor"] ?? '';
$providerCommitAuthorUrl = $commitDetails["commitAuthorUrl"] ?? '';
- $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
+ $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform);
$response->noContent();
}
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php
index 914bcaa93e..c5a8d8f43f 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php
@@ -65,6 +65,7 @@ class Get extends Action
}
$state = \json_decode($state, true);
+ $redirectFailure = $state['failure'] ?? '';
$projectId = $state['projectId'] ?? '';
$project = $dbForPlatform->getDocument('projects', $projectId);
@@ -74,10 +75,11 @@ class Get extends Action
if (!empty($redirectFailure)) {
$separator = \str_contains($redirectFailure, '?') ? '&' : ':';
- return $response
+ $response
->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->addHeader('Pragma', 'no-cache')
->redirect($redirectFailure . $separator . \http_build_query(['error' => $error]));
+ return;
}
throw new Exception(Exception::PROJECT_NOT_FOUND, $error);
@@ -102,7 +104,7 @@ class Get extends Action
$privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
- $owner = $github->getOwnerName($providerInstallationId) ?? '';
+ $owner = $github->getOwnerName($providerInstallationId);
$projectInternalId = $project->getSequence();
@@ -119,11 +121,11 @@ class Get extends Action
if (!empty($code)) {
$oauth2 = new OAuth2Github(System::getEnv('_APP_VCS_GITHUB_CLIENT_ID', ''), System::getEnv('_APP_VCS_GITHUB_CLIENT_SECRET', ''), "");
- $accessToken = $oauth2->getAccessToken($code) ?? '';
- $refreshToken = $oauth2->getRefreshToken($code) ?? '';
+ $accessToken = $oauth2->getAccessToken($code);
+ $refreshToken = $oauth2->getRefreshToken($code);
$accessTokenExpiry = DateTime::addSeconds(new \DateTime(), \intval($oauth2->getAccessTokenExpiry($code)));
- $personalSlug = $oauth2->getUserSlug($accessToken) ?? '';
+ $personalSlug = $oauth2->getUserSlug($accessToken);
$personal = $personalSlug === $owner;
}
@@ -165,10 +167,11 @@ class Get extends Action
if (!empty($redirectFailure)) {
$separator = \str_contains($redirectFailure, '?') ? '&' : ':';
- return $response
+ $response
->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->addHeader('Pragma', 'no-cache')
->redirect($redirectFailure . $separator . \http_build_query(['error' => $error]));
+ return;
}
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $error);
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php
index c9904bb32b..4b48dd49b1 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\VCS\Http\GitHub;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use Appwrite\Vcs\Comment;
@@ -43,12 +44,14 @@ trait Deployment
bool $external,
Database $dbForPlatform,
Authorization $authorization,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
callable $getProjectDB,
array $platform,
) {
$errors = [];
foreach ($repositories as $repository) {
+ $logBase = 'vcs.github.event.repo.unknown';
+
try {
$repositoryId = $repository->getId();
$projectId = $repository->getAttribute('projectId');
@@ -56,9 +59,9 @@ trait Deployment
$resourceType = $repository->getAttribute('resourceType');
$logBase = "vcs.github.event.repo.{$repositoryId}";
- Span::add("{$logBase}.projectId", $projectId);
- Span::add("{$logBase}.resourceId", $resourceId);
- Span::add("{$logBase}.resourceType", $resourceType);
+ Span::add('project.id', $projectId);
+ Span::add("{$logBase}.resource.id", $resourceId);
+ Span::add("{$logBase}.resource.type", $resourceType);
if ($resourceType !== "function" && $resourceType !== "site") {
continue;
@@ -70,6 +73,8 @@ trait Deployment
throw new Exception(Exception::PROJECT_NOT_FOUND, 'Repository references non-existent project');
}
+ $this->beforeCreateGitDeployment($project, $repository, $dbForPlatform, $authorization);
+
try {
$dsn = new DSN($project->getAttribute('database'));
$databaseName = $dsn->getHost();
@@ -103,20 +108,13 @@ trait Deployment
$activate = true;
}
- $owner = $github->getOwnerName($providerInstallationId) ?? '';
+ $owner = $github->getOwnerName($providerInstallationId);
try {
- $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
- if (empty($repositoryName)) {
- throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
- }
+ $repositoryName = $github->getRepositoryName($providerRepositoryId);
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
- if (empty($repositoryName)) {
- throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
- }
-
$isAuthorized = !$external;
if (!$isAuthorized && !empty($providerPullRequestId)) {
@@ -127,7 +125,6 @@ trait Deployment
Span::add("{$logBase}.authorized", $isAuthorized);
- $commentStatus = 'waiting';
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$hostname = $platform['consoleHostname'] ?? '';
@@ -135,6 +132,34 @@ trait Deployment
$action = $isAuthorized ? ['type' => 'logs'] : ['type' => 'authorize', 'url' => $authorizeUrl];
+ $commentStatus = 'waiting';
+ $commentPreviewUrl = '';
+
+ // If this action was triggered by pull request, use most up to date details in comment
+ if (!empty($providerPullRequestId)) {
+ $existingDeployment = $authorization->skip(fn () => $dbForProject->findOne('deployments', [
+ Query::equal('resourceInternalId', [$resource->getSequence()]),
+ Query::equal('resourceType', [$resourceCollection]),
+ Query::equal('providerCommitHash', [$providerCommitHash]),
+ Query::equal('providerBranch', [$providerBranch]),
+ Query::orderDesc('$createdAt')
+ ]));
+
+ $commentStatus = $existingDeployment->getAttribute('status', 'waiting');
+
+ if ($resource->getCollection() === 'sites') {
+ $previewRule = $authorization->skip(fn () => $dbForPlatform->findOne('rules', [
+ Query::equal('projectInternalId', [$project->getSequence()]),
+ Query::equal('type', ['deployment']), // Not redirect
+ Query::equal('trigger', ['deployment']), // Preview - Not manual
+ Query::equal('deploymentResourceType', ['site']), // Not function
+ Query::equal('deploymentInternalId', [$existingDeployment->getSequence()]),
+ ]));
+
+ $commentPreviewUrl = !$previewRule->isEmpty() ? ("{$protocol}://" . $previewRule->getAttribute('domain', '')) : '';
+ }
+ }
+
$latestCommentId = '';
if (!empty($providerPullRequestId) && $resource->getAttribute('providerSilentMode', false) === false) {
@@ -173,7 +198,7 @@ trait Deployment
try {
$comment = new Comment($platform);
$comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId));
- $comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, '');
+ $comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, $commentPreviewUrl);
$latestCommentId = \strval($github->updateComment($owner, $repositoryName, $latestCommentId, $comment->generateComment()));
} finally {
@@ -182,7 +207,7 @@ trait Deployment
}
} else {
$comment = new Comment($platform);
- $comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, '');
+ $comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, $commentPreviewUrl);
$latestCommentId = \strval($github->createComment($owner, $repositoryName, $providerPullRequestId, $comment->generateComment()));
if (!empty($latestCommentId)) {
@@ -262,10 +287,7 @@ trait Deployment
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
try {
- $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
- if (empty($repositoryName)) {
- throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
- }
+ $repositoryName = $github->getRepositoryName($providerRepositoryId);
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
@@ -274,6 +296,19 @@ trait Deployment
continue;
}
+ if (!empty($providerPullRequestId)) {
+ // Update comment ID so running build can update comment
+ $authorization->skip(fn () => $dbForProject->updateDocuments('deployments', new Document([
+ 'providerCommentId' => \strval($latestCommentId)
+ ]), [
+ Query::equal('providerCommitHash', [$providerCommitHash]),
+ Query::equal('providerBranch', [$providerBranch]),
+ ]));
+
+ // Skip rest - prevent double deployments (previous one was made by push)
+ continue;
+ }
+
$commands = [];
if (!empty($resource->getAttribute('installCommand', ''))) {
$commands[] = $resource->getAttribute('installCommand', '');
@@ -431,7 +466,7 @@ trait Deployment
}
}
- if ($resource->getCollection() === 'sites' && !empty($latestCommentId) && !empty($previewRuleId)) {
+ if ($resource->getCollection() === 'sites' && !empty($latestCommentId)) {
$retries = 0;
$lockAcquired = false;
@@ -459,7 +494,7 @@ trait Deployment
$rule = $authorization->skip(fn () => $dbForPlatform->getDocument('rules', $previewRuleId));
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
- $previewUrl = !empty($rule) ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '';
+ $previewUrl = !$rule->isEmpty() ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '';
if (!empty($previewUrl)) {
$comment = new Comment($platform);
@@ -482,10 +517,7 @@ trait Deployment
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
try {
- $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
- if (empty($repositoryName)) {
- throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
- }
+ $repositoryName = $github->getRepositoryName($providerRepositoryId);
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
@@ -497,14 +529,16 @@ trait Deployment
$queueName = $this->getBuildQueueName($project, $dbForPlatform, $authorization);
- $queueForBuilds
- ->setQueue($queueName)
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($resource)
- ->setDeployment($deployment)
- ->setProject($project); // set the project because it won't be set for git deployments
-
- $queueForBuilds->trigger(); // must trigger here so that we create a build for each function/site
+ $publisherForBuilds->enqueue(
+ new BuildMessage(
+ project: $project,
+ resource: $resource,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ),
+ new \Utopia\Queue\Queue($queueName)
+ );
Span::add("{$logBase}.build.triggered", 'true');
//TODO: Add event?
@@ -514,13 +548,15 @@ trait Deployment
}
}
- $queueForBuilds->reset(); // prevent shutdown hook from triggering again
-
if (!empty($errors)) {
throw new Exception(Exception::GENERAL_UNKNOWN, \implode("\n", $errors));
}
}
+ protected function beforeCreateGitDeployment(Document $project, Document $repository, Database $dbForPlatform, Authorization $authorization): void
+ {
+ }
+
protected function getBuildQueueName(Document $project, Database $dbForPlatform, Authorization $authorization): string
{
return System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME);
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php
index c614c80041..0b81504309 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Events;
-use Appwrite\Event\Build;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\Platform\Modules\VCS\Http\GitHub\Deployment;
@@ -41,7 +41,7 @@ class Create extends Action
->inject('dbForPlatform')
->inject('authorization')
->inject('getProjectDB')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('platform')
->callback($this->action(...));
}
@@ -53,7 +53,7 @@ class Create extends Action
Database $dbForPlatform,
Authorization $authorization,
callable $getProjectDB,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
array $platform
) {
$this->preprocessEvent($request);
@@ -65,7 +65,7 @@ class Create extends Action
$signature = $request->getHeader('x-hub-signature-256', '');
$secretKey = System::getEnv('_APP_VCS_GITHUB_WEBHOOK_SECRET', '');
- $valid = empty($signature) ? true : $github->validateWebhookEvent($payload, $signature, $secretKey);
+ $valid = empty($secretKey) ? true : $github->validateWebhookEvent($payload, $signature, $secretKey);
Span::add('vcs.github.event.signature.valid', $valid);
if (!$valid) {
@@ -78,12 +78,12 @@ class Create extends Action
match ($event) {
$github::EVENT_INSTALLATION => $this->handleInstallationEvent($parsedPayload, $dbForPlatform, $authorization),
- $github::EVENT_PUSH => $this->handlePushEvent($parsedPayload, $githubAppId, $privateKey, $github, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform),
- $github::EVENT_PULL_REQUEST => $this->handlePullRequestEvent($parsedPayload, $privateKey, $githubAppId, $github, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform),
+ $github::EVENT_PUSH => $this->handlePushEvent($parsedPayload, $githubAppId, $privateKey, $github, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform),
+ $github::EVENT_PULL_REQUEST => $this->handlePullRequestEvent($parsedPayload, $privateKey, $githubAppId, $github, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform),
default => null,
};
- return $response->json($parsedPayload);
+ $response->json($parsedPayload);
}
protected function preprocessEvent(Request $request)
@@ -129,7 +129,7 @@ class Create extends Action
GitHub $github,
Database $dbForPlatform,
Authorization $authorization,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
callable $getProjectDB,
array $platform,
) {
@@ -162,9 +162,9 @@ class Create extends Action
Query::limit(100),
]));
- // Create new deployment only on push (not committed by us) and not when branch is created or deleted
- if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchCreated && !$providerBranchDeleted) {
- $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
+ // Create new deployment only on push (not committed by us) and not when branch is deleted
+ if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchDeleted) {
+ $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform);
}
}
@@ -175,7 +175,7 @@ class Create extends Action
GitHub $github,
Database $dbForPlatform,
Authorization $authorization,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
callable $getProjectDB,
array $platform,
) {
@@ -216,7 +216,7 @@ class Create extends Action
Query::orderDesc('$createdAt')
]));
- $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
+ $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform);
} elseif ($action == "closed") {
// Allowed external contributions cleanup
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php
index 7bb2dedaf5..4e7b80f5b2 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php
@@ -59,7 +59,7 @@ class Get extends Action
) {
$installation = $dbForPlatform->getDocument('installations', $installationId);
- if ($installation === false || $installation->isEmpty()) {
+ if ($installation->isEmpty()) {
throw new Exception(Exception::INSTALLATION_NOT_FOUND);
}
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php
index 4ed4241d25..fda462159f 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php
@@ -7,9 +7,12 @@ use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
+use Appwrite\Utopia\Database\Validator\Queries\Branches;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
+use Utopia\Database\Exception\Query as QueryException;
+use Utopia\Database\Query;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\Text;
@@ -49,6 +52,8 @@ class XList extends Action
))
->param('installationId', '', new Text(256), 'Installation Id')
->param('providerRepositoryId', '', new Text(256), 'Repository Id')
+ ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
+ ->param('queries', [], new Branches(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit, offset, cursorAfter, and cursorBefore', true)
->inject('gitHub')
->inject('response')
->inject('dbForPlatform')
@@ -58,10 +63,18 @@ class XList extends Action
public function action(
string $installationId,
string $providerRepositoryId,
+ string $search,
+ array $queries,
GitHub $github,
Response $response,
Database $dbForPlatform
) {
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
$installation = $dbForPlatform->getDocument('installations', $installationId);
if ($installation->isEmpty()) {
@@ -73,9 +86,9 @@ class XList extends Action
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
- $owner = $github->getOwnerName($providerInstallationId) ?? '';
+ $owner = $github->getOwnerName($providerInstallationId);
try {
- $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
+ $repositoryName = $github->getRepositoryName($providerRepositoryId);
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
@@ -83,13 +96,50 @@ class XList extends Action
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
- $branches = $github->listBranches($owner, $repositoryName) ?? [];
+ $branches = $github->listBranches($owner, $repositoryName);
+
+ if (!empty($search)) {
+ $branches = \array_values(\array_filter($branches, fn (string $branch) => \stripos($branch, $search) !== false));
+ }
+
+ $total = \count($branches);
+ [
+ 'limit' => $limit,
+ 'offset' => $offset,
+ ] = Query::groupByType($queries);
+ $cursorQuery = \current(Query::getCursorQueries($queries, false));
+
+ $limit ??= APP_LIMIT_LIST_DEFAULT;
+ $offset ??= 0;
+
+ if ($cursorQuery instanceof Query) {
+ $cursor = $cursorQuery->getValue();
+ $cursorDirection = $cursorQuery->getMethod() === Query::TYPE_CURSOR_AFTER
+ ? Database::CURSOR_AFTER
+ : Database::CURSOR_BEFORE;
+
+ $cursorIndex = \array_search($cursor, $branches, true);
+ if ($cursorIndex === false) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Branch '{$cursor}' for the 'cursor' value not found.");
+ }
+
+ $offset += $cursorDirection === Database::CURSOR_AFTER ? $cursorIndex + 1 : 0;
+
+ if ($cursorDirection === Database::CURSOR_BEFORE) {
+ $start = \max(0, $cursorIndex - $limit);
+ $branches = \array_slice($branches, $start, $cursorIndex - $start);
+ } else {
+ $branches = \array_slice($branches, $offset, $limit);
+ }
+ } else {
+ $branches = \array_slice($branches, $offset, $limit);
+ }
$response->dynamic(new Document([
'branches' => \array_map(function ($branch) {
return new Document(['name' => $branch]);
}, $branches),
- 'total' => \count($branches),
+ 'total' => $total,
]), Response::MODEL_BRANCH_LIST);
}
}
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php
index a0dcec8590..89b38e7b79 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php
@@ -79,7 +79,7 @@ class Get extends Action
$owner = $github->getOwnerName($providerInstallationId);
try {
- $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
+ $repositoryName = $github->getRepositoryName($providerRepositoryId);
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php
index 04003812f8..1918e454a4 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php
@@ -152,7 +152,7 @@ class Create extends Action
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Provider Error: ' . $repository['message']);
}
- $repository['id'] = \strval($repository['id']) ?? '';
+ $repository['id'] = \strval($repository['id']);
$repository['pushedAt'] = $repository['pushed_at'] ?? '';
$repository['organization'] = $installation->getAttribute('organization', '');
$repository['provider'] = $installation->getAttribute('provider', '');
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php
index bada6d98bb..aa7d7ae95c 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php
@@ -82,12 +82,11 @@ class Create extends Action
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
- model: Response::MODEL_DETECTION_RUNTIME,
+ model: [
+ Response::MODEL_DETECTION_RUNTIME,
+ Response::MODEL_DETECTION_FRAMEWORK,
+ ],
),
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_DETECTION_FRAMEWORK,
- )
]
))
->param('installationId', '', new Text(256), 'Installation Id')
@@ -122,7 +121,7 @@ class Create extends Action
$owner = $github->getOwnerName($providerInstallationId);
try {
- $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
+ $repositoryName = $github->getRepositoryName($providerRepositoryId);
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
@@ -308,6 +307,7 @@ class Create extends Action
];
}
+ $output->setAttribute('type', $type);
$output->setAttribute('variables', $variables);
$response->dynamic($output, $type === 'framework' ? Response::MODEL_DETECTION_FRAMEWORK : Response::MODEL_DETECTION_RUNTIME);
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php
index 9e32ca8276..ec135dc96e 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php
@@ -73,9 +73,9 @@ class Get extends Action
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
- $owner = $github->getOwnerName($providerInstallationId) ?? '';
+ $owner = $github->getOwnerName($providerInstallationId);
try {
- $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
+ $repositoryName = $github->getRepositoryName($providerRepositoryId);
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
@@ -85,17 +85,19 @@ class Get extends Action
$repository = $github->getRepository($owner, $repositoryName);
- $authorized = false;
- try {
- $installationRepository = $github->getInstallationRepository($repositoryName);
- if (!empty($installationRepository)) {
- $authorized = true;
+ $authorized = $github->hasAccessToAllRepositories();
+ if (!$authorized) {
+ try {
+ $installationRepository = $github->getInstallationRepository($repositoryName);
+ if (!empty($installationRepository)) {
+ $authorized = true;
+ }
+ } catch (RepositoryNotFound $e) {
+ $authorized = false;
}
- } catch (RepositoryNotFound $e) {
- $authorized = false;
}
- $repository['id'] = \strval($repository['id']) ?? '';
+ $repository['id'] = \strval($repository['id']);
$repository['pushedAt'] = $repository['pushed_at'] ?? '';
$repository['organization'] = $installation->getAttribute('organization', '');
$repository['provider'] = $installation->getAttribute('provider', '');
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php
index 53713f8407..b4172fabdf 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php
@@ -86,12 +86,11 @@ class XList extends Action
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST,
+ model: [
+ Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST,
+ Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST,
+ ],
),
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST,
- )
]
))
->param('installationId', '', new Text(256), 'Installation Id')
@@ -141,7 +140,7 @@ class XList extends Action
$page = ($offset / $limit) + 1;
$owner = $github->getOwnerName($providerInstallationId);
- ['items' => $repos, 'total' => $total] = $github->searchRepositories($providerInstallationId, $owner, $page, $limit, $search);
+ ['items' => $repos, 'total' => $total] = $github->searchRepositories($owner, $page, $limit, $search);
$repos = \array_map(function ($repo) use ($installation) {
$repo['id'] = \strval($repo['id'] ?? '');
@@ -314,6 +313,7 @@ class XList extends Action
}, $repos);
$response->dynamic(new Document([
+ 'type' => $type,
$type === 'framework' ? 'frameworkProviderRepositories' : 'runtimeProviderRepositories' => $repos,
'total' => $total,
]), ($type === 'framework') ? Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST : Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST);
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..e192dff2a0
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php
@@ -0,0 +1,131 @@
+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('tls', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true)
+ ->param('authUsername', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true)
+ ->param('authPassword', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true)
+ ->param('secret', null, new Nullable(new Text(256, 8)), 'Webhook secret key. If not provided, a new key will be generated automatically. Key must be at least 8 characters long, and at max 256 characters.', optional: 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 $tls,
+ string $authUsername,
+ string $authPassword,
+ ?string $secret,
+ 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' => $tls,
+ 'httpUser' => $authUsername,
+ 'httpPass' => $authPassword,
+ 'signatureKey' => $secret ?? \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..cd05b6210c
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php
@@ -0,0 +1,93 @@
+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..a42500ca46
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php
@@ -0,0 +1,79 @@
+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);
+ }
+
+ $webhook->removeAttribute('signatureKey');
+
+ $response->dynamic($webhook, Response::MODEL_WEBHOOK);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php
new file mode 100644
index 0000000000..fbff94735e
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php
@@ -0,0 +1,96 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/webhooks/:webhookId/secret')
+ ->httpAlias('/v1/projects/:projectId/webhooks/:webhookId/signature')
+ ->desc('Update webhook secret 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: 'updateSecret',
+ description: <<param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook ID.', false, ['dbForPlatform'])
+ ->param('secret', null, new Nullable(new Text(256, 8)), 'Webhook secret key. If not provided, a new key will be generated automatically. Key must be at least 8 characters long, and at max 256 characters.', optional: true)
+ ->inject('response')
+ ->inject('project')
+ ->inject('queueForEvents')
+ ->inject('dbForPlatform')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $webhookId,
+ ?string $secret,
+ 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' => $secret ?? \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..e7b516449e
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php
@@ -0,0 +1,125 @@
+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('tls', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true)
+ ->param('authUsername', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true)
+ ->param('authPassword', '', 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 $tls,
+ string $authUsername,
+ string $authPassword,
+ 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' => $tls,
+ 'httpUser' => $authUsername,
+ 'httpPass' => $authPassword,
+ '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());
+
+ $webhook->removeAttribute('signatureKey');
+
+ $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..f0961b541c
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php
@@ -0,0 +1,132 @@
+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());
+ }
+
+ foreach ($queries as $query) {
+ $attribute = $query->getAttribute();
+ if ($attribute === 'authUsername') {
+ $query->setAttribute('httpUser');
+ } elseif ($attribute === 'tls') {
+ $query->setAttribute('security');
+ }
+ }
+
+ $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.");
+ }
+
+ foreach ($webhooks as $webhook) {
+ $webhook->removeAttribute('signatureKey');
+ }
+
+ $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..0e9c39a762
--- /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(UpdateWebhookSecret::getName(), new UpdateWebhookSecret());
+ }
+}
diff --git a/src/Appwrite/Platform/Tasks/Doctor.php b/src/Appwrite/Platform/Tasks/Doctor.php
index 9a9c2fdf73..3eeaa95e64 100644
--- a/src/Appwrite/Platform/Tasks/Doctor.php
+++ b/src/Appwrite/Platform/Tasks/Doctor.php
@@ -4,7 +4,6 @@ namespace Appwrite\Platform\Tasks;
use Appwrite\ClamAV\Network;
use Appwrite\PubSub\Adapter\Pool as PubSubPool;
-use PHPMailer\PHPMailer\PHPMailer;
use Utopia\Cache\Adapter\Pool as CachePool;
use Utopia\Config\Config;
use Utopia\Console;
@@ -13,6 +12,8 @@ use Utopia\Domains\Domain;
use Utopia\DSN\DSN;
use Utopia\Http\Http;
use Utopia\Logger\Logger;
+use Utopia\Messaging\Adapter\Email as EmailAdapter;
+use Utopia\Messaging\Messages\Email as EmailMessage;
use Utopia\Platform\Action;
use Utopia\Pools\Group;
use Utopia\Queue\Broker\Pool as BrokerPool;
@@ -124,7 +125,7 @@ class Doctor extends Action
$providerConfig = System::getEnv('_APP_LOGGING_CONFIG', '');
try {
- $loggingProvider = new DSN($providerConfig ?? '');
+ $loggingProvider = new DSN($providerConfig);
$providerName = $loggingProvider->getScheme();
@@ -212,15 +213,18 @@ class Doctor extends Action
}
try {
- /* @var PHPMailer $mail */
- $mail = $register->get('smtp');
+ /** @var EmailAdapter $smtp */
+ $smtp = $register->get('smtp');
- $mail->addAddress('demo@example.com', 'Example.com');
- $mail->Subject = 'Test SMTP Connection';
- $mail->Body = 'Hello World';
- $mail->AltBody = 'Hello World';
+ $emailMessage = new EmailMessage(
+ to: ['demo@example.com'],
+ subject: 'Test SMTP Connection',
+ content: 'Hello World',
+ fromName: \urldecode(System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server')),
+ fromEmail: System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM),
+ );
- $mail->send();
+ $smtp->send($emailMessage);
Console::success('🟢 ' . str_pad("SMTP", 50, '.') . 'connected');
} catch (\Throwable) {
Console::error('🔴 ' . str_pad("SMTP", 47, '.') . 'disconnected');
diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php
index ce9fca67ba..3e11a4060c 100644
--- a/src/Appwrite/Platform/Tasks/Install.php
+++ b/src/Appwrite/Platform/Tasks/Install.php
@@ -4,17 +4,42 @@ 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 Swoole\Coroutine;
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 int PROC_CLOSE_TIMEOUT_SECONDS = 60;
+
+ 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 bool $migrate = 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 +57,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 +86,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,40 +97,31 @@ 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();
+ $oldVersion = $appwrite->getImageVersion();
try {
$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
- if (!$service) {
- continue;
- }
-
+ foreach ($compose->getServices() as $service) {
$env = $service->getEnvironment()->list();
foreach ($env as $key => $value) {
@@ -108,12 +136,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,49 +158,78 @@ 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;
- 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.');
- Console::exit(1);
+ // Detect database type from existing installation.
+ // 1.9.0+ installs have _APP_DB_ADAPTER; pre-1.9.0 installs
+ // can be detected by the DB service name or _APP_DB_HOST.
+ $existingDatabase = null;
+ foreach ($compose->getServices() as $service) {
+ $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 = $this->detectDatabaseFromCompose($compose);
+ }
+ if ($existingDatabase !== null) {
+ if ($existingDatabase !== $database) {
+ $database = $existingDatabase;
+ Console::info("Detected existing database: {$database}");
+ }
+ $vars['_APP_DB_ADAPTER']['default'] = $database;
}
}
-
- 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
+ // Skip the web installer when explicit CLI params are provided
+ if ($interactive === 'Y' && Console::isInteractive() && !$this->hasExplicitCliParams()) {
+ 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');
+
+ $detectedDb = ($existingInstallation && isset($existingDatabase)) ? $existingDatabase : null;
+ $this->startWebServer($defaultHttpPort, $defaultHttpsPort, $organization, $image, $noStart, $vars, $isUpgrade || $existingInstallation, $detectedDb);
+ return;
}
+ // Fall back to CLI mode
$enableAssistant = false;
$assistantExistsInOldCompose = false;
-
- if ($data !== false && isset($compose)) {
+ if ($existingInstallation) {
try {
- $assistantService = $compose->getService('appwrite-assistant');
- $assistantExistsInOldCompose = $assistantService !== null;
+ $compose->getService('appwrite-assistant');
+ $assistantExistsInOldCompose = true;
} 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 +242,1188 @@ 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;
+ if ($var['name'] === '_APP_DB_ADAPTER' && $data !== '') {
+ $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, migrate: $this->migrate);
+ }
+
+
+ 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) {
+ try {
+ $progress($step, $status, $message, $details);
+ } catch (\Throwable $e) {
+ }
+ 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 = [],
+ ?callable $onComplete = null,
+ bool $migrate = false,
+ ): 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) {
+ $currentStep = InstallerServer::STEP_DOCKER_CONTAINERS;
+ $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_IN_PROGRESS, $messages);
+ $this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI, $progress, $isUpgrade);
+
+ if (!$isUpgrade) {
+ $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages);
+ $this->updateProgress($progress, InstallerServer::STEP_ACCOUNT_SETUP, InstallerServer::STATUS_IN_PROGRESS, messageOverride: 'Creating Appwrite account...');
+ }
+
+ if (!$isLocalInstall) {
+ $this->connectInstallerToAppwriteNetwork();
+ }
+
+ $domain = $input['_APP_DOMAIN'] ?? 'localhost';
+
+ $healthStep = $isUpgrade ? InstallerServer::STEP_DOCKER_CONTAINERS : InstallerServer::STEP_ACCOUNT_SETUP;
+ if (!$isUpgrade) {
+ $currentStep = InstallerServer::STEP_ACCOUNT_SETUP;
+ }
+ $apiUrl = $this->waitForApiReady($domain, $httpPort, $isLocalInstall, $progress, $healthStep);
+
+ if ($isUpgrade) {
+ $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages);
+ }
+
+ if (!$isUpgrade) {
+ $this->createInitialAdminAccount($account, $progress, $apiUrl, $domain);
+ }
+
+ if ($isUpgrade && $migrate) {
+ // Allow the containers-completed SSE event to flush
+ // before blocking on migration exec
+ usleep(200_000);
+ $currentStep = InstallerServer::STEP_MIGRATION;
+ $this->runDatabaseMigration($progress, $isLocalInstall);
+ } elseif ($isUpgrade) {
+ $this->updateProgress($progress, InstallerServer::STEP_MIGRATION, InstallerServer::STATUS_COMPLETED, messageOverride: 'Migration skipped');
+ }
+
+ // Signal completion before tracking so the SSE stream
+ // finishes and the frontend can redirect immediately.
+ if ($onComplete) {
+ try {
+ $onComplete();
+ } catch (\Throwable) {
+ }
+ }
+
+ // Run tracking in a coroutine when inside a Swoole
+ // request so it doesn't block the worker.
+ if (Coroutine::getCid() !== -1) {
+ go(function () use ($input, $isUpgrade, $version, $account) {
+ $this->trackSelfHostedInstall($input, $isUpgrade, $version, $account);
+ });
+ } else {
+ $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" and "console
+ // is restricted" errors so we can still create a session
+ // (common when re-running the installer or upgrading).
+ $userId = null;
+ try {
+ $userId = $this->makeApiCall('/v1/account', [
+ 'userId' => 'unique()',
+ 'email' => $email,
+ 'password' => $password,
+ 'name' => $name
+ ], false, $apiUrl, $domain);
+ } catch (\Throwable $e) {
+ $message = $e->getMessage();
+ $accountExists = \stripos($message, 'already exists') !== false
+ || \stripos($message, 'console is restricted') !== false;
+ if (!$accountExists) {
+ 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 runDatabaseMigration(?callable $progress, bool $isLocalInstall): void
+ {
+ $this->updateProgress(
+ $progress,
+ InstallerServer::STEP_MIGRATION,
+ InstallerServer::STATUS_IN_PROGRESS,
+ messageOverride: 'Running database migration...'
+ );
+
+ // Allow the SSE chunk to flush before the blocking exec
+ usleep(100_000);
+
+ // Static command — no user input involved
+ $command = $isLocalInstall
+ ? 'docker compose exec appwrite migrate 2>&1'
+ : 'docker exec appwrite migrate 2>&1';
+
+ $output = [];
+ \exec($command, $output, $exit);
+
+ if ($exit !== 0) {
+ $message = trim(implode("\n", $output));
+ $this->updateProgress(
+ $progress,
+ InstallerServer::STEP_MIGRATION,
+ InstallerServer::STATUS_ERROR,
+ details: ['output' => $message],
+ messageOverride: 'Migration failed: ' . ($message ?: 'exit code ' . $exit)
+ );
+ throw new \RuntimeException('Database migration failed', 0, $message !== '' ? new \RuntimeException($message) : null);
+ }
+
+ $this->updateProgress(
+ $progress,
+ InstallerServer::STEP_MIGRATION,
+ InstallerServer::STATUS_COMPLETED,
+ messageOverride: 'Database migration completed'
+ );
+ }
+
+ 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';
+
+ $hostIp = @gethostbyname($domain);
+
+ $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,
+ 'ip' => ($hostIp !== $domain) ? $hostIp : null,
+ 'os' => php_uname('s') . ' ' . php_uname('r'),
+ 'arch' => php_uname('m'),
+ 'cpus' => ((int) trim((string) \shell_exec('nproc'))) ?: null,
+ 'ram' => (int) round(((float) trim((string) \shell_exec('grep MemTotal /proc/meminfo | awk \'{print $2}\''))) / 1024),
+ ]),
+ ];
+
+ try {
+ $client = new Client();
+ $client
+ ->setConnectTimeout(5000)
+ ->setTimeout(5000)
+ ->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_ACCOUNT_SETUP): string
+ {
+ $client = new Client();
+ $client
+ ->setTimeout(2000)
+ ->setConnectTimeout(2000)
+ ->addHeader('Host', $domain);
+
+ $healthPath = '/v1/health/version';
+
+ if ($isLocalInstall) {
+ $candidates = [
+ 'http://localhost:' . $httpPort . $healthPath,
+ ];
+ } else {
+ $candidates = [
+ self::APPWRITE_API_URL . $healthPath,
+ 'http://host.docker.internal:' . $httpPort . $healthPath,
+ ];
+ }
+
+ $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...',
+ []
+ );
+ } 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, ?callable $progress = null, bool $isUpgrade = false): 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\"");
- $exit = Console::execute("$env docker compose --project-directory $this->path up -d --remove-orphans --renew-anon-volumes", '', $stdout, $stderr);
+ }
+
+ $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));
+
+ if ($progress) {
+ $totalServices = $this->countComposeServices($composeFile);
+ if ($totalServices > 0) {
+ $verb = $isUpgrade ? 'Restarting' : 'Starting';
+ try {
+ $progress(
+ InstallerServer::STEP_DOCKER_CONTAINERS,
+ InstallerServer::STATUS_IN_PROGRESS,
+ "$verb Docker containers...",
+ ['containerStarted' => 0, 'containerTotal' => $totalServices]
+ );
+ } catch (\Throwable) {
+ }
+ }
+ $result = $this->execWithContainerProgress($commandLine, $totalServices, $progress, $isUpgrade);
+ $output = $result['output'];
+ $exit = $result['exit'];
+ } else {
+ \exec($commandLine . ' 2>&1', $output, $exit);
}
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);
+ $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));
}
}
+
+ private function countComposeServices(string $composeFile): int
+ {
+ $content = @file_get_contents($composeFile);
+ if ($content === false) {
+ return 0;
+ }
+ $count = preg_match_all('/^\s*container_name:/m', $content);
+ return $count !== false ? $count : 0;
+ }
+
+ private function execWithContainerProgress(string $commandLine, int $totalServices, callable $progress, bool $isUpgrade): array
+ {
+ $verb = $isUpgrade ? 'Restarting' : 'Starting';
+ $message = "$verb Docker containers...";
+ $started = 0;
+ $output = [];
+
+ $process = proc_open(
+ $commandLine . ' 2>&1',
+ [1 => ['pipe', 'w']],
+ $pipes
+ );
+
+ if (!is_resource($process)) {
+ return ['output' => [], 'exit' => 1];
+ }
+
+ stream_set_blocking($pipes[1], false);
+ $deadline = time() + self::PROC_CLOSE_TIMEOUT_SECONDS;
+ $buffer = '';
+
+ while (time() < $deadline) {
+ $status = proc_get_status($process);
+
+ $read = [$pipes[1]];
+ $write = null;
+ $except = null;
+ $changed = @stream_select($read, $write, $except, 1);
+
+ if ($changed > 0) {
+ $chunk = fread($pipes[1], 8192);
+ if ($chunk === false || $chunk === '') {
+ if (!$status['running']) {
+ break;
+ }
+ continue;
+ }
+ $buffer .= $chunk;
+ while (($pos = strpos($buffer, "\n")) !== false) {
+ $trimmed = rtrim(substr($buffer, 0, $pos), "\r");
+ $buffer = substr($buffer, $pos + 1);
+ $output[] = $trimmed;
+
+ if (str_contains($trimmed, 'Container') && (str_contains($trimmed, 'Started') || str_contains($trimmed, 'Running'))) {
+ $started = min($started + 1, $totalServices);
+ if ($totalServices > 0) {
+ try {
+ $progress(
+ InstallerServer::STEP_DOCKER_CONTAINERS,
+ InstallerServer::STATUS_IN_PROGRESS,
+ $message,
+ ['containerStarted' => $started, 'containerTotal' => $totalServices]
+ );
+ } catch (\Throwable) {
+ }
+ }
+ }
+ }
+ }
+
+ if (!$status['running'] && ($changed === 0 || feof($pipes[1]))) {
+ break;
+ }
+ }
+
+ if ($buffer !== '') {
+ $output[] = rtrim($buffer, "\r\n");
+ }
+
+ fclose($pipes[1]);
+
+ $exit = $this->procCloseWithTimeout($process, self::PROC_CLOSE_TIMEOUT_SECONDS);
+
+ return ['output' => $output, 'exit' => $exit];
+ }
+
+ /**
+ * Wait up to $timeoutSeconds for a process to exit, then kill it.
+ *
+ * proc_close() blocks indefinitely which can hang the installer if
+ * docker compose refuses to exit after all containers are running.
+ *
+ * @param resource $process A process resource from proc_open()
+ */
+ private function procCloseWithTimeout($process, int $timeoutSeconds): int
+ {
+ $deadline = time() + $timeoutSeconds;
+
+ while (time() < $deadline) {
+ $status = proc_get_status($process);
+ if (!$status['running']) {
+ $exitCode = $status['exitcode'];
+ $closeCode = proc_close($process);
+ return $exitCode !== -1 ? $exitCode : $closeCode;
+ }
+ usleep(250_000);
+ }
+
+ proc_terminate($process, SIGTERM);
+ usleep(500_000);
+
+ if (proc_get_status($process)['running']) {
+ proc_terminate($process, SIGKILL);
+ }
+
+ proc_close($process);
+
+ return 124;
+ }
+
+ 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();
+ }
+
+ /**
+ * Check if any installer-specific CLI params were explicitly passed.
+ * When params like --database or --http-port are provided, the user
+ * intends to run in CLI mode rather than launching the web installer.
+ */
+ private function hasExplicitCliParams(): bool
+ {
+ $argv = $_SERVER['argv'] ?? [];
+ foreach ($argv as $arg) {
+ if (\str_starts_with($arg, '--')) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Detect the database adapter from a pre-1.9.0 compose file by
+ * checking which DB service exists or reading _APP_DB_HOST.
+ */
+ private function detectDatabaseFromCompose(Compose $compose): ?string
+ {
+ $serviceNames = array_keys($compose->getServices());
+ $dbServices = ['mariadb', 'mongodb', 'postgresql'];
+ foreach ($dbServices as $db) {
+ if (in_array($db, $serviceNames, true)) {
+ return $db;
+ }
+ }
+
+ foreach ($compose->getServices() as $service) {
+ $env = $service->getEnvironment()->list();
+ $host = $env['_APP_DB_HOST'] ?? null;
+ if ($host !== null && in_array($host, $dbServices, true)) {
+ return $host;
+ }
+ }
+
+ return null;
+ }
+
+ 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/Interval.php b/src/Appwrite/Platform/Tasks/Interval.php
index a7d16e0a52..836508c73d 100644
--- a/src/Appwrite/Platform/Tasks/Interval.php
+++ b/src/Appwrite/Platform/Tasks/Interval.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Tasks;
-use Appwrite\Event\Certificate;
+use Appwrite\Event\Publisher\Certificate;
use DateTime;
use Swoole\Coroutine\Channel;
use Swoole\Process;
@@ -29,16 +29,16 @@ class Interval extends Action
->desc('Schedules tasks on regular intervals by publishing them to our queues')
->inject('dbForPlatform')
->inject('getProjectDB')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->callback($this->action(...));
}
- public function action(Database $dbForPlatform, callable $getProjectDB, Certificate $queueForCertificates): void
+ public function action(Database $dbForPlatform, callable $getProjectDB, Certificate $publisherForCertificates): void
{
Console::title('Interval V1');
Console::success(APP_NAME . ' interval process v1 has started');
- $timers = $this->runTasks($dbForPlatform, $getProjectDB, $queueForCertificates);
+ $timers = $this->runTasks($dbForPlatform, $getProjectDB, $publisherForCertificates);
$chan = new Channel(1);
Process::signal(SIGTERM, function () use ($chan) {
@@ -52,16 +52,16 @@ class Interval extends Action
}
}
- public function runTasks(Database $dbForPlatform, callable $getProjectDB, Certificate $queueForCertificates): array
+ public function runTasks(Database $dbForPlatform, callable $getProjectDB, Certificate $publisherForCertificates): array
{
$timers = [];
$tasks = $this->getTasks();
foreach ($tasks as $task) {
- $timers[] = Timer::tick($task['interval'], function () use ($task, $dbForPlatform, $getProjectDB, $queueForCertificates) {
+ $timers[] = Timer::tick($task['interval'], function () use ($task, $dbForPlatform, $getProjectDB, $publisherForCertificates) {
$taskName = $task['name'];
Span::init("interval.{$taskName}");
try {
- $task['callback']($dbForPlatform, $getProjectDB, $queueForCertificates);
+ $task['callback']($dbForPlatform, $getProjectDB, $publisherForCertificates);
} catch (\Exception $e) {
Span::error($e);
} finally {
@@ -75,20 +75,19 @@ class Interval extends Action
protected function getTasks(): array
{
$intervalDomainVerification = (int) System::getEnv('_APP_INTERVAL_DOMAIN_VERIFICATION', '120'); // 2 minutes
- $intervalCleanupStaleExecutions = (int) System::getEnv('_APP_INTERVAL_CLEANUP_STALE_EXECUTIONS', '300'); // 5 minutes
return [
[
'name' => 'domainVerification',
- "callback" => function (Database $dbForPlatform, callable $getProjectDB, Certificate $queueForCertificates) {
- $this->verifyDomain($dbForPlatform, $queueForCertificates);
+ "callback" => function (Database $dbForPlatform, callable $getProjectDB, Certificate $publisherForCertificates) {
+ $this->verifyDomain($dbForPlatform, $publisherForCertificates);
},
'interval' => $intervalDomainVerification * 1000,
]
];
}
- private function verifyDomain(Database $dbForPlatform, Certificate $queueForCertificates): void
+ private function verifyDomain(Database $dbForPlatform, Certificate $publisherForCertificates): void
{
$time = DatabaseDateTime::now();
$fromTime = new DateTime('-3 days'); // Max 3 days old
@@ -102,11 +101,11 @@ class Interval extends Action
]);
$scanned = \count($rules);
- Span::add("interval.domainVerification.scanned", $scanned);
+ Span::add("interval.domain_verification.scanned", $scanned);
if ($scanned === 0) {
- Span::add("interval.domainVerification.processed", 0);
- Span::add("interval.domainVerification.failed", 0);
+ Span::add("interval.domain_verification.processed", 0);
+ Span::add("interval.domain_verification.failed", 0);
return; // No rules to verify
}
@@ -115,66 +114,24 @@ class Interval extends Action
foreach ($rules as $rule) {
try {
- $queueForCertificates
- ->setDomain(new Document([
+ $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: new Document([
+ '$id' => $rule->getAttribute('projectId', ''),
+ '$sequence' => $rule->getAttribute('projectInternalId', 0),
+ ]),
+ domain: new Document([
'domain' => $rule->getAttribute('domain'),
'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')),
- ]))
- ->setAction(Certificate::ACTION_DOMAIN_VERIFICATION)
- ->trigger();
+ ]),
+ action: \Appwrite\Event\Certificate::ACTION_DOMAIN_VERIFICATION,
+ ));
$processed++;
} catch (\Throwable $th) {
$failed++;
}
}
- Span::add("interval.domainVerification.processed", $processed);
- Span::add("interval.domainVerification.failed", $failed);
- }
-
- private function cleanupStaleExecutions(Database $dbForPlatform, callable $getProjectDB): void
- {
- $staleThreshold = DatabaseDateTime::addSeconds(new DateTime(), -1200); // 20 minutes ago
-
- $scanned = 0;
- $processed = 0;
- $failed = 0;
-
- $dbForPlatform->foreach(
- 'projects',
- function (Document $project) use ($getProjectDB, $staleThreshold, &$scanned, &$processed, &$failed) {
- try {
- $dbForProject = $getProjectDB($project);
-
- $staleExecutions = $dbForProject->find('executions', [
- Query::equal('status', ['processing']),
- Query::lessThan('$createdAt', $staleThreshold),
- Query::limit(100),
- ]);
-
- $scanned += \count($staleExecutions);
-
- if (\count($staleExecutions) === 0) {
- return;
- }
-
- foreach ($staleExecutions as $execution) {
- $dbForProject->updateDocument('executions', $execution->getId(), new Document(['status' => 'failed', 'errors' => 'Execution timed out']));
- }
-
- $processed++;
- } catch (\Throwable $th) {
- $failed++;
- }
- },
- [
- Query::equal('region', [System::getEnv('_APP_REGION', 'default')]),
- Query::limit(100),
- ]
- );
-
- Span::add("interval.cleanupStaleExecutions.scanned", $scanned);
- Span::add("interval.cleanupStaleExecutions.processed", $processed);
- Span::add("interval.cleanupStaleExecutions.failed", $failed);
+ Span::add("interval.domain_verification.processed", $processed);
+ Span::add("interval.domain_verification.failed", $failed);
}
}
diff --git a/src/Appwrite/Platform/Tasks/Maintenance.php b/src/Appwrite/Platform/Tasks/Maintenance.php
index c821435786..fe803f1292 100644
--- a/src/Appwrite/Platform/Tasks/Maintenance.php
+++ b/src/Appwrite/Platform/Tasks/Maintenance.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Tasks;
-use Appwrite\Event\Certificate;
use Appwrite\Event\Delete;
+use Appwrite\Event\Publisher\Certificate;
use DateInterval;
use DateTime;
use Utopia\Console;
@@ -29,12 +29,12 @@ class Maintenance extends Action
->param('type', 'loop', new WhiteList(['loop', 'trigger']), 'How to run task. "loop" is meant for container entrypoint, and "trigger" for manual execution.')
->inject('dbForPlatform')
->inject('console')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->inject('queueForDeletes')
->callback($this->action(...));
}
- public function action(string $type, Database $dbForPlatform, Document $console, Certificate $queueForCertificates, Delete $queueForDeletes): void
+ public function action(string $type, Database $dbForPlatform, Document $console, Certificate $publisherForCertificates, Delete $queueForDeletes): void
{
Console::title('Maintenance V1');
Console::success(APP_NAME . ' maintenance process v1 has started');
@@ -59,7 +59,7 @@ class Maintenance extends Action
$delay = $next->getTimestamp() - $now->getTimestamp();
}
- $action = function () use ($interval, $cacheRetention, $schedulesDeletionRetention, $usageStatsRetentionHourly, $dbForPlatform, $console, $queueForDeletes, $queueForCertificates) {
+ $action = function () use ($interval, $cacheRetention, $schedulesDeletionRetention, $usageStatsRetentionHourly, $dbForPlatform, $console, $queueForDeletes, $publisherForCertificates) {
$time = DatabaseDateTime::now();
Console::info("[{$time}] Notifying workers with maintenance tasks every {$interval} seconds");
@@ -92,7 +92,7 @@ class Maintenance extends Action
->trigger();
$this->notifyDeleteConnections($queueForDeletes);
- $this->renewCertificates($dbForPlatform, $queueForCertificates);
+ $this->renewCertificates($dbForPlatform, $publisherForCertificates);
$this->notifyDeleteCache($cacheRetention, $queueForDeletes);
$this->notifyDeleteSchedules($schedulesDeletionRetention, $queueForDeletes);
$this->notifyDeleteCSVExports($queueForDeletes);
@@ -124,7 +124,7 @@ class Maintenance extends Action
->trigger();
}
- private function renewCertificates(Database $dbForPlatform, Certificate $queueForCertificate): void
+ private function renewCertificates(Database $dbForPlatform, Certificate $publisherForCertificate): void
{
$time = DatabaseDateTime::now();
@@ -158,13 +158,17 @@ class Maintenance extends Action
continue;
}
- $queueForCertificate
- ->setDomain(new Document([
+ $publisherForCertificate->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: new Document([
+ '$id' => $rule->getAttribute('projectId', ''),
+ '$sequence' => $rule->getAttribute('projectInternalId', 0),
+ ]),
+ domain: new Document([
'domain' => $rule->getAttribute('domain'),
'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')),
- ]))
- ->setAction(Certificate::ACTION_GENERATION)
- ->trigger();
+ ]),
+ action: \Appwrite\Event\Certificate::ACTION_GENERATION,
+ ));
}
}
diff --git a/src/Appwrite/Platform/Tasks/Migrate.php b/src/Appwrite/Platform/Tasks/Migrate.php
index cec2f6ec27..6f9f92435a 100644
--- a/src/Appwrite/Platform/Tasks/Migrate.php
+++ b/src/Appwrite/Platform/Tasks/Migrate.php
@@ -9,7 +9,6 @@ use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception;
use Utopia\Database\Validator\Authorization;
-use Utopia\Http\Http;
use Utopia\Platform\Action;
use Utopia\Registry\Registry;
use Utopia\Validator\Text;
@@ -31,7 +30,8 @@ class Migrate extends Action
->inject('dbForPlatform')
->inject('getProjectDB')
->inject('register')
- ->inject('authorisation')
+ ->inject('authorization')
+ ->inject('console')
->callback($this->action(...));
}
@@ -48,7 +48,8 @@ class Migrate extends Action
Database $dbForPlatform,
callable $getProjectDB,
Registry $register,
- Authorization $authorization
+ Authorization $authorization,
+ Document $console
): void {
if (!\array_key_exists($version, Migration::$versions)) {
@@ -64,8 +65,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 +110,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;
@@ -85,13 +126,16 @@ class Migrate extends Action
Console::log('Migrated ' . ++$count . '/' . $total . ' projects...');
});
- $console = (new Http('UTC'))->getResource('console');
-
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/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php
index 528084f4ea..5d52d905f6 100644
--- a/src/Appwrite/Platform/Tasks/SDKs.php
+++ b/src/Appwrite/Platform/Tasks/SDKs.php
@@ -5,7 +5,9 @@ namespace Appwrite\Platform\Tasks;
use Appwrite\SDK\Language\AgentSkills;
use Appwrite\SDK\Language\Android;
use Appwrite\SDK\Language\Apple;
+use Appwrite\SDK\Language\ClaudePlugin;
use Appwrite\SDK\Language\CLI;
+use Appwrite\SDK\Language\CodexPlugin;
use Appwrite\SDK\Language\CursorPlugin;
use Appwrite\SDK\Language\Dart;
use Appwrite\SDK\Language\Deno;
@@ -14,16 +16,17 @@ use Appwrite\SDK\Language\Flutter;
use Appwrite\SDK\Language\Go;
use Appwrite\SDK\Language\GraphQL;
use Appwrite\SDK\Language\Kotlin;
-use Appwrite\SDK\Language\Markdown;
use Appwrite\SDK\Language\Node;
use Appwrite\SDK\Language\PHP;
use Appwrite\SDK\Language\Python;
use Appwrite\SDK\Language\ReactNative;
use Appwrite\SDK\Language\REST;
use Appwrite\SDK\Language\Ruby;
+use Appwrite\SDK\Language\Rust;
use Appwrite\SDK\Language\Swift;
use Appwrite\SDK\Language\Web;
use Appwrite\SDK\SDK;
+use Appwrite\Spec\StaticSpec;
use Appwrite\Spec\Swagger2;
use CzProject\GitPhp\Git;
use Utopia\Agents\Adapters\OpenAI;
@@ -49,7 +52,10 @@ class SDKs extends Action
public static function getPlatforms(): array
{
- return Specs::getPlatforms();
+ return [
+ ...Specs::getPlatforms(),
+ APP_SDK_PLATFORM_STATIC,
+ ];
}
protected function getSdkConfigPath(): string
@@ -89,7 +95,7 @@ class SDKs extends Action
$selectedSDK = $sdk;
if (! $sdks) {
- $selectedPlatform ??= Console::confirm('Choose Platform ("' . implode('", "', static::getPlatforms()) . '" or "*" for all):');
+ $selectedPlatform ??= Console::confirm('Choose Platform ("' . implode('", "', static::getPlatforms()) . '", comma-separated, or "*" for all):');
$selectedSDK ??= \strtolower(Console::confirm('Choose SDK ("*" for all):'));
$supportedSDKs = $this->getSupportedSDKs();
if ($selectedSDK !== '*' && ! \in_array($selectedSDK, $supportedSDKs)) {
@@ -98,11 +104,14 @@ class SDKs extends Action
} else {
$sdks = explode(',', $sdks);
}
- $version ??= Console::confirm('Choose an Appwrite version');
$createRelease = ($release === 'yes');
$commitRelease = ($commit === 'yes');
+ if ($createRelease && $examplesOnly) {
+ throw new \Exception('Cannot use --release=yes with --mode=examples');
+ }
+
if (! $createRelease && ! $examplesOnly) {
$git ??= Console::confirm('Should we use git push? (yes/no)');
$git = ($git === 'yes');
@@ -114,34 +123,50 @@ class SDKs extends Action
$prUrls = [];
}
- if (! \in_array($version, [
- '0.6.x',
- '0.7.x',
- '0.8.x',
- '0.9.x',
- '0.10.x',
- '0.11.x',
- '0.12.x',
- '0.13.x',
- '0.14.x',
- '0.15.x',
- '1.0.x',
- '1.1.x',
- '1.2.x',
- '1.3.x',
- '1.4.x',
- '1.5.x',
- '1.6.x',
- '1.7.x',
- '1.8.x',
- 'latest',
- ])) {
- throw new \Exception('Unknown version given');
+ if (! $createRelease) {
+ $version ??= Console::confirm('Choose an Appwrite version');
+
+ if (! \in_array($version, [
+ '0.6.x',
+ '0.7.x',
+ '0.8.x',
+ '0.9.x',
+ '0.10.x',
+ '0.11.x',
+ '0.12.x',
+ '0.13.x',
+ '0.14.x',
+ '0.15.x',
+ '1.0.x',
+ '1.1.x',
+ '1.2.x',
+ '1.3.x',
+ '1.4.x',
+ '1.5.x',
+ '1.6.x',
+ '1.7.x',
+ '1.8.x',
+ '1.9.x',
+ 'latest',
+ ])) {
+ throw new \Exception('Unknown version given');
+ }
+ }
+
+ $selectedPlatforms = ($selectedPlatform === '*' || $selectedPlatform === null) ? null : \array_map('trim', \explode(',', $selectedPlatform));
+
+ if ($selectedPlatforms !== null) {
+ $validPlatforms = static::getPlatforms();
+ foreach ($selectedPlatforms as $p) {
+ if (! \in_array($p, $validPlatforms)) {
+ throw new \Exception('Unknown platform "' . $p . '". Options are: ' . implode(', ', $validPlatforms));
+ }
+ }
}
$platforms = Config::getParam('sdks');
foreach ($platforms as $key => $platform) {
- if ($selectedPlatform !== $key && $selectedPlatform !== '*' && ($sdks === null)) {
+ if ($selectedPlatforms !== null && ! \in_array($key, $selectedPlatforms) && ($sdks === null)) {
continue;
}
@@ -151,20 +176,146 @@ class SDKs extends Action
}
if (! $language['enabled']) {
- Console::warning($language['name'] . ' for ' . $platform['name'] . ' is disabled');
+ Console::warning("{$language['name']} for {$platform['name']} is disabled");
continue;
}
- Console::info('Fetching API Spec for ' . $language['name'] . ' for ' . $platform['name'] . ' (version: ' . $version . ')');
+ Console::log('');
- $specPath = __DIR__ . '/../../../../app/config/specs/swagger2-' . $version . '-' . $language['family'] . '.json';
+ if ($createRelease) {
+ Console::info("━━━ {$language['name']} SDK ({$platform['name']}, {$language['version']}) ━━━");
+ $changelog = $language['changelog'] ?? '';
+ $changelog = ($changelog) ? \file_get_contents($changelog) : '# Change Log';
- if (!file_exists($specPath)) {
- throw new \Exception('Spec file not found: ' . $specPath . '. Please run "docker compose exec appwrite specs --version=' . $version . '" first to generate the specs.');
+ $repoName = $language['gitUserName'] . '/' . $language['gitRepoName'];
+ $releaseVersion = $language['version'];
+ $releaseNotes = $this->extractReleaseNotes($changelog, $releaseVersion);
+
+ if (empty($releaseNotes)) {
+ $releaseNotes = "Release version {$releaseVersion}";
+ }
+
+ $releaseTitle = $releaseVersion;
+ $releaseTarget = $language['repoBranch'] ?? 'main';
+
+ if ($repoName === '/') {
+ Console::warning(' Not a releasable SDK, skipping');
+
+ continue;
+ }
+
+ // Check if release already exists
+ $checkReleaseCommand = 'gh release view ' . \escapeshellarg($releaseVersion) . ' --repo ' . \escapeshellarg($repoName) . ' --json url --jq ".url" 2>/dev/null';
+ $existingReleaseUrl = trim(\shell_exec($checkReleaseCommand) ?? '');
+
+ if (! empty($existingReleaseUrl)) {
+ Console::warning(" Release {$releaseVersion} already exists, skipping");
+ Console::log(" {$existingReleaseUrl}");
+
+ continue;
+ }
+
+ // Check if the latest commit on the target branch already has a release
+ $latestCommitCommand = 'gh api repos/' . $repoName . '/commits/' . $releaseTarget . ' --jq ".sha" 2>/dev/null';
+ $latestCommitSha = trim(\shell_exec($latestCommitCommand) ?? '');
+
+ if (! empty($latestCommitSha)) {
+ $latestReleaseTagCommand = 'gh api repos/' . $repoName . '/releases --jq ".[0] | .tag_name" 2>/dev/null';
+ $latestReleaseTag = trim(\shell_exec($latestReleaseTagCommand) ?? '');
+
+ if (! empty($latestReleaseTag)) {
+ $tagCommitCommand = 'gh api repos/' . $repoName . '/git/ref/tags/' . $latestReleaseTag . ' --jq ".object.sha" 2>/dev/null';
+ $tagCommitSha = trim(\shell_exec($tagCommitCommand) ?? '');
+
+ if (! empty($tagCommitSha) && $latestCommitSha === $tagCommitSha) {
+ Console::warning(" Latest commit already released ({$latestReleaseTag}), skipping");
+
+ continue;
+ }
+ }
+ }
+
+ $previousVersion = '';
+ $tagListCommand = 'gh release list --repo ' . \escapeshellarg($repoName) . ' --limit 1 --json tagName --jq ".[0].tagName" 2>&1';
+ $previousVersion = trim(\shell_exec($tagListCommand) ?? '');
+
+ $formattedNotes = "## What's Changed\n\n";
+ $formattedNotes .= $releaseNotes . "\n\n";
+
+ if (! empty($previousVersion)) {
+ $formattedNotes .= '**Full Changelog**: https://github.com/' . $repoName . '/compare/' . $previousVersion . '...' . $releaseVersion;
+ } else {
+ $formattedNotes .= '**Full Changelog**: https://github.com/' . $repoName . '/releases/tag/' . $releaseVersion;
+ }
+
+ if (! $commitRelease) {
+ Console::info(' [DRY RUN] Would create release:');
+ Console::log(" Repository: {$repoName}");
+ Console::log(" Version: {$releaseVersion}");
+ Console::log(" Title: {$releaseTitle}");
+ Console::log(" Target Branch: {$releaseTarget}");
+ Console::log(' Previous Version: ' . ($previousVersion ?: 'N/A'));
+ Console::log(' Release Notes:');
+ Console::log(' ' . str_replace("\n", "\n ", $formattedNotes));
+ } else {
+ Console::log(" Creating release {$releaseVersion}...");
+
+ $tempNotesFile = \tempnam(\sys_get_temp_dir(), 'release_notes_');
+ \file_put_contents($tempNotesFile, $formattedNotes);
+
+ $releaseCommand = 'gh release create ' . \escapeshellarg($releaseVersion) . ' \
+ --repo ' . \escapeshellarg($repoName) . ' \
+ --title ' . \escapeshellarg($releaseTitle) . ' \
+ --notes-file ' . \escapeshellarg($tempNotesFile) . ' \
+ --target ' . \escapeshellarg($releaseTarget) . ' \
+ 2>&1';
+
+ $releaseOutput = [];
+ $releaseReturnCode = 0;
+ \exec($releaseCommand, $releaseOutput, $releaseReturnCode);
+
+ \unlink($tempNotesFile);
+
+ if ($releaseReturnCode === 0) {
+ // Extract release URL from output
+ $releaseUrl = '';
+ foreach ($releaseOutput as $line) {
+ if (strpos($line, 'https://github.com/') !== false) {
+ $releaseUrl = trim($line);
+ break;
+ }
+ }
+
+ Console::success(" Release {$releaseVersion} created");
+ if (! empty($releaseUrl)) {
+ Console::log(" {$releaseUrl}");
+ }
+ } else {
+ $errorMessage = implode("\n", $releaseOutput);
+ Console::error(" Failed to create release: " . $errorMessage);
+ }
+ }
+
+ continue;
}
- $spec = file_get_contents($specPath);
+ Console::info("━━━ {$language['name']} SDK ({$platform['name']}, {$version}) ━━━");
+ $specFormat = $language['spec'] ?? 'swagger2';
+ $spec = null;
+ if ($specFormat === 'static') {
+ Console::log(' Using static SDK spec...');
+ } else {
+ Console::log(' Fetching API spec...');
+
+ $specPath = __DIR__ . '/../../../../app/config/specs/swagger2-' . $version . '-' . $language['family'] . '.json';
+
+ if (!file_exists($specPath)) {
+ throw new \Exception('Spec file not found: ' . $specPath . '. Please run "docker compose exec appwrite specs --version=' . $version . '" first to generate the specs.');
+ }
+
+ $spec = file_get_contents($specPath);
+ }
$cover = 'https://github.com/appwrite/appwrite/raw/main/public/images/github.png';
$result = \realpath(__DIR__ . '/../../../../app') . '/sdks/' . $key . '-' . $language['key'];
@@ -287,145 +438,47 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
$config = new Kotlin();
$warning = $warning . "\n\n > This is the Kotlin SDK for integrating with Appwrite from your Kotlin server-side code. If you're looking for the Android SDK you should check [appwrite/sdk-for-android](https://github.com/appwrite/sdk-for-android)";
break;
+ case 'rust':
+ $config = new Rust();
+ break;
case 'graphql':
$config = new GraphQL();
break;
case 'rest':
$config = new REST();
break;
- case 'markdown':
- $config = new Markdown();
- $config->setNPMPackage('@appwrite.io/docs');
- break;
case 'agent-skills':
$config = new AgentSkills();
break;
case 'cursor-plugin':
$config = new CursorPlugin();
break;
+ case 'claude-plugin':
+ $config = new ClaudePlugin();
+ break;
+ case 'codex-plugin':
+ $config = new CodexPlugin();
+ break;
default:
throw new \Exception('Language "' . $language['key'] . '" not supported');
}
- if ($createRelease && ! $examplesOnly) {
- $repoName = $language['gitUserName'] . '/' . $language['gitRepoName'];
- $releaseVersion = $language['version'];
- $releaseNotes = $this->extractReleaseNotes($changelog, $releaseVersion);
+ Console::log($examplesOnly
+ ? ' Generating examples...'
+ : ' Generating SDK...');
- if (empty($releaseNotes)) {
- $releaseNotes = "Release version {$releaseVersion}";
- }
-
- $releaseTitle = $releaseVersion;
- $releaseTarget = $language['repoBranch'] ?? 'main';
-
- if ($repoName === '/') {
- Console::warning("{$language['name']} SDK is not an SDK, skipping release");
-
- continue;
- }
-
- // Check if release already exists
- $checkReleaseCommand = 'gh release view ' . \escapeshellarg($releaseVersion) . ' --repo ' . \escapeshellarg($repoName) . ' --json url --jq ".url" 2>/dev/null';
- $existingReleaseUrl = trim(\shell_exec($checkReleaseCommand) ?? '');
-
- if (! empty($existingReleaseUrl)) {
- Console::warning("Release {$releaseVersion} already exists for {$language['name']} SDK, skipping...");
- Console::info("Existing release: {$existingReleaseUrl}");
-
- continue;
- }
-
- // Check if the latest commit on the target branch already has a release
- $latestCommitCommand = 'gh api repos/' . $repoName . '/commits/' . $releaseTarget . ' --jq ".sha" 2>/dev/null';
- $latestCommitSha = trim(\shell_exec($latestCommitCommand) ?? '');
-
- if (! empty($latestCommitSha)) {
- $latestReleaseTagCommand = 'gh api repos/' . $repoName . '/releases --jq ".[0] | .tag_name" 2>/dev/null';
- $latestReleaseTag = trim(\shell_exec($latestReleaseTagCommand) ?? '');
-
- if (! empty($latestReleaseTag)) {
- $tagCommitCommand = 'gh api repos/' . $repoName . '/git/ref/tags/' . $latestReleaseTag . ' --jq ".object.sha" 2>/dev/null';
- $tagCommitSha = trim(\shell_exec($tagCommitCommand) ?? '');
-
- if (! empty($tagCommitSha) && $latestCommitSha === $tagCommitSha) {
- Console::warning("Latest commit on {$releaseTarget} already has a release ({$latestReleaseTag}) for {$language['name']} SDK, skipping to avoid empty release...");
-
- continue;
- }
- }
- }
-
- $previousVersion = '';
- $tagListCommand = 'gh release list --repo ' . \escapeshellarg($repoName) . ' --limit 1 --json tagName --jq ".[0].tagName" 2>&1';
- $previousVersion = trim(\shell_exec($tagListCommand) ?? '');
-
- $formattedNotes = "## What's Changed\n\n";
- $formattedNotes .= $releaseNotes . "\n\n";
-
- if (! empty($previousVersion)) {
- $formattedNotes .= '**Full Changelog**: https://github.com/' . $repoName . '/compare/' . $previousVersion . '...' . $releaseVersion;
- } else {
- $formattedNotes .= '**Full Changelog**: https://github.com/' . $repoName . '/releases/tag/' . $releaseVersion;
- }
-
- if (! $commitRelease) {
- Console::info("[DRY RUN] Would create release for {$language['name']} SDK:");
- Console::log(" Repository: {$repoName}");
- Console::log(" Version: {$releaseVersion}");
- Console::log(" Title: {$releaseTitle}");
- Console::log(" Target Branch: {$releaseTarget}");
- Console::log(' Previous Version: ' . ($previousVersion ?: 'N/A'));
- Console::log(' Release Notes:');
- Console::log(' ' . str_replace("\n", "\n ", $formattedNotes));
- Console::log('');
- } else {
- Console::info("Creating release {$releaseVersion} for {$language['name']} SDK...");
-
- $tempNotesFile = \tempnam(\sys_get_temp_dir(), 'release_notes_');
- \file_put_contents($tempNotesFile, $formattedNotes);
-
- $releaseCommand = 'gh release create ' . \escapeshellarg($releaseVersion) . ' \
- --repo ' . \escapeshellarg($repoName) . ' \
- --title ' . \escapeshellarg($releaseTitle) . ' \
- --notes-file ' . \escapeshellarg($tempNotesFile) . ' \
- --target ' . \escapeshellarg($releaseTarget) . ' \
- 2>&1';
-
- $releaseOutput = [];
- $releaseReturnCode = 0;
- \exec($releaseCommand, $releaseOutput, $releaseReturnCode);
-
- \unlink($tempNotesFile);
-
- if ($releaseReturnCode === 0) {
- // Extract release URL from output
- $releaseUrl = '';
- foreach ($releaseOutput as $line) {
- if (strpos($line, 'https://github.com/') !== false) {
- $releaseUrl = trim($line);
- break;
- }
- }
-
- Console::success("Successfully created release {$releaseVersion} for {$language['name']} SDK");
- if (! empty($releaseUrl)) {
- Console::info("Release URL: {$releaseUrl}");
- }
- } else {
- $errorMessage = implode("\n", $releaseOutput);
- Console::error("Failed to create release for {$language['name']} SDK: " . $errorMessage);
- }
- }
-
- continue;
- }
-
- Console::info($examplesOnly
- ? "Generating examples for {$language['name']} SDK..."
- : "Generating {$language['name']} SDK...");
-
- $sdk = new SDK($config, new Swagger2($spec));
+ $sdk = new SDK(
+ $config,
+ $specFormat === 'static'
+ ? new StaticSpec(
+ title: 'Appwrite',
+ description: 'Appwrite backend as a service',
+ version: $version,
+ licenseName: 'BSD-3-Clause',
+ licenseURL: 'https://raw.githubusercontent.com/appwrite/appwrite/master/LICENSE',
+ )
+ : new Swagger2($spec)
+ );
$sdk
->setName($language['name'])
@@ -440,7 +493,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
->setGitRepo($language['gitUrl'])
->setGitRepoName($language['gitRepoName'])
->setGitUserName($language['gitUserName'])
- ->setLogo($cover)
+ ->setCoverImage($cover)
->setURL('https://appwrite.io')
->setShareText('Appwrite is a backend as a service for building web or mobile apps')
->setShareURL('http://appwrite.io')
@@ -466,6 +519,9 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
try {
$sdk->generate($result);
+ Console::success($examplesOnly
+ ? " Examples generated at {$result}"
+ : " SDK generated at {$result}");
} catch (\Throwable $exception) {
Console::error($exception->getMessage());
}
@@ -477,11 +533,11 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
$aiChangelog = ''; // Track AI-generated changelog for PR description
if (! empty($apiKey) && ! $examplesOnly) {
- Console::info("Analyzing SDK changes with AI...");
+ Console::log(' Analyzing changes with AI...');
$aiResult = $this->generateVersionAndChangelog($language, $result);
if (!empty($aiResult['skip'])) {
- Console::warning("Skipping {$language['name']} SDK generation");
+ Console::warning(' Skipping (no relevant changes)');
continue;
} elseif ($aiResult !== null) {
$newVersion = $aiResult['version'];
@@ -509,7 +565,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
Console::error($exception->getMessage());
}
} else {
- Console::warning('AI analysis failed, using existing version');
+ Console::warning(' AI analysis failed, using existing version');
}
}
@@ -518,6 +574,8 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
$repoBranch = $language['repoBranch'] ?? 'main';
if ($git && !empty($gitUrl)) {
+ $prUrls = [];
+
// Generate commit message: use provided message, AI changelog, or fallback
if (! empty($message)) {
$commitMessage = $message;
@@ -530,10 +588,11 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
$pushSuccess = $this->pushToGit($language, $target, $result, $gitUrl, $gitBranch, $repoBranch, $commitMessage);
if ($pushSuccess) {
- $this->createPullRequest($language, $target, $gitBranch, $repoBranch, $aiChangelog, $prUrls);
+ $this->createPullRequest($language, $platform['name'], $target, $gitBranch, $repoBranch, $aiChangelog, $prUrls);
}
- $this->cleanupTarget($target, $language['name']);
+ \exec('chmod -R u+w ' . $target . ' && rm -rf ' . $target);
+ Console::log(' Cleaned up temp directory');
}
$this->copyExamples($language, $version, $result, $resultExamples);
@@ -542,9 +601,13 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
if (! empty($prUrls)) {
Console::log('');
- Console::log('Pull Request Summary');
- foreach ($prUrls as $sdkName => $url) {
- Console::log("{$sdkName}: {$url}");
+ Console::info('━━━ Pull Request Summary ━━━');
+ foreach ($prUrls as $platformName => $sdks) {
+ Console::log('');
+ Console::info(" {$platformName}:");
+ foreach ($sdks as $sdkName => $url) {
+ Console::log(" {$sdkName}: {$url}");
+ }
}
Console::log('');
}
@@ -552,7 +615,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
private function pushToGit(array $language, string $target, string $result, string $gitUrl, string $gitBranch, string $repoBranch, string $commitMessage): bool
{
- Console::info("Preparing {$language['name']} SDK repository...");
+ Console::log(' Preparing git repository...');
try {
// Init fresh repo
@@ -567,46 +630,31 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
$repo->execute('config', 'advice.defaultBranchName', 'false');
$repo->addRemote('origin', $gitUrl);
- // Fetch and checkout base branch (or create if new repo)
+ // Fetch and checkout the target branch (e.g. dev) if it exists on remote,
+ // otherwise create it from the base branch (e.g. main).
+ // We build on top of the existing remote branch so a regular push
+ // works without force-pushing against protected branches.
+ $hasBranch = false;
try {
- $repo->execute('fetch', 'origin', '--quiet', '--no-tags', '--depth', '1', $repoBranch);
+ $repo->execute('fetch', 'origin', '--quiet', '--no-tags', '--depth', '1', $gitBranch);
+ $hasBranch = true;
+ } catch (\Throwable) {
+ // Branch doesn't exist on remote yet
+ }
+
+ if ($hasBranch) {
+ $repo->execute('checkout', '-f', $gitBranch);
+ } else {
+ // Fetch base branch to create the target branch from it
try {
+ $repo->execute('fetch', 'origin', '--quiet', '--no-tags', '--depth', '1', $repoBranch);
$repo->execute('checkout', '-f', $repoBranch);
} catch (\Throwable) {
$repo->execute('checkout', '-b', $repoBranch);
}
- } catch (\Throwable) {
- $repo->execute('checkout', '-b', $repoBranch);
- }
-
- try {
- $repo->execute('pull', 'origin', $repoBranch, '--quiet', '--no-tags');
- } catch (\Throwable) {
- }
-
- // Checkout dev branch (or create if it doesn't exist)
- try {
- $repo->execute('checkout', '-f', $gitBranch);
- } catch (\Throwable) {
$repo->execute('checkout', '-b', $gitBranch);
}
- // Fetch dev branch, or push to create it on remote
- try {
- $repo->execute('fetch', 'origin', $gitBranch, '--quiet', '--no-tags', '--depth', '1');
- } catch (\Throwable) {
- try {
- $repo->execute('push', '-u', 'origin', $gitBranch, '--quiet');
- } catch (\Throwable) {
- }
- }
-
- // Sync with remote dev branch
- try {
- $repo->execute('reset', '--hard', "origin/{$gitBranch}");
- } catch (\Throwable) {
- }
-
// Backup .github before cleaning working tree
$githubDir = $target . '/.github';
$githubBackup = \sys_get_temp_dir() . '/.github-backup-' . \getmypid();
@@ -635,18 +683,26 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
// Stage, commit, push
$repo->addAllChanges();
- $repo->commit($commitMessage);
+
+ try {
+ $repo->commit($commitMessage);
+ } catch (\Throwable $e) {
+ // Exit code 1 (256 in PHP) = nothing to commit
+ Console::log(' No changes to commit, SDK is up to date');
+ return true;
+ }
+
$repo->execute('push', '-u', 'origin', $gitBranch, '--quiet');
} catch (\Throwable $e) {
- Console::warning("Git operations failed for {$language['name']} SDK: " . $e->getMessage());
+ Console::warning(" Git push failed: " . $e->getMessage());
return false;
}
- Console::success("Pushed {$language['name']} SDK to {$gitUrl}");
+ Console::success(" Pushed to {$gitUrl}");
return true;
}
- private function createPullRequest(array $language, string $target, string $gitBranch, string $repoBranch, string $aiChangelog, array &$prUrls): void
+ private function createPullRequest(array $language, string $platformName, string $target, string $gitBranch, string $repoBranch, string $aiChangelog, array &$prUrls): void
{
$prTitle = "feat: {$language['name']} SDK update for version {$language['version']}";
$prBody = "This PR contains updates to the {$language['name']} SDK for version {$language['version']}.";
@@ -655,7 +711,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
}
$repoName = $language['gitUserName'] . '/' . $language['gitRepoName'];
- Console::info("Creating pull request for {$language['name']} SDK...");
+ Console::log(' Creating pull request...');
$prCommand = 'cd ' . $target . ' && \
gh pr create \
@@ -671,29 +727,32 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
\exec($prCommand, $prOutput, $prReturnCode);
if ($prReturnCode === 0) {
- Console::success("Successfully created pull request for {$language['name']} SDK");
+ Console::success(" Pull request created");
foreach ($prOutput as $line) {
if (\str_starts_with(trim($line), 'https://')) {
- $prUrls[$language['name']] = trim($line);
+ $prUrls[$platformName][$language['name']] = trim($line);
break;
}
}
} else {
$errorMessage = implode("\n", $prOutput);
if (strpos($errorMessage, 'already exists') === false) {
- Console::error("Failed to create pull request for {$language['name']} SDK: " . $errorMessage);
+ Console::error(" Failed to create pull request: " . $errorMessage);
} else {
- $this->updateExistingPr($target, $repoName, $gitBranch, $prTitle, $prBody, $language['name'], $prUrls);
+ // Extract PR URL from the error output (gh includes it in "already exists" messages)
+ $existingPrUrl = '';
+ foreach ($prOutput as $line) {
+ if (\preg_match('#(https://github\.com/[^\s]+/pull/\d+)#', $line, $urlMatch)) {
+ $existingPrUrl = $urlMatch[1];
+ break;
+ }
+ }
+
+ $this->updateExistingPr($repoName, $gitBranch, $prTitle, $prBody, $platformName, $language['name'], $prUrls, $existingPrUrl);
}
}
}
- private function cleanupTarget(string $target, string $languageName): void
- {
- \exec('chmod -R u+w ' . $target . ' && rm -rf ' . $target);
- Console::success("Remove temp directory '{$target}' for {$languageName} SDK");
- }
-
private function copyExamples(array $language, string $version, string $result, string $resultExamples): void
{
$docDirectories = $language['docDirectories'] ?? [''];
@@ -707,7 +766,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
$examplesSource = $result . '/docs/examples' . $languagePath;
if (! \is_dir($examplesSource)) {
- Console::warning("No code examples found for {$language['name']} SDK at: {$examplesSource}. Skipping copy.");
+ Console::warning(" No code examples found at: {$examplesSource}");
continue;
}
@@ -716,7 +775,8 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
'mkdir -p ' . $resultExamples . $languagePath . ' && \
cp -r ' . $examplesSource . ' ' . $resultExamples
);
- Console::success("Copied code examples for {$language['name']} SDK to: {$resultExamples}");
+ $label = \is_string($languageTitle) ? " ({$languageTitle})" : '';
+ Console::success(" Examples{$label} copied to {$resultExamples}{$languagePath}");
}
}
@@ -772,13 +832,13 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
$repoBranch = $language['repoBranch'] ?? 'main';
if (empty($gitUrl)) {
- Console::warning("No git URL for {$language['name']} SDK, skipping AI analysis");
+ Console::warning(' No git URL, skipping AI analysis');
return null;
}
$apiKey = System::getEnv('_APP_ASSISTANT_OPENAI_API_KEY', '');
if (empty($apiKey)) {
- Console::warning('_APP_ASSISTANT_OPENAI_API_KEY not set, cannot use AI for version analysis');
+ Console::warning(' _APP_ASSISTANT_OPENAI_API_KEY not set, skipping AI analysis');
return null;
}
@@ -870,7 +930,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
->setMaxDiffLines(500)
->setUserId('sdk-analyst');
- Console::info("Running DiffCheck for {$language['name']} SDK...");
+ Console::log(' Running DiffCheck...');
$result = (new DiffCheck())->run(
runner: $adapter,
@@ -881,43 +941,42 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
);
if (!$result['hasChanges']) {
- Console::info("✓ No changes detected - SDK is up to date");
+ Console::success(' No changes detected, SDK is up to date');
return null;
}
$responseContent = $result['response'];
if (empty(trim($responseContent))) {
- Console::warning('AI returned empty response');
+ Console::warning(' AI returned empty response');
return null;
}
$parsed = json_decode($responseContent, true);
if (json_last_error() !== JSON_ERROR_NONE) {
- Console::warning('Failed to parse AI response as JSON: ' . json_last_error_msg());
- Console::log('Raw response:');
- Console::log($responseContent);
+ Console::warning(' Failed to parse AI response: ' . json_last_error_msg());
+ Console::log(' Raw response: ' . $responseContent);
return null;
}
if (empty($parsed['version']) || empty($parsed['changelog']) || empty($parsed['versionBump'])) {
- Console::warning('AI response missing required fields');
+ Console::warning(' AI response missing required fields');
return null;
}
// Guard: beta SDKs must not be bumped to >= 1.0.0
if ($isBeta && ($parsed['versionBump'] === 'major' || \version_compare($parsed['version'], '1.0.0', '>='))) {
- Console::warning("Beta SDK {$language['name']} cannot have a major bump or version >= 1.0.0 (AI suggested {$parsed['version']}), skipping");
+ Console::warning(" Beta SDK cannot bump to {$parsed['version']}, skipping");
return ['skip' => true];
}
- Console::success("✓ Analysis complete");
- Console::log(" Version: {$language['version']} → {$parsed['version']} ({$parsed['versionBump']} bump)");
- Console::log(" Changelog:");
+ Console::success(" AI analysis complete");
+ Console::log(" Version: {$language['version']} → {$parsed['version']} ({$parsed['versionBump']})");
+ Console::log(" Changelog:");
foreach (explode("\n", $parsed['changelog']) as $line) {
if (trim($line)) {
- Console::log(" {$line}");
+ Console::log(" {$line}");
}
}
@@ -927,7 +986,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
'versionBump' => $parsed['versionBump'],
];
} catch (\Throwable $e) {
- Console::error('Error generating version and changelog: ' . $e->getMessage());
+ Console::error(' AI error: ' . $e->getMessage());
return null;
}
}
@@ -945,7 +1004,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
$configPath = $this->getSdkConfigPath();
if (! file_exists($configPath)) {
- Console::error("Config file not found: {$configPath}");
+ Console::error(" Config file not found: {$configPath}");
return false;
}
@@ -960,10 +1019,10 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
$newContent = preg_replace($inlinePattern, '${1}' . $newVersion . '${3}', $content);
if (file_put_contents($configPath, $newContent) !== false) {
- Console::success("Updated {$sdkKey} version from {$oldVersion} to {$newVersion} in config");
+ Console::success(" Config updated: {$sdkKey} {$oldVersion} → {$newVersion}");
return true;
} else {
- Console::error('Failed to write config file');
+ Console::error(' Failed to write config file');
return false;
}
}
@@ -971,24 +1030,45 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
// Second, try to find version in array format (pattern 2)
// Pattern matches: 'nodejs' => '22.1.2', or "nodejs" => "22.1.2",
// Also handles extra whitespace: 'nodejs' => '22.1.2',
- $arrayPattern = '/([\'"]' . preg_quote($sdkKey, '/') . '[\'"]\s*=>\s*[\'"])([^\'"]+)([\'"],)/m';
+ // Scoped to the correct $Versions array block to avoid
+ // updating duplicate keys that appear under a different platform.
+ $blockPattern = '/(\$' . preg_quote($platform, '/') . 'Versions\s*=\s*\[)([\s\S]*?)(\];)/m';
+ $entryPattern = '/([\'"]' . preg_quote($sdkKey, '/') . '[\'"]\s*=>\s*[\'"])([^\'"]+)([\'"],?)/m';
- if (preg_match($arrayPattern, $content, $matches)) {
- $oldVersion = $matches[2];
- $newContent = preg_replace($arrayPattern, '${1}' . $newVersion . '${3}', $content);
-
- if (file_put_contents($configPath, $newContent) !== false) {
- Console::success("Updated {$sdkKey} version from {$oldVersion} to {$newVersion} in config");
- return true;
- } else {
- Console::error('Failed to write config file');
- return false;
- }
+ if (! preg_match($blockPattern, $content)) {
+ Console::warning(" Could not find \${$platform}Versions block in config file");
+ return false;
}
- Console::warning("Could not find version entry for {$sdkKey} in config");
+ $updated = false;
+ $oldVersion = '';
+ $newContent = preg_replace_callback($blockPattern, function ($blockMatch) use ($entryPattern, $newVersion, &$updated, &$oldVersion) {
+ $blockContent = $blockMatch[2];
+ if (preg_match($entryPattern, $blockContent, $entryMatch)) {
+ $oldVersion = $entryMatch[2];
+ $blockContent = preg_replace($entryPattern, '${1}' . $newVersion . '${3}', $blockContent);
+ $updated = true;
+ }
+ return $blockMatch[1] . $blockContent . $blockMatch[3];
+ }, $content);
- return false;
+ if ($newContent === null) {
+ Console::error(' preg_replace_callback failed while updating config');
+ return false;
+ }
+
+ if (! $updated) {
+ Console::warning(" Could not find version entry for {$sdkKey} in \${$platform}Versions block");
+ return false;
+ }
+
+ if (file_put_contents($configPath, $newContent) === false) {
+ Console::error(' Failed to write config file');
+ return false;
+ }
+
+ Console::success(" Config updated: {$sdkKey} {$oldVersion} → {$newVersion}");
+ return true;
}
/**
@@ -1002,7 +1082,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
private function updateChangelogFile(string $changelogPath, string $version, string $notes): bool
{
if (empty($changelogPath) || ! file_exists($changelogPath)) {
- Console::warning("Changelog file not found: {$changelogPath}");
+ Console::warning(" Changelog file not found: {$changelogPath}");
return false;
}
@@ -1011,7 +1091,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
// Check if version already exists
if (strpos($content, "## {$version}") !== false) {
- Console::warning("Version {$version} already exists in changelog, skipping update");
+ Console::warning(" Version {$version} already in changelog, skipping");
return false;
}
@@ -1039,72 +1119,74 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
$newContent = implode("\n", $newLines);
if (file_put_contents($changelogPath, $newContent) !== false) {
- Console::success("Updated changelog at {$changelogPath} with version {$version}");
+ Console::success(" Changelog updated with version {$version}");
return true;
} else {
- Console::error('Failed to write changelog file');
+ Console::error(' Failed to write changelog file');
return false;
}
}
- private function updateExistingPr(string $target, string $repoName, string $gitBranch, string $prTitle, string $prBody, string $sdkName, array &$prUrls): void
+ private function updateExistingPr(string $repoName, string $gitBranch, string $prTitle, string $prBody, string $platformName, string $sdkName, array &$prUrls, string $existingPrUrl = ''): void
{
- Console::warning("Pull request already exists for {$sdkName} SDK, updating title and body...");
+ Console::log(' Pull request already exists, updating...');
- $prNumberCommand = 'cd ' . $target . ' && \
- gh pr list \
- --repo ' . \escapeshellarg($repoName) . ' \
- --head ' . \escapeshellarg($gitBranch) . ' \
- --json number \
- --jq ".[0].number" \
- 2>&1';
+ $prNumber = '';
+ $prUrl = '';
- $prNumberOutput = [];
- $prNumberReturnCode = 0;
- \exec($prNumberCommand, $prNumberOutput, $prNumberReturnCode);
+ // Try extracting from the gh pr create error output first (free, no API call)
+ if (! empty($existingPrUrl) && \preg_match('#/pull/(\d+)#', $existingPrUrl, $matches)) {
+ $prNumber = $matches[1];
+ $prUrl = $existingPrUrl;
+ }
- if ($prNumberReturnCode !== 0 || empty($prNumberOutput[0])) {
- Console::error("Failed to get PR number for {$sdkName} SDK");
+ // Otherwise, look it up via gh pr list
+ if (empty($prNumber)) {
+ $prListCommand = 'gh pr list'
+ . ' --repo ' . \escapeshellarg($repoName)
+ . ' --head ' . \escapeshellarg($gitBranch)
+ . ' --json number,url'
+ . ' --jq ".[0] | (.number|tostring) + \" \" + .url"'
+ . ' 2>&1';
+
+ $prListOutput = [];
+ \exec($prListCommand, $prListOutput);
+
+ if (! empty($prListOutput[0])) {
+ $parts = \explode(' ', trim($prListOutput[0]), 2);
+ $prNumber = $parts[0];
+ $prUrl = $parts[1] ?? '';
+ }
+ }
+
+ if (empty($prNumber)) {
+ Console::error(" Failed to find existing PR for branch {$gitBranch}");
return;
}
- $prNumber = trim($prNumberOutput[0]);
$apiPath = "/repos/{$repoName}/pulls/{$prNumber}";
- $updateCommand = 'cd ' . $target . ' && \
- gh api \
- --method PATCH \
- -H "Accept: application/vnd.github+json" \
- -H "X-GitHub-Api-Version: 2022-11-28" \
- ' . \escapeshellarg($apiPath) . ' \
- -f title=' . \escapeshellarg($prTitle) . ' \
- -f body=' . \escapeshellarg($prBody) . ' \
- 2>&1';
+ $updateCommand = 'gh api'
+ . ' --method PATCH'
+ . ' -H "Accept: application/vnd.github+json"'
+ . ' -H "X-GitHub-Api-Version: 2022-11-28"'
+ . ' ' . \escapeshellarg($apiPath)
+ . ' -f title=' . \escapeshellarg($prTitle)
+ . ' -f body=' . \escapeshellarg($prBody)
+ . ' 2>&1';
$updateOutput = [];
$updateReturnCode = 0;
\exec($updateCommand, $updateOutput, $updateReturnCode);
if ($updateReturnCode !== 0) {
- Console::error("Failed to update pull request for {$sdkName} SDK: " . implode("\n", $updateOutput));
+ Console::error(" Failed to update pull request: " . implode("\n", $updateOutput));
return;
}
- Console::success("Successfully updated pull request for {$sdkName} SDK");
+ Console::success(" Pull request updated");
- $prUrlCommand = 'cd ' . $target . ' && \
- gh pr list \
- --repo ' . \escapeshellarg($repoName) . ' \
- --head ' . \escapeshellarg($gitBranch) . ' \
- --json url \
- --jq ".[0].url" \
- 2>&1';
-
- $prUrlOutput = [];
- $prUrlReturnCode = 0;
- \exec($prUrlCommand, $prUrlOutput, $prUrlReturnCode);
-
- if ($prUrlReturnCode === 0 && ! empty($prUrlOutput)) {
- $prUrls[$sdkName] = trim($prUrlOutput[0]);
+ if (! empty($prUrl)) {
+ $prUrls[$platformName][$sdkName] = $prUrl;
}
}
}
diff --git a/src/Appwrite/Platform/Tasks/SSL.php b/src/Appwrite/Platform/Tasks/SSL.php
index ef8283f168..cb33836a99 100644
--- a/src/Appwrite/Platform/Tasks/SSL.php
+++ b/src/Appwrite/Platform/Tasks/SSL.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Tasks;
-use Appwrite\Event\Certificate;
+use Appwrite\Event\Publisher\Certificate;
use Utopia\Console;
use Utopia\Database\Database;
use Utopia\Database\Document;
@@ -29,11 +29,11 @@ class SSL extends Action
->param('skip-check', 'true', new Boolean(true), 'If DNS and renew check should be skipped. Defaults to true, and when true, all jobs will result in certificate generation attempt.', true)
->inject('console')
->inject('dbForPlatform')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->callback($this->action(...));
}
- public function action(string $domain, bool|string $skipCheck, Document $console, Database $dbForPlatform, Certificate $queueForCertificates): void
+ public function action(string $domain, bool|string $skipCheck, Document $console, Database $dbForPlatform, Certificate $publisherForCertificates): void
{
$domain = new Domain(!empty($domain) ? $domain : '');
if (!$domain->isKnown() || $domain->isTest()) {
@@ -98,12 +98,13 @@ class SSL extends Action
Console::info('Updated existing rule ' . $rule->getId() . ' for domain: ' . $domain->get());
}
- $queueForCertificates
- ->setDomain(new Document([
- 'domain' => $domain->get()
- ]))
- ->setSkipRenewCheck($skipCheck)
- ->trigger();
+ $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: $console,
+ domain: new Document([
+ 'domain' => $domain->get(),
+ ]),
+ skipRenewCheck: $skipCheck,
+ ));
Console::success('Scheduled a job to issue a TLS certificate for domain: ' . $domain->get());
}
diff --git a/src/Appwrite/Platform/Tasks/ScheduleBase.php b/src/Appwrite/Platform/Tasks/ScheduleBase.php
index c55e3d4a6a..1213f78924 100644
--- a/src/Appwrite/Platform/Tasks/ScheduleBase.php
+++ b/src/Appwrite/Platform/Tasks/ScheduleBase.php
@@ -73,7 +73,7 @@ abstract class ScheduleBase extends Action
* 2. Create timer that sync all changes from 'schedules' collection to local copy. Only reading changes thanks to 'resourceUpdatedAt' attribute
* 3. Create timer that prepares coroutines for soon-to-execute schedules. When it's ready, coroutine sleeps until exact time before sending request to worker.
*/
- public function action(BrokerPool $publisher, BrokerPool $publisherMigrations, BrokerPool $publisherFunctions, BrokerPool $publisherMessaging, callable $isResourceBlocked, Database $dbForPlatform, callable $getProjectDB, Telemetry $telemetry): void
+ public function action(BrokerPool $publisher, BrokerPool $publisherMigrations, BrokerPool $publisherFunctions, BrokerPool $publisherMessaging, callable $isResourceBlocked, Database $dbForPlatform, callable $getProjectDB, Telemetry $telemetry): never
{
Console::title(\ucfirst(static::getSupportedResource()) . ' scheduler V1');
Console::success(APP_NAME . ' ' . \ucfirst(static::getSupportedResource()) . ' scheduler v1 has started');
diff --git a/src/Appwrite/Platform/Tasks/ScheduleExecutions.php b/src/Appwrite/Platform/Tasks/ScheduleExecutions.php
index cd7873bab6..49dd851b6d 100644
--- a/src/Appwrite/Platform/Tasks/ScheduleExecutions.php
+++ b/src/Appwrite/Platform/Tasks/ScheduleExecutions.php
@@ -2,7 +2,8 @@
namespace Appwrite\Platform\Tasks;
-use Appwrite\Event\Func;
+use Appwrite\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Swoole\Coroutine as Co;
use Utopia\Database\Database;
@@ -36,7 +37,10 @@ class ScheduleExecutions extends ScheduleBase
{
$intervalEnd = (new \DateTime())->modify('+' . self::ENQUEUE_TIMER . ' seconds');
- $queueForFunctions = new Func($this->publisherFunctions);
+ $publisherForFunctions = new FunctionPublisher(
+ $this->publisherFunctions,
+ new \Utopia\Queue\Queue(\Utopia\System\System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', \Appwrite\Event\Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', \Appwrite\Event\Event::FUNCTIONS_QUEUE_TTL)
+ );
foreach ($this->schedules as $schedule) {
if (!$schedule['active']) {
@@ -63,23 +67,22 @@ class ScheduleExecutions extends ScheduleBase
$this->updateProjectAccess($schedule['project'], $dbForPlatform);
- \go(function () use ($queueForFunctions, $schedule, $scheduledAt, $delay, $data, $dbForPlatform) {
+ \go(function () use ($publisherForFunctions, $schedule, $scheduledAt, $delay, $data, $dbForPlatform) {
if ($delay > 0) {
Co::sleep($delay);
}
- $queueForFunctions->setType('schedule')
- // Set functionId instead of function as we don't have $dbForProject
- // TODO: Refactor to use function instead of functionId
- ->setFunctionId($schedule['resource']['resourceId'])
- ->setExecution($schedule['resource'])
- ->setMethod($data['method'] ?? 'POST')
- ->setPath($data['path'] ?? '/')
- ->setHeaders($data['headers'] ?? [])
- ->setBody($data['body'] ?? '')
- ->setProject($schedule['project'])
- ->setUserId($data['userId'] ?? '')
- ->trigger();
+ $publisherForFunctions->enqueue(new FunctionMessage(
+ project: $schedule['project'],
+ userId: $data['userId'] ?? '',
+ functionId: $schedule['resource']['resourceId'],
+ execution: $schedule['resource'],
+ type: 'schedule',
+ body: $data['body'] ?? '',
+ path: $data['path'] ?? '/',
+ headers: $data['headers'] ?? [],
+ method: $data['method'] ?? 'POST',
+ ));
$dbForPlatform->deleteDocument(
'schedules',
diff --git a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php
index 88725a190a..c1a1891386 100644
--- a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php
+++ b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php
@@ -2,11 +2,13 @@
namespace Appwrite\Platform\Tasks;
-use Appwrite\Event\Func;
+use Appwrite\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Cron\CronExpression;
use Utopia\Console;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
+use Utopia\Span\Span;
/**
* ScheduleFunctions
@@ -19,8 +21,6 @@ class ScheduleFunctions extends ScheduleBase
public const UPDATE_TIMER = 10; // seconds
public const ENQUEUE_TIMER = 60; // seconds
- private ?float $lastEnqueueUpdate = null;
-
public static function getName(): string
{
return 'schedule-functions';
@@ -41,7 +41,10 @@ class ScheduleFunctions extends ScheduleBase
$timerStart = \microtime(true);
$time = DateTime::now();
- $enqueueDiff = $this->lastEnqueueUpdate === null ? 0 : $timerStart - $this->lastEnqueueUpdate;
+ // TODO: Track the last enqueue timestamp to subtract ENQUEUE_TIMER drift from
+ // the time frame. Previously this used $this->lastEnqueueUpdate as a property
+ // but enabling the assignment broke scheduling, so the diff stays 0.
+ $enqueueDiff = 0;
$timeFrame = DateTime::addSeconds(new \DateTime(), static::ENQUEUE_TIMER - $enqueueDiff);
Console::log("Enqueue tick: started at: $time (with diff $enqueueDiff)");
@@ -88,33 +91,42 @@ class ScheduleFunctions extends ScheduleBase
$scheduleKey = $delayConfig['key'];
// Ensure schedule was not deleted
if (!\array_key_exists($scheduleKey, $this->schedules)) {
- return;
+ continue;
}
$schedule = $this->schedules[$scheduleKey];
$this->updateProjectAccess($schedule['project'], $dbForPlatform);
- $queueForFunctions = new Func($this->publisherFunctions);
+ $publisherForFunctions = new FunctionPublisher(
+ $this->publisherFunctions,
+ new \Utopia\Queue\Queue(\Utopia\System\System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', \Appwrite\Event\Event::FUNCTIONS_QUEUE_NAME), 'utopia-queue', \Appwrite\Event\Event::FUNCTIONS_QUEUE_TTL)
+ );
- $queueForFunctions
- ->setType('schedule')
- ->setFunction($schedule['resource'])
- ->setMethod('POST')
- ->setPath('/')
- ->setProject($schedule['project'])
- ->trigger();
+ Span::init('schedule.functions.enqueue');
+ try {
+ Span::add('project.id', $schedule['project']->getId());
+ Span::add('function.id', $schedule['resource']->getId());
+ Span::add('schedule.id', $schedule['$id'] ?? '');
- $this->recordEnqueueDelay($delayConfig['nextDate']);
+ $publisherForFunctions->enqueue(new FunctionMessage(
+ project: $schedule['project'],
+ function: $schedule['resource'],
+ type: 'schedule',
+ method: 'POST',
+ path: '/',
+ ));
+
+ $this->recordEnqueueDelay($delayConfig['nextDate']);
+ } finally {
+ Span::current()?->finish();
+ }
}
});
}
$timerEnd = \microtime(true);
- // TODO: This was a bug before because it wasn't passed by reference, enabling it breaks scheduling
- //$this->lastEnqueueUpdate = $timerStart;
-
Console::log("Enqueue tick: {$total} executions were enqueued in " . ($timerEnd - $timerStart) . " seconds");
}
}
diff --git a/src/Appwrite/Platform/Tasks/ScheduleMessages.php b/src/Appwrite/Platform/Tasks/ScheduleMessages.php
index 57f6dd8002..634fb26dc2 100644
--- a/src/Appwrite/Platform/Tasks/ScheduleMessages.php
+++ b/src/Appwrite/Platform/Tasks/ScheduleMessages.php
@@ -2,14 +2,20 @@
namespace Appwrite\Platform\Tasks;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Event;
+use Appwrite\Event\Message\Messaging as MessagingMessage;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Utopia\Database\Database;
+use Utopia\Queue\Queue;
+use Utopia\System\System;
class ScheduleMessages extends ScheduleBase
{
public const UPDATE_TIMER = 3; // seconds
public const ENQUEUE_TIMER = 4; // seconds
+ private ?MessagingPublisher $publisherForMessaging = null;
+
public static function getName(): string
{
return 'schedule-messages';
@@ -27,6 +33,11 @@ class ScheduleMessages extends ScheduleBase
protected function enqueueResources(Database $dbForPlatform, callable $getProjectDB): void
{
+ $publisherForMessaging = $this->publisherForMessaging ??= new MessagingPublisher(
+ $this->publisherMessaging,
+ new Queue(System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME))
+ );
+
foreach ($this->schedules as $schedule) {
if (!$schedule['active']) {
continue;
@@ -39,16 +50,14 @@ class ScheduleMessages extends ScheduleBase
continue;
}
- \go(function () use ($schedule, $scheduledAt, $dbForPlatform) {
- $queueForMessaging = new Messaging($this->publisherMessaging);
-
+ \go(function () use ($schedule, $scheduledAt, $dbForPlatform, $publisherForMessaging) {
$this->updateProjectAccess($schedule['project'], $dbForPlatform);
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($schedule['resourceId'])
- ->setProject($schedule['project'])
- ->trigger();
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $schedule['project'],
+ messageId: $schedule['resourceId'],
+ ));
$dbForPlatform->deleteDocument(
'schedules',
diff --git a/src/Appwrite/Platform/Tasks/Screenshot.php b/src/Appwrite/Platform/Tasks/Screenshot.php
index 59e0b11c89..3b50ed7e00 100644
--- a/src/Appwrite/Platform/Tasks/Screenshot.php
+++ b/src/Appwrite/Platform/Tasks/Screenshot.php
@@ -40,9 +40,6 @@ class Screenshot extends Action
throw new \Exception('Invalid JSON in --variables flag');
}
}
- if ($variables === null) {
- throw new \Exception('Invalid JSON in --variables flag');
- }
$templates = Config::getParam('templates-site', []);
diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php
index e68656f55d..c8120bd017 100644
--- a/src/Appwrite/Platform/Tasks/Specs.php
+++ b/src/Appwrite/Platform/Tasks/Specs.php
@@ -2,6 +2,7 @@
namespace Appwrite\Platform\Tasks;
+use Appwrite\Network\Validator\Redirect;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Specification\Format\OpenAPI3;
@@ -18,6 +19,8 @@ use Utopia\Config\Config;
use Utopia\Console;
use Utopia\Database\Adapter\MySQL;
use Utopia\Database\Database;
+use Utopia\Database\Document;
+use Utopia\DI\Container;
use Utopia\Http\Http;
use Utopia\Http\Request as UtopiaRequest;
use Utopia\Http\Response as UtopiaResponse;
@@ -159,7 +162,31 @@ class Specs extends Action
'name' => 'X-Appwrite-Dev-Key',
'description' => 'Your secret dev API key',
'in' => 'header',
- ]
+ ],
+ 'Cookie' => [
+ 'type' => 'apiKey',
+ 'name' => 'Cookie',
+ 'description' => 'The user cookie to authenticate with. Used by SDKs that forward an incoming Cookie header in server-side runtimes.',
+ '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 +225,36 @@ class Specs extends Action
'description' => 'The user agent string of the client that made the request',
'in' => 'header',
],
+ 'DevKey' => [
+ 'type' => 'apiKey',
+ 'name' => 'X-Appwrite-Dev-Key',
+ 'description' => 'Your secret dev API key',
+ 'in' => 'header',
+ ],
+ 'Cookie' => [
+ 'type' => 'apiKey',
+ 'name' => 'Cookie',
+ 'description' => 'The user cookie to authenticate with. Used by SDKs that forward an incoming Cookie header in server-side runtimes.',
+ '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' => [
@@ -233,13 +290,187 @@ class Specs extends Action
'Cookie' => [
'type' => 'apiKey',
'name' => 'Cookie',
- 'description' => 'The user cookie to authenticate with',
+ 'description' => 'The user cookie to authenticate with. Used by SDKs that forward an incoming Cookie header in server-side runtimes.',
+ 'in' => 'header',
+ ],
+ 'Session' => [
+ 'type' => 'apiKey',
+ 'name' => 'X-Appwrite-Session',
+ 'description' => 'The user session to authenticate with',
+ 'in' => 'header',
+ ],
+ 'DevKey' => [
+ 'type' => 'apiKey',
+ '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',
],
],
];
}
+ protected function verifyParsedSpec(array $spec): void
+ {
+ $services = [];
+ foreach ($spec['tags'] ?? [] as $tag) {
+ if (!\is_array($tag)) {
+ continue;
+ }
+
+ $service = $tag['name'] ?? null;
+ if (!\is_string($service) || $service === '') {
+ continue;
+ }
+
+ $services[$this->normalizeSdkName($service)] = $service;
+ }
+
+ if (empty($services)) {
+ return;
+ }
+
+ $enums = [];
+ $this->collectSpecEnumNames($spec, $enums);
+
+ if (empty($enums)) {
+ return;
+ }
+
+ $overlaps = [];
+ foreach ($services as $normalized => $service) {
+ if (!isset($enums[$normalized])) {
+ continue;
+ }
+
+ foreach ($enums[$normalized] as $enum) {
+ $overlaps[] = "service '{$service}' with enum '{$enum}'";
+ }
+ }
+
+ if (!empty($overlaps)) {
+ throw new \RuntimeException(
+ 'Spec service names must not overlap enum names. Overlaps: '
+ . \implode(', ', \array_unique($overlaps))
+ );
+ }
+ }
+
+ private function collectSpecEnumNames(array $node, array &$enums, ?string $fallbackName = null, bool $skipCurrentEnum = false): void
+ {
+ if (!$skipCurrentEnum && isset($node['enum']) && \is_array($node['enum'])) {
+ $enumName = $this->getExplicitSpecEnumName($node)
+ ?? $this->getFallbackSpecEnumName($node, $fallbackName);
+
+ if (!\is_null($enumName)) {
+ $this->addSpecEnumName($enums, $enumName);
+ }
+ }
+
+ $itemsEnumHandled = false;
+ if (
+ isset($node['items'])
+ && \is_array($node['items'])
+ && isset($node['items']['enum'])
+ && \is_array($node['items']['enum'])
+ ) {
+ $enumName = $this->getExplicitSpecEnumName($node['items'])
+ ?? $this->getExplicitSpecEnumName($node)
+ ?? $this->getFallbackSpecEnumName($node, $fallbackName);
+
+ if (!\is_null($enumName)) {
+ $this->addSpecEnumName($enums, $enumName);
+ }
+
+ $itemsEnumHandled = true;
+ }
+
+ $explicitEnumName = $this->getExplicitSpecEnumName($node);
+ if (!\is_null($explicitEnumName) && !isset($node['enum']) && !$itemsEnumHandled) {
+ $this->addSpecEnumName($enums, $explicitEnumName);
+ }
+
+ foreach ($node as $key => $value) {
+ if (!\is_array($value)) {
+ continue;
+ }
+
+ $this->collectSpecEnumNames(
+ $value,
+ $enums,
+ $this->getChildSpecEnumFallbackName($node, $key, $value, $fallbackName),
+ $key === 'items' && $itemsEnumHandled
+ );
+ }
+ }
+
+ private function addSpecEnumName(array &$enums, string $name): void
+ {
+ $enums[$this->normalizeSdkName($name)][] = $this->formatSdkName($name);
+ }
+
+ private function getExplicitSpecEnumName(array $node): ?string
+ {
+ $enumName = $node['x-enum-name'] ?? null;
+
+ return \is_string($enumName) && $enumName !== '' ? $enumName : null;
+ }
+
+ private function getFallbackSpecEnumName(array $node, ?string $fallbackName): ?string
+ {
+ $name = $node['name'] ?? $fallbackName;
+
+ return \is_string($name) && $name !== '' ? $name : null;
+ }
+
+ private function getChildSpecEnumFallbackName(
+ array $parent,
+ int|string $key,
+ array $child,
+ ?string $fallbackName
+ ): ?string {
+ if (isset($child['name']) && \is_string($child['name']) && $child['name'] !== '') {
+ return $child['name'];
+ }
+
+ if ($key === 'schema' || $key === 'items') {
+ return $this->getFallbackSpecEnumName($parent, $fallbackName);
+ }
+
+ if (\is_string($key) && !\in_array($key, ['components', 'content', 'definitions', 'delete', 'get', 'head', 'options', 'parameters', 'patch', 'paths', 'post', 'properties', 'put', 'responses'], true)) {
+ return $key;
+ }
+
+ return $fallbackName;
+ }
+
+ private function formatSdkName(string $name): string
+ {
+ return \str_replace(' ', '', \ucwords(\str_replace(['-', '_', '/'], ' ', $name)));
+ }
+
+ private function normalizeSdkName(string $name): string
+ {
+ return \strtolower((string) \preg_replace('/[^a-z0-9]/i', '', $name));
+ }
+
public function getSDKPlatformsForRouteSecurity(array $routeSecurity): array
{
$sdkPlatforms = [];
@@ -282,17 +513,30 @@ class Specs extends Action
$mocks = ($mode === 'mocks');
- // Mock dependencies
- Http::setResource('request', fn () => $this->getRequest());
- Http::setResource('response', fn () => $response);
- Http::setResource('dbForPlatform', fn () => new Database(new MySQL(''), new Cache(new None())));
- Http::setResource('dbForProject', fn () => new Database(new MySQL(''), new Cache(new None())));
+ // Mock dependencies needed by param validator injections in route definitions
+ $specsContainer = new Container();
+ $specsContainer->set('request', fn () => $this->getRequest());
+ $specsContainer->set('response', fn () => $response);
+ $specsContainer->set('dbForPlatform', fn () => new Database(new MySQL(''), new Cache(new None())));
+ $specsContainer->set('dbForProject', fn () => new Database(new MySQL(''), new Cache(new None())));
+ $specsContainer->set('redirectValidator', fn () => new Redirect([], []));
+ $specsContainer->set('project', fn () => new Document([]));
+ $specsContainer->set('passwordsDictionary', fn () => []);
+ $specsContainer->set('localeCodes', fn () => \array_map(fn ($locale) => $locale['code'], Config::getParam('locale-codes', [])));
+ $specsContainer->set('plan', fn () => []);
$platforms = static::getPlatforms();
$authCounts = $this->getAuthCounts();
$keys = $this->getKeys();
$generatedFiles = [];
+ $endpoint = System::getEnv('_APP_HOME', 'https://appwrite.io');
+ $email = System::getEnv('_APP_SYSTEM_TEAM_EMAIL', 'team@appwrite.io');
+ $specsDir = __DIR__ . '/../../../../app/config/specs';
+
+ if (!is_dir($specsDir) && !@mkdir($specsDir, 0755, true) && !is_dir($specsDir)) {
+ throw new Exception('Failed to create specs directory: ' . $specsDir);
+ }
foreach ($platforms as $platform) {
$routes = [];
@@ -377,7 +621,7 @@ class Specs extends Action
}
$arguments = [
- new Http('UTC'),
+ $specsContainer,
$services,
$routes,
$models,
@@ -389,8 +633,6 @@ class Specs extends Action
foreach (['swagger2', 'open-api3'] as $format) {
$formatInstance = $this->getFormatInstance($format, $arguments);
$specs = new Specification($formatInstance);
- $endpoint = System::getEnv('_APP_HOME', '[HOSTNAME]');
- $email = System::getEnv('_APP_SYSTEM_TEAM_EMAIL', APP_EMAIL_TEAM);
$formatInstance
->setParam('name', APP_NAME)
@@ -409,36 +651,36 @@ class Specs extends Action
->setParam('docs.description', 'Full API docs, specs and tutorials')
->setParam('docs.url', $endpoint . '/docs');
- $specsDir = __DIR__ . '/../../../../app/config/specs';
+ $path = $mocks
+ ? $specsDir . '/' . $format . '-mocks-' . $platform . '.json'
+ : $specsDir . '/' . $format . '-' . $version . '-' . $platform . '.json';
- if (!is_dir($specsDir)) {
- if (!mkdir($specsDir, 0755, true)) {
- throw new Exception('Failed to create specs directory: ' . $specsDir);
- }
+ try {
+ $parsedSpecs = $specs->parse();
+ $this->verifyParsedSpec($parsedSpecs);
+ } catch (\RuntimeException $e) {
+ throw new \RuntimeException("Spec generation failed for {$platform} ({$format}): " . $e->getMessage(), 0, $e);
}
- if ($mocks) {
- $path = $specsDir . '/' . $format . '-mocks-' . $platform . '.json';
+ $encodedSpecs = \json_encode($parsedSpecs, JSON_PRETTY_PRINT);
- if (!file_put_contents($path, json_encode($specs->parse(), JSON_PRETTY_PRINT))) {
- throw new Exception('Failed to save mocks spec file: ' . $path);
- }
+ unset($parsedSpecs);
- $generatedFiles[] = realpath($path);
- Console::success('Saved mocks spec file: ' . realpath($path));
-
- continue;
+ if ($encodedSpecs === false) {
+ throw new Exception('Failed to encode ' . ($mocks ? 'mocks ' : '') . 'spec file: ' . \json_last_error_msg());
}
- $path = $specsDir . '/' . $format . '-' . $version . '-' . $platform . '.json';
-
- if (!file_put_contents($path, json_encode($specs->parse(), JSON_PRETTY_PRINT))) {
- throw new Exception('Failed to save spec file: ' . $path);
+ if (\file_put_contents($path, $encodedSpecs) === false) {
+ throw new Exception('Failed to save ' . ($mocks ? 'mocks ' : '') . 'spec file: ' . $path);
}
$generatedFiles[] = realpath($path);
- Console::success('Saved spec file: ' . realpath($path));
+ Console::success('Saved ' . ($mocks ? 'mocks ' : '') . 'spec file: ' . realpath($path));
+
+ unset($encodedSpecs, $specs, $formatInstance);
}
+
+ unset($arguments, $models, $routes, $services);
}
if ($git === 'yes') {
diff --git a/src/Appwrite/Platform/Tasks/StatsResources.php b/src/Appwrite/Platform/Tasks/StatsResources.php
index 6c0e8da48b..8699d73bbb 100644
--- a/src/Appwrite/Platform/Tasks/StatsResources.php
+++ b/src/Appwrite/Platform/Tasks/StatsResources.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Tasks;
-use Appwrite\Event\StatsResources as EventStatsResources;
+use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher;
use Appwrite\Platform\Action;
use Utopia\Console;
use Utopia\Database\Database;
@@ -43,11 +43,11 @@ class StatsResources extends Action
->desc('Schedules projects for usage count')
->inject('dbForPlatform')
->inject('logError')
- ->inject('queueForStatsResources')
+ ->inject('publisherForStatsResources')
->callback($this->action(...));
}
- public function action(Database $dbForPlatform, callable $logError, EventStatsResources $queueForStatsResources): void
+ public function action(Database $dbForPlatform, callable $logError, StatsResourcesPublisher $publisherForStatsResources): void
{
$this->logError = $logError;
$this->dbForPlatform = $dbForPlatform;
@@ -60,7 +60,7 @@ class StatsResources extends Action
$interval = (int) System::getEnv('_APP_STATS_RESOURCES_INTERVAL', '3600');
- Console::loop(function () use ($queueForStatsResources, $dbForPlatform) {
+ Console::loop(function () use ($publisherForStatsResources) {
$last24Hours = (new \DateTime())->sub(\DateInterval::createFromDateString('24 hours'));
/**
@@ -69,10 +69,10 @@ class StatsResources extends Action
$this->foreachDocument($this->dbForPlatform, 'projects', [
Query::greaterThanEqual('accessedAt', DateTime::format($last24Hours)),
Query::equal('region', [System::getEnv('_APP_REGION', 'default')])
- ], function ($project) use ($queueForStatsResources) {
- $queueForStatsResources
- ->setProject($project)
- ->trigger();
+ ], function ($project) use ($publisherForStatsResources) {
+ $publisherForStatsResources->enqueue(new \Appwrite\Event\Message\StatsResources(
+ project: $project,
+ ));
Console::success('project: ' . $project->getId() . '(' . $project->getSequence() . ')' . ' queued');
});
}, $interval);
diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php
index 2e77ddd885..bde73fd05c 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)
@@ -27,29 +30,41 @@ class Upgrade extends Install
->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(length: 0), 'Database to use (mongodb|mariadb|postgresql)', true)
+ ->param('migrate', false, new Boolean(true), 'Run database migration after upgrade', 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,
+ bool $migrate = false,
+ ): void {
+ $this->isUpgrade = true;
+ $this->migrate = $migrate;
+ $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) {
- if (!$service) {
- continue;
- }
$env = $service->getEnvironment()->list();
if (isset($env['_APP_DB_ADAPTER'])) {
$database = $env['_APP_DB_ADAPTER'];
@@ -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..f6b0345381 100644
--- a/src/Appwrite/Platform/Workers/Audits.php
+++ b/src/Appwrite/Platform/Workers/Audits.php
@@ -2,11 +2,11 @@
namespace Appwrite\Platform\Workers;
+use Appwrite\Event\Message\Audit;
use Exception;
use Throwable;
use Utopia\Console;
use Utopia\Database\Document;
-use Utopia\Database\Exception\Authorization;
use Utopia\Database\Exception\Structure;
use Utopia\Platform\Action;
use Utopia\Queue\Message;
@@ -41,7 +41,6 @@ class Audits extends Action
$this
->desc('Audits worker')
->inject('message')
- ->inject('project')
->inject('getAudit')
->callback($this->action(...));
@@ -51,53 +50,61 @@ class Audits extends Action
/**
* @param Message $message
- * @param callable $getProjectDB
- * @param Document $project
- * @param callable $getAudit
+ * @param callable(Document): \Utopia\Audit\Audit $getAudit
* @return Commit|NoCommit
* @throws Throwable
* @throws \Utopia\Database\Exception
- * @throws Authorization
* @throws Structure
*/
- public function action(Message $message, Document $project, callable $getAudit): Commit|NoCommit
+ public function action(Message $message, callable $getAudit): Commit|NoCommit
{
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new Exception('Missing payload');
}
+ $auditMessage = Audit::fromArray($payload);
+
Console::info('Aggregating audit logs');
- $event = $payload['event'] ?? '';
+ $event = $auditMessage->event;
$auditPayload = '';
- if ($project->getId() === 'console') {
- $auditPayload = $payload['payload'] ?? '';
+ if ($auditMessage->project->getId() === 'console') {
+ $auditPayload = $auditMessage->payload;
}
- $mode = $payload['mode'] ?? '';
- $resource = $payload['resource'] ?? '';
- $userAgent = $payload['userAgent'] ?? '';
- $ip = $payload['ip'] ?? '';
- $user = new Document($payload['user'] ?? []);
+ $mode = $auditMessage->mode;
+ $resource = $auditMessage->resource;
+ $userAgent = $auditMessage->userAgent;
+ $ip = $auditMessage->ip;
+ $user = $auditMessage->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,14 +112,29 @@ class Audits extends Action
'time' => date("Y-m-d H:i:s", $message->getTimestamp()),
];
- if (isset($this->logs[$project->getSequence()])) {
- $this->logs[$project->getSequence()]['logs'][] = $eventData;
+ 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[$auditMessage->project->getSequence()])) {
+ $this->logs[$auditMessage->project->getSequence()]['logs'][] = $eventData;
} else {
- $this->logs[$project->getSequence()] = [
+ $this->logs[$auditMessage->project->getSequence()] = [
'project' => new Document([
- '$id' => $project->getId(),
- '$sequence' => $project->getSequence(),
- 'database' => $project->getAttribute('database'),
+ '$id' => $auditMessage->project->getId(),
+ '$sequence' => $auditMessage->project->getSequence(),
+ 'database' => $auditMessage->project->getAttribute('database'),
]),
'logs' => [$eventData]
];
diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php
index 73509819a9..4a31216599 100644
--- a/src/Appwrite/Platform/Workers/Certificates.php
+++ b/src/Appwrite/Platform/Workers/Certificates.php
@@ -3,10 +3,12 @@
namespace Appwrite\Platform\Workers;
use Appwrite\Certificates\Adapter as CertificatesAdapter;
-use Appwrite\Event\Certificate;
use Appwrite\Event\Event;
-use Appwrite\Event\Func;
-use Appwrite\Event\Mail;
+use Appwrite\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Message\Mail as MailMessage;
+use Appwrite\Event\Publisher\Certificate;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception as AppwriteException;
@@ -50,12 +52,12 @@ class Certificates extends Action
->desc('Certificates worker')
->inject('message')
->inject('dbForPlatform')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('queueForEvents')
->inject('queueForWebhooks')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForRealtime')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->inject('log')
->inject('certificates')
->inject('plan')
@@ -66,12 +68,12 @@ class Certificates extends Action
/**
* @param Message $message
* @param Database $dbForPlatform
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param Event $queueForEvents
* @param Webhook $queueForWebhooks
- * @param Func $queueForFunctions
+ * @param FunctionPublisher $publisherForFunctions
* @param Realtime $queueForRealtime
- * @param Certificate $queueForCertificates
+ * @param Certificate $publisherForCertificates
* @param Log $log
* @param CertificatesAdapter $certificates
* @param array $plan
@@ -83,39 +85,40 @@ class Certificates extends Action
public function action(
Message $message,
Database $dbForPlatform,
- Mail $queueForMails,
+ MailPublisher $publisherForMails,
Event $queueForEvents,
Webhook $queueForWebhooks,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Realtime $queueForRealtime,
- Certificate $queueForCertificates,
+ Certificate $publisherForCertificates,
Log $log,
CertificatesAdapter $certificates,
array $plan,
ValidatorAuthorization $authorization,
): void {
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new Exception('Missing payload');
}
- $document = new Document($payload['domain'] ?? []);
+ $certificateMessage = \Appwrite\Event\Message\Certificate::fromArray($payload);
+ $document = $certificateMessage->domain;
$domain = new Domain($document->getAttribute('domain', ''));
$domainType = $document->getAttribute('domainType');
- $skipRenewCheck = $payload['skipRenewCheck'] ?? false;
- $validationDomain = $payload['validationDomain'] ?? null;
- $action = $payload['action'] ?? Certificate::ACTION_GENERATION;
+ $skipRenewCheck = $certificateMessage->skipRenewCheck;
+ $validationDomain = $certificateMessage->validationDomain;
+ $action = $certificateMessage->action;
$log->addTag('domain', $domain->get());
switch ($action) {
- case Certificate::ACTION_DOMAIN_VERIFICATION:
- $this->handleDomainVerificationAction($domain, $dbForPlatform, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime, $queueForCertificates, $log, $authorization, $validationDomain);
+ case \Appwrite\Event\Certificate::ACTION_DOMAIN_VERIFICATION:
+ $this->handleDomainVerificationAction($domain, $dbForPlatform, $queueForEvents, $queueForWebhooks, $publisherForFunctions, $queueForRealtime, $publisherForCertificates, $log, $authorization, $validationDomain);
break;
- case Certificate::ACTION_GENERATION:
- $this->handleCertificateGenerationAction($domain, $domainType, $dbForPlatform, $queueForMails, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime, $log, $certificates, $authorization, $skipRenewCheck, $plan, $validationDomain);
+ case \Appwrite\Event\Certificate::ACTION_GENERATION:
+ $this->handleCertificateGenerationAction($domain, $domainType, $dbForPlatform, $publisherForMails, $queueForEvents, $queueForWebhooks, $publisherForFunctions, $queueForRealtime, $log, $certificates, $authorization, $skipRenewCheck, $plan, $validationDomain);
break;
default:
@@ -128,9 +131,9 @@ class Certificates extends Action
* @param Database $dbForPlatform
* @param Event $queueForEvents
* @param Webhook $queueForWebhooks
- * @param Func $queueForFunctions
+ * @param FunctionPublisher $publisherForFunctions
* @param Realtime $queueForRealtime
- * @param Certificate $queueForCertificates
+ * @param Certificate $publisherForCertificates
* @param Log $log
* @param ValidatorAuthorization $authorization
* @param string|null $validationDomain
@@ -144,9 +147,9 @@ class Certificates extends Action
Database $dbForPlatform,
Event $queueForEvents,
Webhook $queueForWebhooks,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Realtime $queueForRealtime,
- Certificate $queueForCertificates,
+ Certificate $publisherForCertificates,
Log $log,
ValidatorAuthorization $authorization,
?string $validationDomain = null
@@ -183,18 +186,22 @@ class Certificates extends Action
$rule->setAttribute('logs', $logs);
} finally {
// Update rule and emit events
- $this->updateRuleAndSendEvents($rule, $dbForPlatform, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime);
+ $this->updateRuleAndSendEvents($rule, $dbForPlatform, $queueForEvents, $queueForWebhooks, $publisherForFunctions, $queueForRealtime);
}
// Issue a TLS certificate when domain is verified
if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) {
- $queueForCertificates
- ->setDomain(new Document([
+ $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
+ project: new Document([
+ '$id' => $rule->getAttribute('projectId', ''),
+ '$sequence' => $rule->getAttribute('projectInternalId', 0),
+ ]),
+ domain: new Document([
'domain' => $rule->getAttribute('domain'),
'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')),
- ]))
- ->setAction(Certificate::ACTION_GENERATION)
- ->trigger();
+ ]),
+ action: \Appwrite\Event\Certificate::ACTION_GENERATION,
+ ));
Console::success('Certificate generation triggered successfully.');
}
@@ -204,10 +211,10 @@ class Certificates extends Action
* @param Domain $domain
* @param ?string $domainType
* @param Database $dbForPlatform
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param Event $queueForEvents
* @param Webhook $queueForWebhooks
- * @param Func $queueForFunctions
+ * @param FunctionPublisher $publisherForFunctions
* @param Realtime $queueForRealtime
* @param Log $log
* @param CertificatesAdapter $certificates
@@ -228,10 +235,10 @@ class Certificates extends Action
Domain $domain,
?string $domainType,
Database $dbForPlatform,
- Mail $queueForMails,
+ MailPublisher $publisherForMails,
Event $queueForEvents,
Webhook $queueForWebhooks,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Realtime $queueForRealtime,
Log $log,
CertificatesAdapter $certificates,
@@ -353,7 +360,7 @@ class Certificates extends Action
$rule->setAttribute('status', RULE_STATUS_CERTIFICATE_GENERATION_FAILED);
// Send email to security email
- $this->notifyError($domain->get(), $e->getMessage(), $attempts, $queueForMails, $plan);
+ $this->notifyError($domain->get(), $e->getMessage(), $attempts, $publisherForMails, $plan);
throw $e;
} finally {
@@ -364,7 +371,7 @@ class Certificates extends Action
// Update rule and emit events
$rule->setAttribute('certificateId', $certificate->getId());
$rule->setAttribute('logs', $logs);
- $this->updateRuleAndSendEvents($rule, $dbForPlatform, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime);
+ $this->updateRuleAndSendEvents($rule, $dbForPlatform, $queueForEvents, $queueForWebhooks, $publisherForFunctions, $queueForRealtime);
}
}
@@ -410,7 +417,7 @@ class Certificates extends Action
* @param Database $dbForPlatform Database connection for console
* @param Event $queueForEvents Event publisher for events
* @param Webhook $queueForWebhooks Webhook publisher for webhooks
- * @param Func $queueForFunctions Function publisher for functions
+ * @param FunctionPublisher $publisherForFunctions Function publisher for functions
* @param Realtime $queueForRealtime Realtime publisher for realtime events
*
* @return void
@@ -420,7 +427,7 @@ class Certificates extends Action
Database $dbForPlatform,
Event $queueForEvents,
Webhook $queueForWebhooks,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Realtime $queueForRealtime
): void {
$rule = $dbForPlatform->updateDocument('rules', $rule->getId(), new Document([
@@ -453,9 +460,15 @@ class Certificates extends Action
->trigger();
/** Trigger Functions */
- $queueForFunctions
- ->from($queueForEvents)
- ->trigger();
+ $publisherForFunctions->enqueue(FunctionMessage::fromEvent(
+ event: $queueForEvents->getEvent(),
+ params: $queueForEvents->getParams(),
+ project: $queueForEvents->getProject(),
+ user: $queueForEvents->getUser(),
+ userId: $queueForEvents->getUserId(),
+ payload: $queueForEvents->getPayload(),
+ platform: $queueForEvents->getPlatform(),
+ ));
/** Trigger Realtime Events */
$queueForRealtime
@@ -519,12 +532,12 @@ class Certificates extends Action
* @param string $domain Domain that caused the error
* @param string $errorMessage Verbose error message
* @param int $attempt How many times it failed already
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param array $plan
* @return void
* @throws Exception
*/
- private function notifyError(string $domain, string $errorMessage, int $attempt, Mail $queueForMails, array $plan): void
+ private function notifyError(string $domain, string $errorMessage, int $attempt, MailPublisher $publisherForMails, array $plan): void
{
// Log error into console
Console::warning('Cannot renew domain (' . $domain . ') on attempt no. ' . $attempt . ' certificate: ' . $errorMessage);
@@ -555,14 +568,14 @@ class Certificates extends Action
$subject = $locale->getText("emails.certificate.subject");
$preview = $locale->getText("emails.certificate.preview");
- $queueForMails
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body)
- ->setName('Appwrite Administrator')
- ->setBodyTemplate(__DIR__ . '/../../../../app/config/locale/templates/email-base-styled.tpl')
- ->setVariables($emailVariables)
- ->setRecipient(System::getEnv('_APP_EMAIL_CERTIFICATES', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS')))
- ->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ recipient: System::getEnv('_APP_EMAIL_CERTIFICATES', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS')),
+ name: 'Appwrite Administrator',
+ subject: $subject,
+ bodyTemplate: __DIR__ . '/../../../../app/config/locale/templates/email-base-styled.tpl',
+ body: $body,
+ preview: $preview,
+ variables: $emailVariables,
+ ));
}
}
diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php
index 500d42f037..49bbb2cf9e 100644
--- a/src/Appwrite/Platform/Workers/Deletes.php
+++ b/src/Appwrite/Platform/Workers/Deletes.php
@@ -24,7 +24,6 @@ use Utopia\Database\Exception\Conflict;
use Utopia\Database\Exception\Restricted;
use Utopia\Database\Exception\Structure;
use Utopia\Database\Query;
-use Utopia\Database\Validator\Authorization;
use Utopia\DSN\DSN;
use Utopia\Logger\Log;
use Utopia\Platform\Action;
@@ -97,7 +96,7 @@ class Deletes extends Action
DeleteEvent $queueForDeletes,
callable $getAudit,
): void {
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new Exception('Missing payload');
@@ -143,7 +142,7 @@ class Deletes extends Action
case DELETE_TYPE_RULES:
$this->deleteRule($dbForPlatform, $document, $certificates);
break;
- case DELETE_TYPE_TRANSACTION:
+ case DELETE_TYPE_TRANSACTIONS:
$this->deleteTransactionLogs($getProjectDB, $document, $project);
break;
default:
@@ -217,11 +216,25 @@ class Deletes extends Action
$this->deleteExpiredTransactions($project, $getProjectDB);
$this->deleteOldDeployments($queueForDeletes, $project, $getProjectDB);
break;
+ case DELETE_TYPE_REPORT:
+ $this->deleteReport($dbForPlatform, $project, $document);
+ break;
default:
throw new \Exception('No delete operation for type: ' . \strval($type));
}
}
+ private function deleteReport(Database $dbForPlatform, Document $project, Document $report): void
+ {
+ $projectInternalId = $project->getSequence();
+ $reportInternalId = $report->getSequence();
+
+ $this->deleteByGroup('insights', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::equal('reportInternalId', [$reportInternalId]),
+ ], $dbForPlatform);
+ }
+
private function cleanDatabase(
Document $databaseDoc,
callable $executionActionPerDatabase,
@@ -305,7 +318,8 @@ class Deletes extends Action
$collectionId = match ($document->getAttribute('resourceType')) {
'function' => 'functions',
'execution' => 'executions',
- 'message' => 'messages'
+ 'message' => 'messages',
+ default => throw new \Exception('Unknown resource type: ' . $document->getAttribute('resourceType')),
};
try {
@@ -363,7 +377,6 @@ class Deletes extends Action
/**
* @param Document $project
* @param callable $getProjectDB
- * @param Document $target
* @return void
* @throws Exception
*/
@@ -438,7 +451,6 @@ class Deletes extends Action
* @param string $resource
* @param string|null $resourceType
* @return void
- * @throws Authorization
* @throws Exception
*/
private function deleteCacheByResource(Document $project, callable $getProjectDB, string $resource, ?string $resourceType = null): void
@@ -518,7 +530,6 @@ class Deletes extends Action
}
/**
- * @param Database $dbForPlatform
* @param callable $getProjectDB
* @param string $hourlyUsageRetentionDatetime
* @return void
@@ -586,7 +597,6 @@ class Deletes extends Action
* @param Database $dbForPlatform
* @param Document $document
* @return void
- * @throws Authorization
* @throws DatabaseException
* @throws Conflict
* @throws Restricted
@@ -623,7 +633,6 @@ class Deletes extends Action
* @param Document $document
* @return void
* @throws Exception
- * @throws Authorization
* @throws DatabaseException
*/
protected function deleteProject(Database $dbForPlatform, callable $getProjectDB, callable $getDatabasesDB, Device $deviceForFiles, Device $deviceForSites, Device $deviceForFunctions, Device $deviceForBuilds, Device $deviceForCache, CertificatesAdapter $certificates, Document $document): void
@@ -638,6 +647,109 @@ class Deletes extends Action
$dsn = new DSN('mysql://' . $document->getAttribute('database', 'console'));
}
+ // Delete Platforms
+ try {
+ $this->deleteByGroup('platforms', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete platforms: ' . $th->getMessage());
+ }
+
+ // Delete project and function rules
+ try {
+ $this->deleteByGroup('rules', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform, function (Document $document) use ($dbForPlatform, $certificates) {
+ $this->deleteRule($dbForPlatform, $document, $certificates);
+ });
+ } catch (Throwable $th) {
+ Console::error('Failed to delete rules: ' . $th->getMessage());
+ }
+
+ // Delete Keys
+ try {
+ $this->deleteByGroup('keys', [
+ Query::equal('resourceType', ['projects']),
+ Query::equal('resourceInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete keys: ' . $th->getMessage());
+ }
+
+ // Delete Webhooks
+ try {
+ $this->deleteByGroup('webhooks', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete webhooks: ' . $th->getMessage());
+ }
+
+ // Delete VCS Installations
+ try {
+ $this->deleteByGroup('installations', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete installations: ' . $th->getMessage());
+ }
+
+ // Delete VCS Repositories
+ try {
+ $this->deleteByGroup('repositories', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete repositories: ' . $th->getMessage());
+ }
+
+ // Delete VCS comments
+ try {
+ $this->deleteByGroup('vcsComments', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete VCS comments: ' . $th->getMessage());
+ }
+
+ // Delete Schedules
+ try {
+ $this->deleteByGroup('schedules', [
+ Query::equal('projectId', [$projectId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete schedules: ' . $th->getMessage());
+ }
+
+ // Delete Advisor insights
+ try {
+ $this->deleteByGroup('insights', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete insights: ' . $th->getMessage());
+ }
+
+ // Delete Advisor reports
+ try {
+ $this->deleteByGroup('reports', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete reports: ' . $th->getMessage());
+ }
+
/**
* @var Database $dbForProject
*/
@@ -657,11 +769,8 @@ class Deletes extends Action
];
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
- $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', ''));
$projectTables = !\in_array($dsn->getHost(), $sharedTables);
- $sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1);
- $sharedTablesV2 = !$projectTables && !$sharedTablesV1;
$allDatabases = [
new Document([
@@ -693,86 +802,38 @@ class Deletes extends Action
};
batch(array_map(
- fn ($databaseDoc) => fn () => $this->cleanDatabase(
- $databaseDoc,
- $executionActionPerDatabase,
- $projectTables,
- $projectCollectionIds
- ),
+ fn ($databaseDoc) => function () use ($databaseDoc, $executionActionPerDatabase, $projectTables, $projectCollectionIds) {
+ try {
+ $this->cleanDatabase(
+ $databaseDoc,
+ $executionActionPerDatabase,
+ $projectTables,
+ $projectCollectionIds
+ );
+ } catch (Throwable $th) {
+ Console::error('Failed to delete database ' . $databaseDoc->getAttribute('database') . ': ' . $th->getMessage());
+ }
+ },
$databasesToClean
));
- // Delete Platforms
- $this->deleteByGroup('platforms', [
- Query::equal('projectInternalId', [$projectInternalId]),
- Query::orderAsc()
- ], $dbForPlatform);
-
- // Delete project and function rules
- $this->deleteByGroup('rules', [
- Query::equal('projectInternalId', [$projectInternalId]),
- Query::orderAsc()
- ], $dbForPlatform, function (Document $document) use ($dbForPlatform, $certificates) {
- $this->deleteRule($dbForPlatform, $document, $certificates);
- });
-
- // Delete Keys
- $this->deleteByGroup('keys', [
- Query::equal('resourceType', ['projects']),
- Query::equal('resourceInternalId', [$projectInternalId]),
- Query::orderAsc()
- ], $dbForPlatform);
-
- // Delete Webhooks
- $this->deleteByGroup('webhooks', [
- Query::equal('projectInternalId', [$projectInternalId]),
- Query::orderAsc()
- ], $dbForPlatform);
-
- // Delete VCS Installations
- $this->deleteByGroup('installations', [
- Query::equal('projectInternalId', [$projectInternalId]),
- Query::orderAsc()
- ], $dbForPlatform);
-
- // Delete VCS Repositories
- $this->deleteByGroup('repositories', [
- Query::equal('projectInternalId', [$projectInternalId]),
- Query::orderAsc()
- ], $dbForPlatform);
-
- // Delete VCS comments
- $this->deleteByGroup('vcsComments', [
- Query::equal('projectInternalId', [$projectInternalId]),
- Query::orderAsc()
- ], $dbForPlatform);
-
- // Delete Schedules
- $this->deleteByGroup('schedules', [
- Query::equal('projectId', [$projectId]),
- Query::orderAsc()
- ], $dbForPlatform);
-
// Delete metadata table
if ($projectTables) {
batch(array_map(
- fn ($databaseDoc) => fn () =>
- $executionActionPerDatabase(
- $databaseDoc,
- fn (Database $dbForDatabases) =>
- $dbForDatabases->deleteCollection(Database::METADATA)
- ),
+ fn ($databaseDoc) => function () use ($databaseDoc, $executionActionPerDatabase) {
+ try {
+ $executionActionPerDatabase(
+ $databaseDoc,
+ fn (Database $dbForDatabases) =>
+ $dbForDatabases->deleteCollection(Database::METADATA)
+ );
+ } catch (Throwable $th) {
+ Console::error('Failed to delete metadata table for database ' . $databaseDoc->getAttribute('database') . ': ' . $th->getMessage());
+ }
+ },
$databasesToClean
));
- } elseif ($sharedTablesV1) {
- $this->deleteByGroup(
- Database::METADATA,
- [
- Query::orderAsc()
- ],
- $dbForProject
- );
- } elseif ($sharedTablesV2) {
+ } else {
$queries = \array_map(
fn ($id) => Query::notEqual('$id', $id),
$projectCollectionIds
@@ -780,19 +841,47 @@ class Deletes extends Action
$queries[] = Query::orderAsc();
- $this->deleteByGroup(
- Database::METADATA,
- $queries,
- $dbForProject
- );
+ try {
+ $this->deleteByGroup(
+ Database::METADATA,
+ $queries,
+ $dbForProject
+ );
+ } catch (Throwable $th) {
+ Console::error('Failed to delete metadata documents: ' . $th->getMessage());
+ }
}
// Delete all storage directories
- $deviceForFiles->delete($deviceForFiles->getRoot(), true);
- $deviceForSites->delete($deviceForSites->getRoot(), true);
- $deviceForFunctions->delete($deviceForFunctions->getRoot(), true);
- $deviceForBuilds->delete($deviceForBuilds->getRoot(), true);
- $deviceForCache->delete($deviceForCache->getRoot(), true);
+ try {
+ $deviceForFiles->delete($deviceForFiles->getRoot(), true);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete files storage directory: ' . $th->getMessage());
+ }
+
+ try {
+ $deviceForSites->delete($deviceForSites->getRoot(), true);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete sites storage directory: ' . $th->getMessage());
+ }
+
+ try {
+ $deviceForFunctions->delete($deviceForFunctions->getRoot(), true);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete functions storage directory: ' . $th->getMessage());
+ }
+
+ try {
+ $deviceForBuilds->delete($deviceForBuilds->getRoot(), true);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete builds storage directory: ' . $th->getMessage());
+ }
+
+ try {
+ $deviceForCache->delete($deviceForCache->getRoot(), true);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete cache storage directory: ' . $th->getMessage());
+ }
} finally {
$dbForProject->enableValidation();
@@ -952,7 +1041,7 @@ class Deletes extends Action
// fast path, no need to list anything!
$delete($dbForProject, $resourceInternalId, $resourceType);
} else {
- $processResource = function (string $type) use ($dbForProject, $delete, $resourceType) {
+ $processResource = function (string $type) use ($dbForProject, $delete) {
$this->listByGroup(
collection: $type,
queries: [Query::select(['$id', '$sequence'])],
@@ -1083,7 +1172,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()])
@@ -1110,7 +1198,7 @@ class Deletes extends Action
Query::equal('resourceInternalId', [$siteInternalId]),
Query::equal('resourceType', ['sites']),
Query::orderAsc()
- ], $dbForProject, function (Document $document) use ($project, $certificates, $deviceForSites, $deviceForBuilds, $deviceForFiles, $dbForPlatform, &$deploymentInternalIds) {
+ ], $dbForProject, function (Document $document) use ($deviceForSites, $deviceForBuilds, $deviceForFiles, $dbForPlatform, &$deploymentInternalIds, &$deploymentIds) {
$deploymentInternalIds[] = $document->getSequence();
$deploymentIds[] = $document->getId();
$this->deleteBuildFiles($deviceForBuilds, $document);
@@ -1169,12 +1257,11 @@ 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()]),
Query::orderAsc()
- ], $dbForPlatform, function (Document $document) use ($project, $dbForPlatform, $certificates) {
+ ], $dbForPlatform, function (Document $document) use ($dbForPlatform, $certificates) {
$this->deleteRule($dbForPlatform, $document, $certificates);
});
@@ -1198,7 +1285,7 @@ class Deletes extends Action
Query::equal('resourceInternalId', [$functionInternalId]),
Query::equal('resourceType', ['functions']),
Query::orderAsc()
- ], $dbForProject, function (Document $document) use ($dbForPlatform, $project, $certificates, $deviceForFunctions, $deviceForBuilds, &$deploymentInternalIds) {
+ ], $dbForProject, function (Document $document) use ($deviceForFunctions, $deviceForBuilds, &$deploymentInternalIds) {
$deploymentInternalIds[] = $document->getSequence();
$this->deleteDeploymentFiles($deviceForFunctions, $document);
$this->deleteBuildFiles($deviceForBuilds, $document);
@@ -1323,7 +1410,7 @@ class Deletes extends Action
/**
* @param Device $device
- * @param Document $build
+ * @param Document $deployment
* @return void
*/
private function deleteBuildFiles(Device $device, Document $deployment): void
@@ -1633,9 +1720,9 @@ class Deletes extends Action
try {
$dbForProject->deleteDocuments('transactions', [
Query::lessThan('expiresAt', DateTime::format(new \DateTime())),
- ], onNext: function (Document $transaction) use ($dbForProject, $project, &$transactionInternalIds) {
+ ], onNext: function (Document $transaction) use (&$transactionInternalIds) {
$transactionInternalIds[] = $transaction->getSequence();
- }, onError: function (Throwable $th) use ($project) {
+ }, onError: function (Throwable $th) {
// Swallow errors to avoid breaking the cleanup process
});
} catch (Throwable $th) {
@@ -1648,7 +1735,7 @@ class Deletes extends Action
$dbForProject->deleteDocuments('transactionLogs', [
Query::equal('transactionInternalId', $transactionInternalIds),
- ], onError: function (Throwable $th) use ($project) {
+ ], onError: function (Throwable $th) {
// Swallow errors to avoid breaking the cleanup process
});
}
diff --git a/src/Appwrite/Platform/Workers/Executions.php b/src/Appwrite/Platform/Workers/Executions.php
index d874e26267..8dbf10cae6 100644
--- a/src/Appwrite/Platform/Workers/Executions.php
+++ b/src/Appwrite/Platform/Workers/Executions.php
@@ -2,11 +2,12 @@
namespace Appwrite\Platform\Workers;
+use Appwrite\Event\Message\Execution;
use Exception;
use Utopia\Database\Database;
-use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Queue\Message;
+use Utopia\Span\Span;
class Executions extends Action
{
@@ -32,21 +33,19 @@ class Executions extends Action
Message $message,
Database $dbForProject,
): void {
- $payload = $message->getPayload() ?? [];
-
- if (empty($payload)) {
- throw new Exception('Missing payload');
- }
-
- $execution = new Document($payload['execution'] ?? []);
+ $executionMessage = Execution::fromArray($message->getPayload());
+ $execution = $executionMessage->execution;
if ($execution->isEmpty()) {
throw new Exception('Missing execution');
}
- $project = new Document($payload['project'] ?? []);
- if ($project->getId() != '6862e6a6000cce69f9da') {
- $dbForProject->upsertDocument('executions', $execution);
- }
+ Span::add('project.id', $executionMessage->project->getId());
+ Span::add('function.id', $execution->getAttribute('resourceId', ''));
+ Span::add('execution.id', $execution->getId());
+ Span::add('deployment.id', $execution->getAttribute('deploymentId', ''));
+ Span::add('resource.type', $execution->getAttribute('resourceType', ''));
+
+ $dbForProject->upsertDocument('executions', $execution);
}
}
diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php
index 29ccb0ef09..73c1db9444 100644
--- a/src/Appwrite/Platform/Workers/Functions.php
+++ b/src/Appwrite/Platform/Workers/Functions.php
@@ -5,11 +5,13 @@ namespace Appwrite\Platform\Workers;
use Ahc\Jwt\JWT;
use Appwrite\Bus\Events\ExecutionCompleted;
use Appwrite\Event\Event;
-use Appwrite\Event\Func;
+use Appwrite\Event\Message\Func as FunctionMessage;
+use Appwrite\Event\Publisher\Func as FunctionPublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Utopia\Response\Model\Execution;
+use Executor\Exception\Timeout as ExecutorTimeout;
use Executor\Executor;
use Utopia\Bus\Bus;
use Utopia\Config\Config;
@@ -23,6 +25,7 @@ use Utopia\Database\Query;
use Utopia\Logger\Log;
use Utopia\Platform\Action;
use Utopia\Queue\Message;
+use Utopia\Span\Span;
use Utopia\System\System;
class Functions extends Action
@@ -33,7 +36,7 @@ class Functions extends Action
}
/**
- * @throws Exception
+ * @throws \Exception
*/
public function __construct()
{
@@ -44,7 +47,7 @@ class Functions extends Action
->inject('message')
->inject('dbForProject')
->inject('queueForWebhooks')
- ->inject('queueForFunctions')
+ ->inject('publisherForFunctions')
->inject('queueForRealtime')
->inject('queueForEvents')
->inject('bus')
@@ -59,7 +62,7 @@ class Functions extends Action
Message $message,
Database $dbForProject,
Webhook $queueForWebhooks,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Realtime $queueForRealtime,
Event $queueForEvents,
Bus $bus,
@@ -67,7 +70,7 @@ class Functions extends Action
Executor $executor,
callable $isResourceBlocked
): void {
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new AppwriteException(
@@ -76,20 +79,27 @@ class Functions extends Action
);
}
- $type = $payload['type'] ?? '';
+ $functionMessage = FunctionMessage::fromArray($payload);
+ $type = $functionMessage->type;
- $events = $payload['events'] ?? [];
- $data = $payload['body'] ?? '';
- $eventData = $payload['payload'] ?? '';
- $platform = $payload['platform'] ?? Config::getParam('platform', []);
- $function = new Document($payload['function'] ?? []);
- $functionId = $payload['functionId'] ?? '';
- $user = new Document($payload['user'] ?? []);
- $userId = $payload['userId'] ?? '';
- $method = $payload['method'] ?? 'POST';
- $headers = $payload['headers'] ?? [];
- $path = $payload['path'] ?? '/';
- $jwt = $payload['jwt'] ?? '';
+ Span::add('project.id', $project->getId());
+ Span::add('payload.type', $type);
+ Span::add('queue.pid', $message->getPid());
+ Span::add('queue.name', $message->getQueue());
+ Span::add('message.timestamp', (string) $message->getTimestamp());
+
+ $events = $functionMessage->events;
+ $data = $functionMessage->body;
+ $eventData = $functionMessage->payload;
+ $platform = !empty($functionMessage->platform) ? $functionMessage->platform : Config::getParam('platform', []);
+ $function = $functionMessage->function ?? new Document();
+ $functionId = $functionMessage->functionId ?? '';
+ $user = $functionMessage->user ?? new Document();
+ $userId = $functionMessage->userId ?? '';
+ $method = $functionMessage->method ?: 'POST';
+ $headers = $functionMessage->headers;
+ $path = $functionMessage->path ?: '/';
+ $jwt = $functionMessage->jwt;
if ($user->isEmpty() && !empty($userId)) {
$user = $dbForProject->getDocument('users', $userId);
@@ -115,6 +125,10 @@ class Functions extends Action
$log->addTag('projectId', $project->getId());
$log->addTag('type', $type);
+ if (empty($events) && !$function->isEmpty()) {
+ Span::add('function.id', $function->getId());
+ }
+
if (!empty($events)) {
$limit = 100;
$sum = 100;
@@ -154,7 +168,7 @@ class Functions extends Action
log: $log,
dbForProject: $dbForProject,
queueForWebhooks: $queueForWebhooks,
- queueForFunctions: $queueForFunctions,
+ publisherForFunctions: $publisherForFunctions,
queueForRealtime: $queueForRealtime,
queueForEvents: $queueForEvents,
bus: $bus,
@@ -173,7 +187,7 @@ class Functions extends Action
user: $user,
jwt: null,
event: $events[0],
- eventData: \is_string($eventData) ? $eventData : \json_encode($eventData),
+ eventData: \json_encode($eventData) ?: null,
executionId: null,
);
Console::success('Triggered function: ' . $events[0]);
@@ -198,7 +212,7 @@ class Functions extends Action
log: $log,
dbForProject: $dbForProject,
queueForWebhooks: $queueForWebhooks,
- queueForFunctions: $queueForFunctions,
+ publisherForFunctions: $publisherForFunctions,
queueForRealtime: $queueForRealtime,
queueForEvents: $queueForEvents,
bus: $bus,
@@ -224,7 +238,7 @@ class Functions extends Action
log: $log,
dbForProject: $dbForProject,
queueForWebhooks: $queueForWebhooks,
- queueForFunctions: $queueForFunctions,
+ publisherForFunctions: $publisherForFunctions,
queueForRealtime: $queueForRealtime,
queueForEvents: $queueForEvents,
bus: $bus,
@@ -241,7 +255,7 @@ class Functions extends Action
jwt: $jwt,
event: null,
eventData: null,
- executionId: $execution->getId() ?? null
+ executionId: $execution->getId()
);
break;
}
@@ -256,7 +270,7 @@ class Functions extends Action
* @param Document $user
* @param string|null $jwt
* @param string|null $event
- * @throws Exception
+ * @throws \Exception
*/
private function fail(
string $message,
@@ -271,10 +285,10 @@ class Functions extends Action
?string $event = null,
): void {
$executionId = ID::unique();
- $headers['x-appwrite-execution-id'] = $executionId ?? '';
+ $headers['x-appwrite-execution-id'] = $executionId;
$headers['x-appwrite-trigger'] = $trigger;
$headers['x-appwrite-event'] = $event ?? '';
- $headers['x-appwrite-user-id'] = $user->getId() ?? '';
+ $headers['x-appwrite-user-id'] = $user->getId();
$headers['x-appwrite-user-jwt'] = $jwt ?? '';
$headersFiltered = [];
@@ -304,6 +318,12 @@ class Functions extends Action
'duration' => 0.0,
]);
+ Span::add('function.id', $function->getId());
+ Span::add('execution.id', $execution->getId());
+ Span::add('deployment.id', $execution->getAttribute('deploymentId', ''));
+ Span::add('execution.trigger', $trigger);
+ Span::add('execution.status', $execution->getAttribute('status', ''));
+
$bus->dispatch(new ExecutionCompleted(
execution: $execution->getArrayCopy(),
project: $project->getArrayCopy(),
@@ -313,7 +333,7 @@ class Functions extends Action
/**
* @param Log $log
* @param Database $dbForProject
- * @param Func $queueForFunctions
+ * @param FunctionPublisher $publisherForFunctions
* @param Realtime $queueForRealtime
* @param Event $queueForEvents
* @param Document $project
@@ -335,7 +355,7 @@ class Functions extends Action
Log $log,
Database $dbForProject,
Webhook $queueForWebhooks,
- Func $queueForFunctions,
+ FunctionPublisher $publisherForFunctions,
Realtime $queueForRealtime,
Event $queueForEvents,
Bus $bus,
@@ -359,6 +379,10 @@ class Functions extends Action
$deploymentId = $function->getAttribute('deploymentId', '');
$spec = Config::getParam('specifications')[$function->getAttribute('runtimeSpecification', APP_COMPUTE_SPECIFICATION_DEFAULT)];
+ Span::add('function.id', $functionId);
+ Span::add('deployment.id', $deploymentId);
+ Span::add('execution.trigger', $trigger);
+
$log->addTag('deploymentId', $deploymentId);
/** Check if deployment exists */
@@ -403,10 +427,10 @@ class Functions extends Action
]);
$headers['x-appwrite-execution-id'] = $executionId ?? '';
- $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey;
+ $headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $apiKey;
$headers['x-appwrite-trigger'] = $trigger;
$headers['x-appwrite-event'] = $event ?? '';
- $headers['x-appwrite-user-id'] = $user->getId() ?? '';
+ $headers['x-appwrite-user-id'] = $user->getId();
$headers['x-appwrite-user-jwt'] = $jwt ?? '';
$headers['x-appwrite-country-code'] = '';
$headers['x-appwrite-continent-code'] = '';
@@ -418,6 +442,8 @@ class Functions extends Action
}
$headers['x-appwrite-execution-id'] = $executionId;
+ Span::add('execution.id', $executionId);
+
$headersFiltered = [];
foreach ($headers as $key => $value) {
if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_REQUEST)) {
@@ -457,12 +483,12 @@ class Functions extends Action
// V2 vars
if ($version === 'v2') {
$vars = \array_merge($vars, [
- 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '',
- 'APPWRITE_FUNCTION_DATA' => $body ?? '',
- 'APPWRITE_FUNCTION_EVENT_DATA' => $body ?? '',
- 'APPWRITE_FUNCTION_EVENT' => $headers['x-appwrite-event'] ?? '',
- 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '',
- 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? ''
+ 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'],
+ 'APPWRITE_FUNCTION_DATA' => $body,
+ 'APPWRITE_FUNCTION_EVENT_DATA' => $body,
+ 'APPWRITE_FUNCTION_EVENT' => $headers['x-appwrite-event'],
+ 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'],
+ 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt']
]);
}
@@ -508,35 +534,42 @@ class Functions extends Action
]);
/** Execute function */
+ $error = null;
+ $errorCode = 0;
+
try {
$version = $function->getAttribute('version', 'v2');
$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', '');
$extension = str_ends_with($source, '.tar') ? 'tar' : 'tar.gz';
$command = $version === 'v2' ? '' : "cp /tmp/code.$extension /mnt/code/code.$extension && nohup helpers/start.sh \"$command\"";
- $executionResponse = $executor->createExecution(
- projectId: $project->getId(),
- deploymentId: $deploymentId,
- body: \strlen($body) > 0 ? $body : null,
- variables: $vars,
- timeout: $function->getAttribute('timeout', 0),
- image: $runtime['image'],
- source: $source,
- entrypoint: $deployment->getAttribute('entrypoint', ''),
- version: $version,
- path: $path,
- method: $method,
- headers: $headers,
- runtimeEntrypoint: $command,
- cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT,
- memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT,
- logging: $function->getAttribute('logging', true),
- );
+ try {
+ $executionResponse = $executor->createExecution(
+ projectId: $project->getId(),
+ deploymentId: $deploymentId,
+ body: \strlen($body) > 0 ? $body : null,
+ variables: $vars,
+ timeout: $function->getAttribute('timeout', 0),
+ image: $runtime['image'],
+ source: $source,
+ entrypoint: $deployment->getAttribute('entrypoint', ''),
+ version: $version,
+ path: $path,
+ method: $method,
+ headers: $headers,
+ runtimeEntrypoint: $command,
+ cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT,
+ memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT,
+ logging: $function->getAttribute('logging', true),
+ );
+ } catch (ExecutorTimeout $th) {
+ throw new AppwriteException(AppwriteException::FUNCTION_ASYNCHRONOUS_TIMEOUT, previous: $th);
+ }
$status = $executionResponse['statusCode'] >= 500 ? 'failed' : 'completed';
@@ -591,6 +624,8 @@ class Functions extends Action
$errorCode = $th->getCode();
} finally {
/** Persist final execution status and record usage */
+ Span::add('execution.status', $execution->getAttribute('status', ''));
+
$bus->dispatch(new ExecutionCompleted(
execution: $execution->getArrayCopy(),
project: $project->getArrayCopy(),
@@ -616,9 +651,15 @@ class Functions extends Action
->trigger();
/** Trigger Functions */
- $queueForFunctions
- ->from($queueForEvents)
- ->trigger();
+ $publisherForFunctions->enqueue(FunctionMessage::fromEvent(
+ event: $queueForEvents->getEvent(),
+ params: $queueForEvents->getParams(),
+ project: $queueForEvents->getProject(),
+ user: $queueForEvents->getUser(),
+ userId: $queueForEvents->getUserId(),
+ payload: $queueForEvents->getPayload(),
+ platform: $queueForEvents->getPlatform(),
+ ));
/** Trigger Realtime Events */
$queueForRealtime
@@ -629,7 +670,7 @@ class Functions extends Action
if (!empty($error)) {
throw new AppwriteException(
AppwriteException::GENERAL_SERVER_ERROR,
- 'Function execution failed: ' . ($error ?: 'No error message provided'),
+ 'Function execution failed: ' . $error,
$errorCode
);
}
diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php
index f144c58e1b..5cd4639988 100644
--- a/src/Appwrite/Platform/Workers/Mails.php
+++ b/src/Appwrite/Platform/Workers/Mails.php
@@ -4,10 +4,13 @@ namespace Appwrite\Platform\Workers;
use Appwrite\Template\Template;
use Exception;
-use PHPMailer\PHPMailer\PHPMailer;
use Swoole\Runtime;
use Utopia\Database\Document;
use Utopia\Logger\Log;
+use Utopia\Messaging\Adapter\Email as EmailAdapter;
+use Utopia\Messaging\Adapter\Email\SMTP;
+use Utopia\Messaging\Messages\Email as EmailMessage;
+use Utopia\Messaging\Messages\Email\Attachment;
use Utopia\Platform\Action;
use Utopia\Queue\Message;
use Utopia\Registry\Registry;
@@ -49,16 +52,16 @@ class Mails extends Action
/**
* @param Message $message
+ * @param Document $project
* @param Registry $register
* @param Log $log
- * @throws \PHPMailer\PHPMailer\Exception
* @return void
* @throws Exception
*/
public function action(Message $message, Document $project, Registry $register, Log $log): void
{
Runtime::setHookFlags(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP);
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new Exception('Missing payload');
@@ -132,58 +135,76 @@ class Mails extends Action
// render() will return the subject in tags, so use strip_tags() to remove them
$subject = \strip_tags($subjectTemplate->render());
- /** @var PHPMailer $mail */
- $mail = empty($smtp)
+ /** @var EmailAdapter $adapter */
+ $adapter = empty($smtp)
? $register->get('smtp')
- : $this->getMailer($smtp);
+ : new SMTP(
+ host: $smtp['host'],
+ port: (int) $smtp['port'],
+ username: $smtp['username'] ?? '',
+ password: $smtp['password'] ?? '',
+ smtpSecure: $smtp['secure'] ?? '',
+ smtpAutoTLS: false,
+ xMailer: 'Appwrite Mailer',
+ timeout: 10,
+ keepAlive: true,
+ timelimit: 30,
+ );
- $mail->clearAddresses();
- $mail->clearAllRecipients();
- $mail->clearReplyTos();
- $mail->clearAttachments();
- $mail->clearBCCs();
- $mail->clearCCs();
- $mail->addAddress($recipient, $name);
- $mail->Subject = $subject;
- $mail->Body = $body;
+ // Resolve from/replyTo using fallback hierarchy: Custom options > SMTP config > Defaults
+ $defaultFromEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
+ $defaultFromName = \urldecode(System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'));
- $mail->AltBody = $body;
- $mail->AltBody = preg_replace('/