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 0df9cb42f4..4a6a3ac344 100644
--- a/.env
+++ b/.env
@@ -39,7 +39,7 @@ _APP_REDIS_HOST=redis
_APP_REDIS_PORT=6379
_APP_REDIS_PASS=
_APP_REDIS_USER=
-COMPOSE_PROFILES=mongodb
+COMPOSE_PROFILES=mariadb,mongodb,postgresql
_APP_DB_ADAPTER=mongodb
_APP_DB_HOST=mongodb
_APP_DB_PORT=27017
@@ -47,6 +47,17 @@ _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
+_APP_DB_ADAPTER_VECTORSDB=postgresql
+_APP_DB_HOST_VECTORSDB=postgresql
+_APP_DB_PORT_VECTORSDB=5432
+_APP_EMBEDDING_MODELS=embeddinggemma
+_APP_EMBEDDING_ENDPOINT='http://ollama:11434/api/embed'
+_APP_EMBEDDING_TIMEOUT=30000
_APP_STORAGE_DEVICE=Local
_APP_STORAGE_S3_ACCESS_KEY=
_APP_STORAGE_S3_SECRET=
@@ -137,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 483f3dbeee..948fa6c0c1 100644
--- a/.github/workflows/ai-moderator.yml
+++ b/.github/workflows/ai-moderator.yml
@@ -25,6 +25,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: AI Moderator
- uses: github/ai-moderator@v1
+ uses: github/ai-moderator@81159c370785e295c97461ade67d7c33576e9319 # v1.1.4
with:
token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/auto-label-issue.yml b/.github/workflows/auto-label-issue.yml
index e0eb0de98d..0151c2f9c1 100644
--- a/.github/workflows/auto-label-issue.yml
+++ b/.github/workflows/auto-label-issue.yml
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Issue Labeler
- uses: github/issue-labeler@v3.4
+ uses: github/issue-labeler@c1b0f9f52a63158c4adc09425e858e87b32e9685 # v3.4
with:
configuration-path: .github/labeler.yml
enable-versioned-regex: false
diff --git a/.github/workflows/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/ci.yml b/.github/workflows/ci.yml
index aa6dbe2bc3..ac16c80264 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,6 +7,8 @@ concurrency:
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:
@@ -18,6 +20,10 @@ on:
type: string
default: ''
+permissions:
+ contents: read
+ packages: write
+
jobs:
dependencies:
name: Checks / Dependencies
@@ -26,7 +32,7 @@ jobs:
actions: read
security-events: write
contents: read
- uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@v2.3.3"
+ uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
security:
name: Checks / Image
@@ -37,13 +43,13 @@ jobs:
security-events: write
steps:
- name: Check out code
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
submodules: 'recursive'
- name: Build the Docker image
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
push: false
@@ -52,7 +58,7 @@ jobs:
target: production
- name: Run Trivy vulnerability scanner on image
- uses: aquasecurity/trivy-action@0.35.0
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: 'pr_image:${{ github.sha }}'
format: 'sarif'
@@ -60,7 +66,7 @@ jobs:
severity: 'CRITICAL,HIGH'
- name: Run Trivy vulnerability scanner on source code
- uses: aquasecurity/trivy-action@0.35.0
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'
@@ -70,14 +76,14 @@ jobs:
skip-setup-trivy: true
- name: Upload image scan results
- uses: github/codeql-action/upload-sarif@v4
+ uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
if: always() && hashFiles('trivy-image-results.sarif') != ''
with:
sarif_file: 'trivy-image-results.sarif'
category: 'trivy-image'
- name: Upload source code scan results
- uses: github/codeql-action/upload-sarif@v4
+ uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
if: always() && hashFiles('trivy-fs-results.sarif') != ''
with:
sarif_file: 'trivy-fs-results.sarif'
@@ -88,10 +94,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup PHP
- uses: shivammathur/setup-php@v2
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version: '8.3'
tools: composer:v2
@@ -113,7 +119,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2
@@ -121,7 +127,7 @@ jobs:
if: github.event_name == 'pull_request'
- name: Setup PHP
- uses: shivammathur/setup-php@v2
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version: '8.3'
tools: composer:v2
@@ -138,10 +144,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup PHP
- uses: shivammathur/setup-php@v2
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version: '8.3'
tools: composer:v2
@@ -150,18 +156,47 @@ jobs:
- 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
+ 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@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
@@ -177,11 +212,11 @@ jobs:
steps:
- name: Generate matrix
id: generate
- uses: actions/github-script@v8
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const allDatabases = ['MariaDB', 'PostgreSQL', 'MongoDB'];
- const allModes = ['dedicated', 'shared_v1', 'shared_v2'];
+ const allModes = ['dedicated', 'shared'];
const defaultDatabases = ['MongoDB'];
const defaultModes = ['dedicated'];
@@ -218,42 +253,40 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v4
+ - name: Login to GHCR
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
- - name: Build Appwrite
- uses: docker/build-push-action@v6
+ - 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: false
- tags: ${{ env.IMAGE }}
- load: true
+ push: true
+ tags: ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- outputs: type=docker,dest=/tmp/${{ env.IMAGE }}.tar
target: development
build-args: |
DEBUG=false
TESTING=true
VERSION=dev
- - name: Upload Docker Image
- uses: actions/upload-artifact@v7
- with:
- name: ${{ env.IMAGE }}
- path: /tmp/${{ env.IMAGE }}.tar
- retention-days: 1
-
unit:
name: Tests / Unit
runs-on: ubuntu-latest
@@ -261,26 +294,32 @@ jobs:
permissions:
contents: read
pull-requests: write
+ packages: read
steps:
- name: checkout
- uses: actions/checkout@v6
-
- - name: Download Docker Image
- uses: actions/download-artifact@v7
- with:
- name: ${{ env.IMAGE }}
- path: /tmp
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@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 load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
@@ -288,7 +327,7 @@ jobs:
run: docker compose exec -T appwrite vars
- name: Run Unit Tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
@@ -308,26 +347,32 @@ jobs:
permissions:
contents: read
pull-requests: write
+ packages: read
steps:
- name: checkout
- uses: actions/checkout@v6
-
- - name: Download Docker Image
- uses: actions/download-artifact@v7
- with:
- name: ${{ env.IMAGE }}
- path: /tmp
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@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 load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
@@ -340,7 +385,7 @@ jobs:
done
- name: Run General Tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
@@ -361,11 +406,12 @@ jobs:
e2e_service:
name: Tests / E2E / ${{ matrix.database }} (${{ matrix.mode }}) / ${{ matrix.service }}
- runs-on: ${{ matrix.runner || 'ubuntu-latest' }}
+ 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:
@@ -381,6 +427,7 @@ jobs:
FunctionsSchedule,
GraphQL,
Health,
+ Advisor,
Locale,
Projects,
Realtime,
@@ -399,29 +446,23 @@ jobs:
]
include:
- service: Databases
- runner: blacksmith-4vcpu-ubuntu-2404
- - service: Sites
- runner: blacksmith-4vcpu-ubuntu-2404
- - service: Functions
- runner: blacksmith-4vcpu-ubuntu-2404
- - service: Avatars
- runner: blacksmith-4vcpu-ubuntu-2404
- - service: Realtime
- runner: blacksmith-4vcpu-ubuntu-2404
+ runner: runs-on=${{ github.run_id }}/runner=8cpu-linux-x64/volume=120g/spot=false
+ paratest_processes: 3
+ timeout_minutes: 30
- service: TablesDB
- runner: blacksmith-4vcpu-ubuntu-2404
+ 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@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - name: Download Docker Image
- uses: actions/download-artifact@v7
- with:
- name: ${{ env.IMAGE }}
- path: /tmp
-
- - name: Set database environment
+ - name: Set environment
run: |
+ echo "_APP_OPTIONS_ROUTER_PROTECTION=enabled" >> $GITHUB_ENV
+
if [ "${{ matrix.database }}" = "MariaDB" ]; then
echo "COMPOSE_PROFILES=mariadb" >> $GITHUB_ENV
echo "_APP_DB_ADAPTER=mariadb" >> $GITHUB_ENV
@@ -440,19 +481,31 @@ jobs:
fi
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@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_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && '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 load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
@@ -465,11 +518,11 @@ jobs:
done
- name: Run tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
- timeout_minutes: 20
+ timeout_minutes: ${{ matrix.timeout_minutes || 20 }}
job_id: ${{ job.check_run_id }}
github_token: ${{ secrets.GITHUB_TOKEN }}
test_dir: tests/e2e/Services/${{ matrix.service }}
@@ -479,12 +532,19 @@ jobs:
# Services that rely on sequential test method execution (shared static state)
FUNCTIONAL_FLAG="--functional"
case "${{ matrix.service }}" in
- Databases|TablesDB|Functions|Realtime) FUNCTIONAL_FLAG="" ;;
+ 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 }}" \
- appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml
+ -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()
@@ -499,39 +559,48 @@ jobs:
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@v6
-
- - name: Download Docker Image
- uses: actions/download-artifact@v7
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
- name: ${{ env.IMAGE }}
- path: /tmp
+ fetch-depth: 1
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@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_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && '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 load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
- name: Run tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
@@ -557,33 +626,40 @@ jobs:
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@v6
-
- - name: Download Docker Image
- uses: actions/download-artifact@v7
- with:
- name: ${{ env.IMAGE }}
- path: /tmp
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@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_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && '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 load --input /tmp/${{ env.IMAGE }}.tar
docker compose pull --quiet --ignore-buildable
docker compose up -d --quiet-pull --wait
@@ -596,7 +672,7 @@ jobs:
done
- name: Run tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
@@ -617,99 +693,174 @@ jobs:
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@v6
-
- - name: Download Docker Image
- uses: actions/download-artifact@v7
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
- name: ${{ env.IMAGE }}
- path: /tmp
+ fetch-depth: 1
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- - name: Load and Start Appwrite
- run: |
- sed -i 's/traefik/localhost/g' .env
- docker load --input /tmp/${{ env.IMAGE }}.tar
- docker compose up -d
- sleep 10
-
- - name: Install Oha
- run: |
- echo "deb [signed-by=/usr/share/keyrings/azlux-archive-keyring.gpg] http://packages.azlux.fr/debian/ stable main" | sudo tee /etc/apt/sources.list.d/azlux.list
- sudo wget -O /usr/share/keyrings/azlux-archive-keyring.gpg https://azlux.fr/repo.gpg
- sudo apt update
- sudo apt install oha
- oha --version
-
- - name: Benchmark PR
- run: 'oha -z 180s http://localhost/v1/health/version --output-format json > benchmark.json'
-
- - name: Cleaning
- run: docker compose down -v
-
- - name: Installing latest version
- run: |
- rm docker-compose.yml
- rm .env
- curl https://appwrite.io/install/compose -o docker-compose.yml
- curl https://appwrite.io/install/env -o .env
- sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env
- docker compose up -d
- sleep 10
-
- - name: Benchmark Latest
- run: oha -z 180s http://localhost/v1/health/version --output-format json > benchmark-latest.json
-
- - name: Prepare comment
- run: |
- echo '## :sparkles: Benchmark results' > benchmark.txt
- echo ' ' >> benchmark.txt
- echo "- Requests per second: $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt
- echo "- Requests with 200 status code: $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt
- echo "- P99 latency: $(jq -r '.latencyPercentiles.p99' benchmark.json )" >> benchmark.txt
- echo " " >> benchmark.txt
- echo " " >> benchmark.txt
- echo "## :zap: Benchmark Comparison" >> benchmark.txt
- echo " " >> benchmark.txt
- echo "| Metric | This PR | Latest version | " >> benchmark.txt
- echo "| --- | --- | --- | " >> benchmark.txt
- echo "| RPS | $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.summary.requestsPerSec|tonumber|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt
- echo "| 200 | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt
- echo "| P99 | $(jq -r '.latencyPercentiles.p99' benchmark.json ) | $(jq -r '.latencyPercentiles.p99' benchmark-latest.json ) | " >> benchmark.txt
-
- - name: Save results
- uses: actions/upload-artifact@v7
- if: ${{ !cancelled() }}
+ - name: Login to GHCR
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
- name: benchmark.json
- path: benchmark.json
- retention-days: 7
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
- - name: Find Comment
- if: github.event.pull_request.head.repo.full_name == github.repository
- uses: peter-evans/find-comment@v3
- id: fc
+ - 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:
- issue-number: ${{ github.event.pull_request.number }}
- comment-author: 'github-actions[bot]'
- body-includes: Benchmark results
+ 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: github.event.pull_request.head.repo.full_name == github.repository
- uses: peter-evans/create-or-update-comment@v4
+ 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:
- comment-id: ${{ steps.fc.outputs.comment-id }}
- issue-number: ${{ github.event.pull_request.number }}
- body-path: benchmark.txt
- edit-mode: replace
+ 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/nightly.yml b/.github/workflows/nightly.yml
index 5cbec8f867..0a49f658ac 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -10,13 +10,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
- name: Build the Docker image
run: DOCKER_BUILDKIT=1 docker build . --target production -t appwrite_image:latest
- name: Run Trivy vulnerability scanner on image
- uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: 'appwrite_image:latest'
format: 'sarif'
@@ -24,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@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: 'fs'
format: 'sarif'
output: 'trivy-fs-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Code Scan Results
- uses: github/codeql-action/upload-sarif@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/publish.yml b/.github/workflows/publish.yml
index 692861d44d..68ab657213 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -12,33 +12,33 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2
submodules: recursive
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to Docker Hub
- uses: docker/login-action@v3
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
- uses: docker/metadata-action@v4
+ uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: appwrite/cloud
tags: |
type=ref,event=tag
- name: Build & Publish to DockerHub
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64,linux/arm64
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 84fc4c9fba..ed4e46d811 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -20,20 +20,20 @@ jobs:
submodules: recursive
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to Docker Hub
- uses: docker/login-action@v3
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
- uses: docker/metadata-action@v4
+ uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: appwrite/appwrite
tags: |
@@ -42,7 +42,7 @@ jobs:
type=semver,pattern={{major}}
- name: Build and push
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64,linux/arm64
diff --git a/.github/workflows/sdk-preview.yml b/.github/workflows/sdk-preview.yml
index f81346a7d1..dacc37a64a 100644
--- a/.github/workflows/sdk-preview.yml
+++ b/.github/workflows/sdk-preview.yml
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set SDK type
id: set-sdk
@@ -49,7 +49,7 @@ jobs:
docker compose exec appwrite sdks --platform=${{ steps.set-sdk.outputs.platform }} --sdk=${{ steps.set-sdk.outputs.sdk_type }} --version=latest --git=no
sudo chown -R $USER:$USER ./app/sdks/${{ steps.set-sdk.outputs.platform }}-${{ steps.set-sdk.outputs.sdk_type }}
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml
index 6f377354d5..85c76bacd3 100644
--- a/.github/workflows/specs.yml
+++ b/.github/workflows/specs.yml
@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 6e4a8ba73b..73b767aafe 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/stale@v10
+ - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been labeled as a 'question', indicating that it requires additional information from the requestor. It has been inactive for 7 days. If no further activity occurs, this issue will be closed in 14 days."
diff --git a/.gitignore b/.gitignore
index d6e138a382..6846e19aa7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,7 @@ appwrite.config.json
/app/config/specs/
/docs/examples/
.phpunit.cache
+.phpstan-cache
playwright-report
test-results
docker-compose.web-installer.yml
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 548c0d72b0..6894322043 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -892,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/Dockerfile b/Dockerfile
index 7cb007c188..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.1 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
@@ -100,7 +104,8 @@ RUN mkdir -p /etc/letsencrypt/live/ && chmod -Rf 755 /etc/letsencrypt/live/
FROM base AS production
RUN rm -rf /usr/src/code/app/config/specs && \
- rm -f /usr/local/lib/php/extensions/no-debug-non-zts-20240924/xdebug.so && \
+ rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini && \
+ rm -f /usr/local/lib/php/extensions/no-debug-non-zts-*/xdebug.so && \
find /usr -name '*.a' -delete 2>/dev/null || true && \
find /usr -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true && \
find /usr -name '*.pyc' -delete 2>/dev/null || true
diff --git a/README.md b/README.md
index 457863d236..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,6 +71,7 @@ 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" \
@@ -84,6 +84,7 @@ 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" ^
@@ -94,6 +95,7 @@ docker run -it --rm ^
```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" `
@@ -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 b8721320be..ada155c4dc 100644
--- a/app/cli.php
+++ b/app/cli.php
@@ -2,12 +2,12 @@
require_once __DIR__ . '/init.php';
-use Appwrite\Event\Certificate;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
+use Appwrite\Event\Publisher\Certificate as CertificatePublisher;
+use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
-use Appwrite\Event\StatsResources;
use Appwrite\Platform\Appwrite;
use Appwrite\Runtimes\Runtimes;
use Appwrite\Usage\Context as UsageContext;
@@ -18,13 +18,15 @@ 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;
@@ -47,7 +49,7 @@ 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])) {
@@ -56,21 +58,15 @@ if (! isset($args[0])) {
}
$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 = [];
@@ -81,18 +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;
@@ -135,17 +131,17 @@ $setResource('dbForPlatform', function ($pools, $cache, $authorization) {
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) {
@@ -161,12 +157,19 @@ $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)
+ ->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -186,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($project->getSequence())
+ ->setGlobalCollections($projectsGlobalCollections)
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -207,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($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);
@@ -224,6 +239,7 @@ $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);
@@ -235,41 +251,43 @@ $setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $a
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('publisherMessaging', function (BrokerPool $publisher) {
+$container->set('publisherMessaging', function (BrokerPool $publisher) {
return $publisher;
}, ['publisher']);
-$setResource('usage', function () {
+$container->set('usage', function () {
return new UsageContext();
}, []);
-$setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
+$container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
$publisher,
new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
), ['publisher']);
-$setResource('queueForStatsResources', function (Publisher $publisher) {
- return new StatsResources($publisher);
-}, ['publisher']);
-$setResource('queueForFunctions', function (Publisher $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('queueForFunctions', function (Publisher $publisher) {
return new Func($publisher);
}, ['publisher']);
-$setResource('queueForDeletes', function (Publisher $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));
@@ -321,25 +339,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();
});
@@ -348,3 +369,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.php b/app/config/collections.php
index a74e079dce..3af20ff2ac 100644
--- a/app/config/collections.php
+++ b/app/config/collections.php
@@ -4,6 +4,7 @@
$common = include __DIR__ . '/collections/common.php';
$projects = include __DIR__ . '/collections/projects.php';
$databases = include __DIR__ . '/collections/databases.php';
+$vectorsdb = include __DIR__ . '/collections/vectorsdb.php';
$platform = include __DIR__ . '/collections/platform.php';
$logs = include __DIR__ . '/collections/logs.php';
@@ -26,6 +27,7 @@ unset($common['files']);
$collections = [
'buckets' => $buckets,
'databases' => $databases,
+ 'vectorsdb' => $vectorsdb,
'projects' => array_merge_recursive($projects, $common),
'console' => array_merge_recursive($platform, $common),
'logs' => $logs,
diff --git a/app/config/collections/common.php b/app/config/collections/common.php
index 80bb717423..37fbcc8ca3 100644
--- a/app/config/collections/common.php
+++ b/app/config/collections/common.php
@@ -1523,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 55dceb9b40..9568c59369 100644
--- a/app/config/collections/projects.php
+++ b/app/config/collections/projects.php
@@ -61,6 +61,15 @@ return [
'array' => false,
'filters' => [],
],
+ [
+ '$id' => ID::custom('database'),
+ 'type' => Database::VAR_STRING,
+ 'size' => 2000,
+ 'required' => false,
+ 'signed' => true,
+ 'array' => false,
+ 'filters' => [],
+ ]
],
'indexes' => [
[
diff --git a/app/config/collections/vectorsdb.php b/app/config/collections/vectorsdb.php
new file mode 100644
index 0000000000..817863cffa
--- /dev/null
+++ b/app/config/collections/vectorsdb.php
@@ -0,0 +1,165 @@
+ [
+ '$collection' => ID::custom('databases'),
+ '$id' => ID::custom('collections'),
+ 'name' => 'Collections',
+ 'attributes' => [
+ [
+ '$id' => ID::custom('databaseInternalId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('databaseId'),
+ 'type' => Database::VAR_STRING,
+ 'signed' => true,
+ 'size' => Database::LENGTH_KEY,
+ 'format' => '',
+ 'filters' => [],
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ ],
+ [
+ '$id' => ID::custom('name'),
+ 'type' => Database::VAR_STRING,
+ 'size' => 256,
+ 'required' => true,
+ 'signed' => true,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('dimension'),
+ 'type' => Database::VAR_INTEGER,
+ 'size' => 0,
+ 'required' => true,
+ 'signed' => false,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('enabled'),
+ 'type' => Database::VAR_BOOLEAN,
+ 'signed' => true,
+ 'size' => 0,
+ 'format' => '',
+ 'filters' => [],
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ ],
+ [
+ '$id' => ID::custom('documentSecurity'),
+ 'type' => Database::VAR_BOOLEAN,
+ 'signed' => true,
+ 'size' => 0,
+ 'format' => '',
+ 'filters' => [],
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ ],
+ [
+ '$id' => ID::custom('attributes'),
+ 'type' => Database::VAR_STRING,
+ 'size' => 1000000,
+ 'required' => false,
+ 'signed' => true,
+ 'array' => false,
+ 'filters' => ['subQueryAttributes'],
+ ],
+ [
+ '$id' => ID::custom('indexes'),
+ 'type' => Database::VAR_STRING,
+ 'size' => 1000000,
+ 'required' => false,
+ 'signed' => true,
+ 'array' => false,
+ 'filters' => ['subQueryIndexes'],
+ ],
+ [
+ '$id' => ID::custom('search'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 16384,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ ],
+ 'defaultAttributes' => [
+ [
+ '$id' => ID::custom('embeddings'),
+ 'type' => Database::VAR_VECTOR,
+ 'required' => true,
+ 'signed' => false,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('metadata'),
+ 'type' => Database::VAR_OBJECT,
+ 'default' => [],
+ 'required' => false,
+ 'size' => 0,
+ 'signed' => false,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ ],
+ 'indexes' => [
+ [
+ '$id' => ID::custom('_fulltext_search'),
+ 'type' => Database::INDEX_FULLTEXT,
+ 'attributes' => ['search'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_name'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['name'],
+ 'lengths' => [256],
+ 'orders' => [Database::ORDER_ASC],
+ ],
+ [
+ '$id' => ID::custom('_key_enabled'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['enabled'],
+ 'lengths' => [],
+ 'orders' => [Database::ORDER_ASC],
+ ],
+ [
+ '$id' => ID::custom('_key_documentSecurity'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['documentSecurity'],
+ 'lengths' => [],
+ 'orders' => [Database::ORDER_ASC],
+ ],
+ ],
+ 'defaultIndexes' => [
+ // not creating default indexes on the embeddings as it depends on the type of query users using the most
+ [
+ '$id' => ID::custom('_key_metadata'),
+ 'type' => Database::INDEX_OBJECT,
+ 'attributes' => ['metadata'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ ]
+ ]
+];
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/errors.php b/app/config/errors.php
index 278dbb3458..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 => [
@@ -1164,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 +1241,31 @@ return [
'description' => 'Migration is already in progress. You can check the status of the migration in your Appwrite Console\'s "Settings" > "Migrations".',
'code' => 409,
],
+ Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED => [
+ 'name' => Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED,
+ '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 116e8ac932..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',
@@ -73,8 +81,8 @@ $admins = [
'sites.write',
'log.read',
'log.write',
- 'execution.read',
- 'execution.write',
+ 'executions.read',
+ 'executions.write',
'rules.read',
'rules.write',
'migrations.read',
@@ -95,6 +103,10 @@ $admins = [
'tokens.write',
'schedules.read',
'schedules.write',
+ 'insights.read',
+ 'insights.write',
+ 'reports.read',
+ 'reports.write',
];
return [
@@ -115,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 9420486776..0000000000
--- a/app/config/scopes/consoleProject.php
+++ /dev/null
@@ -1,7 +0,0 @@
- 'platforms.read',
-];
diff --git a/app/config/scopes/organization.php b/app/config/scopes/organization.php
index 8d85662652..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',
],
diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php
index f5d8461aff..3d8998fb2f 100644
--- a/app/config/scopes/project.php
+++ b/app/config/scopes/project.php
@@ -1,191 +1,382 @@
[
- 'description' => 'Access to create, update, and delete user sessions',
- ],
- 'users.read' => [
- 'description' => 'Access to read your project\'s users',
- ],
- 'users.write' => [
- 'description' => 'Access to create, update, and delete your project\'s users',
- ],
- 'teams.read' => [
- 'description' => 'Access to read your project\'s teams',
- ],
- 'teams.write' => [
- 'description' => 'Access to create, update, and delete your project\'s teams',
- ],
- 'databases.read' => [
- 'description' => 'Access to read your project\'s databases',
- ],
- 'databases.write' => [
- 'description' => 'Access to create, update, and delete your project\'s databases',
- ],
- 'collections.read' => [
- 'description' => 'Access to read your project\'s database collections',
- ],
- 'collections.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database collections',
- ],
- 'tables.read' => [
- 'description' => 'Access to read your project\'s database tables',
- ],
- 'tables.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database tables',
- ],
- 'attributes.read' => [
- 'description' => 'Access to read your project\'s database collection\'s attributes',
- ],
- 'attributes.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database collection\'s attributes',
- ],
- 'columns.read' => [
- 'description' => 'Access to read your project\'s database table\'s columns',
- ],
- 'columns.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database table\'s columns',
- ],
- 'indexes.read' => [
- 'description' => 'Access to read your project\'s database table\'s indexes',
- ],
- 'indexes.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database table\'s indexes',
- ],
- 'documents.read' => [
- 'description' => 'Access to read your project\'s database documents',
- ],
- 'documents.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database documents',
- ],
- 'rows.read' => [
- 'description' => 'Access to read your project\'s database rows',
- ],
- 'rows.write' => [
- 'description' => 'Access to create, update, and delete your project\'s database rows',
- ],
- 'files.read' => [
- 'description' => 'Access to read your project\'s storage files and preview images',
- ],
- 'files.write' => [
- 'description' => 'Access to create, update, and delete your project\'s storage files',
- ],
- 'buckets.read' => [
- 'description' => 'Access to read your project\'s storage buckets',
- ],
- 'buckets.write' => [
- 'description' => 'Access to create, update, and delete your project\'s storage buckets',
- ],
- 'functions.read' => [
- 'description' => 'Access to read your project\'s functions and code deployments',
- ],
- 'functions.write' => [
- 'description' => 'Access to create, update, and delete your project\'s functions and code deployments',
- ],
- 'sites.read' => [
- 'description' => 'Access to read your project\'s sites and deployments',
- ],
- 'sites.write' => [
- 'description' => 'Access to create, update, and delete your project\'s sites and deployments',
- ],
- 'log.read' => [
- 'description' => 'Access to read your site\'s logs',
- ],
- 'log.write' => [
- 'description' => 'Access to update, and delete your site\'s logs',
- ],
- 'execution.read' => [
- 'description' => 'Access to read your project\'s execution logs',
- ],
- 'execution.write' => [
- 'description' => 'Access to execute your project\'s functions',
- ],
- 'locale.read' => [
- 'description' => 'Access to access your project\'s Locale service',
- ],
- 'avatars.read' => [
- 'description' => 'Access to access your project\'s Avatars service',
- ],
- 'health.read' => [
- 'description' => 'Access to read your project\'s health status',
- ],
- 'providers.read' => [
- 'description' => 'Access to read your project\'s providers',
- ],
- 'providers.write' => [
- 'description' => 'Access to create, update, and delete your project\'s providers',
- ],
- 'messages.read' => [
- 'description' => 'Access to read your project\'s messages',
- ],
- 'messages.write' => [
- 'description' => 'Access to create, update, and delete your project\'s messages',
- ],
- 'topics.read' => [
- 'description' => 'Access to read your project\'s topics',
- ],
- 'topics.write' => [
- 'description' => 'Access to create, update, and delete your project\'s topics',
- ],
- 'subscribers.read' => [
- 'description' => 'Access to read your project\'s subscribers',
- ],
- 'subscribers.write' => [
- 'description' => 'Access to create, update, and delete your project\'s subscribers',
- ],
- 'targets.read' => [
- 'description' => 'Access to read your project\'s targets',
- ],
- 'targets.write' => [
- 'description' => 'Access to create, update, and delete your project\'s targets',
- ],
- 'rules.read' => [
- 'description' => 'Access to read your project\'s proxy rules',
- ],
- 'rules.write' => [
- 'description' => 'Access to create, update, and delete your project\'s proxy rules',
- ],
- 'schedules.read' => [
- 'description' => 'Access to read your project\'s schedules',
- ],
- 'schedules.write' => [
- 'description' => 'Access to create, update, and delete your project\'s schedules',
- ],
- 'migrations.read' => [
- 'description' => 'Access to read your project\'s migrations',
- ],
- 'migrations.write' => [
- 'description' => 'Access to create, update, and delete your project\'s migrations.',
- ],
- 'vcs.read' => [
- 'description' => 'Access to read your project\'s VCS repositories',
- ],
- 'vcs.write' => [
- 'description' => 'Access to create, update, and delete your project\'s VCS repositories',
- ],
- 'assistant.read' => [
- 'description' => 'Access to read the Assistant service',
- ],
- 'tokens.read' => [
- 'description' => 'Access to read your project\'s tokens',
- ],
- 'tokens.write' => [
- 'description' => 'Access to create, update, and delete your project\'s tokens',
- ],
- "webhooks.read" => [
- "description" =>
- "Access to read project\'s webhooks",
- ],
- "webhooks.write" => [
- "description" =>
- "Access to create, update, and delete project\'s webhooks",
- ],
+// List of publicly visible scopes
+return [
+ // Project
"project.read" => [
"description" =>
"Access to read project\'s information",
+ "category" => "Project",
],
"project.write" => [
"description" =>
"Access to update project\'s information",
+ "category" => "Project",
+ ],
+ "keys.read" => [
+ "description" =>
+ "Access to read project\'s keys",
+ "category" => "Project",
+ ],
+ "keys.write" => [
+ "description" =>
+ "Access to create, update, and delete project\'s keys",
+ "category" => "Project",
+ ],
+ "platforms.read" => [
+ "description" =>
+ "Access to read project\'s platforms",
+ "category" => "Project",
+ ],
+ "platforms.write" => [
+ "description" =>
+ "Access to create, update, and delete project\'s platforms",
+ "category" => "Project",
+ ],
+ "mocks.read" => [
+ "description" =>
+ "Access to read project\'s mocks",
+ "category" => "Project",
+ ],
+ "mocks.write" => [
+ "description" =>
+ "Access to create, update, and delete project\'s mocks",
+ "category" => "Project",
+ ],
+ "policies.read" => [
+ "description" =>
+ "Access to read project\'s policies. 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 users',
+ 'category' => 'Auth',
+ ],
+ 'users.write' => [
+ 'description' => 'Access to create, update, and delete users',
+ 'category' => 'Auth',
+ ],
+ 'sessions.read' => [
+ 'description' => 'Access to read user sessions',
+ 'category' => 'Auth',
+ ],
+ 'sessions.write' => [
+ 'description' => 'Access to create, update, and delete user sessions',
+ 'category' => 'Auth',
+ ],
+ 'teams.read' => [
+ 'description' => 'Access to read teams',
+ 'category' => 'Auth',
+ ],
+ 'teams.write' => [
+ 'description' => 'Access to create, update, and delete teams',
+ 'category' => 'Auth',
+ ],
+
+ // Databases
+ 'databases.read' => [
+ 'description' => 'Access to read databases',
+ 'category' => 'Databases',
+ ],
+ 'databases.write' => [
+ 'description' => 'Access to create, update, and delete databases',
+ 'category' => 'Databases',
+ ],
+ 'tables.read' => [
+ 'description' => 'Access to read database tables',
+ 'category' => 'Databases',
+ ],
+ 'tables.write' => [
+ 'description' => 'Access to create, update, and delete database tables',
+ 'category' => 'Databases',
+ ],
+ 'columns.read' => [
+ 'description' => 'Access to read database table columns',
+ 'category' => 'Databases',
+ ],
+ 'columns.write' => [
+ 'description' => 'Access to create, update, and delete database table columns',
+ 'category' => 'Databases',
+ ],
+ 'indexes.read' => [
+ 'description' => 'Access to read database table indexes',
+ 'category' => 'Databases',
+ ],
+ 'indexes.write' => [
+ 'description' => 'Access to create, update, and delete database table indexes',
+ 'category' => 'Databases',
+ ],
+ 'rows.read' => [
+ 'description' => 'Access to read database table rows',
+ 'category' => 'Databases',
+ ],
+ 'rows.write' => [
+ 'description' => 'Access to create, update, and delete database table rows',
+ 'category' => 'Databases',
+ ],
+ 'collections.read' => [
+ 'description' => 'Access to read database collections',
+ 'category' => 'Databases',
+ 'deprecated' => true,
+ ],
+ 'collections.write' => [
+ 'description' => 'Access to create, update, and delete database collections',
+ 'category' => 'Databases',
+ 'deprecated' => true,
+ ],
+ 'attributes.read' => [
+ 'description' => 'Access to read database collection attributes',
+ 'category' => 'Databases',
+ 'deprecated' => true,
+ ],
+ 'attributes.write' => [
+ 'description' => 'Access to create, update, and delete database collection attributes',
+ 'category' => 'Databases',
+ 'deprecated' => true,
+ ],
+ 'documents.read' => [
+ 'description' => 'Access to read database collection documents',
+ 'category' => 'Databases',
+ 'deprecated' => true,
+ ],
+ 'documents.write' => [
+ 'description' => 'Access to create, update, and delete database collection documents',
+ 'category' => 'Databases',
+ 'deprecated' => true,
+ ],
+
+ // Storage
+ 'buckets.read' => [
+ 'description' => 'Access to read storage buckets',
+ 'category' => 'Storage',
+ ],
+ 'buckets.write' => [
+ 'description' => 'Access to create, update, and delete storage buckets',
+ 'category' => 'Storage',
+ ],
+ 'files.read' => [
+ 'description' => 'Access to read storage files and preview images',
+ 'category' => 'Storage',
+ ],
+ 'files.write' => [
+ 'description' => 'Access to create, update, and delete storage files',
+ 'category' => 'Storage',
+ ],
+ 'tokens.read' => [
+ 'description' => 'Access to read storage file tokens',
+ 'category' => 'Storage',
+ ],
+ 'tokens.write' => [
+ 'description' => 'Access to create, update, and delete storage file tokens',
+ 'category' => 'Storage',
+ ],
+
+ // Functions
+ 'functions.read' => [
+ 'description' => 'Access to read functions and deployments',
+ 'category' => 'Functions',
+ ],
+ 'functions.write' => [
+ 'description' => 'Access to create, update, and delete functions and deployments',
+ 'category' => 'Functions',
+ ],
+ 'executions.read' => [
+ 'description' => 'Access to read function executions',
+ 'category' => 'Functions',
+ ],
+ 'executions.write' => [
+ 'description' => 'Access to create function executions',
+ 'category' => 'Functions',
+ ],
+ '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 3d7db8f457..e01c27e45c 100644
--- a/app/controllers/api/account.php
+++ b/app/controllers/api/account.php
@@ -9,11 +9,14 @@ 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\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\Redirect;
@@ -41,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;
@@ -75,139 +79,8 @@ 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');
- }
-
- $bodyTemplate = __DIR__ . '/../../config/locale/templates/' . $smtpBaseTemplate . '.tpl';
-
- $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'];
- }
-
- $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);
- }
-
- // 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) {
+$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) {
// Attempt to decode secret as a JWT (used by OAuth2 token flow to carry provider info)
$oauthProvider = null;
@@ -262,9 +135,6 @@ $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 => $oauthProvider,
@@ -318,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())
@@ -345,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]));
}
@@ -353,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'));
@@ -403,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()) {
@@ -452,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 {
@@ -487,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');
@@ -565,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',
@@ -586,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.
}
}
@@ -691,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([]));
}
@@ -712,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));
@@ -728,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();
});
@@ -776,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;
}
}
@@ -816,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')
@@ -842,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());
@@ -929,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'] ?? '{}';
@@ -956,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')
@@ -997,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();
@@ -1064,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)
;
@@ -1102,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);
});
@@ -1151,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()) {
@@ -1243,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)
;
@@ -1302,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')
@@ -1504,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();
@@ -1694,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.');
}
@@ -1711,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]),
]);
@@ -1724,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]),
]);
@@ -1736,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) {
@@ -1747,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 {
@@ -1780,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');
@@ -1859,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'))) {
@@ -1965,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]));
}
@@ -1978,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;
@@ -2083,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') {
@@ -2106,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')
@@ -2148,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');
}
@@ -2187,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([
@@ -2217,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');
@@ -2275,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();
@@ -2305,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'])) {
@@ -2315,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'])) {
@@ -2333,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');
@@ -2366,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);
@@ -2428,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');
}
@@ -2465,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([
@@ -2493,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');
@@ -2556,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();
@@ -2592,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'])) {
@@ -2601,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'])) {
@@ -2619,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');
@@ -2666,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);
@@ -2735,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')
@@ -2784,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);
@@ -2825,7 +2879,7 @@ Http::post('/v1/account/tokens/phone')
->inject('platform')
->inject('dbForProject')
->inject('queueForEvents')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('locale')
->inject('timelimit')
->inject('usage')
@@ -2833,7 +2887,7 @@ Http::post('/v1/account/tokens/phone')
->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, Context $usage, 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');
}
@@ -2945,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'];
@@ -2971,11 +3020,13 @@ 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 {
@@ -3244,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) {
@@ -3314,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');
@@ -3344,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)) {
@@ -3378,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()) {
@@ -3467,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()) {
@@ -3550,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);
@@ -3560,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);
@@ -3606,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');
@@ -3672,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
@@ -3689,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'])) {
@@ -3698,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'])) {
@@ -3716,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 = [
@@ -3740,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);
@@ -3760,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)
@@ -3861,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);
});
@@ -3920,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');
@@ -3980,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();
@@ -4006,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'])) {
@@ -4015,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'])) {
@@ -4033,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 = [
@@ -4071,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)
@@ -4183,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);
});
@@ -4218,7 +4320,7 @@ Http::post('/v1/account/verifications/phone')
->inject('user')
->inject('dbForProject')
->inject('queueForEvents')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('project')
->inject('locale')
->inject('timelimit')
@@ -4226,7 +4328,7 @@ Http::post('/v1/account/verifications/phone')
->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, Context $usage, 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');
}
@@ -4279,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'))
@@ -4300,11 +4397,13 @@ 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 {
@@ -4558,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',
@@ -4715,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 1ba5eb1119..f59f606174 100644
--- a/app/controllers/api/messaging.php
+++ b/app/controllers/api/messaging.php
@@ -5,7 +5,8 @@ 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\Permission;
@@ -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 fb5dc1f62d..0000000000
--- a/app/controllers/api/migrations.php
+++ /dev/null
@@ -1,1013 +0,0 @@
-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');
- }
-
- $fileSize = $deviceForMigrations->getFileSize($newPath);
- $resources = Transfer::extractServices([Transfer::GROUP_DATABASES]);
-
- $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());
- }
-
- $migration = $dbForProject->createDocument('migrations', new Document([
- '$id' => ID::unique(),
- 'status' => 'pending',
- 'stage' => 'init',
- 'source' => Appwrite::getName(),
- 'destination' => CSV::getName(),
- 'resources' => Transfer::extractServices([Transfer::GROUP_DATABASES]),
- '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('dbForProject')
- ->inject('project')
- ->inject('user')
- ->action(function (array $resources, string $endpoint, string $projectID, string $key, Response $response) {
- try {
- $appwrite = new Appwrite($projectID, $endpoint, $key);
- $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 8668a21e24..544beade77 100644
--- a/app/controllers/api/project.php
+++ b/app/controllers/api/project.php
@@ -52,16 +52,33 @@ Http::get('/v1/project/usage')
METRIC_EXECUTIONS_MB_SECONDS,
METRIC_BUILDS_MB_SECONDS,
METRIC_DOCUMENTS,
+ METRIC_DOCUMENTS_DOCUMENTSDB,
METRIC_DATABASES,
+ METRIC_DATABASES_DOCUMENTSDB,
METRIC_USERS,
METRIC_BUCKETS,
METRIC_FILES_STORAGE,
METRIC_DATABASES_STORAGE,
+ METRIC_DATABASES_STORAGE_DOCUMENTSDB,
METRIC_DEPLOYMENTS_STORAGE,
METRIC_BUILDS_STORAGE,
METRIC_DATABASES_OPERATIONS_READS,
+ METRIC_DATABASES_OPERATIONS_READS_DOCUMENTSDB,
METRIC_DATABASES_OPERATIONS_WRITES,
+ METRIC_DATABASES_OPERATIONS_WRITES_DOCUMENTSDB,
METRIC_FILES_IMAGES_TRANSFORMED,
+ // VectorsDB totals
+ METRIC_DATABASES_VECTORSDB,
+ METRIC_COLLECTIONS_VECTORSDB,
+ METRIC_DOCUMENTS_VECTORSDB,
+ METRIC_DATABASES_STORAGE_VECTORSDB,
+ METRIC_DATABASES_OPERATIONS_READS_VECTORSDB,
+ METRIC_DATABASES_OPERATIONS_WRITES_VECTORSDB,
+ // Embeddings totals
+ METRIC_EMBEDDINGS_TEXT,
+ METRIC_EMBEDDINGS_TEXT_TOTAL_TOKENS,
+ METRIC_EMBEDDINGS_TEXT_TOTAL_DURATION,
+ METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR
],
'period' => [
METRIC_NETWORK_REQUESTS,
@@ -70,22 +87,38 @@ Http::get('/v1/project/usage')
METRIC_USERS,
METRIC_EXECUTIONS,
METRIC_DATABASES_STORAGE,
+ METRIC_DATABASES_STORAGE_DOCUMENTSDB,
METRIC_EXECUTIONS_MB_SECONDS,
METRIC_BUILDS_MB_SECONDS,
METRIC_DATABASES_OPERATIONS_READS,
+ METRIC_DATABASES_OPERATIONS_READS_DOCUMENTSDB,
METRIC_DATABASES_OPERATIONS_WRITES,
+ METRIC_DATABASES_OPERATIONS_WRITES_DOCUMENTSDB,
METRIC_FILES_IMAGES_TRANSFORMED,
+ // VectorsDB time series
+ METRIC_DATABASES_VECTORSDB,
+ METRIC_COLLECTIONS_VECTORSDB,
+ METRIC_DOCUMENTS_VECTORSDB,
+ METRIC_DATABASES_STORAGE_VECTORSDB,
+ METRIC_DATABASES_OPERATIONS_READS_VECTORSDB,
+ METRIC_DATABASES_OPERATIONS_WRITES_VECTORSDB,
+ // Embeddings time series
+ METRIC_EMBEDDINGS_TEXT,
+ METRIC_EMBEDDINGS_TEXT_TOTAL_TOKENS,
+ METRIC_EMBEDDINGS_TEXT_TOTAL_DURATION,
+ METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR
]
];
$factor = match ($period) {
'1h' => 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) {
@@ -347,8 +380,11 @@ Http::get('/v1/project/usage')
'buildsMbSecondsTotal' => $total[METRIC_BUILDS_MB_SECONDS],
'documentsTotal' => $total[METRIC_DOCUMENTS],
'rowsTotal' => $total[METRIC_DOCUMENTS],
+ 'documentsdbDocumentsTotal' => $total[METRIC_DOCUMENTS_DOCUMENTSDB],
'databasesTotal' => $total[METRIC_DATABASES],
+ 'documentsdbTotal' => $total[METRIC_DATABASES_DOCUMENTSDB],
'databasesStorageTotal' => $total[METRIC_DATABASES_STORAGE],
+ 'documentsdbDatabasesStorageTotal' => $total[METRIC_DATABASES_STORAGE_DOCUMENTSDB],
'usersTotal' => $total[METRIC_USERS],
'bucketsTotal' => $total[METRIC_BUCKETS],
'filesStorageTotal' => $total[METRIC_FILES_STORAGE],
@@ -357,10 +393,27 @@ Http::get('/v1/project/usage')
'deploymentsStorageTotal' => $total[METRIC_DEPLOYMENTS_STORAGE],
'databasesReadsTotal' => $total[METRIC_DATABASES_OPERATIONS_READS],
'databasesWritesTotal' => $total[METRIC_DATABASES_OPERATIONS_WRITES],
+ 'documentsdbDatabasesReadsTotal' => $total[METRIC_DATABASES_OPERATIONS_READS_DOCUMENTSDB],
+ 'documentsdbDatabasesWritesTotal' => $total[METRIC_DATABASES_OPERATIONS_WRITES_DOCUMENTSDB],
+ 'vectorsdbDatabasesTotal' => $total[METRIC_DATABASES_VECTORSDB] ?? 0,
+ 'vectorsdbCollectionsTotal' => $total[METRIC_COLLECTIONS_VECTORSDB] ?? 0,
+ 'vectorsdbDocumentsTotal' => $total[METRIC_DOCUMENTS_VECTORSDB] ?? 0,
+ 'vectorsdbDatabasesStorageTotal' => $total[METRIC_DATABASES_STORAGE_VECTORSDB] ?? 0,
+ 'vectorsdbDatabasesReadsTotal' => $total[METRIC_DATABASES_OPERATIONS_READS_VECTORSDB] ?? 0,
+ 'vectorsdbDatabasesWritesTotal' => $total[METRIC_DATABASES_OPERATIONS_WRITES_VECTORSDB] ?? 0,
'executionsBreakdown' => $executionsBreakdown,
'bucketsBreakdown' => $bucketsBreakdown,
'databasesReads' => $usage[METRIC_DATABASES_OPERATIONS_READS],
'databasesWrites' => $usage[METRIC_DATABASES_OPERATIONS_WRITES],
+ 'documentsdbDatabasesReads' => $usage[METRIC_DATABASES_OPERATIONS_READS_DOCUMENTSDB],
+ 'documentsdbDatabasesWrites' => $usage[METRIC_DATABASES_OPERATIONS_WRITES_DOCUMENTSDB],
+ 'documentsdbDatabasesStorage' => $usage[METRIC_DATABASES_STORAGE_DOCUMENTSDB],
+ 'vectorsdbDatabases' => $usage[METRIC_DATABASES_VECTORSDB] ?? [],
+ 'vectorsdbCollections' => $usage[METRIC_COLLECTIONS_VECTORSDB] ?? [],
+ 'vectorsdbDocuments' => $usage[METRIC_DOCUMENTS_VECTORSDB] ?? [],
+ 'vectorsdbDatabasesStorage' => $usage[METRIC_DATABASES_STORAGE_VECTORSDB] ?? [],
+ 'vectorsdbDatabasesReads' => $usage[METRIC_DATABASES_OPERATIONS_READS_VECTORSDB] ?? [],
+ 'vectorsdbDatabasesWrites' => $usage[METRIC_DATABASES_OPERATIONS_WRITES_VECTORSDB] ?? [],
'databasesStorageBreakdown' => $databasesStorageBreakdown,
'executionsMbSecondsBreakdown' => $executionsMbSecondsBreakdown,
'buildsMbSecondsBreakdown' => $buildsMbSecondsBreakdown,
@@ -370,5 +423,13 @@ Http::get('/v1/project/usage')
'authPhoneCountryBreakdown' => $authPhoneCountryBreakdown,
'imageTransformations' => $usage[METRIC_FILES_IMAGES_TRANSFORMED],
'imageTransformationsTotal' => $total[METRIC_FILES_IMAGES_TRANSFORMED],
+ 'embeddingsText' => $usage[METRIC_EMBEDDINGS_TEXT] ?? [],
+ 'embeddingsTextTokens' => $usage[METRIC_EMBEDDINGS_TEXT_TOTAL_TOKENS] ?? [],
+ 'embeddingsTextDuration' => $usage[METRIC_EMBEDDINGS_TEXT_TOTAL_DURATION] ?? [],
+ 'embeddingsTextErrors' => $usage[METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR] ?? [],
+ 'embeddingsTextTotal' => $total[METRIC_EMBEDDINGS_TEXT] ?? 0,
+ 'embeddingsTextTokensTotal' => $total[METRIC_EMBEDDINGS_TEXT_TOTAL_TOKENS] ?? 0,
+ 'embeddingsTextDurationTotal' => $total[METRIC_EMBEDDINGS_TEXT_TOTAL_DURATION] ?? 0,
+ 'embeddingsTextErrorsTotal' => $total[METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR] ?? 0,
]), Response::MODEL_USAGE_PROJECT);
});
diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php
index 2fc20ba83f..8ab30fac99 100644
--- a/app/controllers/api/projects.php
+++ b/app/controllers/api/projects.php
@@ -1,43 +1,17 @@
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)
@@ -330,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')
@@ -725,1303 +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();
- });
-
-// 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 3b21d4797d..3f52069609 100644
--- a/app/controllers/api/users.php
+++ b/app/controllers/api/users.php
@@ -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'],
@@ -1470,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);
@@ -1495,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 {
@@ -1528,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()) {
@@ -1614,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);
}
}
@@ -1624,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()) {
@@ -2185,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(
@@ -2253,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')
@@ -2337,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')
@@ -2402,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(
@@ -2462,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(
@@ -2658,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')
@@ -2777,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 1a099c4bde..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'),
@@ -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 868fb34815..8365274e98 100644
--- a/app/controllers/shared/api.php
+++ b/app/controllers/shared/api.php
@@ -3,21 +3,22 @@
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\Message\Audit as AuditMessage;
use Appwrite\Event\Message\Usage as UsageMessage;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Publisher\Audit;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Realtime;
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;
@@ -37,13 +38,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);
@@ -51,11 +53,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,
+ 'project' => $project->getArrayCopy(),
'request' => $requestParams,
default => $responsePayload,
};
@@ -87,7 +90,7 @@ Http::init()
->inject('request')
->inject('dbForPlatform')
->inject('dbForProject')
- ->inject('queueForAudits')
+ ->inject('auditContext')
->inject('project')
->inject('user')
->inject('session')
@@ -96,8 +99,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.
@@ -176,20 +182,21 @@ Http::init()
// Handle special app role case
if ($apiKey->getRole() === User::ROLE_APPS) {
// Disable authorization checks for project API keys
- if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_DYNAMIC) && $apiKey->getProjectId() === $project->getId()) {
+ // Dynamic supported for backwards compatibility
+ if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_EPHEMERAL || $apiKey->getType() === 'dynamic') && $apiKey->getProjectId() === $project->getId()) {
$authorization->setDefaultStatus(false);
}
$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
@@ -253,7 +260,13 @@ Http::init()
}
}
- $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
@@ -351,23 +364,6 @@ 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);
- }
- }
-
$authorization->addRole($role);
foreach ($user->getRoles($authorization) as $authRole) {
$authorization->addRole($authRole);
@@ -389,7 +385,7 @@ Http::init()
}
// 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([
@@ -419,9 +415,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,
@@ -431,17 +424,26 @@ Http::init()
}
if (! empty($method)) {
- $namespace = $method->getNamespace();
+ $namespace = \strtolower($method->getNamespace());
if (
array_key_exists($namespace, $project->getAttribute('services', []))
&& ! $project->getAttribute('services', [])[$namespace]
- && ! (User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
+ && ! ($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');
if (empty(\array_intersect($allowed, $scopes))) {
@@ -482,14 +484,11 @@ Http::init()
->inject('project')
->inject('user')
->inject('queueForEvents')
- ->inject('queueForMessaging')
- ->inject('queueForAudits')
+ ->inject('auditContext')
->inject('queueForDeletes')
->inject('queueForDatabase')
- ->inject('queueForBuilds')
->inject('usage')
->inject('queueForFunctions')
- ->inject('queueForMails')
->inject('dbForProject')
->inject('timelimit')
->inject('resourceToken')
@@ -500,18 +499,24 @@ Http::init()
->inject('telemetry')
->inject('platform')
->inject('authorization')
- ->action(function (Http $utopia, Request $request, Response $response, Document $project, Document $user, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) {
+ ->inject('cacheControlForStorage')
+ ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Context $usage, Func $queueForFunctions, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization, callable $cacheControlForStorage) {
+
+ $response->setUser($user);
+ $request->setUser($user);
$route = $utopia->getRoute();
-
- 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);
+ if ($route === null) {
+ throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND);
}
+ $path = $route->getMatchedPath();
+ $databaseType = match (true) {
+ str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB,
+ str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB,
+ default => '',
+ };
+
/*
* Abuse Check
*/
@@ -539,8 +544,8 @@ Http::init()
$closestLimit = null;
$roles = $authorization->getRoles();
- $isPrivilegedUser = User::isPrivileged($roles);
- $isAppUser = User::isApp($roles);
+ $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
@@ -588,43 +593,40 @@ 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()) {
$userClone = clone $user;
// $user doesn't support `type` and can cause unintended effects.
- $userClone->setAttribute('type', ACTIVITY_TYPE_USER);
- $queueForAudits->setUser($userClone);
+ 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);
$isImageTransformation = $route->getPath() === '/v1/storage/buckets/:bucketId/files/:fileId/preview';
- $isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && ! User::isPrivileged($authorization->getRoles());
+ $isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && ! $user->isPrivileged($authorization->getRoles());
$key = $request->cacheIdentifier();
+ 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())
@@ -633,15 +635,16 @@ Http::init()
$data = $cache->load($key, $timestamp);
if (! empty($data) && ! $cacheLog->isEmpty()) {
+ $cacheControl = \sprintf('private, max-age=%d', $timestamp);
$parts = explode('/', $cacheLog->getAttribute('resourceType', ''));
- $type = $parts[0] ?? null;
+ $type = $parts[0];
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());
+ $isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (! $bucket->getAttribute('enabled') && ! $isAppUser && ! $isPrivilegedUser)) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
@@ -673,8 +676,10 @@ Http::init()
if ($file->isEmpty()) {
throw new Exception(Exception::STORAGE_FILE_NOT_FOUND);
}
+ 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())) {
+ 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());
@@ -683,18 +688,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) {
+ 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')
@@ -708,7 +739,7 @@ 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;
}
@@ -732,7 +763,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)) {
@@ -766,13 +802,12 @@ Http::shutdown()
->inject('project')
->inject('user')
->inject('queueForEvents')
- ->inject('queueForAudits')
+ ->inject('auditContext')
+ ->inject('publisherForAudits')
->inject('usage')
->inject('publisherForUsage')
->inject('queueForDeletes')
->inject('queueForDatabase')
- ->inject('queueForBuilds')
- ->inject('queueForMessaging')
->inject('queueForFunctions')
->inject('queueForWebhooks')
->inject('queueForRealtime')
@@ -782,7 +817,8 @@ Http::shutdown()
->inject('eventProcessor')
->inject('bus')
->inject('apiKey')
- ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey) use ($parseLabel) {
+ ->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, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) {
$responsePayload = $response->getPayload();
@@ -875,18 +911,20 @@ Http::shutdown()
*/
$pattern = $route->getLabel('audits.resource', null);
if (! empty($pattern)) {
- $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user);
+ $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project);
if (! empty($resource) && $resource !== $pattern) {
- $queueForAudits->setResource($resource);
+ $auditContext->resource = $resource;
}
}
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:
@@ -904,24 +942,21 @@ 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);
+ $auditContext->payload = $responsePayload;
}
- foreach ($queueForEvents->getParams() as $key => $value) {
- $queueForAudits->setParam($key, $value);
- }
-
- $queueForAudits->trigger();
+ $publisherForAudits->enqueue(AuditMessage::fromContext($auditContext));
}
if (! empty($queueForDeletes->getType())) {
@@ -932,28 +967,21 @@ Http::shutdown()
$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);
+ $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project);
}
$pattern = $route->getLabel('cache.resourceType', null);
if (! empty($pattern)) {
- $resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user);
+ $resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project);
}
$cache = new Cache(
@@ -995,7 +1023,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,
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 1302940856..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,13 +191,11 @@ $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;
@@ -203,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
@@ -286,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);
@@ -409,13 +408,21 @@ $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', ''));
+ $vectorSharedTables = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''));
- $cache = $app->getResource('cache');
+ $cache = $container->get('cache');
- foreach ($sharedTablesV2 as $hostname) {
+ // All shared tables pools that need project metadata collections
+ $allSharedTables = \array_values(\array_unique(\array_filter([
+ ...$sharedTables,
+ ...$documentsSharedTables,
+ ...$vectorSharedTables,
+ ])));
+
+ foreach ($allSharedTables as $hostname) {
Span::init('database.setup');
Span::add('database.hostname', $hostname);
@@ -492,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());
@@ -515,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);
@@ -539,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()));
@@ -605,6 +612,7 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool
}
}
+ $swooleResponse = $utopiaResponse->getSwooleResponse();
$swooleResponse->setStatusCode(500);
$output = ((Http::isDevelopment())) ? [
@@ -628,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;
@@ -657,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());
@@ -707,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 079208d093..966035b956 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';
@@ -404,3 +501,16 @@ const CACHE_RECONNECT_RETRY_DELAY = 1000;
// Project status
const PROJECT_STATUS_ACTIVE = 'active';
+
+// Database types
+const DATABASE_TYPE_LEGACY = 'legacy';
+const DATABASE_TYPE_TABLESDB = 'tablesdb';
+const DATABASE_TYPE_DOCUMENTSDB = 'documentsdb';
+const DATABASE_TYPE_VECTORSDB = 'vectorsdb';
+
+// CSV import/export allowed database types
+const CSV_ALLOWED_DATABASE_TYPES = [
+ DATABASE_TYPE_LEGACY,
+ DATABASE_TYPE_TABLESDB,
+ DATABASE_TYPE_VECTORSDB
+];
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 29a4f0c7d4..9ecf07716a 100644
--- a/app/init/database/formats.php
+++ b/app/init/database/formats.php
@@ -36,6 +36,13 @@ Structure::addFormat(APP_DATABASE_ATTRIBUTE_INT_RANGE, function ($attribute) {
return new Range($min, $max, Range::TYPE_INTEGER);
}, Database::VAR_INTEGER);
+// BigInt uses a dedicated bigintRange format name to avoid clobbering `intRange`.
+Structure::addFormat(APP_DATABASE_ATTRIBUTE_BIGINT_RANGE, function ($attribute) {
+ $min = $attribute['formatOptions']['min'] ?? -INF;
+ $max = $attribute['formatOptions']['max'] ?? INF;
+ return new Range($min, $max, Range::TYPE_INTEGER);
+}, Database::VAR_BIGINT);
+
Structure::addFormat(APP_DATABASE_ATTRIBUTE_FLOAT_RANGE, function ($attribute) {
$min = $attribute['formatOptions']['min'] ?? -INF;
$max = $attribute['formatOptions']['max'] ?? INF;
diff --git a/app/init/models.php b/app/init/models.php
index 413018689a..521a3b77cd 100644
--- a/app/init/models.php
+++ b/app/init/models.php
@@ -11,6 +11,7 @@ use Appwrite\Utopia\Response\Model\AlgoScryptModified;
use Appwrite\Utopia\Response\Model\AlgoSha;
use Appwrite\Utopia\Response\Model\Any;
use Appwrite\Utopia\Response\Model\Attribute;
+use Appwrite\Utopia\Response\Model\AttributeBigInt;
use Appwrite\Utopia\Response\Model\AttributeBoolean;
use Appwrite\Utopia\Response\Model\AttributeDatetime;
use Appwrite\Utopia\Response\Model\AttributeEmail;
@@ -22,6 +23,7 @@ use Appwrite\Utopia\Response\Model\AttributeLine;
use Appwrite\Utopia\Response\Model\AttributeList;
use Appwrite\Utopia\Response\Model\AttributeLongtext;
use Appwrite\Utopia\Response\Model\AttributeMediumtext;
+use Appwrite\Utopia\Response\Model\AttributeObject;
use Appwrite\Utopia\Response\Model\AttributePoint;
use Appwrite\Utopia\Response\Model\AttributePolygon;
use Appwrite\Utopia\Response\Model\AttributeRelationship;
@@ -29,12 +31,14 @@ use Appwrite\Utopia\Response\Model\AttributeString;
use Appwrite\Utopia\Response\Model\AttributeText;
use Appwrite\Utopia\Response\Model\AttributeURL;
use Appwrite\Utopia\Response\Model\AttributeVarchar;
+use Appwrite\Utopia\Response\Model\AttributeVector;
use Appwrite\Utopia\Response\Model\AuthProvider;
use Appwrite\Utopia\Response\Model\BaseList;
use Appwrite\Utopia\Response\Model\Branch;
use Appwrite\Utopia\Response\Model\Bucket;
use Appwrite\Utopia\Response\Model\Collection;
use Appwrite\Utopia\Response\Model\Column;
+use Appwrite\Utopia\Response\Model\ColumnBigInt;
use Appwrite\Utopia\Response\Model\ColumnBoolean;
use Appwrite\Utopia\Response\Model\ColumnDatetime;
use Appwrite\Utopia\Response\Model\ColumnEmail;
@@ -54,6 +58,11 @@ use Appwrite\Utopia\Response\Model\ColumnString;
use Appwrite\Utopia\Response\Model\ColumnText;
use Appwrite\Utopia\Response\Model\ColumnURL;
use Appwrite\Utopia\Response\Model\ColumnVarchar;
+use Appwrite\Utopia\Response\Model\ConsoleKeyScope;
+use Appwrite\Utopia\Response\Model\ConsoleKeyScopeList;
+use Appwrite\Utopia\Response\Model\ConsoleOAuth2Provider;
+use Appwrite\Utopia\Response\Model\ConsoleOAuth2ProviderList;
+use Appwrite\Utopia\Response\Model\ConsoleOAuth2ProviderParameter;
use Appwrite\Utopia\Response\Model\ConsoleVariables;
use Appwrite\Utopia\Response\Model\Continent;
use Appwrite\Utopia\Response\Model\Country;
@@ -65,6 +74,8 @@ use Appwrite\Utopia\Response\Model\DetectionRuntime;
use Appwrite\Utopia\Response\Model\DetectionVariable;
use Appwrite\Utopia\Response\Model\DevKey;
use Appwrite\Utopia\Response\Model\Document as ModelDocument;
+use Appwrite\Utopia\Response\Model\Embedding;
+use Appwrite\Utopia\Response\Model\EphemeralKey;
use Appwrite\Utopia\Response\Model\Error;
use Appwrite\Utopia\Response\Model\ErrorDev;
use Appwrite\Utopia\Response\Model\Execution;
@@ -81,6 +92,8 @@ use Appwrite\Utopia\Response\Model\HealthTime;
use Appwrite\Utopia\Response\Model\HealthVersion;
use Appwrite\Utopia\Response\Model\Identity;
use Appwrite\Utopia\Response\Model\Index;
+use Appwrite\Utopia\Response\Model\Insight;
+use Appwrite\Utopia\Response\Model\InsightCTA;
use Appwrite\Utopia\Response\Model\Installation;
use Appwrite\Utopia\Response\Model\JWT;
use Appwrite\Utopia\Response\Model\Key;
@@ -98,19 +111,80 @@ use Appwrite\Utopia\Response\Model\MFARecoveryCodes;
use Appwrite\Utopia\Response\Model\MFAType;
use Appwrite\Utopia\Response\Model\Migration;
use Appwrite\Utopia\Response\Model\MigrationFirebaseProject;
-use Appwrite\Utopia\Response\Model\MigrationKey;
use Appwrite\Utopia\Response\Model\MigrationReport;
use Appwrite\Utopia\Response\Model\Mock;
use Appwrite\Utopia\Response\Model\MockNumber;
use Appwrite\Utopia\Response\Model\None;
+use Appwrite\Utopia\Response\Model\OAuth2Amazon;
+use Appwrite\Utopia\Response\Model\OAuth2Apple;
+use Appwrite\Utopia\Response\Model\OAuth2Auth0;
+use Appwrite\Utopia\Response\Model\OAuth2Authentik;
+use Appwrite\Utopia\Response\Model\OAuth2Autodesk;
+use Appwrite\Utopia\Response\Model\OAuth2Bitbucket;
+use Appwrite\Utopia\Response\Model\OAuth2Bitly;
+use Appwrite\Utopia\Response\Model\OAuth2Box;
+use Appwrite\Utopia\Response\Model\OAuth2Dailymotion;
+use Appwrite\Utopia\Response\Model\OAuth2Discord;
+use Appwrite\Utopia\Response\Model\OAuth2Disqus;
+use Appwrite\Utopia\Response\Model\OAuth2Dropbox;
+use Appwrite\Utopia\Response\Model\OAuth2Etsy;
+use Appwrite\Utopia\Response\Model\OAuth2Facebook;
+use Appwrite\Utopia\Response\Model\OAuth2Figma;
+use Appwrite\Utopia\Response\Model\OAuth2FusionAuth;
+use Appwrite\Utopia\Response\Model\OAuth2GitHub;
+use Appwrite\Utopia\Response\Model\OAuth2Gitlab;
+use Appwrite\Utopia\Response\Model\OAuth2Google;
+use Appwrite\Utopia\Response\Model\OAuth2Keycloak;
+use Appwrite\Utopia\Response\Model\OAuth2Kick;
+use Appwrite\Utopia\Response\Model\OAuth2Linkedin;
+use Appwrite\Utopia\Response\Model\OAuth2Microsoft;
+use Appwrite\Utopia\Response\Model\OAuth2Notion;
+use Appwrite\Utopia\Response\Model\OAuth2Oidc;
+use Appwrite\Utopia\Response\Model\OAuth2Okta;
+use Appwrite\Utopia\Response\Model\OAuth2Paypal;
+use Appwrite\Utopia\Response\Model\OAuth2Podio;
+use Appwrite\Utopia\Response\Model\OAuth2ProviderList;
+use Appwrite\Utopia\Response\Model\OAuth2Salesforce;
+use Appwrite\Utopia\Response\Model\OAuth2Slack;
+use Appwrite\Utopia\Response\Model\OAuth2Spotify;
+use Appwrite\Utopia\Response\Model\OAuth2Stripe;
+use Appwrite\Utopia\Response\Model\OAuth2Tradeshift;
+use Appwrite\Utopia\Response\Model\OAuth2Twitch;
+use Appwrite\Utopia\Response\Model\OAuth2WordPress;
+use Appwrite\Utopia\Response\Model\OAuth2X;
+use Appwrite\Utopia\Response\Model\OAuth2Yahoo;
+use Appwrite\Utopia\Response\Model\OAuth2Yandex;
+use Appwrite\Utopia\Response\Model\OAuth2Zoho;
+use Appwrite\Utopia\Response\Model\OAuth2Zoom;
use Appwrite\Utopia\Response\Model\Phone;
-use Appwrite\Utopia\Response\Model\Platform;
+use Appwrite\Utopia\Response\Model\PlatformAndroid;
+use Appwrite\Utopia\Response\Model\PlatformApple;
+use Appwrite\Utopia\Response\Model\PlatformLinux;
+use Appwrite\Utopia\Response\Model\PlatformList;
+use Appwrite\Utopia\Response\Model\PlatformWeb;
+use Appwrite\Utopia\Response\Model\PlatformWindows;
+use Appwrite\Utopia\Response\Model\PolicyList;
+use Appwrite\Utopia\Response\Model\PolicyMembershipPrivacy;
+use Appwrite\Utopia\Response\Model\PolicyPasswordDictionary;
+use Appwrite\Utopia\Response\Model\PolicyPasswordHistory;
+use Appwrite\Utopia\Response\Model\PolicyPasswordPersonalData;
+use Appwrite\Utopia\Response\Model\PolicySessionAlert;
+use Appwrite\Utopia\Response\Model\PolicySessionDuration;
+use Appwrite\Utopia\Response\Model\PolicySessionInvalidation;
+use Appwrite\Utopia\Response\Model\PolicySessionLimit;
+use Appwrite\Utopia\Response\Model\PolicyUserLimit;
use Appwrite\Utopia\Response\Model\Preferences;
use Appwrite\Utopia\Response\Model\Project;
+use Appwrite\Utopia\Response\Model\ProjectAuthMethod;
+use Appwrite\Utopia\Response\Model\ProjectProtocol;
+use Appwrite\Utopia\Response\Model\ProjectService;
use Appwrite\Utopia\Response\Model\Provider;
use Appwrite\Utopia\Response\Model\ProviderRepository;
use Appwrite\Utopia\Response\Model\ProviderRepositoryFramework;
+use Appwrite\Utopia\Response\Model\ProviderRepositoryFrameworkList;
use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntime;
+use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntimeList;
+use Appwrite\Utopia\Response\Model\Report;
use Appwrite\Utopia\Response\Model\ResourceToken;
use Appwrite\Utopia\Response\Model\Row;
use Appwrite\Utopia\Response\Model\Rule;
@@ -128,7 +202,6 @@ use Appwrite\Utopia\Response\Model\TemplateFramework;
use Appwrite\Utopia\Response\Model\TemplateFunction;
use Appwrite\Utopia\Response\Model\TemplateRuntime;
use Appwrite\Utopia\Response\Model\TemplateSite;
-use Appwrite\Utopia\Response\Model\TemplateSMS;
use Appwrite\Utopia\Response\Model\TemplateVariable;
use Appwrite\Utopia\Response\Model\Token;
use Appwrite\Utopia\Response\Model\Topic;
@@ -137,6 +210,8 @@ use Appwrite\Utopia\Response\Model\UsageBuckets;
use Appwrite\Utopia\Response\Model\UsageCollection;
use Appwrite\Utopia\Response\Model\UsageDatabase;
use Appwrite\Utopia\Response\Model\UsageDatabases;
+use Appwrite\Utopia\Response\Model\UsageDocumentsDB;
+use Appwrite\Utopia\Response\Model\UsageDocumentsDBs;
use Appwrite\Utopia\Response\Model\UsageFunction;
use Appwrite\Utopia\Response\Model\UsageFunctions;
use Appwrite\Utopia\Response\Model\UsageProject;
@@ -145,9 +220,12 @@ use Appwrite\Utopia\Response\Model\UsageSites;
use Appwrite\Utopia\Response\Model\UsageStorage;
use Appwrite\Utopia\Response\Model\UsageTable;
use Appwrite\Utopia\Response\Model\UsageUsers;
+use Appwrite\Utopia\Response\Model\UsageVectorsDB;
+use Appwrite\Utopia\Response\Model\UsageVectorsDBs;
use Appwrite\Utopia\Response\Model\User;
use Appwrite\Utopia\Response\Model\Variable;
use Appwrite\Utopia\Response\Model\VcsContent;
+use Appwrite\Utopia\Response\Model\VectorsDBCollection;
use Appwrite\Utopia\Response\Model\Webhook;
// General
@@ -178,8 +256,8 @@ Response::setModel(new BaseList('Site Templates List', Response::MODEL_TEMPLATE_
Response::setModel(new BaseList('Functions List', Response::MODEL_FUNCTION_LIST, 'functions', Response::MODEL_FUNCTION));
Response::setModel(new BaseList('Function Templates List', Response::MODEL_TEMPLATE_FUNCTION_LIST, 'templates', Response::MODEL_TEMPLATE_FUNCTION));
Response::setModel(new BaseList('Installations List', Response::MODEL_INSTALLATION_LIST, 'installations', Response::MODEL_INSTALLATION));
-Response::setModel(new BaseList('Framework Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, 'frameworkProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK));
-Response::setModel(new BaseList('Runtime Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, 'runtimeProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME));
+Response::setModel(new ProviderRepositoryFrameworkList());
+Response::setModel(new ProviderRepositoryRuntimeList());
Response::setModel(new BaseList('Branches List', Response::MODEL_BRANCH_LIST, 'branches', Response::MODEL_BRANCH));
Response::setModel(new BaseList('Frameworks List', Response::MODEL_FRAMEWORK_LIST, 'frameworks', Response::MODEL_FRAMEWORK));
Response::setModel(new BaseList('Runtimes List', Response::MODEL_RUNTIME_LIST, 'runtimes', Response::MODEL_RUNTIME));
@@ -190,7 +268,6 @@ Response::setModel(new BaseList('Webhooks List', Response::MODEL_WEBHOOK_LIST, '
Response::setModel(new BaseList('API Keys List', Response::MODEL_KEY_LIST, 'keys', Response::MODEL_KEY, true, true));
Response::setModel(new BaseList('Dev Keys List', Response::MODEL_DEV_KEY_LIST, 'devKeys', Response::MODEL_DEV_KEY, true, false));
Response::setModel(new BaseList('Auth Providers List', Response::MODEL_AUTH_PROVIDER_LIST, 'platforms', Response::MODEL_AUTH_PROVIDER, true, false));
-Response::setModel(new BaseList('Platforms List', Response::MODEL_PLATFORM_LIST, 'platforms', Response::MODEL_PLATFORM, true, false));
Response::setModel(new BaseList('Countries List', Response::MODEL_COUNTRY_LIST, 'countries', Response::MODEL_COUNTRY));
Response::setModel(new BaseList('Continents List', Response::MODEL_CONTINENT_LIST, 'continents', Response::MODEL_CONTINENT));
Response::setModel(new BaseList('Languages List', Response::MODEL_LANGUAGE_LIST, 'languages', Response::MODEL_LANGUAGE));
@@ -198,6 +275,9 @@ Response::setModel(new BaseList('Currencies List', Response::MODEL_CURRENCY_LIST
Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phones', Response::MODEL_PHONE));
Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'metrics', Response::MODEL_METRIC, true, false));
Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE));
+Response::setModel(new BaseList('Mock Numbers List', Response::MODEL_MOCK_NUMBER_LIST, 'mockNumbers', Response::MODEL_MOCK_NUMBER));
+Response::setModel(new PolicyList());
+Response::setModel(new BaseList('Email Templates List', Response::MODEL_EMAIL_TEMPLATE_LIST, 'templates', Response::MODEL_EMAIL_TEMPLATE));
Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS));
Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE));
Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE));
@@ -212,9 +292,14 @@ Response::setModel(new BaseList('Migrations List', Response::MODEL_MIGRATION_LIS
Response::setModel(new BaseList('Migrations Firebase Projects List', Response::MODEL_MIGRATION_FIREBASE_PROJECT_LIST, 'projects', Response::MODEL_MIGRATION_FIREBASE_PROJECT));
Response::setModel(new BaseList('Specifications List', Response::MODEL_SPECIFICATION_LIST, 'specifications', Response::MODEL_SPECIFICATION));
Response::setModel(new BaseList('VCS Content List', Response::MODEL_VCS_CONTENT_LIST, 'contents', Response::MODEL_VCS_CONTENT));
+Response::setModel(new BaseList('VectorsDB Collections List', Response::MODEL_VECTORSDB_COLLECTION_LIST, 'collections', Response::MODEL_VECTORSDB_COLLECTION));
+Response::setModel(new BaseList('Embedding list', Response::MODEL_EMBEDDING_LIST, 'embeddings', Response::MODEL_EMBEDDING));
+Response::setModel(new BaseList('Insights List', Response::MODEL_INSIGHT_LIST, 'insights', Response::MODEL_INSIGHT));
+Response::setModel(new BaseList('Reports List', Response::MODEL_REPORT_LIST, 'reports', Response::MODEL_REPORT));
// Entities
Response::setModel(new Database());
+Response::setModel(new Embedding());
// Collection API Models
Response::setModel(new Collection());
@@ -222,6 +307,7 @@ Response::setModel(new Attribute());
Response::setModel(new AttributeList());
Response::setModel(new AttributeString());
Response::setModel(new AttributeInteger());
+Response::setModel(new AttributeBigInt());
Response::setModel(new AttributeFloat());
Response::setModel(new AttributeBoolean());
Response::setModel(new AttributeEmail());
@@ -238,12 +324,24 @@ Response::setModel(new AttributeText());
Response::setModel(new AttributeMediumtext());
Response::setModel(new AttributeLongtext());
+// DocumentsDB API Models
+Response::setModel(new UsageDocumentsDBs());
+Response::setModel(new UsageDocumentsDB());
+
+// VectorsDB API Models
+Response::setModel(new VectorsDBCollection());
+Response::setModel(new AttributeObject());
+Response::setModel(new AttributeVector());
+Response::setModel(new UsageVectorsDBs());
+Response::setModel(new UsageVectorsDB());
+
// Table API Models
Response::setModel(new Table());
Response::setModel(new Column());
Response::setModel(new ColumnList());
Response::setModel(new ColumnString());
Response::setModel(new ColumnInteger());
+Response::setModel(new ColumnBigInt());
Response::setModel(new ColumnFloat());
Response::setModel(new ColumnBoolean());
Response::setModel(new ColumnEmail());
@@ -307,12 +405,71 @@ Response::setModel(new FrameworkAdapter());
Response::setModel(new Deployment());
Response::setModel(new Execution());
Response::setModel(new Project());
+Response::setModel(new ProjectAuthMethod());
+Response::setModel(new ProjectService());
+Response::setModel(new ProjectProtocol());
Response::setModel(new Webhook());
Response::setModel(new Key());
+Response::setModel(new EphemeralKey());
Response::setModel(new DevKey());
Response::setModel(new MockNumber());
+Response::setModel(new OAuth2GitHub());
+Response::setModel(new OAuth2Discord());
+Response::setModel(new OAuth2Figma());
+Response::setModel(new OAuth2Dropbox());
+Response::setModel(new OAuth2Dailymotion());
+Response::setModel(new OAuth2Bitbucket());
+Response::setModel(new OAuth2Bitly());
+Response::setModel(new OAuth2Box());
+Response::setModel(new OAuth2Autodesk());
+Response::setModel(new OAuth2Google());
+Response::setModel(new OAuth2Zoom());
+Response::setModel(new OAuth2Zoho());
+Response::setModel(new OAuth2Yandex());
+Response::setModel(new OAuth2X());
+Response::setModel(new OAuth2WordPress());
+Response::setModel(new OAuth2Twitch());
+Response::setModel(new OAuth2Stripe());
+Response::setModel(new OAuth2Spotify());
+Response::setModel(new OAuth2Slack());
+Response::setModel(new OAuth2Podio());
+Response::setModel(new OAuth2Notion());
+Response::setModel(new OAuth2Salesforce());
+Response::setModel(new OAuth2Yahoo());
+Response::setModel(new OAuth2Linkedin());
+Response::setModel(new OAuth2Disqus());
+Response::setModel(new OAuth2Amazon());
+Response::setModel(new OAuth2Etsy());
+Response::setModel(new OAuth2Facebook());
+Response::setModel(new OAuth2Tradeshift());
+Response::setModel(new OAuth2Paypal());
+Response::setModel(new OAuth2Gitlab());
+Response::setModel(new OAuth2Authentik());
+Response::setModel(new OAuth2Auth0());
+Response::setModel(new OAuth2FusionAuth());
+Response::setModel(new OAuth2Keycloak());
+Response::setModel(new OAuth2Oidc());
+Response::setModel(new OAuth2Okta());
+Response::setModel(new OAuth2Kick());
+Response::setModel(new OAuth2Apple());
+Response::setModel(new OAuth2Microsoft());
+Response::setModel(new OAuth2ProviderList());
+Response::setModel(new PolicyPasswordDictionary());
+Response::setModel(new PolicyPasswordHistory());
+Response::setModel(new PolicyPasswordPersonalData());
+Response::setModel(new PolicySessionAlert());
+Response::setModel(new PolicySessionDuration());
+Response::setModel(new PolicySessionInvalidation());
+Response::setModel(new PolicySessionLimit());
+Response::setModel(new PolicyUserLimit());
+Response::setModel(new PolicyMembershipPrivacy());
Response::setModel(new AuthProvider());
-Response::setModel(new Platform());
+Response::setModel(new PlatformWeb());
+Response::setModel(new PlatformApple());
+Response::setModel(new PlatformAndroid());
+Response::setModel(new PlatformWindows());
+Response::setModel(new PlatformLinux());
+Response::setModel(new PlatformList());
Response::setModel(new Variable());
Response::setModel(new Country());
Response::setModel(new Continent());
@@ -343,9 +500,13 @@ Response::setModel(new Headers());
Response::setModel(new Specification());
Response::setModel(new Rule());
Response::setModel(new Schedule());
-Response::setModel(new TemplateSMS());
Response::setModel(new TemplateEmail());
Response::setModel(new ConsoleVariables());
+Response::setModel(new ConsoleOAuth2ProviderParameter());
+Response::setModel(new ConsoleOAuth2Provider());
+Response::setModel(new ConsoleOAuth2ProviderList());
+Response::setModel(new ConsoleKeyScope());
+Response::setModel(new ConsoleKeyScopeList());
Response::setModel(new MFAChallenge());
Response::setModel(new MFARecoveryCodes());
Response::setModel(new MFAType());
@@ -358,8 +519,10 @@ Response::setModel(new Subscriber());
Response::setModel(new Target());
Response::setModel(new Migration());
Response::setModel(new MigrationReport());
-Response::setModel(new MigrationKey());
Response::setModel(new MigrationFirebaseProject());
+Response::setModel(new Insight());
+Response::setModel(new InsightCTA());
+Response::setModel(new Report());
// Tests (keep last)
Response::setModel(new Mock());
diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php
new file mode 100644
index 0000000000..a090635bb5
--- /dev/null
+++ b/app/init/realtime/connection.php
@@ -0,0 +1,368 @@
+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 7b68c2af9a..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;
}
@@ -160,7 +160,6 @@ $register->set('pools', function () {
'pass' => System::getEnv('_APP_DB_PASS', ''),
'path' => System::getEnv('_APP_DB_SCHEMA', ''),
]);
-
$fallbackForRedis = 'redis_main=' . AppwriteURL::unparse([
'scheme' => 'redis',
'host' => System::getEnv('_APP_REDIS_HOST', 'redis'),
@@ -169,6 +168,23 @@ $register->set('pools', function () {
'pass' => System::getEnv('_APP_REDIS_PASS', ''),
]);
+ $fallbackForDocumentsDB = 'db_main=' . AppwriteURL::unparse([
+ 'scheme' => System::getEnv('_APP_DB_ADAPTER_DOCUMENTSDB', 'mongodb'),
+ 'host' => System::getEnv('_APP_DB_HOST_DOCUMENTSDB', 'mongodb'),
+ 'port' => System::getEnv('_APP_DB_PORT_DOCUMENTSDB', '27017'),
+ 'user' => System::getEnv('_APP_DB_USER', ''),
+ 'pass' => System::getEnv('_APP_DB_PASS', ''),
+ 'path' => System::getEnv('_APP_DB_SCHEMA', ''),
+ ]);
+ $fallbackForVectorsDB = 'db_main=' . AppwriteURL::unparse([
+ 'scheme' => System::getEnv('_APP_DB_ADAPTER_VECTORSDB', 'postgresql'),
+ 'host' => System::getEnv('_APP_DB_HOST_VECTORSDB', 'postgresql'),
+ 'port' => System::getEnv('_APP_DB_PORT_VECTORSDB', '5432'),
+ 'user' => System::getEnv('_APP_DB_USER', ''),
+ 'pass' => System::getEnv('_APP_DB_PASS', ''),
+ 'path' => System::getEnv('_APP_DB_SCHEMA', ''),
+ ]);
+
$connections = [
'console' => [
'type' => 'database',
@@ -180,13 +196,25 @@ $register->set('pools', function () {
'type' => 'database',
'dsns' => $fallbackForDB,
'multiple' => true,
- 'schemes' => ['mariadb', 'mongodb', 'mysql', 'postgresql'],
+ 'schemes' => ['mongodb','mariadb', 'mysql','postgresql'],
+ ],
+ 'documentsdb' => [
+ 'type' => 'database',
+ 'dsns' => System::getEnv('_APP_CONNECTIONS_DATABASE_DOCUMENTSDB', $fallbackForDocumentsDB),
+ 'multiple' => true,
+ 'schemes' => ['mongodb'],
+ ],
+ 'vectorsdb' => [
+ 'type' => 'database',
+ 'dsns' => System::getEnv('_APP_CONNECTIONS_DATABASE_VECTORSDB', $fallbackForVectorsDB),
+ 'multiple' => true,
+ 'schemes' => ['postgresql'],
],
'logs' => [
'type' => 'database',
'dsns' => System::getEnv('_APP_CONNECTIONS_DB_LOGS', $fallbackForDB),
'multiple' => false,
- 'schemes' => ['mariadb', 'mongodb', 'mysql', 'postgresql'],
+ 'schemes' => ['mongodb','mariadb', 'mysql','postgresql'],
],
'publisher' => [
'type' => 'publisher',
@@ -214,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;
@@ -280,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();
@@ -301,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) {
@@ -311,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();
@@ -405,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 3465f22560..a626b612cb 100644
--- a/app/init/resources.php
+++ b/app/init/resources.php
@@ -1,45 +1,20 @@
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'));
+$container->set('register', fn () => $register);
- return $locale;
-});
-
-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('publisherMails', function (Publisher $publisher) {
+$container->set('publisherMails', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
-Http::setResource('publisherDeletes', function (Publisher $publisher) {
+$container->set('publisherDeletes', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
-Http::setResource('publisherMessaging', function (Publisher $publisher) {
+$container->set('publisherMessaging', function (Publisher $publisher) {
return $publisher;
}, ['publisher']);
-Http::setResource('publisherWebhooks', function (Publisher $publisher) {
+$container->set('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('usage', function () {
- return new UsageContext();
-}, []);
-Http::setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
+$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']);
-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('publisherForExecutions', fn (Publisher $publisher) => new ExecutionPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_EXECUTIONS_QUEUE_NAME', Event::EXECUTIONS_QUEUE_NAME))
+), ['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 ($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
- 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 ($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 are set, act as another user
- $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', '');
- $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', '');
- $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', '');
- if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) {
- $userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject;
- $targetUser = null;
- if (!empty($impersonateUserId)) {
- $targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->getDocument('users', $impersonateUserId));
- } elseif (!empty($impersonateEmail)) {
- $targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->findOne('users', [Query::equal('email', [\strtolower($impersonateEmail)])]));
- } elseif (!empty($impersonatePhone)) {
- $targetUser = $userDb->getAuthorization()->skip(fn () => $userDb->findOne('users', [Query::equal('phone', [$impersonatePhone])]));
- }
- if ($targetUser !== null && !$targetUser->isEmpty()) {
- $impersonator = clone $user;
- $user = clone $targetUser;
- $user->setAttribute('impersonatorUserId', $impersonator->getId());
- $user->setAttribute('impersonatorUserInternalId', $impersonator->getSequence());
- $user->setAttribute('impersonatorUserName', $impersonator->getAttribute('name', ''));
- $user->setAttribute('impersonatorUserEmail', $impersonator->getAttribute('email', ''));
- $user->setAttribute('impersonatorAccessedAt', $impersonator->getAttribute('accessedAt', 0));
- }
- }
-
- $dbForProject->setMetadata('user', $user->getId());
- $dbForPlatform->setMetadata('user', $user->getId());
-
- return $user;
-}, ['mode', 'project', 'console', 'request', 'response', 'dbForProject', 'dbForPlatform', 'store', 'proofForToken', 'authorization']);
-
-Http::setResource('project', function ($dbForPlatform, $request, $console, $authorization, Http $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']);
-
-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;
- }
- }
-
-}, ['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, UsageContext $usage, Authorization $authorization) {
- 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($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);
- };
-
- $usageDatabaseListener = function (string $event, Document $document, UsageContext $usage) {
- $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
- $usage->addMetric(METRIC_DATABASES, $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;
- $usage
- ->addMetric(METRIC_COLLECTIONS, $value) // per project
- ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_COLLECTIONS), $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;
- $usage
- ->addMetric(METRIC_DOCUMENTS, $value) // per project
- ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_DOCUMENTS), $value) // per database
- ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS), $value); // per collection
- break;
- case $document->getCollection() === 'buckets': // buckets
- $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);
- $queueForFunctions = new Func($publisherFunctions);
- $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),
- $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', 'usage', 'authorization']);
-
-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);
@@ -852,68 +163,7 @@ Http::setResource('dbForPlatform', function (Group $pools, Cache $cache, Authori
return $database;
}, ['pools', 'cache', '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($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) {
@@ -925,10 +175,16 @@ Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorizati
$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);
@@ -942,15 +198,9 @@ Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorizati
};
}, ['pools', 'cache', 'authorization']);
-Http::setResource('audit', function ($dbForProject) {
- $adapter = new AdapterDatabase($dbForProject);
+$container->set('telemetry', fn () => new NoTelemetry());
- return new Audit($adapter);
-}, ['dbForProject']);
-
-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 = [];
@@ -964,7 +214,11 @@ Http::setResource('cache', function (Group $pools, Telemetry $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', '');
@@ -979,31 +233,15 @@ 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', '');
@@ -1054,7 +292,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);
@@ -1111,33 +349,17 @@ function getDevice(string $root, string $connection = ''): Device
}
}
-Http::setResource('mode', function (Request $request, Document $project) {
- /**
- * Defines the mode for the request:
- * - 'default' => Requests for Client and Server Side
- * - 'admin' => Request from the Console on non-console projects
- */
- $mode = $request->getParam('mode', $request->getHeader('x-appwrite-mode', APP_MODE_DEFAULT));
-
- $projectId = $request->getParam('project', $request->getHeader('x-appwrite-project', ''));
- if (!empty($projectId) && $project->getId() !== $projectId) {
- $mode = APP_MODE_ADMIN;
- }
-
- return $mode;
-}, ['request', 'project']);
-
-Http::setResource('geodb', function ($register) {
+$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];
@@ -1148,351 +370,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) {
- return new TransactionState($dbForProject, $authorization);
-}, ['dbForProject', 'authorization']);
-
-Http::setResource('executionsRetentionCount', function (Document $project, array $plan) {
- if ($project->getId() === 'console' || empty($plan)) {
- return 0;
- }
-
- return (int) ($plan['executionsRetentionCount'] ?? 100);
-}, ['project', 'plan']);
+$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..68a5a3edf5
--- /dev/null
+++ b/app/init/resources/request.php
@@ -0,0 +1,1436 @@
+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('queueForFunctions', fn (Publisher $publisher) => new Func($publisher), ['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, Func $queueForFunctions, 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, 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, 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);
+ $queueForFunctions = new Func($publisherFunctions);
+ $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),
+ $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', '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 8afa01b2df..f6871badfa 100644
--- a/app/init/span.php
+++ b/app/init/span.php
@@ -3,11 +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(), function (Span $span): bool {
+
+// 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..791bf5edf0
--- /dev/null
+++ b/app/init/worker/message.php
@@ -0,0 +1,459 @@
+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('queueForFunctions', function (Publisher $publisher) {
+ return new Func($publisher);
+ }, ['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 @@
getHost(), $sharedTables)) {
+ $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 {
@@ -237,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();
/**
@@ -256,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
@@ -320,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();
@@ -357,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');
@@ -388,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.
*/
@@ -442,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']
]));
@@ -508,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);
@@ -530,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
);
}
@@ -544,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)));
@@ -586,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;
@@ -615,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
@@ -642,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);
}
@@ -656,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
*
@@ -676,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());
@@ -688,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());
@@ -697,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(
@@ -726,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;
@@ -746,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);
@@ -770,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';
}
@@ -787,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();
@@ -855,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.');
@@ -867,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);
@@ -912,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);
@@ -920,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()
);
}
}
@@ -936,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([
@@ -948,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);
@@ -961,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';
}
@@ -986,19 +1342,52 @@ $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)) {
+ $projectId = $realtime->connections[$connection]['projectId'] ?? null;
+ $userId = $realtime->connections[$connection]['userId'] ?? null;
+ }
+
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'));
+ }
$projectId = $realtime->connections[$connection]['projectId'];
@@ -1006,12 +1395,30 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) {
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 0f4df352bd..6ce1fb5cea 100644
--- a/app/views/install/compose.phtml
+++ b/app/views/install/compose.phtml
@@ -13,7 +13,7 @@ $organization = $this->getParam('organization', '');
$image = $this->getParam('image', '');
$enableAssistant = $this->getParam('enableAssistant', false);
$dbService = $this->getParam('database', 'mongodb');
-$allowedDbServices = ['mariadb', 'mongodb', 'postgresql'];
+$allowedDbServices = ['mariadb', 'mongodb'];
if (!\in_array($dbService, $allowedDbServices, true)) {
$dbService = 'mongodb';
}
@@ -120,7 +120,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_SMTP_HOST
- _APP_SMTP_PORT
- _APP_SMTP_SECURE
@@ -194,7 +193,7 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
appwrite-console:
<<: *x-logging
container_name: appwrite-console
- image: /console:7.6.4
+ image: /console:7.8.26
restart: unless-stopped
networks:
- appwrite
@@ -256,7 +255,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_USAGE_STATS
- _APP_LOGGING_CONFIG
@@ -287,7 +285,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
appwrite-worker-webhooks:
@@ -315,7 +312,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -356,7 +352,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_STORAGE_DEVICE
- _APP_STORAGE_S3_ACCESS_KEY
- _APP_STORAGE_S3_SECRET
@@ -416,7 +411,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
appwrite-worker-builds:
@@ -453,7 +447,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _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
@@ -529,7 +522,35 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _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:
@@ -563,7 +584,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_FUNCTIONS_TIMEOUT
- _APP_SITES_TIMEOUT
- _APP_COMPUTE_BUILD_TIMEOUT
@@ -601,7 +621,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -644,7 +663,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_LOGGING_CONFIG
- _APP_SMS_FROM
- _APP_SMS_PROVIDER
@@ -705,7 +723,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _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
@@ -744,7 +761,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_MAINTENANCE_INTERVAL
- _APP_MAINTENANCE_RETENTION_EXECUTION
- _APP_MAINTENANCE_RETENTION_CACHE
@@ -777,7 +793,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -810,7 +825,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -842,7 +856,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -868,6 +881,13 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_OPENSSL_KEY_V1
+ - _APP_OPTIONS_FORCE_HTTPS
+ - _APP_DOMAIN
+ - _APP_CONSOLE_DOMAIN
+ - _APP_DOMAIN_FUNCTIONS
+ - _APP_DOMAIN_SITES
+ - _APP_MIGRATION_HOST
+ - _APP_CONSOLE_SCHEMA
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -878,7 +898,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
appwrite-task-scheduler-executions:
image: /:
@@ -897,6 +916,13 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_OPENSSL_KEY_V1
+ - _APP_OPTIONS_FORCE_HTTPS
+ - _APP_DOMAIN
+ - _APP_CONSOLE_DOMAIN
+ - _APP_DOMAIN_FUNCTIONS
+ - _APP_DOMAIN_SITES
+ - _APP_MIGRATION_HOST
+ - _APP_CONSOLE_SCHEMA
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -907,7 +933,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
appwrite-task-scheduler-messages:
image: /:
@@ -936,7 +961,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- - _APP_DB_ADAPTER
appwrite-assistant:
@@ -964,7 +988,7 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
<<: *x-logging
restart: unless-stopped
stop_signal: SIGINT
- image: openruntimes/executor:0.7.22
+ image: openruntimes/executor:0.11.4
networks:
- appwrite
- runtimes
@@ -1039,13 +1063,12 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
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}
@@ -1176,7 +1199,6 @@ volumes:
appwrite-mongodb:
appwrite-mongodb-keyfile:
- appwrite-mongodb-config:
appwrite-redis:
appwrite-cache:
diff --git a/app/views/install/installer.phtml b/app/views/install/installer.phtml
index 05bc1b80ed..d33838c8c6 100644
--- a/app/views/install/installer.phtml
+++ b/app/views/install/installer.phtml
@@ -13,7 +13,7 @@ $enabledDatabases = $enabledDatabases ?? ['mongodb', 'mariadb', 'postgresql'];
$isLocalInstall = $isLocalInstall ?? false;
-$cardStep = min(4, $step);
+$cardStep = ($step === 5) ? 4 : $step;
$stepFile = __DIR__ . "/installer/templates/steps/step-{$cardStep}.phtml";
if (!is_file($stepFile)) {
$stepFile = __DIR__ . "/installer/templates/steps/step-1.phtml";
diff --git a/app/views/install/installer/css/styles.css b/app/views/install/installer/css/styles.css
index b1d8fe5089..8fd28a12a3 100644
--- a/app/views/install/installer/css/styles.css
+++ b/app/views/install/installer/css/styles.css
@@ -478,6 +478,10 @@ body {
overflow: hidden;
}
+.installer-page[data-upgrade='true'] .installer-step {
+ min-height: 0;
+}
+
.action-shell {
display: flex;
flex-direction: column;
@@ -691,6 +695,19 @@ body {
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;
@@ -897,6 +914,17 @@ body {
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;
@@ -1784,3 +1812,92 @@ body {
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/js/installer.js b/app/views/install/installer/js/installer.js
index 463b7f6221..07ec7bb1ef 100644
--- a/app/views/install/installer/js/installer.js
+++ b/app/views/install/installer/js/installer.js
@@ -12,7 +12,7 @@
const { validateInstallRequest } = window.InstallerStepsProgress || {};
const isUpgrade = document.body?.dataset.upgrade === 'true';
- const stepFlow = isUpgrade ? [1, 4, 5] : [1, 2, 3, 4, 5];
+ const stepFlow = isUpgrade ? [1, 6, 4, 5] : [1, 2, 3, 4, 5];
const cardSteps = stepFlow.filter((step) => step !== 5);
const normalizeStep = (step) => {
@@ -53,7 +53,7 @@
let pendingStep = null;
let pendingPushState = false;
- const clampStep = (step) => Math.max(1, Math.min(5, step));
+ const clampStep = (step) => Math.max(1, Math.min(6, step));
const isInstallLocked = () => Boolean(window.InstallerSteps?.isInstallLocked?.());
const scrollToFirstError = (panel) => {
@@ -399,11 +399,18 @@
}
}
}
- if (action === 'next' && String(target) === '5' && typeof validateInstallRequest === 'function') {
- const isValid = await validateInstallRequest();
- if (!isValid) {
- 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);
diff --git a/app/views/install/installer/js/modules/context.js b/app/views/install/installer/js/modules/context.js
index c531ecddce..6f215da899 100644
--- a/app/views/install/installer/js/modules/context.js
+++ b/app/views/install/installer/js/modules/context.js
@@ -13,7 +13,10 @@
DOCKER_COMPOSE: 'docker-compose',
ENV_VARS: 'env-vars',
DOCKER_CONTAINERS: 'docker-containers',
- ACCOUNT_SETUP: 'account-setup'
+ ACCOUNT_SETUP: 'account-setup',
+ MIGRATION: 'migration',
+ SSL_CERTIFICATE: 'ssl-certificate',
+ REDIRECT: 'redirect'
});
const STATUS = Object.freeze({
@@ -50,6 +53,11 @@
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'
}
] : [
{
@@ -75,7 +83,7 @@
{
id: STEP_IDS.ACCOUNT_SETUP,
inProgress: 'Creating Appwrite account...',
- done: 'Appwrite account created (redirecting...)'
+ done: 'Appwrite account created'
}
]);
@@ -93,7 +101,7 @@
const clampStep = (step) => {
const numeric = Number(step);
if (Number.isNaN(numeric)) return 1;
- return Math.max(1, Math.min(5, numeric));
+ return Math.max(1, Math.min(6, numeric));
};
window.InstallerStepsContext = Object.freeze({
diff --git a/app/views/install/installer/js/modules/progress.js b/app/views/install/installer/js/modules/progress.js
index 7f7b23e3fc..7c36fd7951 100644
--- a/app/views/install/installer/js/modules/progress.js
+++ b/app/views/install/installer/js/modules/progress.js
@@ -21,7 +21,7 @@
storeInstallId,
clearInstallId
} = window.InstallerStepsState || {};
- const { extractHostname, isLocalHost } = window.InstallerStepsValidation || {};
+ const { extractHostname, isLocalHost, isIPAddress } = window.InstallerStepsValidation || {};
const { generateSecretKey } = window.InstallerStepsUI || {};
const { showToast } = window.InstallerToast || {};
@@ -111,10 +111,10 @@
return normalized.summary || 'Installation failed.';
}
if (status === STATUS.COMPLETED) return step.done;
- return step.inProgress;
+ return message || step.inProgress;
};
- const updateInstallRow = (row, step, status, message) => {
+ const updateInstallRow = (row, step, status, message, details) => {
if (!row || !step) return;
row.dataset.status = status;
row.dataset.step = step.id;
@@ -138,6 +138,15 @@
}
}
+ 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) {
@@ -251,7 +260,7 @@
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
};
- const buildRedirectUrl = () => {
+ const buildRedirectUrl = (protocol) => {
const dataset = getBodyDataset?.() ?? {};
const rawDomain = (formState?.appDomain || dataset.defaultAppDomain || '').trim();
if (!rawDomain) return '';
@@ -266,22 +275,53 @@
} else if (normalizedHost === 'traefik') {
host = rawDomain.replace(hostForProtocol, 'localhost');
}
- let protocol = 'http';
- let port = httpPort;
- if (httpsPort && httpsPort !== '0' && !isLocalHost?.(normalizedHost)) {
- protocol = 'https';
- port = httpsPort;
- }
- if (!hasPort && port && ((protocol === 'http' && port !== '80') || (protocol === 'https' && port !== '443'))) {
+ const port = protocol === 'https' ? httpsPort : httpPort;
+ const defaultPort = protocol === 'https' ? '443' : '80';
+ if (!hasPort && port && port !== defaultPort) {
host = `${host}:${port}`;
}
return `${protocol}://${host}`;
};
- const redirectToApp = () => {
- const url = buildRedirectUrl();
+ 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;
- // Fire-and-forget: tell the installer server it can shut down
fetch('/install/shutdown', { method: 'POST', headers: withCsrfHeader() }).catch(() => {});
window.location.href = url;
};
@@ -318,7 +358,7 @@
const normalizedDomain = (formState?.appDomain || '').trim() || 'localhost';
const normalizedHttpPort = (formState?.httpPort || '').trim() || '80';
const normalizedHttpsPort = (formState?.httpsPort || '').trim() || '443';
- const normalizedEmail = (formState?.emailCertificates || '').trim();
+ const normalizedEmail = (formState?.emailCertificates || '').trim() || (formState?.accountEmail || '').trim();
const normalizedAssistantKey = (formState?.assistantOpenAIKey || '').trim();
const normalizedAccountEmail = (formState?.accountEmail || '').trim();
const normalizedAccountPassword = (formState?.accountPassword || '').trim();
@@ -333,7 +373,8 @@
opensslKey: (formState?.opensslKey || '').trim(),
assistantOpenAIKey: normalizedAssistantKey,
accountEmail: normalizedAccountEmail,
- accountPassword: normalizedAccountPassword
+ accountPassword: normalizedAccountPassword,
+ migrate: formState?.migrate ?? false
};
};
@@ -406,6 +447,7 @@
const initStep5 = (root) => {
if (!root) return;
+ let resolvedProtocol = 'http';
if (activeInstall?.controller) {
activeInstall.controller.abort();
@@ -497,7 +539,7 @@
if (!state) return;
const row = ensureRow(step);
if (row) {
- updateInstallRow(row, step, state.status || STATUS.IN_PROGRESS, state.message);
+ updateInstallRow(row, step, state.status || STATUS.IN_PROGRESS, state.message, state.details);
if (state.status === STATUS?.ERROR) {
updateInstallErrorDetails(row, {
message: state.message,
@@ -547,6 +589,9 @@
}
}
}
+ if (payload.status === STATUS.ERROR) {
+ showGlobalActions();
+ }
scheduleFallback();
};
@@ -584,6 +629,7 @@
const applySnapshot = (snapshot) => {
if (!snapshot || !snapshot.steps) return;
+ let hasErrors = false;
INSTALLATION_STEPS.forEach((step) => {
const detail = snapshot.steps[step.id];
if (!detail) return;
@@ -592,8 +638,14 @@
message: detail.message,
details: snapshot.details?.[step.id]
});
+ if (detail.status === STATUS.ERROR) {
+ hasErrors = true;
+ }
});
renderProgress();
+ if (hasErrors) {
+ showGlobalActions();
+ }
};
const checkAllCompleted = () => {
@@ -605,9 +657,7 @@
const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP);
const sessionDetails = sseSessionDetails || accountState?.details;
finalizeInstall();
- notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => {
- setTimeout(() => redirectToApp(), TIMINGS?.redirectDelay ?? 0);
- });
+ startSslCheck(sessionDetails);
};
const startPolling = () => {
@@ -644,6 +694,78 @@
}
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 = {}) => {
@@ -746,9 +868,7 @@
const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP);
const sessionDetails = sseSessionDetails || accountState?.details;
finalizeInstall();
- notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => {
- setTimeout(() => redirectToApp(), TIMINGS?.redirectDelay ?? 0);
- });
+ startSslCheck(sessionDetails);
return;
}
if (event === SSE_EVENTS.ERROR) {
@@ -792,9 +912,29 @@
}
};
+ 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);
- if (!snapshot) return false;
+ const terminal = isSnapshotTerminal(snapshot);
+ if (!snapshot || terminal) {
+ if (terminal === 'completed') {
+ return 'completed';
+ }
+ return false;
+ }
activeInstall = {
installId,
controller: new AbortController(),
@@ -857,7 +997,7 @@
const retryButton = event.target.closest('[data-install-retry]');
if (consoleButton) {
- redirectToApp();
+ redirectToApp(resolvedProtocol);
return;
}
@@ -868,6 +1008,60 @@
}
});
+ 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', () => {
@@ -876,22 +1070,45 @@
}
});
- const lock = getInstallLock?.();
- const existingInstallId = lock?.installId || getStoredInstallId?.();
- if (existingInstallId) {
- resumeInstall(existingInstallId).then((resumed) => {
- if (!resumed) {
- clearInstallId?.();
- clearInstallLock?.();
- const newInstallId = generateInstallId();
- storeInstallId?.(newInstallId);
- startInstallStream(newInstallId);
- }
- });
- } else {
+ const 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();
}
};
diff --git a/app/views/install/installer/js/modules/state.js b/app/views/install/installer/js/modules/state.js
index 9fcf9969a8..3c7fcd2427 100644
--- a/app/views/install/installer/js/modules/state.js
+++ b/app/views/install/installer/js/modules/state.js
@@ -7,6 +7,8 @@
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,
@@ -55,13 +57,24 @@
const getInstallLock = () => {
try {
const raw = sessionStorage.getItem(INSTALL_LOCK_KEY);
- if (!raw) return null;
- const parsed = JSON.parse(raw);
- if (!parsed || typeof parsed !== 'object') return null;
- return parsed;
- } catch (error) {
- return null;
- }
+ 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) => {
@@ -79,6 +92,9 @@
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';
}
@@ -89,6 +105,9 @@
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;
}
@@ -121,22 +140,31 @@
const getStoredInstallId = () => {
try {
- return sessionStorage.getItem(INSTALL_ID_KEY);
- } catch (error) {
- return null;
- }
+ 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 = {
diff --git a/app/views/install/installer/js/modules/ui.js b/app/views/install/installer/js/modules/ui.js
index bde4cb7c44..a41a657602 100644
--- a/app/views/install/installer/js/modules/ui.js
+++ b/app/views/install/installer/js/modules/ui.js
@@ -240,6 +240,9 @@
if (key === 'database') {
value = toDatabaseLabel(formState?.database);
}
+ if (key === 'emailCertificates' && !value) {
+ value = formState?.accountEmail;
+ }
if (value) {
node.textContent = value;
}
diff --git a/app/views/install/installer/js/modules/validation.js b/app/views/install/installer/js/modules/validation.js
index 13ab60ef4e..daa66eb8d6 100644
--- a/app/views/install/installer/js/modules/validation.js
+++ b/app/views/install/installer/js/modules/validation.js
@@ -106,12 +106,18 @@
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
+ isLocalHost,
+ isIPAddress
};
})();
diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js
index 2a71d075cc..b34389b561 100644
--- a/app/views/install/installer/js/steps.js
+++ b/app/views/install/installer/js/steps.js
@@ -329,6 +329,30 @@
}
};
+ 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;
@@ -346,6 +370,7 @@
if (normalized === 3) initStep3(root);
if (normalized === 4) initStep4(root);
if (normalized === 5) Progress.initStep5?.(root);
+ if (normalized === 6) initStep6(root);
};
window.InstallerSteps = {
@@ -390,10 +415,7 @@
if (!parsePort(httpPort, 'HTTP')) valid = false;
if (!parsePort(httpsPort, 'HTTPS')) valid = false;
- if (!sslEmail || !sslEmail.value.trim()) {
- setFieldError?.(sslEmail, 'Please enter an email address for SSL certificates');
- valid = false;
- } else if (!isValidEmail?.(sslEmail.value.trim())) {
+ if (sslEmail && sslEmail.value.trim() && !isValidEmail?.(sslEmail.value.trim())) {
setFieldError?.(sslEmail, 'Please enter a valid email address');
valid = false;
}
diff --git a/app/views/install/installer/templates/steps/step-4.phtml b/app/views/install/installer/templates/steps/step-4.phtml
index 07dc865257..8468de30f4 100644
--- a/app/views/install/installer/templates/steps/step-4.phtml
+++ b/app/views/install/installer/templates/steps/step-4.phtml
@@ -62,12 +62,14 @@ $badgeClass = $defaultSecretKey !== '' ? 'badge-success' : 'badge-warning';
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
index 8fa810b259..c18c3ea748 100644
--- a/app/views/install/installer/templates/steps/step-5.phtml
+++ b/app/views/install/installer/templates/steps/step-5.phtml
@@ -6,7 +6,7 @@ $isUpgrade = $isUpgrade ?? false;
@@ -30,6 +30,7 @@ $isUpgrade = $isUpgrade ?? false;
+
@@ -50,4 +51,13 @@ $isUpgrade = $isUpgrade ?? false;
+
+
+
+ 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 840231f16c..169b8e9770 100644
--- a/app/worker.php
+++ b/app/worker.php
@@ -1,511 +1,70 @@
$register);
+global $container;
+$container->set('pools', function ($register) {
+ return $register->get('pools');
+}, ['register']);
-Server::setResource('authorization', function () {
+$container->set('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);
+$container->set('project', fn () => new Document([]), []);
- $dbForPlatform
- ->setDatabase(APP_DATABASE)
- ->setAuthorization($authorization)
- ->setNamespace('_console')
- ->setDocumentType('users', User::class);
+$container->set('log', fn () => new Log(), []);
- 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($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($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($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($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);
-
- if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
- $database->setTenant($project->getSequence());
- }
-
- return $database;
- };
-}, ['pools', 'cache', '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('publisherMessaging', function (BrokerPool $publisher) {
- return $publisher;
-}, ['publisher']);
-
-Server::setResource('consumer', function (Group $pools) {
+$container->set('consumer', function (Group $pools) {
return new BrokerPool(consumer: $pools->get('consumer'));
}, ['pools']);
-Server::setResource('consumerDatabases', function (BrokerPool $consumer) {
+$container->set('consumerDatabases', function (BrokerPool $consumer) {
return $consumer;
}, ['consumer']);
-Server::setResource('consumerMigrations', function (BrokerPool $consumer) {
+$container->set('consumerMigrations', function (BrokerPool $consumer) {
return $consumer;
}, ['consumer']);
-Server::setResource('consumerStatsUsage', function (BrokerPool $consumer) {
+$container->set('consumerStatsUsage', function (BrokerPool $consumer) {
return $consumer;
}, ['consumer']);
-Server::setResource('usage', function () {
- return new Context();
-}, []);
-Server::setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
- $publisher,
- new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
-), ['publisher']);
-
-Server::setResource('queueForDatabase', function (Publisher $publisher) {
- return new EventDatabase($publisher);
-}, ['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) {
- return $register->get('pools');
-}, ['register']);
-
-Server::setResource('telemetry', fn () => new NoTelemetry());
-
-Server::setResource('deviceForSites', function (Document $project, Telemetry $telemetry) {
- return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
-
-Server::setResource('deviceForMigrations', function (Document $project, Telemetry $telemetry) {
- return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
-
-Server::setResource('deviceForFunctions', function (Document $project, Telemetry $telemetry) {
- return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
-
-Server::setResource('deviceForFiles', function (Document $project, Telemetry $telemetry) {
- return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
-
-Server::setResource('deviceForBuilds', function (Document $project, Telemetry $telemetry) {
- return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
-
-Server::setResource('deviceForCache', function (Document $project, Telemetry $telemetry) {
- return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_CACHE . '/app-' . $project->getId()));
-}, ['project', 'telemetry']);
-
-Server::setResource(
- 'isResourceBlocked',
- fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false
-);
-
-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])) {
Console::error('Missing worker name');
@@ -521,40 +80,54 @@ 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::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 ($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->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
@@ -564,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/composer.json b/composer.json
index 1fcac20b22..3983fd25cd 100644
--- a/composer.json
+++ b/composer.json
@@ -49,42 +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/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/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-feat-platform-db-access as 1.5.0",
- "utopia-php/platform": "0.7.*",
+ "utopia-php/logger": "0.8.*",
+ "utopia-php/messaging": "0.22.*",
+ "utopia-php/migration": "dev-feat-platform-db-access as 1.10.999",
+ "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/servers": "0.2.5",
+ "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.*",
@@ -92,17 +93,10 @@
"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.*",
- "utopia-php/di": "0.1.0"
+ "enshrined/svg-sanitize": "0.22.*"
},
- "repositories": [
- {
- "type": "vcs",
- "url": "https://github.com/utopia-php/database"
- }
- ],
"require-dev": {
"ext-fileinfo": "*",
"appwrite/sdk-generator": "*",
@@ -114,18 +108,11 @@
"czproject/git-php": "4.*",
"laravel/pint": "1.*"
},
- "repositories": [
- {
- "type": "vcs",
- "url": "https://github.com/utopia-php/database"
- }
- ],
"provide": {
"ext-phpiredis": "*"
},
"config": {
"platform": {
- "php": "8.3"
},
"allow-plugins": {
"php-http/discovery": true,
diff --git a/composer.lock b/composer.lock
index d7b9b6425b..33a08f1269 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": "3331dd1068f4abc907bbca3b2bf4e69d",
+ "content-hash": "a4a706c346d59204264c4a4734564d64",
"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",
@@ -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.50",
+ "version": "3.0.52",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
- "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b"
+ "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/aa6ad8321ed103dc3624fb600a25b66ebf78ec7b",
- "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b",
+ "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.50"
+ "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52"
},
"funding": [
{
@@ -2102,7 +2102,7 @@
"type": "tidelift"
}
],
- "time": "2026-03-19T02:57:58+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,22 @@
},
{
"name": "utopia-php/audit",
- "version": "2.2.1",
+ "version": "2.2.3",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/audit.git",
- "reference": "e3e2d6ad5c7f6377d9237df296a12eb7943892fd"
+ "reference": "95e9961fa286d2fdb6bf3eaa198f21d51bf58d9c"
},
"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/95e9961fa286d2fdb6bf3eaa198f21d51bf58d9c",
+ "reference": "95e9961fa286d2fdb6bf3eaa198f21d51bf58d9c",
"shasum": ""
},
"require": {
"php": ">=8.0",
"utopia-php/database": "5.*",
- "utopia-php/fetch": "0.5.*",
+ "utopia-php/fetch": "^1.1",
"utopia-php/validators": "0.2.*"
},
"require-dev": {
@@ -3545,9 +3553,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/audit/issues",
- "source": "https://github.com/utopia-php/audit/tree/2.2.1"
+ "source": "https://github.com/utopia-php/audit/tree/2.2.3"
},
- "time": "2026-02-02T10:39:25+00:00"
+ "time": "2026-05-08T10:38:23+00:00"
},
{
"name": "utopia-php/auth",
@@ -3606,23 +3614,24 @@
},
{
"name": "utopia-php/cache",
- "version": "1.0.1",
+ "version": "2.1.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/cache.git",
- "reference": "05ceba981436a4022553f7aaa2a05fa049d0f71c"
+ "reference": "fc3b9ae33c4b83e0e2c91ecf60b4f40fb7ee8f8e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/cache/zipball/05ceba981436a4022553f7aaa2a05fa049d0f71c",
- "reference": "05ceba981436a4022553f7aaa2a05fa049d0f71c",
+ "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 +3639,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 +3662,89 @@
],
"support": {
"issues": "https://github.com/utopia-php/cache/issues",
- "source": "https://github.com/utopia-php/cache/tree/1.0.1"
+ "source": "https://github.com/utopia-php/cache/tree/2.1.0"
},
- "time": "2026-03-12T03:39:09+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 +3775,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,24 +3922,25 @@
},
{
"name": "utopia-php/database",
- "version": "5.3.17",
+ "version": "5.8.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/database.git",
- "reference": "cff2b6ed63d3291b74110d086e16ff089fe05993"
+ "reference": "3391c97318f0e7f94d2c1ea0f7d09e5ba8aad696"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/database/zipball/cff2b6ed63d3291b74110d086e16ff089fe05993",
- "reference": "cff2b6ed63d3291b74110d086e16ff089fe05993",
+ "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/cache": "^2.0",
"utopia-php/console": "0.1.*",
"utopia-php/mongo": "1.*",
"utopia-php/pools": "1.*",
@@ -3889,38 +3962,7 @@
"Utopia\\Database\\": "src/Database"
}
},
- "autoload-dev": {
- "psr-4": {
- "Tests\\E2E\\": "tests/e2e",
- "Tests\\Unit\\": "tests/unit"
- }
- },
- "scripts": {
- "build": [
- "Composer\\Config::disableProcessTimeout",
- "docker compose build"
- ],
- "start": [
- "Composer\\Config::disableProcessTimeout",
- "docker compose up -d"
- ],
- "test": [
- "Composer\\Config::disableProcessTimeout",
- "docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml"
- ],
- "lint": [
- "php -d memory_limit=2G ./vendor/bin/pint --test"
- ],
- "format": [
- "php -d memory_limit=2G ./vendor/bin/pint"
- ],
- "check": [
- "./vendor/bin/phpstan analyse --level 7 src tests --memory-limit 2G"
- ],
- "coverage": [
- "./vendor/bin/coverage-check ./tmp/clover.xml 90"
- ]
- },
+ "notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
@@ -3933,10 +3975,10 @@
"utopia"
],
"support": {
- "source": "https://github.com/utopia-php/database/tree/5.3.17",
- "issues": "https://github.com/utopia-php/database/issues"
+ "issues": "https://github.com/utopia-php/database/issues",
+ "source": "https://github.com/utopia-php/database/tree/5.8.0"
},
- "time": "2026-03-20T01:18:52+00:00"
+ "time": "2026-05-12T12:52:44+00:00"
},
{
"name": "utopia-php/detector",
@@ -3985,25 +4027,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"
},
@@ -4020,34 +4063,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.*"
@@ -4084,27 +4129,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": {
@@ -4146,9 +4191,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",
@@ -4199,22 +4244,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": {
@@ -4222,7 +4266,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": {
@@ -4254,22 +4299,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": {
@@ -4278,7 +4323,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": {
@@ -4293,36 +4339,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": {
@@ -4334,30 +4386,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": {
@@ -4366,10 +4419,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": {
@@ -4391,9 +4446,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",
@@ -4444,20 +4499,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.*",
@@ -4492,29 +4548,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": {
@@ -4543,33 +4599,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-feat-platform-db-access",
+ "version": "1.11.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
- "reference": "86843355dced5e4ad763b19764a766a3821fb6b2"
+ "reference": "0fca44f40ad07bf2d56e9396afa6fa6d9b098ef1"
},
"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/0fca44f40ad07bf2d56e9396afa6fa6d9b098ef1",
+ "reference": "0fca44f40ad07bf2d56e9396afa6fa6d9b098ef1",
"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": "*",
@@ -4598,22 +4654,22 @@
],
"support": {
"issues": "https://github.com/utopia-php/migration/issues",
- "source": "https://github.com/utopia-php/migration/tree/feat-platform-db-access"
+ "source": "https://github.com/utopia-php/migration/tree/1.11.0"
},
- "time": "2026-03-14T23:10:53+00:00"
+ "time": "2026-05-11T08:13:06+00:00"
},
{
"name": "utopia-php/mongo",
- "version": "1.0.2",
+ "version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/mongo.git",
- "reference": "677a21c53f7a1316c528b4b45b3fce886cee7223"
+ "reference": "73593682deee4696525a04e26524c1c1226e1530"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/mongo/zipball/677a21c53f7a1316c528b4b45b3fce886cee7223",
- "reference": "677a21c53f7a1316c528b4b45b3fce886cee7223",
+ "url": "https://api.github.com/repos/utopia-php/mongo/zipball/73593682deee4696525a04e26524c1c1226e1530",
+ "reference": "73593682deee4696525a04e26524c1c1226e1530",
"shasum": ""
},
"require": {
@@ -4659,36 +4715,36 @@
],
"support": {
"issues": "https://github.com/utopia-php/mongo/issues",
- "source": "https://github.com/utopia-php/mongo/tree/1.0.2"
+ "source": "https://github.com/utopia-php/mongo/tree/1.1.0"
},
- "time": "2026-03-18T02:45:50+00:00"
+ "time": "2026-04-24T06:15:10+00:00"
},
{
"name": "utopia-php/platform",
- "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": {
@@ -4710,9 +4766,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",
@@ -4822,32 +4878,32 @@
},
{
"name": "utopia-php/queue",
- "version": "0.15.6",
+ "version": "0.18.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/queue.git",
- "reference": "08e361d69610f371382b344c369eef355ca414b4"
+ "reference": "f85ca003c99ff475708c05466643d067403c0c22"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/queue/zipball/08e361d69610f371382b344c369eef355ca414b4",
- "reference": "08e361d69610f371382b344c369eef355ca414b4",
+ "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"
},
@@ -4882,9 +4938,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",
@@ -4940,21 +4996,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": {
@@ -4988,9 +5044,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",
@@ -5038,16 +5094,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": {
@@ -5061,9 +5117,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": {
@@ -5085,22 +5140,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.1",
+ "version": "0.10.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/system.git",
- "reference": "7c1669533bb9c285de19191270c8c1439161a78a"
+ "reference": "04229a822b147c1abaf1a92fb42c2d7aad4625df"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/system/zipball/7c1669533bb9c285de19191270c8c1439161a78a",
- "reference": "7c1669533bb9c285de19191270c8c1439161a78a",
+ "url": "https://api.github.com/repos/utopia-php/system/zipball/04229a822b147c1abaf1a92fb42c2d7aad4625df",
+ "reference": "04229a822b147c1abaf1a92fb42c2d7aad4625df",
"shasum": ""
},
"require": {
@@ -5141,9 +5196,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/system/issues",
- "source": "https://github.com/utopia-php/system/tree/0.10.1"
+ "source": "https://github.com/utopia-php/system/tree/0.10.2"
},
- "time": "2026-03-15T21:07:41+00:00"
+ "time": "2026-05-05T14:33:41+00:00"
},
{
"name": "utopia-php/telemetry",
@@ -5202,16 +5257,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": {
@@ -5241,35 +5296,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": {
@@ -5290,9 +5345,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",
@@ -5403,38 +5458,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"
},
@@ -5456,30 +5522,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": {
@@ -5515,22 +5585,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": {
@@ -5544,9 +5614,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": {
@@ -5554,11 +5624,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",
@@ -5598,7 +5668,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": [
{
@@ -5610,7 +5680,7 @@
"type": "paypal"
}
],
- "time": "2026-02-06T10:53:26+00:00"
+ "time": "2026-03-29T15:46:14+00:00"
},
{
"name": "czproject/git-php",
@@ -5799,16 +5869,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": {
@@ -5819,13 +5889,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"
@@ -5862,7 +5933,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",
@@ -6225,11 +6296,11 @@
},
{
"name": "phpstan/phpstan",
- "version": "2.1.42",
+ "version": "2.1.54",
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1279e1ce86ba768f0780c9d889852b4e02ff40d0",
- "reference": "1279e1ce86ba768f0780c9d889852b4e02ff40d0",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
+ "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
"shasum": ""
},
"require": {
@@ -6274,20 +6345,20 @@
"type": "github"
}
],
- "time": "2026-03-17T14:58:32+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": {
@@ -6296,7 +6367,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",
@@ -6343,7 +6413,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": [
{
@@ -6363,7 +6433,7 @@
"type": "tidelift"
}
],
- "time": "2026-02-06T06:01:44+00:00"
+ "time": "2026-04-15T08:23:17+00:00"
},
{
"name": "phpunit/php-file-iterator",
@@ -6624,16 +6694,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": {
@@ -6647,15 +6717,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",
@@ -6702,31 +6772,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"
+ "time": "2026-05-01T04:21:04+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -6799,16 +6853,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": {
@@ -6867,7 +6921,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": [
{
@@ -6887,7 +6941,7 @@
"type": "tidelift"
}
],
- "time": "2026-01-24T09:28:48+00:00"
+ "time": "2026-04-14T08:23:15+00:00"
},
{
"name": "sebastian/complexity",
@@ -7016,16 +7070,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": {
@@ -7040,7 +7094,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "8.0-dev"
+ "dev-main": "8.1-dev"
}
},
"autoload": {
@@ -7068,7 +7122,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": [
{
@@ -7088,7 +7142,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-12T14:11:56+00:00"
+ "time": "2026-04-15T12:13:01+00:00"
},
{
"name": "sebastian/exporter",
@@ -7711,16 +7765,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": {
@@ -7777,7 +7831,7 @@
"terminal"
],
"support": {
- "source": "https://github.com/symfony/console/tree/v8.0.4"
+ "source": "https://github.com/symfony/console/tree/v8.0.9"
},
"funding": [
{
@@ -7797,20 +7851,20 @@
"type": "tidelift"
}
],
- "time": "2026-01-13T13:06:50+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": {
@@ -7860,7 +7914,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": [
{
@@ -7880,20 +7934,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": {
@@ -7942,7 +7996,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": [
{
@@ -7962,11 +8016,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",
@@ -8027,7 +8081,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": [
{
@@ -8051,7 +8105,7 @@
},
{
"name": "symfony/polyfill-php81",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php81.git",
@@ -8107,7 +8161,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": [
{
@@ -8131,16 +8185,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": {
@@ -8172,7 +8226,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": [
{
@@ -8192,20 +8246,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": {
@@ -8262,7 +8316,7 @@
"utf8"
],
"support": {
- "source": "https://github.com/symfony/string/tree/v8.0.4"
+ "source": "https://github.com/symfony/string/tree/v8.0.8"
},
"funding": [
{
@@ -8282,7 +8336,7 @@
"type": "tidelift"
}
],
- "time": "2026-01-12T12:37:40+00:00"
+ "time": "2026-03-30T15:14:47+00:00"
},
{
"name": "textalk/websocket",
@@ -8463,17 +8517,11 @@
"time": "2024-11-07T12:36:22+00:00"
}
],
- "aliases": [
- {
- "package": "utopia-php/migration",
- "version": "dev-feat-platform-db-access",
- "alias": "1.5.0",
- "alias_normalized": "1.5.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,
@@ -8495,8 +8543,5 @@
"platform-dev": {
"ext-fileinfo": "*"
},
- "platform-overrides": {
- "php": "8.3"
- },
"plugin-api-version": "2.9.0"
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 7d64dfa867..76f06c672a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -112,6 +112,8 @@ services:
condition: service_healthy
coredns:
condition: service_started
+ ollama:
+ condition: service_started
entrypoint:
- php
- -e
@@ -159,6 +161,12 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
+ - _APP_DB_ADAPTER_VECTORSDB
+ - _APP_DB_HOST_VECTORSDB
+ - _APP_DB_PORT_VECTORSDB
+ - _APP_DB_SCHEMA_VECTORSDB
+ - _APP_DB_USER_VECTORSDB
+ - _APP_DB_PASS_VECTORSDB
- _APP_SMTP_HOST
- _APP_SMTP_PORT
- _APP_SMTP_SECURE
@@ -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
@@ -295,6 +304,7 @@ services:
depends_on:
- ${_APP_DB_HOST:-mongodb}
- redis
+ - ollama
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -311,6 +321,12 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
+ - _APP_DB_ADAPTER_VECTORSDB
+ - _APP_DB_HOST_VECTORSDB
+ - _APP_DB_PORT_VECTORSDB
+ - _APP_DB_SCHEMA_VECTORSDB
+ - _APP_DB_USER_VECTORSDB
+ - _APP_DB_PASS_VECTORSDB
- _APP_USAGE_STATS
- _APP_LOGGING_CONFIG
- _APP_LOGGING_CONFIG_REALTIME
@@ -330,6 +346,7 @@ services:
depends_on:
- redis
- ${_APP_DB_HOST:-mongodb}
+ - ollama
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -363,6 +380,7 @@ services:
- ${_APP_DB_HOST:-mongodb}
- request-catcher-sms
- request-catcher-webhook
+ - ollama
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -393,6 +411,7 @@ services:
depends_on:
- redis
- ${_APP_DB_HOST:-mongodb}
+ - ollama
volumes:
- appwrite-uploads:/storage/uploads:rw
- appwrite-cache:/storage/cache:rw
@@ -402,6 +421,7 @@ services:
- appwrite-certificates:/storage/certificates:rw
- ./app:/usr/src/code/app
- ./src:/usr/src/code/src
+
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -443,7 +463,6 @@ 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
@@ -458,9 +477,11 @@ services:
volumes:
- ./app:/usr/src/code/app
- ./src:/usr/src/code/src
+
depends_on:
- redis
- ${_APP_DB_HOST:-mongodb}
+ - ollama
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -476,6 +497,12 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
+ - _APP_DB_ADAPTER_VECTORSDB
+ - _APP_DB_HOST_VECTORSDB
+ - _APP_DB_PORT_VECTORSDB
+ - _APP_DB_SCHEMA_VECTORSDB
+ - _APP_DB_USER_VECTORSDB
+ - _APP_DB_PASS_VECTORSDB
- _APP_LOGGING_CONFIG
- _APP_WORKERS_NUM
- _APP_QUEUE_NAME
@@ -497,6 +524,7 @@ services:
depends_on:
- redis
- ${_APP_DB_HOST:-mongodb}
+ - ollama
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -629,6 +657,7 @@ services:
depends_on:
- redis
- ${_APP_DB_HOST:-mongodb}
+ - ollama
volumes:
- appwrite-config:/storage/config:rw
- appwrite-certificates:/storage/certificates:rw
@@ -848,6 +877,7 @@ services:
- ./app:/usr/src/code/app
- ./src:/usr/src/code/src
- ./tests:/usr/src/code/tests
+
depends_on:
- ${_APP_DB_HOST:-mongodb}
environment:
@@ -1044,6 +1074,7 @@ services:
depends_on:
- redis
- ${_APP_DB_HOST:-mongodb}
+ - ollama
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -1077,11 +1108,19 @@ services:
depends_on:
- ${_APP_DB_HOST:-mongodb}
- redis
+ - ollama
environment:
- _APP_ENV
- _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
@@ -1107,11 +1146,19 @@ services:
depends_on:
- ${_APP_DB_HOST:-mongodb}
- redis
+ - ollama
environment:
- _APP_ENV
- _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
@@ -1137,6 +1184,7 @@ services:
depends_on:
- ${_APP_DB_HOST:-mongodb}
- redis
+ - ollama
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -1228,7 +1276,6 @@ services:
start_period: 5s
mariadb:
- profiles: ["mariadb"]
image: mariadb:10.11 # fix issues when upgrading using: mysql_upgrade -u root -p
container_name: appwrite-mariadb
<<: *x-logging
@@ -1252,10 +1299,10 @@ services:
retries: 12
mongodb:
- profiles: ["mongodb"]
image: mongo:8.2.5
container_name: appwrite-mongodb
<<: *x-logging
+ restart: on-failure:3
networks:
- appwrite
volumes:
@@ -1288,32 +1335,41 @@ services:
retries: 10
start_period: 30s
-
-
postgresql:
- profiles: ["postgresql"]
- build:
- context: ./tests/resources/postgresql
- args:
- POSTGRES_VERSION: 17
+ image: appwrite/postgres:0.1.0
container_name: appwrite-postgresql
<<: *x-logging
networks:
- appwrite
volumes:
- - appwrite-postgresql:/var/lib/postgresql:rw
+ - appwrite-postgresql:/var/lib/postgresql/18/data:rw
ports:
- "5432:5432"
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}"]
+ test: ["CMD-SHELL", "pg_isready -U ${_APP_DB_USER} -d ${_APP_DB_SCHEMA}"]
interval: 5s
timeout: 5s
- retries: 12
+ retries: 10
+ start_period: 10s
+ command: "postgres"
+
+ ollama:
+ image: appwrite/ollama:0.1.1
+ container_name: ollama
+ ports:
+ - "11434:11434"
+ restart: unless-stopped
+ environment:
+ MODELS: ${_APP_EMBEDDING_MODELS:-embeddinggemma}
+ OLLAMA_KEEP_ALIVE: 24h
+ volumes:
+ - appwrite-models:/root/.ollama
+ networks:
+ - appwrite
redis:
image: redis:7.4.7-alpine
@@ -1436,3 +1492,4 @@ volumes:
appwrite-sites:
appwrite-builds:
appwrite-config:
+ 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-collection.md b/docs/references/documentsdb/create-collection.md
new file mode 100644
index 0000000000..c6293a4c38
--- /dev/null
+++ b/docs/references/documentsdb/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/documentsdb/create-document.md b/docs/references/documentsdb/create-document.md
new file mode 100644
index 0000000000..197744b4a0
--- /dev/null
+++ b/docs/references/documentsdb/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/documentsdb/create-documents.md b/docs/references/documentsdb/create-documents.md
new file mode 100644
index 0000000000..9f4a4a1396
--- /dev/null
+++ b/docs/references/documentsdb/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/documentsdb/create-index.md b/docs/references/documentsdb/create-index.md
new file mode 100644
index 0000000000..164b754161
--- /dev/null
+++ b/docs/references/documentsdb/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/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/create.md b/docs/references/documentsdb/create.md
new file mode 100644
index 0000000000..b608485341
--- /dev/null
+++ b/docs/references/documentsdb/create.md
@@ -0,0 +1 @@
+Create a new Database.
diff --git a/docs/references/documentsdb/decrement-document-attribute.md b/docs/references/documentsdb/decrement-document-attribute.md
new file mode 100644
index 0000000000..b7b32d6148
--- /dev/null
+++ b/docs/references/documentsdb/decrement-document-attribute.md
@@ -0,0 +1 @@
+Decrement a specific column of a row by a given value.
\ No newline at end of file
diff --git a/docs/references/documentsdb/delete-collection.md b/docs/references/documentsdb/delete-collection.md
new file mode 100644
index 0000000000..90f7aa6aa5
--- /dev/null
+++ b/docs/references/documentsdb/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/documentsdb/delete-document.md b/docs/references/documentsdb/delete-document.md
new file mode 100644
index 0000000000..36fbf6802d
--- /dev/null
+++ b/docs/references/documentsdb/delete-document.md
@@ -0,0 +1 @@
+Delete a document by its unique ID.
\ No newline at end of file
diff --git a/docs/references/documentsdb/delete-documents.md b/docs/references/documentsdb/delete-documents.md
new file mode 100644
index 0000000000..a7b05503de
--- /dev/null
+++ b/docs/references/documentsdb/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/documentsdb/delete-index.md b/docs/references/documentsdb/delete-index.md
new file mode 100644
index 0000000000..c5b8f49e5f
--- /dev/null
+++ b/docs/references/documentsdb/delete-index.md
@@ -0,0 +1 @@
+Delete an index.
\ 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/delete.md b/docs/references/documentsdb/delete.md
new file mode 100644
index 0000000000..605fa290d3
--- /dev/null
+++ b/docs/references/documentsdb/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/documentsdb/get-collection-usage.md b/docs/references/documentsdb/get-collection-usage.md
new file mode 100644
index 0000000000..48682a075f
--- /dev/null
+++ b/docs/references/documentsdb/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/documentsdb/get-collection.md b/docs/references/documentsdb/get-collection.md
new file mode 100644
index 0000000000..97b39e8474
--- /dev/null
+++ b/docs/references/documentsdb/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/documentsdb/get-database-usage.md b/docs/references/documentsdb/get-database-usage.md
new file mode 100644
index 0000000000..2c2628a464
--- /dev/null
+++ b/docs/references/documentsdb/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/documentsdb/get-document.md b/docs/references/documentsdb/get-document.md
new file mode 100644
index 0000000000..4e4d76bec0
--- /dev/null
+++ b/docs/references/documentsdb/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/documentsdb/get-index.md b/docs/references/documentsdb/get-index.md
new file mode 100644
index 0000000000..cdea5b4f27
--- /dev/null
+++ b/docs/references/documentsdb/get-index.md
@@ -0,0 +1 @@
+Get index by ID.
\ No newline at end of file
diff --git a/docs/references/documentsdb/get-logs.md b/docs/references/documentsdb/get-logs.md
new file mode 100644
index 0000000000..8e49da4603
--- /dev/null
+++ b/docs/references/documentsdb/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/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/tablesdb/get-database.md b/docs/references/documentsdb/get.md
similarity index 100%
rename from docs/references/tablesdb/get-database.md
rename to docs/references/documentsdb/get.md
diff --git a/docs/references/documentsdb/increment-document-attribute.md b/docs/references/documentsdb/increment-document-attribute.md
new file mode 100644
index 0000000000..7a19b3fbc7
--- /dev/null
+++ b/docs/references/documentsdb/increment-document-attribute.md
@@ -0,0 +1 @@
+Increment a specific column of a row by a given value.
\ No newline at end of file
diff --git a/docs/references/documentsdb/list-collections.md b/docs/references/documentsdb/list-collections.md
new file mode 100644
index 0000000000..e437674915
--- /dev/null
+++ b/docs/references/documentsdb/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/documentsdb/list-documents.md b/docs/references/documentsdb/list-documents.md
new file mode 100644
index 0000000000..4e2ae91792
--- /dev/null
+++ b/docs/references/documentsdb/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/documentsdb/list-indexes.md b/docs/references/documentsdb/list-indexes.md
new file mode 100644
index 0000000000..a8c687fb2b
--- /dev/null
+++ b/docs/references/documentsdb/list-indexes.md
@@ -0,0 +1 @@
+List indexes 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/list-usage.md b/docs/references/documentsdb/list-usage.md
new file mode 100644
index 0000000000..a88e76680e
--- /dev/null
+++ b/docs/references/documentsdb/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/documentsdb/list.md b/docs/references/documentsdb/list.md
new file mode 100644
index 0000000000..d93fb9d7a8
--- /dev/null
+++ b/docs/references/documentsdb/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/documentsdb/update-collection.md b/docs/references/documentsdb/update-collection.md
new file mode 100644
index 0000000000..b8f6bef997
--- /dev/null
+++ b/docs/references/documentsdb/update-collection.md
@@ -0,0 +1 @@
+Update a collection by its unique ID.
\ No newline at end of file
diff --git a/docs/references/documentsdb/update-document.md b/docs/references/documentsdb/update-document.md
new file mode 100644
index 0000000000..526f3971d1
--- /dev/null
+++ b/docs/references/documentsdb/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/documentsdb/update-documents.md b/docs/references/documentsdb/update-documents.md
new file mode 100644
index 0000000000..5f560c6435
--- /dev/null
+++ b/docs/references/documentsdb/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/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/documentsdb/update.md b/docs/references/documentsdb/update.md
new file mode 100644
index 0000000000..4e99bf2e07
--- /dev/null
+++ b/docs/references/documentsdb/update.md
@@ -0,0 +1 @@
+Update a database by its unique ID.
\ No newline at end of file
diff --git a/docs/references/documentsdb/upsert-document.md b/docs/references/documentsdb/upsert-document.md
new file mode 100644
index 0000000000..f1b68d13d5
--- /dev/null
+++ b/docs/references/documentsdb/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/documentsdb/upsert-documents.md b/docs/references/documentsdb/upsert-documents.md
new file mode 100644
index 0000000000..4feb473076
--- /dev/null
+++ b/docs/references/documentsdb/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/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-appwrite-console-key.md b/docs/references/migrations/migration-appwrite-console-key.md
deleted file mode 100644
index 5fcdda1cc6..0000000000
--- a/docs/references/migrations/migration-appwrite-console-key.md
+++ /dev/null
@@ -1 +0,0 @@
-Generate a temporary console-scoped API key for migrating project settings (platforms, keys, schedules).
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/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/vectorsdb/get.md b/docs/references/vectorsdb/get.md
new file mode 100644
index 0000000000..24183f6f6b
--- /dev/null
+++ b/docs/references/vectorsdb/get.md
@@ -0,0 +1 @@
+Get a database by its unique ID. This endpoint response returns a JSON object with the database metadata.
\ No newline at end of file
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/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/mongo-init-replicaset.sh b/mongo-init-replicaset.sh
old mode 100755
new mode 100644
diff --git a/mongo-init.js b/mongo-init.js
index edff6cc499..bc06ba5b23 100644
--- a/mongo-init.js
+++ b/mongo-init.js
@@ -15,4 +15,4 @@ adminDb.createUser({
roles: [
{ role: 'readWrite', db: database }
]
-});
+});
\ No newline at end of file
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
deleted file mode 100644
index 979deae17e..0000000000
--- a/phpstan-baseline.neon
+++ /dev/null
@@ -1,1849 +0,0 @@
-parameters:
- ignoreErrors:
- -
- message: '#^PHPDoc tag @var above a method has no effect\.$#'
- identifier: varTag.misplaced
- count: 1
- path: app/config/templates/function.php
-
- -
- message: '#^Array has 2 duplicate keys with value ''outputDirectory'' \(''outputDirectory'', ''outputDirectory''\)\.$#'
- identifier: array.duplicateKey
- count: 3
- path: app/config/templates/site.php
-
- -
- message: '#^PHPDoc tag @var above a method has no effect\.$#'
- identifier: varTag.misplaced
- count: 1
- path: app/config/templates/site.php
-
- -
- message: '#^Result of method Appwrite\\Utopia\\Response\:\:dynamic\(\) \(void\) is used\.$#'
- identifier: method.void
- count: 2
- path: app/controllers/api/account.php
-
- -
- message: '#^Result of method Utopia\\Http\\Response\:\:noContent\(\) \(void\) is used\.$#'
- identifier: method.void
- count: 1
- path: app/controllers/api/account.php
-
- -
- message: '#^Variable \$session might not be defined\.$#'
- identifier: variable.undefined
- count: 3
- path: app/controllers/api/account.php
-
- -
- message: '#^Variable \$output might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: app/controllers/api/locale.php
-
- -
- message: '#^Variable \$currentScheduledAt on left side of \?\? is never defined\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: app/controllers/api/messaging.php
-
- -
- message: '#^Result of method Utopia\\Http\\Response\:\:noContent\(\) \(void\) is used\.$#'
- identifier: method.void
- count: 1
- path: app/controllers/api/projects.php
-
- -
- message: '#^Result of method Appwrite\\Utopia\\Response\:\:dynamic\(\) \(void\) is used\.$#'
- identifier: method.void
- count: 2
- path: app/controllers/api/users.php
-
- -
- message: '#^Result of method Utopia\\Http\\Response\:\:noContent\(\) \(void\) is used\.$#'
- identifier: method.void
- count: 1
- path: app/controllers/api/users.php
-
- -
- message: '#^Result of method Utopia\\Http\\Response\:\:redirect\(\) \(void\) is used\.$#'
- identifier: method.void
- count: 2
- path: app/controllers/general.php
-
- -
- message: '#^Result of method Utopia\\Http\\Response\:\:send\(\) \(void\) is used\.$#'
- identifier: method.void
- count: 1
- path: app/controllers/general.php
-
- -
- message: '#^Variable \$body on left side of \?\? always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: app/controllers/general.php
-
- -
- message: '#^Variable \$deployment in PHPDoc tag @var does not exist\.$#'
- identifier: varTag.variableNotFound
- count: 1
- path: app/controllers/general.php
-
- -
- message: '#^Variable \$executionId on left side of \?\? always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: app/controllers/general.php
-
- -
- message: '#^Variable \$executionResponse might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: app/controllers/general.php
-
- -
- message: '#^Variable \$user might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: app/controllers/general.php
-
- -
- message: '#^Variable \$installation might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: app/controllers/mock.php
-
- -
- message: '#^Call to an undefined method Utopia\\Database\\Document\:\:getRoles\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: app/controllers/shared/api.php
-
- -
- message: '#^Variable \$register might not be defined\.$#'
- identifier: variable.undefined
- count: 3
- path: app/http.php
-
- -
- message: '#^Variable \$tag on left side of \?\? always exists and is always null\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: app/init/database/filters.php
-
- -
- message: '#^Anonymous function has an unused use \$dsn\.$#'
- identifier: closure.unusedUse
- count: 1
- path: app/init/registers.php
-
- -
- message: '#^Binary operation "/" between string and string results in an error\.$#'
- identifier: binaryOp.invalid
- count: 1
- path: app/init/registers.php
-
- -
- message: '#^Property PHPMailer\\PHPMailer\\PHPMailer\:\:\$Port \(int\) does not accept string\.$#'
- identifier: assign.propertyType
- count: 1
- path: app/init/registers.php
-
- -
- message: '#^Variable \$providerConfig in empty\(\) always exists and is not falsy\.$#'
- identifier: empty.variable
- count: 2
- path: app/init/registers.php
-
- -
- message: '#^Variable \$providerConfig on left side of \?\? always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: app/init/registers.php
-
- -
- message: '#^Variable \$hostname on left side of \?\? always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: app/init/resources.php
-
- -
- message: '#^Anonymous function has an unused use \$register\.$#'
- identifier: closure.unusedUse
- count: 4
- path: app/realtime.php
-
- -
- message: '#^Class Utopia\\Database\\Validator\\Authorization does not have a constructor and must be instantiated without any parameters\.$#'
- identifier: new.noConstructor
- count: 1
- path: app/realtime.php
-
- -
- message: '#^Function triggerStats\(\) returns void but does not have any side effects\.$#'
- identifier: void.pure
- count: 1
- path: app/realtime.php
-
- -
- message: '#^Binary operation "\*" between \-1 and string results in an error\.$#'
- identifier: binaryOp.invalid
- count: 3
- path: app/worker.php
-
- -
- message: '#^PHPDoc tag @return with type string is incompatible with native type int\.$#'
- identifier: return.phpDocType
- count: 1
- path: src/Appwrite/Auth/OAuth2.php
-
- -
- message: '#^PHPDoc tag @param references unknown parameter\: \$token$#'
- identifier: parameter.notFound
- count: 1
- path: src/Appwrite/Auth/OAuth2/Disqus.php
-
- -
- message: '#^PHPDoc tag @param references unknown parameter\: \$value$#'
- identifier: parameter.notFound
- count: 1
- path: src/Appwrite/Auth/Validator/PersonalData.php
-
- -
- message: '#^PHPDoc tag @var above a method has no effect\.$#'
- identifier: varTag.misplaced
- count: 1
- path: src/Appwrite/Databases/TransactionState.php
-
- -
- message: '#^PHPDoc tag @param has invalid value \(DeviceDetector\)\: Unexpected token "\\n ", expected variable at offset 32 on line 2$#'
- identifier: phpDoc.parseError
- count: 1
- path: src/Appwrite/Detector/Detector.php
-
- -
- message: '#^PHPDoc tag @param has invalid value \(string\)\: Unexpected token "\\n ", expected variable at offset 24 on line 2$#'
- identifier: phpDoc.parseError
- count: 1
- path: src/Appwrite/Detector/Detector.php
-
- -
- message: '#^PHPDoc tag @var above a method has no effect\.$#'
- identifier: varTag.misplaced
- count: 1
- path: src/Appwrite/Docker/Compose.php
-
- -
- message: '#^PHPDoc tag @var above a method has no effect\.$#'
- identifier: varTag.misplaced
- count: 1
- path: src/Appwrite/Docker/Compose/Service.php
-
- -
- message: '#^PHPDoc tag @var above a method has no effect\.$#'
- identifier: varTag.misplaced
- count: 1
- path: src/Appwrite/Docker/Env.php
-
- -
- message: '#^PHPDoc tag @param has invalid value \(int port\)\: Unexpected token "port", expected variable at offset 50 on line 4$#'
- identifier: phpDoc.parseError
- count: 1
- path: src/Appwrite/Event/Mail.php
-
- -
- message: '#^PHPDoc tag @param references unknown parameter\: \$password$#'
- identifier: parameter.notFound
- count: 1
- path: src/Appwrite/Event/Mail.php
-
- -
- message: '#^PHPDoc tag @return with type string is incompatible with native type Appwrite\\Event\\Mail\.$#'
- identifier: return.phpDocType
- count: 1
- path: src/Appwrite/Event/Mail.php
-
- -
- message: '#^Method Appwrite\\Event\\Message\\Usage\:\:fromArray\(\) should return static\(Appwrite\\Event\\Message\\Usage\) but returns Appwrite\\Event\\Message\\Usage\.$#'
- identifier: return.type
- count: 1
- path: src/Appwrite/Event/Message/Usage.php
-
- -
- message: '#^PHPDoc tag @param references unknown parameter\: \$message$#'
- identifier: parameter.notFound
- count: 1
- path: src/Appwrite/Event/Messaging.php
-
- -
- message: '#^PHPDoc tag @return with type string is incompatible with native type Utopia\\Database\\Document\.$#'
- identifier: return.phpDocType
- count: 1
- path: src/Appwrite/Event/Messaging.php
-
- -
- message: '#^Method Appwrite\\Functions\\EventProcessor\:\:getFunctionsEvents\(\) should return array\ but returns array\\>\.$#'
- identifier: return.type
- count: 1
- path: src/Appwrite/Functions/EventProcessor.php
-
- -
- message: '#^Method Appwrite\\Functions\\EventProcessor\:\:getWebhooksEvents\(\) should return array\ but returns array\\>\.$#'
- identifier: return.type
- count: 1
- path: src/Appwrite/Functions/EventProcessor.php
-
- -
- message: '#^Variable \$hostname on left side of \?\? always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Functions/EventProcessor.php
-
- -
- message: '#^Anonymous function has an unused use \$context\.$#'
- identifier: closure.unusedUse
- count: 1
- path: src/Appwrite/GraphQL/Resolvers.php
-
- -
- message: '#^Anonymous function has an unused use \$info\.$#'
- identifier: closure.unusedUse
- count: 1
- path: src/Appwrite/GraphQL/Resolvers.php
-
- -
- message: '#^Anonymous function has an unused use \$type\.$#'
- identifier: closure.unusedUse
- count: 5
- path: src/Appwrite/GraphQL/Resolvers.php
-
- -
- message: '#^Variable \$request in PHPDoc tag @var does not exist\.$#'
- identifier: varTag.variableNotFound
- count: 1
- path: src/Appwrite/GraphQL/Resolvers.php
-
- -
- message: '#^Variable \$response in PHPDoc tag @var does not exist\.$#'
- identifier: varTag.variableNotFound
- count: 1
- path: src/Appwrite/GraphQL/Resolvers.php
-
- -
- message: '#^Variable \$databaseId might not be defined\.$#'
- identifier: variable.undefined
- count: 5
- path: src/Appwrite/GraphQL/Schema.php
-
- -
- message: '#^Variable \$sdk in empty\(\) always exists and is not falsy\.$#'
- identifier: empty.variable
- count: 1
- path: src/Appwrite/GraphQL/Schema.php
-
- -
- message: '#^Method Appwrite\\GraphQL\\Types\:\:assoc\(\) should return Appwrite\\GraphQL\\Types\\Json but returns GraphQL\\Type\\Definition\\Type\.$#'
- identifier: return.type
- count: 1
- path: src/Appwrite/GraphQL/Types.php
-
- -
- message: '#^Method Appwrite\\GraphQL\\Types\:\:inputFile\(\) should return Appwrite\\GraphQL\\Types\\InputFile but returns GraphQL\\Type\\Definition\\Type\.$#'
- identifier: return.type
- count: 1
- path: src/Appwrite/GraphQL/Types.php
-
- -
- message: '#^Method Appwrite\\GraphQL\\Types\:\:json\(\) should return Appwrite\\GraphQL\\Types\\Json but returns GraphQL\\Type\\Definition\\Type\.$#'
- identifier: return.type
- count: 1
- path: src/Appwrite/GraphQL/Types.php
-
- -
- message: '#^Class Appwrite\\Network\\Validator\\CNAME not found\.$#'
- identifier: class.notFound
- count: 1
- path: src/Appwrite/GraphQL/Types/Mapper.php
-
- -
- message: '#^Class Utopia\\Validator\\Origin not found\.$#'
- identifier: class.notFound
- count: 1
- path: src/Appwrite/GraphQL/Types/Mapper.php
-
- -
- message: '#^Unsafe access to private property Appwrite\\GraphQL\\Types\\Mapper\:\:\$models through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 3
- path: src/Appwrite/GraphQL/Types/Mapper.php
-
- -
- message: '#^Unsafe call to private method Appwrite\\GraphQL\\Types\\Mapper\:\:getColumnImplementation\(\) through static\:\:\.$#'
- identifier: staticClassAccess.privateMethod
- count: 2
- path: src/Appwrite/GraphQL/Types/Mapper.php
-
- -
- message: '#^Unsafe call to private method Appwrite\\GraphQL\\Types\\Mapper\:\:getHashOptionsImplementation\(\) through static\:\:\.$#'
- identifier: staticClassAccess.privateMethod
- count: 1
- path: src/Appwrite/GraphQL/Types/Mapper.php
-
- -
- message: '#^Unsafe call to private method Appwrite\\GraphQL\\Types\\Mapper\:\:getUnionImplementation\(\) through static\:\:\.$#'
- identifier: staticClassAccess.privateMethod
- count: 1
- path: src/Appwrite/GraphQL/Types/Mapper.php
-
- -
- message: '#^Call to an undefined method Appwrite\\Migration\\Version\\V15\:\:documentsIterator\(\)\.$#'
- identifier: method.notFound
- count: 7
- path: src/Appwrite/Migration/Version/V15.php
-
- -
- message: '#^Method Appwrite\\Migration\\Version\\V15\:\:fixDocument\(\) should return Utopia\\Database\\Document but empty return statement found\.$#'
- identifier: return.empty
- count: 1
- path: src/Appwrite/Migration/Version/V15.php
-
- -
- message: '#^PHPDoc tag @return with type string\|false is not subtype of native type string\.$#'
- identifier: return.phpDocType
- count: 1
- path: src/Appwrite/Migration/Version/V15.php
-
- -
- message: '#^Variable \$tag on left side of \?\? always exists and is always null\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Migration/Version/V15.php
-
- -
- message: '#^Call to an undefined method Appwrite\\Migration\\Version\\V17\:\:documentsIterator\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: src/Appwrite/Migration/Version/V17.php
-
- -
- message: '#^Call to an undefined method Appwrite\\Migration\\Version\\V18\:\:documentsIterator\(\)\.$#'
- identifier: method.notFound
- count: 2
- path: src/Appwrite/Migration/Version/V18.php
-
- -
- message: '#^Call to an undefined method Appwrite\\Migration\\Version\\V19\:\:documentsIterator\(\)\.$#'
- identifier: method.notFound
- count: 4
- path: src/Appwrite/Migration/Version/V19.php
-
- -
- message: '#^Call to an undefined method Appwrite\\Migration\\Version\\V20\:\:documentsIterator\(\)\.$#'
- identifier: method.notFound
- count: 6
- path: src/Appwrite/Migration/Version/V20.php
-
- -
- message: '#^Variable \$query on left side of \?\? always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Migration/Version/V20.php
-
- -
- message: '#^Method Appwrite\\Network\\Cors\:\:headers\(\) should return array\ but returns array\\.$#'
- identifier: return.type
- count: 5
- path: src/Appwrite/Network/Cors.php
-
- -
- message: '#^Parameter &\$tag by\-ref type of method Appwrite\\OpenSSL\\OpenSSL\:\:encrypt\(\) expects null, string\|null given\.$#'
- identifier: parameterByRef.type
- count: 1
- path: src/Appwrite/OpenSSL/OpenSSL.php
-
- -
- message: '#^PHPDoc tag @param references unknown parameter\: \$projectId$#'
- identifier: parameter.notFound
- count: 1
- path: src/Appwrite/Platform/Action.php
-
- -
- message: '#^Variable \$output in empty\(\) always exists and is not falsy\.$#'
- identifier: empty.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Avatars/Http/Action.php
-
- -
- message: '#^Variable \$output in empty\(\) always exists and is not falsy\.$#'
- identifier: empty.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php
-
- -
- message: '#^Variable \$output in empty\(\) always exists and is not falsy\.$#'
- identifier: empty.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Avatars/Http/Image/Get.php
-
- -
- message: '#^Binary operation "%%" between string and 5 results in an error\.$#'
- identifier: binaryOp.invalid
- count: 1
- path: src/Appwrite/Platform/Modules/Avatars/Http/Initials/Get.php
-
- -
- message: '#^Method Appwrite\\Platform\\Modules\\Databases\\Http\\Databases\\Action\:\:setHttpPath\(\) should return Appwrite\\Platform\\Action but returns Utopia\\Platform\\Action\.$#'
- identifier: return.type
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php
-
- -
- message: '#^Method Appwrite\\Platform\\Modules\\Databases\\Http\\Databases\\Collections\\Documents\\Action\:\:setHttpPath\(\) should return Appwrite\\Platform\\Modules\\Databases\\Http\\Databases\\Action but returns Appwrite\\Platform\\Action\.$#'
- identifier: return.type
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php
-
- -
- message: '#^Variable \$relations in empty\(\) always exists and is not falsy\.$#'
- identifier: empty.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php
-
- -
- message: '#^Anonymous function has an unused use \$dbForProject\.$#'
- identifier: closure.unusedUse
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php
-
- -
- message: '#^Variable \$document in PHPDoc tag @var does not match assigned variable \$collectionTableId\.$#'
- identifier: varTag.differentVariable
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php
-
- -
- message: '#^Variable \$hostname on left side of \?\? always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php
-
- -
- message: '#^Variable \$enabled on left side of \?\?\= always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php
-
- -
- message: '#^Offset ''deviceBrand'' does not exist on int\.$#'
- identifier: offsetAccess.notFound
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php
-
- -
- message: '#^Offset ''deviceModel'' does not exist on int\.$#'
- identifier: offsetAccess.notFound
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php
-
- -
- message: '#^Offset ''deviceName'' does not exist on int\.$#'
- identifier: offsetAccess.notFound
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php
-
- -
- message: '#^Method Appwrite\\Platform\\Modules\\Databases\\Http\\Databases\\Transactions\\Action\:\:setHttpPath\(\) should return Appwrite\\Platform\\Modules\\Databases\\Http\\Databases\\Action but returns Appwrite\\Platform\\Action\.$#'
- identifier: return.type
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php
-
- -
- message: '#^Anonymous function has an unused use \$existing\.$#'
- identifier: closure.unusedUse
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php
-
- -
- message: '#^Anonymous function has an unused use \$queueForEvents\.$#'
- identifier: closure.unusedUse
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php
-
- -
- message: '#^Anonymous function has an unused use \$queueForFunctions\.$#'
- identifier: closure.unusedUse
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php
-
- -
- message: '#^Anonymous function has an unused use \$queueForRealtime\.$#'
- identifier: closure.unusedUse
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php
-
- -
- message: '#^Anonymous function has an unused use \$queueForWebhooks\.$#'
- identifier: closure.unusedUse
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php
-
- -
- message: '#^Anonymous function has an unused use \$usage\.$#'
- identifier: closure.unusedUse
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php
-
- -
- message: '#^PHPDoc tag @throws with type Appwrite\\Platform\\Modules\\Databases\\Http\\Databases\\Transactions\\Structure\|Throwable\|Utopia\\Database\\Validator\\Authorization is not subtype of Throwable$#'
- identifier: throws.notThrowable
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php
-
- -
- message: '#^Variable \$currentDocumentId on left side of \?\? always exists and is always null\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php
-
- -
- message: '#^Offset ''deviceBrand'' does not exist on int\.$#'
- identifier: offsetAccess.notFound
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php
-
- -
- message: '#^Offset ''deviceModel'' does not exist on int\.$#'
- identifier: offsetAccess.notFound
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php
-
- -
- message: '#^Offset ''deviceName'' does not exist on int\.$#'
- identifier: offsetAccess.notFound
- count: 1
- path: src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php
-
- -
- message: '#^Variable \$device might not be defined\.$#'
- identifier: variable.undefined
- count: 5
- path: src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php
-
- -
- message: '#^Variable \$path might not be defined\.$#'
- identifier: variable.undefined
- count: 5
- path: src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php
-
- -
- message: '#^Call to method getAttribute\(\) on an unknown class Appwrite\\Platform\\Modules\\Functions\\Http\\Executions\\Utopia\\Database\\Document\.$#'
- identifier: class.notFound
- count: 1
- path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
-
- -
- message: '#^Call to method getId\(\) on an unknown class Appwrite\\Platform\\Modules\\Functions\\Http\\Executions\\Utopia\\Database\\Document\.$#'
- identifier: class.notFound
- count: 1
- path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
-
- -
- message: '#^Call to method isEmpty\(\) on an unknown class Appwrite\\Platform\\Modules\\Functions\\Http\\Executions\\Utopia\\Database\\Document\.$#'
- identifier: class.notFound
- count: 1
- path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
-
- -
- message: '#^Method Appwrite\\Platform\\Modules\\Functions\\Http\\Executions\\Create\:\:enqueueDeletes\(\) returns void but does not have any side effects\.$#'
- identifier: void.pure
- count: 1
- path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
-
- -
- message: '#^PHPDoc tag @var for variable \$session contains unknown class Appwrite\\Platform\\Modules\\Functions\\Http\\Executions\\Utopia\\Database\\Document\.$#'
- identifier: class.notFound
- count: 1
- path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
-
- -
- message: '#^Result of method Appwrite\\Utopia\\Response\:\:dynamic\(\) \(void\) is used\.$#'
- identifier: method.void
- count: 1
- path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
-
- -
- message: '#^Variable \$body on left side of \?\? always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
-
- -
- message: '#^Variable \$executionId on left side of \?\? always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
-
- -
- message: '#^Variable \$jwt on left side of \?\? always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
-
- -
- message: '#^Call to an undefined method Appwrite\\Event\\Event\:\:setSubscribers\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php
-
- -
- message: '#^Variable \$enabled on left side of \?\?\= always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php
-
- -
- message: '#^Undefined variable\: \$cpus$#'
- identifier: variable.undefined
- count: 3
- path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
-
- -
- message: '#^Undefined variable\: \$memory$#'
- identifier: variable.undefined
- count: 3
- path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
-
- -
- message: '#^Variable \$deployment might not be defined\.$#'
- identifier: variable.undefined
- count: 4
- path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
-
- -
- message: '#^Variable \$logsAfter on left side of \?\? always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
-
- -
- message: '#^Variable \$logsBefore on left side of \?\? always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
-
- -
- message: '#^Variable \$providerCommitHash on left side of \?\? always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
-
- -
- message: '#^Variable \$rule in empty\(\) always exists and is not falsy\.$#'
- identifier: empty.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
-
- -
- message: '#^Result of method Appwrite\\Utopia\\Response\:\:dynamic\(\) \(void\) is used\.$#'
- identifier: method.void
- count: 1
- path: src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php
-
- -
- message: '#^Variable \$device might not be defined\.$#'
- identifier: variable.undefined
- count: 5
- path: src/Appwrite/Platform/Modules/Sites/Http/Deployments/Download/Get.php
-
- -
- message: '#^Variable \$path might not be defined\.$#'
- identifier: variable.undefined
- count: 5
- path: src/Appwrite/Platform/Modules/Sites/Http/Deployments/Download/Get.php
-
- -
- message: '#^Variable \$enabled on left side of \?\?\= always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php
-
- -
- message: '#^Variable \$iv might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php
-
- -
- message: '#^Variable \$tag might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php
-
- -
- message: '#^Variable \$allowedFileExtensions on left side of \?\?\= always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php
-
- -
- message: '#^Variable \$antivirus on left side of \?\?\= always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php
-
- -
- message: '#^Variable \$enabled on left side of \?\?\= always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php
-
- -
- message: '#^Variable \$transformations on left side of \?\?\= always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Storage/Http/Buckets/Update.php
-
- -
- message: '#^Caught class Appwrite\\Platform\\Modules\\Teams\\Http\\Memberships\\Throwable not found\.$#'
- identifier: class.notFound
- count: 1
- path: src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
-
- -
- message: '#^Variable \$email in empty\(\) always exists and is always falsy\.$#'
- identifier: empty.variable
- count: 1
- path: src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
-
- -
- message: '#^Variable \$hash might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
-
- -
- message: '#^Variable \$invitee might not be defined\.$#'
- identifier: variable.undefined
- count: 14
- path: src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
-
- -
- message: '#^Variable \$logBase might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php
-
- -
- message: '#^Variable \$previewUrl in empty\(\) always exists and is not falsy\.$#'
- identifier: empty.variable
- count: 1
- path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php
-
- -
- message: '#^Variable \$repositoryName in empty\(\) always exists and is not falsy\.$#'
- identifier: empty.variable
- count: 1
- path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php
-
- -
- message: '#^Variable \$rule in empty\(\) always exists and is not falsy\.$#'
- identifier: empty.variable
- count: 1
- path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php
-
- -
- message: '#^Result of method Utopia\\Http\\Response\:\:redirect\(\) \(void\) is used\.$#'
- identifier: method.void
- count: 2
- path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php
-
- -
- message: '#^Undefined variable\: \$redirectFailure$#'
- identifier: variable.undefined
- count: 2
- path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php
-
- -
- message: '#^Variable \$redirectFailure in empty\(\) is never defined\.$#'
- identifier: empty.variable
- count: 1
- path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php
-
- -
- message: '#^Result of method Appwrite\\Utopia\\Response\:\:json\(\) \(void\) is used\.$#'
- identifier: method.void
- count: 1
- path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php
-
- -
- message: '#^Variable \$logBase might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php
-
- -
- message: '#^Variable \$previewUrl in empty\(\) always exists and is not falsy\.$#'
- identifier: empty.variable
- count: 1
- path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php
-
- -
- message: '#^Variable \$repositoryName in empty\(\) always exists and is not falsy\.$#'
- identifier: empty.variable
- count: 1
- path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php
-
- -
- message: '#^Variable \$rule in empty\(\) always exists and is not falsy\.$#'
- identifier: empty.variable
- count: 1
- path: src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php
-
- -
- message: '#^Variable \$providerConfig on left side of \?\? always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Platform/Tasks/Doctor.php
-
- -
- message: '#^Variable \$compose in isset\(\) always exists and is not nullable\.$#'
- identifier: isset.variable
- count: 1
- path: src/Appwrite/Platform/Tasks/Install.php
-
- -
- message: '#^Variable \$prUrls might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: src/Appwrite/Platform/Tasks/SDKs.php
-
- -
- message: '#^Anonymous function has an unused use \$dbForPlatform\.$#'
- identifier: closure.unusedUse
- count: 1
- path: src/Appwrite/Platform/Tasks/StatsResources.php
-
- -
- message: '#^PHPDoc tag @param references unknown parameter\: \$getProjectDB$#'
- identifier: parameter.notFound
- count: 1
- path: src/Appwrite/Platform/Workers/Audits.php
-
- -
- message: '#^Anonymous function has an unused use \$certificates\.$#'
- identifier: closure.unusedUse
- count: 2
- path: src/Appwrite/Platform/Workers/Deletes.php
-
- -
- message: '#^Anonymous function has an unused use \$dbForPlatform\.$#'
- identifier: closure.unusedUse
- count: 1
- path: src/Appwrite/Platform/Workers/Deletes.php
-
- -
- message: '#^Anonymous function has an unused use \$dbForProject\.$#'
- identifier: closure.unusedUse
- count: 1
- path: src/Appwrite/Platform/Workers/Deletes.php
-
- -
- message: '#^Anonymous function has an unused use \$project\.$#'
- identifier: closure.unusedUse
- count: 6
- path: src/Appwrite/Platform/Workers/Deletes.php
-
- -
- message: '#^Anonymous function has an unused use \$resourceType\.$#'
- identifier: closure.unusedUse
- count: 1
- path: src/Appwrite/Platform/Workers/Deletes.php
-
- -
- message: '#^PHPDoc tag @param references unknown parameter\: \$build$#'
- identifier: parameter.notFound
- count: 1
- path: src/Appwrite/Platform/Workers/Deletes.php
-
- -
- message: '#^PHPDoc tag @param references unknown parameter\: \$dbForPlatform$#'
- identifier: parameter.notFound
- count: 1
- path: src/Appwrite/Platform/Workers/Deletes.php
-
- -
- message: '#^PHPDoc tag @param references unknown parameter\: \$target$#'
- identifier: parameter.notFound
- count: 1
- path: src/Appwrite/Platform/Workers/Deletes.php
-
- -
- message: '#^PHPDoc tag @throws with type Appwrite\\Extend\\Exception\|Utopia\\Database\\Exception\|Utopia\\Database\\Validator\\Authorization is not subtype of Throwable$#'
- identifier: throws.notThrowable
- count: 2
- path: src/Appwrite/Platform/Workers/Deletes.php
-
- -
- message: '#^PHPDoc tag @throws with type Appwrite\\Extend\\Exception\|Utopia\\Database\\Validator\\Authorization is not subtype of Throwable$#'
- identifier: throws.notThrowable
- count: 1
- path: src/Appwrite/Platform/Workers/Deletes.php
-
- -
- message: '#^PHPDoc tag @throws with type Appwrite\\Platform\\Workers\\Exception is not subtype of Throwable$#'
- identifier: throws.notThrowable
- count: 2
- path: src/Appwrite/Platform/Workers/Functions.php
-
- -
- message: '#^Variable \$body on left side of \?\? always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 2
- path: src/Appwrite/Platform/Workers/Functions.php
-
- -
- message: '#^Variable \$errorCode might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: src/Appwrite/Platform/Workers/Functions.php
-
- -
- message: '#^Variable \$executionId on left side of \?\? always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Platform/Workers/Functions.php
-
- -
- message: '#^Binary operation "\+\=" between 0 and array\\|int\|string\>\|int\|string results in an error\.$#'
- identifier: assignOp.invalid
- count: 1
- path: src/Appwrite/Platform/Workers/Messaging.php
-
- -
- message: '#^Variable \$provider might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: src/Appwrite/Platform/Workers/Messaging.php
-
- -
- message: '#^Variable \$aggregatedResources might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: src/Appwrite/Platform/Workers/Migrations.php
-
- -
- message: '#^Anonymous function has an unused use \$dbForLogs\.$#'
- identifier: closure.unusedUse
- count: 1
- path: src/Appwrite/Platform/Workers/StatsResources.php
-
- -
- message: '#^Anonymous function has an unused use \$sequence\.$#'
- identifier: closure.unusedUse
- count: 1
- path: src/Appwrite/Platform/Workers/StatsUsage.php
-
- -
- message: '#^Callable callable\(\)\: Utopia\\Database\\Database invoked with 1 parameter, 0 required\.$#'
- identifier: arguments.count
- count: 2
- path: src/Appwrite/Platform/Workers/StatsUsage.php
-
- -
- message: '#^Variable \$curlError on left side of \?\? is never defined\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/Platform/Workers/Webhooks.php
-
- -
- message: '#^Unsafe usage of new static\(\)\.$#'
- identifier: new.static
- count: 3
- path: src/Appwrite/Promises/Promise.php
-
- -
- message: '#^Property Appwrite\\SDK\\Method\:\:\$hide on left side of \?\? is not nullable nor uninitialized\.$#'
- identifier: nullCoalesce.initializedProperty
- count: 1
- path: src/Appwrite/SDK/Method.php
-
- -
- message: '#^PHPDoc tag @param references unknown parameter\: \$services$#'
- identifier: parameter.notFound
- count: 1
- path: src/Appwrite/SDK/Specification/Format.php
-
- -
- message: '#^PHPDoc tag @return with type Appwrite\\SDK\\Specification\\Format is incompatible with native type array\.$#'
- identifier: return.phpDocType
- count: 1
- path: src/Appwrite/SDK/Specification/Format.php
-
- -
- message: '#^Cannot unset offset ''schema'' on array\{description\: ''No content'', content\?\: non\-empty\-array\<''''\|''\*/\*''\|''application/json''\|''image/\*''\|''image/png''\|''multipart/form\-data''\|''text/html''\|''text/plain'', array\{schema\: array\{''\$ref''\: non\-falsy\-string\}\}\|array\{schema\: array\{oneOf\: array\\}\}\>\}\.$#'
- identifier: unset.offset
- count: 1
- path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php
-
- -
- message: '#^Class Utopia\\Database\\Validator\\DatetimeValidator not found\.$#'
- identifier: class.notFound
- count: 1
- path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php
-
- -
- message: '#^Class Utopia\\Validator\\Length not found\.$#'
- identifier: class.notFound
- count: 1
- path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php
-
- -
- message: '#^Class Utopia\\Validator\\Mock not found\.$#'
- identifier: class.notFound
- count: 1
- path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php
-
- -
- message: '#^Offset ''securityDefinitions'' on array\{openapi\: ''3\.0\.0'', info\: array\{version\: string, title\: string, description\: string, termsOfService\: string, contact\: array\{name\: string, url\: string, email\: string\}, license\: array\{name\: ''BSD\-3\-Clause'', url\: ''https\://raw…''\}\}, servers\: array\{array\{url\: string\}, array\{url\: string\}\}, paths\: array\{\}, tags\: array, components\: array\{schemas\: array\{\}, securitySchemes\: array\}, externalDocs\: array\{description\: string, url\: string\}\} in isset\(\) does not exist\.$#'
- identifier: isset.offset
- count: 1
- path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php
-
- -
- message: '#^Variable \$desc on left side of \?\?\= always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php
-
- -
- message: '#^Variable \$validator in empty\(\) always exists and is not falsy\.$#'
- identifier: empty.variable
- count: 1
- path: src/Appwrite/SDK/Specification/Format/OpenAPI3.php
-
- -
- message: '#^Class Utopia\\Database\\Validator\\DatetimeValidator not found\.$#'
- identifier: class.notFound
- count: 1
- path: src/Appwrite/SDK/Specification/Format/Swagger2.php
-
- -
- message: '#^Class Utopia\\Validator\\Length not found\.$#'
- identifier: class.notFound
- count: 1
- path: src/Appwrite/SDK/Specification/Format/Swagger2.php
-
- -
- message: '#^Class Utopia\\Validator\\Mock not found\.$#'
- identifier: class.notFound
- count: 1
- path: src/Appwrite/SDK/Specification/Format/Swagger2.php
-
- -
- message: '#^PHPDoc tag @var with type Appwrite\\SDK\\Method is not subtype of native type \*NEVER\*\.$#'
- identifier: varTag.nativeType
- count: 1
- path: src/Appwrite/SDK/Specification/Format/Swagger2.php
-
- -
- message: '#^Variable \$additionalMethods in empty\(\) always exists and is always falsy\.$#'
- identifier: empty.variable
- count: 1
- path: src/Appwrite/SDK/Specification/Format/Swagger2.php
-
- -
- message: '#^Variable \$desc on left side of \?\?\= always exists and is not nullable\.$#'
- identifier: nullCoalesce.variable
- count: 1
- path: src/Appwrite/SDK/Specification/Format/Swagger2.php
-
- -
- message: '#^Variable \$sdk in empty\(\) always exists and is not falsy\.$#'
- identifier: empty.variable
- count: 1
- path: src/Appwrite/SDK/Specification/Format/Swagger2.php
-
- -
- message: '#^Variable \$validator in empty\(\) always exists and is not falsy\.$#'
- identifier: empty.variable
- count: 1
- path: src/Appwrite/SDK/Specification/Format/Swagger2.php
-
- -
- message: '#^PHPDoc tag @var above a method has no effect\.$#'
- identifier: varTag.misplaced
- count: 2
- path: src/Appwrite/Template/Template.php
-
- -
- message: '#^PHPDoc tag @param has invalid value \(Document \$this\)\: Unexpected token "\$this", expected variable at offset 69 on line 4$#'
- identifier: phpDoc.parseError
- count: 1
- path: src/Appwrite/Utopia/Database/Documents/User.php
-
- -
- message: '#^PHPDoc tag @param references unknown parameter\: \$sessions$#'
- identifier: parameter.notFound
- count: 1
- path: src/Appwrite/Utopia/Database/Documents/User.php
-
- -
- message: '#^Unsafe call to private method Appwrite\\Utopia\\Request\\Filters\\V17\:\:appendSymbol\(\) through static\:\:\.$#'
- identifier: staticClassAccess.privateMethod
- count: 4
- path: src/Appwrite/Utopia/Request/Filters/V17.php
-
- -
- message: '#^Unsafe call to private method Appwrite\\Utopia\\Request\\Filters\\V17\:\:isSpecialChar\(\) through static\:\:\.$#'
- identifier: staticClassAccess.privateMethod
- count: 1
- path: src/Appwrite/Utopia/Request/Filters/V17.php
-
- -
- message: '#^PHPDoc tag @param has invalid value \(callable The callback to show sensitive information for\)\: Unexpected token "The", expected variable at offset 91 on line 4$#'
- identifier: phpDoc.parseError
- count: 1
- path: src/Appwrite/Utopia/Response.php
-
- -
- message: '#^PHPDoc tag @return with type Appwrite\\Utopia\\Response\\Filter is incompatible with native type array\.$#'
- identifier: return.phpDocType
- count: 1
- path: src/Appwrite/Utopia/Response.php
-
- -
- message: '#^PHPDoc tag @return with type string is incompatible with native type Utopia\\Database\\Document\.$#'
- identifier: return.phpDocType
- count: 1
- path: src/Appwrite/Utopia/Response/Model/User.php
-
- -
- message: '#^Attribute class Tests\\E2E\\General\\Retry does not exist\.$#'
- identifier: attribute.notFound
- count: 1
- path: tests/e2e/General/UsageTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\Databases\\Legacy\\DatabasesStringTypesTest\:\:\$setupCache through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/Databases/Legacy/DatabasesStringTypesTest.php
-
- -
- message: '#^Variable \$library might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: tests/e2e/Services/Databases/LegacyConsoleClientTest.php
-
- -
- message: '#^Variable \$person might not be defined\.$#'
- identifier: variable.undefined
- count: 4
- path: tests/e2e/Services/Databases/LegacyConsoleClientTest.php
-
- -
- message: '#^Variable \$library might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: tests/e2e/Services/Databases/LegacyCustomClientTest.php
-
- -
- message: '#^Variable \$person might not be defined\.$#'
- identifier: variable.undefined
- count: 4
- path: tests/e2e/Services/Databases/LegacyCustomClientTest.php
-
- -
- message: '#^Variable \$library might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: tests/e2e/Services/Databases/LegacyCustomServerTest.php
-
- -
- message: '#^Variable \$person might not be defined\.$#'
- identifier: variable.undefined
- count: 4
- path: tests/e2e/Services/Databases/LegacyCustomServerTest.php
-
- -
- message: '#^Call to an undefined method Tests\\E2E\\Services\\Databases\\Permissions\\LegacyPermissionsGuestTest\:\:getIndexUrl\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: tests/e2e/Services/Databases/Permissions/LegacyPermissionsGuestTest.php
-
- -
- message: '#^Call to an undefined method Tests\\E2E\\Services\\Databases\\Permissions\\LegacyPermissionsMemberTest\:\:getIndexUrl\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: tests/e2e/Services/Databases/Permissions/LegacyPermissionsMemberTest.php
-
- -
- message: '#^Call to an undefined method Tests\\E2E\\Services\\Databases\\Permissions\\LegacyPermissionsTeamTest\:\:getIndexUrl\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: tests/e2e/Services/Databases/Permissions/LegacyPermissionsTeamTest.php
-
- -
- message: '#^Method PHPUnit\\Framework\\TestCase\:\:addToAssertionCount\(\) invoked with 2 parameters, 1 required\.$#'
- identifier: arguments.count
- count: 1
- path: tests/e2e/Services/Functions/FunctionsCustomClientTest.php
-
- -
- message: '#^Variable \$largeTag might not be defined\.$#'
- identifier: variable.undefined
- count: 8
- path: tests/e2e/Services/Functions/FunctionsCustomServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsClientTest\:\:\$cachedDeployment through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 3
- path: tests/e2e/Services/GraphQL/FunctionsClientTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsClientTest\:\:\$cachedExecution through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/GraphQL/FunctionsClientTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsClientTest\:\:\$cachedFunction through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 3
- path: tests/e2e/Services/GraphQL/FunctionsClientTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsServerTest\:\:\$cachedDeployment through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/GraphQL/FunctionsServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsServerTest\:\:\$cachedExecution through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 5
- path: tests/e2e/Services/GraphQL/FunctionsServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsServerTest\:\:\$cachedFunction through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/GraphQL/FunctionsServerTest.php
-
- -
- message: '#^Binary operation "\+" between string and 1 results in an error\.$#'
- identifier: binaryOp.invalid
- count: 1
- path: tests/e2e/Services/GraphQL/Legacy/AbuseTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$bulkData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$collection through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$database through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$document through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedEmail through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 2
- path: tests/e2e/Services/GraphQL/MessagingTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedProviders through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 8
- path: tests/e2e/Services/GraphQL/MessagingTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedPush through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/GraphQL/MessagingTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedSms through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/GraphQL/MessagingTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedSubscriber through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 5
- path: tests/e2e/Services/GraphQL/MessagingTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedTopic through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 9
- path: tests/e2e/Services/GraphQL/MessagingTest.php
-
- -
- message: '#^Variable \$from in empty\(\) is never defined\.$#'
- identifier: empty.variable
- count: 1
- path: tests/e2e/Services/GraphQL/MessagingTest.php
-
- -
- message: '#^Method Tests\\E2E\\Services\\GraphQL\\StorageClientTest\:\:testGetFileDownload\(\) should return array but return statement is missing\.$#'
- identifier: return.missing
- count: 1
- path: tests/e2e/Services/GraphQL/StorageClientTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageClientTest\:\:\$cachedBucket through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 3
- path: tests/e2e/Services/GraphQL/StorageClientTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageClientTest\:\:\$cachedFile through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 5
- path: tests/e2e/Services/GraphQL/StorageClientTest.php
-
- -
- message: '#^Method Tests\\E2E\\Services\\GraphQL\\StorageServerTest\:\:testGetFileDownload\(\) should return array but return statement is missing\.$#'
- identifier: return.missing
- count: 1
- path: tests/e2e/Services/GraphQL/StorageServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageServerTest\:\:\$cachedBucket through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/GraphQL/StorageServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageServerTest\:\:\$cachedFile through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 6
- path: tests/e2e/Services/GraphQL/StorageServerTest.php
-
- -
- message: '#^Binary operation "\+" between string and 1 results in an error\.$#'
- identifier: binaryOp.invalid
- count: 1
- path: tests/e2e/Services/GraphQL/TablesDB/AbuseTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedBooleanColumnData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 5
- path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedBulkData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedDatabase through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedDatetimeColumnData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 5
- path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedEmailColumnData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 5
- path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedEnumColumnData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 5
- path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedFloatColumnData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 5
- path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedIPColumnData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 5
- path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedIndexData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 7
- path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedIntegerColumnData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 5
- path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedRelationshipColumnData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 5
- path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedRowData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 5
- path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedStringColumnData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 5
- path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedTableData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedURLColumnData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 5
- path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsClientTest\:\:\$cachedMembership through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/GraphQL/TeamsClientTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsClientTest\:\:\$cachedTeam through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 3
- path: tests/e2e/Services/GraphQL/TeamsClientTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsServerTest\:\:\$cachedMembership through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/GraphQL/TeamsServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsServerTest\:\:\$cachedTeam through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 3
- path: tests/e2e/Services/GraphQL/TeamsServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsServerTest\:\:\$cachedTeamWithPrefs through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 3
- path: tests/e2e/Services/GraphQL/TeamsServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\UsersTest\:\:\$cachedUser through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 3
- path: tests/e2e/Services/GraphQL/UsersTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\UsersTest\:\:\$cachedUserTarget through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 5
- path: tests/e2e/Services/GraphQL/UsersTest.php
-
- -
- message: '#^Variable \$from in empty\(\) is never defined\.$#'
- identifier: empty.variable
- count: 1
- path: tests/e2e/Services/Messaging/MessagingConsoleClientTest.php
-
- -
- message: '#^Variable \$from in empty\(\) is never defined\.$#'
- identifier: empty.variable
- count: 1
- path: tests/e2e/Services/Messaging/MessagingCustomClientTest.php
-
- -
- message: '#^Variable \$from in empty\(\) is never defined\.$#'
- identifier: empty.variable
- count: 1
- path: tests/e2e/Services/Messaging/MessagingCustomServerTest.php
-
- -
- message: '#^Anonymous function has an unused use \$databaseId\.$#'
- identifier: closure.unusedUse
- count: 5
- path: tests/e2e/Services/Migrations/MigrationsConsoleClientTest.php
-
- -
- message: '#^Anonymous function has an unused use \$tableId\.$#'
- identifier: closure.unusedUse
- count: 5
- path: tests/e2e/Services/Migrations/MigrationsConsoleClientTest.php
-
- -
- message: '#^Variable \$largeFile might not be defined\.$#'
- identifier: variable.undefined
- count: 8
- path: tests/e2e/Services/Storage/StorageConsoleClientTest.php
-
- -
- message: '#^Variable \$largeFile might not be defined\.$#'
- identifier: variable.undefined
- count: 8
- path: tests/e2e/Services/Storage/StorageCustomClientTest.php
-
- -
- message: '#^Variable \$largeFile might not be defined\.$#'
- identifier: variable.undefined
- count: 8
- path: tests/e2e/Services/Storage/StorageCustomServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\TablesDB\\DatabasesStringTypesTest\:\:\$setupCache through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/TablesDB/DatabasesStringTypesTest.php
-
- -
- message: '#^Call to an undefined method Tests\\E2E\\Services\\TablesDB\\Permissions\\TablesDBPermissionsGuestTest\:\:getIndexUrl\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsGuestTest.php
-
- -
- message: '#^Call to an undefined method Tests\\E2E\\Services\\TablesDB\\Permissions\\TablesDBPermissionsMemberTest\:\:getIndexUrl\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsMemberTest.php
-
- -
- message: '#^Call to an undefined method Tests\\E2E\\Services\\TablesDB\\Permissions\\TablesDBPermissionsTeamTest\:\:getIndexUrl\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsTeamTest.php
-
- -
- message: '#^Variable \$library might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: tests/e2e/Services/TablesDB/TablesDBConsoleClientTest.php
-
- -
- message: '#^Variable \$person might not be defined\.$#'
- identifier: variable.undefined
- count: 4
- path: tests/e2e/Services/TablesDB/TablesDBConsoleClientTest.php
-
- -
- message: '#^Variable \$library might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: tests/e2e/Services/TablesDB/TablesDBCustomClientTest.php
-
- -
- message: '#^Variable \$person might not be defined\.$#'
- identifier: variable.undefined
- count: 4
- path: tests/e2e/Services/TablesDB/TablesDBCustomClientTest.php
-
- -
- message: '#^Variable \$library might not be defined\.$#'
- identifier: variable.undefined
- count: 1
- path: tests/e2e/Services/TablesDB/TablesDBCustomServerTest.php
-
- -
- message: '#^Variable \$person might not be defined\.$#'
- identifier: variable.undefined
- count: 4
- path: tests/e2e/Services/TablesDB/TablesDBCustomServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensConsoleClientTest\:\:\$bucketAndFileData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/Tokens/TokensConsoleClientTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensConsoleClientTest\:\:\$tokenData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/Tokens/TokensConsoleClientTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensCustomClientTest\:\:\$bucketAndFileData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/Tokens/TokensCustomClientTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensCustomServerTest\:\:\$bucketAndFileData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/Tokens/TokensCustomServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensCustomServerTest\:\:\$tokenData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/Tokens/TokensCustomServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$cachedHashedPasswordUsers through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 2
- path: tests/e2e/Services/Users/UsersCustomServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$cachedUser through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 7
- path: tests/e2e/Services/Users/UsersCustomServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$cachedUserTarget through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 7
- path: tests/e2e/Services/Users/UsersCustomServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$userEmailUpdated through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 3
- path: tests/e2e/Services/Users/UsersCustomServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$userNameUpdated through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 3
- path: tests/e2e/Services/Users/UsersCustomServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$userNumberUpdated through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 3
- path: tests/e2e/Services/Users/UsersCustomServerTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\VCS\\VCSConsoleClientTest\:\:\$cachedFunctionData through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/VCS/VCSConsoleClientTest.php
-
- -
- message: '#^Unsafe access to private property Tests\\E2E\\Services\\VCS\\VCSConsoleClientTest\:\:\$cachedInstallationId through static\:\:\.$#'
- identifier: staticClassAccess.privateProperty
- count: 4
- path: tests/e2e/Services/VCS/VCSConsoleClientTest.php
-
- -
- message: '#^Unsafe call to private method Tests\\Unit\\Auth\\KeyTest\:\:generateKey\(\) through static\:\:\.$#'
- identifier: staticClassAccess.privateMethod
- count: 3
- path: tests/unit/Auth/KeyTest.php
-
- -
- message: '#^Call to an undefined method Utopia\\Queue\\Publisher\:\:getEvents\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: tests/unit/Event/EventTest.php
-
- -
- message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#'
- identifier: class.notFound
- count: 6
- path: tests/unit/Utopia/Response/Filters/V16Test.php
-
- -
- message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V16Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V16\.$#'
- identifier: assign.propertyType
- count: 1
- path: tests/unit/Utopia/Response/Filters/V16Test.php
-
- -
- message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V16Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#'
- identifier: class.notFound
- count: 1
- path: tests/unit/Utopia/Response/Filters/V16Test.php
-
- -
- message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#'
- identifier: class.notFound
- count: 5
- path: tests/unit/Utopia/Response/Filters/V17Test.php
-
- -
- message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V17Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V17\.$#'
- identifier: assign.propertyType
- count: 1
- path: tests/unit/Utopia/Response/Filters/V17Test.php
-
- -
- message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V17Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#'
- identifier: class.notFound
- count: 1
- path: tests/unit/Utopia/Response/Filters/V17Test.php
-
- -
- message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#'
- identifier: class.notFound
- count: 4
- path: tests/unit/Utopia/Response/Filters/V18Test.php
-
- -
- message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V18Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V18\.$#'
- identifier: assign.propertyType
- count: 1
- path: tests/unit/Utopia/Response/Filters/V18Test.php
-
- -
- message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V18Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#'
- identifier: class.notFound
- count: 1
- path: tests/unit/Utopia/Response/Filters/V18Test.php
-
- -
- message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#'
- identifier: class.notFound
- count: 11
- path: tests/unit/Utopia/Response/Filters/V19Test.php
-
- -
- message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V19Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V19\.$#'
- identifier: assign.propertyType
- count: 1
- path: tests/unit/Utopia/Response/Filters/V19Test.php
-
- -
- message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V19Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#'
- identifier: class.notFound
- count: 1
- path: tests/unit/Utopia/Response/Filters/V19Test.php
diff --git a/phpstan.neon b/phpstan.neon
index b87ad46eca..0b8761c19e 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -1,8 +1,6 @@
-includes:
- - phpstan-baseline.neon
-
parameters:
- level: 3
+ level: 4
+ tmpDir: .phpstan-cache
paths:
- src
- app
@@ -14,4 +12,3 @@ parameters:
- vendor/swoole/ide-helper
excludePaths:
- tests/resources
-
diff --git a/phpunit.xml b/phpunit.xml
index 9748c5a5c8..32e865fe35 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -38,6 +38,7 @@
./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..0cbaefa4b3 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,7 @@ 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;
@@ -201,9 +183,7 @@ class Key
$bannerDisabled,
$projectCheckDisabled,
$previewAuthDisabled,
- $deploymentStatusIgnored,
- $scopedProjectId,
- $source
+ $deploymentStatusIgnored
);
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/Databases/TransactionState.php b/src/Appwrite/Databases/TransactionState.php
index 8e098774e6..71bd8799c7 100644
--- a/src/Appwrite/Databases/TransactionState.php
+++ b/src/Appwrite/Databases/TransactionState.php
@@ -21,17 +21,23 @@ class TransactionState
{
private Database $dbForProject;
private Authorization $authorization;
- /** @var Authorization $authorization */
- public function __construct(Database $dbForProject, Authorization $authorization)
+ /**
+ * @var callable(Document $database): Database
+ */
+ private mixed $getDatabasesDB;
+
+ public function __construct(Database $dbForProject, Authorization $authorization, callable $getDatabasesDB)
{
$this->dbForProject = $dbForProject;
$this->authorization = $authorization;
+ $this->getDatabasesDB = $getDatabasesDB;
}
/**
* Get a document with transaction-aware logic
*
+ * @param Document $database Target database document
* @param string $collectionId Collection ID
* @param string $documentId Document ID
* @param string|null $transactionId Optional transaction ID
@@ -42,13 +48,15 @@ class TransactionState
* @throws Timeout
*/
public function getDocument(
+ Document $database,
string $collectionId,
string $documentId,
?string $transactionId = null,
array $queries = []
): Document {
+ $dbForDatabases = ($this->getDatabasesDB)($database);
if ($transactionId === null) {
- return $this->dbForProject->getDocument($collectionId, $documentId, $queries);
+ return $dbForDatabases->getDocument($collectionId, $documentId, $queries);
}
$state = $this->getTransactionState($transactionId);
@@ -66,7 +74,7 @@ class TransactionState
if ($docState['action'] === 'update' || $docState['action'] === 'upsert') {
// Merge with committed version
- $committedDoc = $this->dbForProject->getDocument($collectionId, $documentId, $queries);
+ $committedDoc = $dbForDatabases->getDocument($collectionId, $documentId, $queries);
if (!$committedDoc->isEmpty()) {
foreach ($docState['document']->getAttributes() as $key => $value) {
if ($key !== '$id') {
@@ -80,13 +88,13 @@ class TransactionState
}
}
}
-
- return $this->dbForProject->getDocument($collectionId, $documentId, $queries);
+ return $dbForDatabases->getDocument($collectionId, $documentId, $queries);
}
/**
* List documents with transaction-aware logic
*
+ * @param Document $database Target database document
* @param string $collectionId Collection ID
* @param string|null $transactionId Optional transaction ID
* @param array $queries Optional query filters
@@ -96,17 +104,19 @@ class TransactionState
* @throws Timeout
*/
public function listDocuments(
+ Document $database,
string $collectionId,
?string $transactionId = null,
array $queries = []
): array {
+ $dbForDatabases = ($this->getDatabasesDB)($database);
// If no transaction, use normal database retrieval
if ($transactionId === null) {
- return $this->dbForProject->find($collectionId, $queries);
+ return $dbForDatabases->find($collectionId, $queries);
}
$state = $this->getTransactionState($transactionId);
- $committedDocs = $this->dbForProject->find($collectionId, $queries);
+ $committedDocs = $dbForDatabases->find($collectionId, $queries);
$documentMap = [];
// Build map of committed documents
@@ -147,6 +157,7 @@ class TransactionState
/**
* Count documents with transaction-aware logic
*
+ * @param Document $database Target database document
* @param string $collectionId Collection ID
* @param string|null $transactionId Optional transaction ID
* @param array $queries Optional query filters
@@ -156,23 +167,23 @@ class TransactionState
* @throws Timeout
*/
public function countDocuments(
+ Document $database,
string $collectionId,
?string $transactionId = null,
array $queries = []
): int {
+ $dbForDatabases = ($this->getDatabasesDB)($database);
if ($transactionId === null) {
- return $this->dbForProject->count($collectionId, $queries, APP_LIMIT_COUNT);
+ return $dbForDatabases->count($collectionId, $queries, APP_LIMIT_COUNT);
}
$state = $this->getTransactionState($transactionId);
-
- $baseCount = $this->dbForProject->count($collectionId, $queries, APP_LIMIT_COUNT);
+ $baseCount = $dbForDatabases->count($collectionId, $queries, APP_LIMIT_COUNT);
if (!isset($state[$collectionId])) {
return $baseCount;
}
-
- $committedDocs = $this->dbForProject->find($collectionId, $queries);
+ $committedDocs = $dbForDatabases->find($collectionId, $queries);
$committedDocIds = [];
foreach ($committedDocs as $doc) {
$committedDocIds[$doc->getId()] = true;
@@ -214,17 +225,19 @@ class TransactionState
/**
* Check if a document exists with transaction-aware logic
*
+ * @param Document $database Target database document
* @param string $collectionId Collection ID
* @param string $documentId Document ID
* @param string|null $transactionId Optional transaction ID
* @return bool True if document exists
*/
public function documentExists(
+ Document $database,
string $collectionId,
string $documentId,
?string $transactionId = null
): bool {
- $doc = $this->getDocument($collectionId, $documentId, $transactionId);
+ $doc = $this->getDocument($database, $collectionId, $documentId, $transactionId);
return !$doc->isEmpty();
}
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 3bf6fb2d50..7e44a6c5cf 100644
--- a/src/Appwrite/Docker/Env.php
+++ b/src/Appwrite/Docker/Env.php
@@ -9,16 +9,13 @@ 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;
+ $key = trim($row[0]);
$value = (isset($row[1])) ? (function (string $v): string {
$v = trim($v);
if (
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 ba633b4478..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;
@@ -519,6 +519,7 @@ class Event
* @param string $pattern
* @param array $params
* @param ?Document $database
+ * @param ?Document $database
* @return array
* @throws \InvalidArgumentException
*/
@@ -533,7 +534,7 @@ class Event
$parsed = self::parseEventPattern($pattern);
// to switch the resource types from databases to the required prefix
// eg; all databases events get fired with databases. prefix which mainly depicts legacy type
- // so a projection from databases to the actual prefix
+ // so a projection from databases to the actual prefix(documentsdb, vectorsdb,etc)
if ((str_contains($pattern, 'databases.') && $database && $database->getAttribute('type') !== 'legacy')) {
$parsed = self::getDatabaseTypeEvents($database, $parsed);
}
@@ -636,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);
}
@@ -647,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();
@@ -663,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',
@@ -688,37 +698,30 @@ class Event
if (
(
str_contains($pattern, 'databases.') &&
- str_contains($firstEvent, 'collections')
+ (
+ str_contains($firstEvent, 'collections') ||
+ str_contains($firstEvent, 'tables')
+ )
) ||
(
str_contains($firstEvent, 'tablesdb.')
)
) {
$pairedEvents = [];
-
foreach ($events as $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
*/
@@ -745,6 +762,13 @@ class Event
'attributes' => 'columns',
];
break;
+ case 'documentsdb':
+ case 'vectorsdb':
+ // sending the type itself(eg: documentsdb, vectorsdb)
+ $eventMap = [
+ 'databases' => $database->getAttribute('type')
+ ];
+ break;
}
foreach ($event as $eventKey => $eventValue) {
if (isset($eventMap[$eventValue])) {
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/Build.php b/src/Appwrite/Event/Message/Build.php
new file mode 100644
index 0000000000..0c8967aff6
--- /dev/null
+++ b/src/Appwrite/Event/Message/Build.php
@@ -0,0 +1,45 @@
+platform) ? $this->platform : Config::getParam('platform', []);
+
+ return [
+ 'project' => $this->project->getArrayCopy(),
+ 'resource' => $this->resource->getArrayCopy(),
+ 'deployment' => $this->deployment->getArrayCopy(),
+ 'type' => $this->type,
+ 'template' => $this->template?->getArrayCopy(),
+ 'platform' => $platform,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: new Document($data['project'] ?? []),
+ resource: new Document($data['resource'] ?? []),
+ deployment: new Document($data['deployment'] ?? []),
+ type: $data['type'] ?? '',
+ template: !empty($data['template']) ? new Document($data['template']) : null,
+ platform: $data['platform'] ?? [],
+ );
+ }
+}
diff --git a/src/Appwrite/Event/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/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
index 776188d5b5..c72bc8ae2a 100644
--- a/src/Appwrite/Event/Message/Usage.php
+++ b/src/Appwrite/Event/Message/Usage.php
@@ -40,7 +40,8 @@ class Usage extends Base
*/
public static function fromArray(array $data): static
{
- return new self(
+ /** @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/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/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/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/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 a54edf7074..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';
@@ -330,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 +347,11 @@ class Exception extends \Exception
public const string MIGRATION_ALREADY_EXISTS = 'migration_already_exists';
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 037f80bcf7..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 \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 85ae4fde25..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}"
);
@@ -492,6 +692,8 @@ class Realtime extends MessagingAdapter
break;
case 'databases':
case 'tablesdb':
+ case 'documentsdb':
+ case 'vectorsdb':
$resource = $parts[4] ?? '';
if (in_array($resource, ['columns', 'attributes', 'indexes'])) {
$channels[] = 'console';
@@ -511,12 +713,20 @@ class Realtime extends MessagingAdapter
$resourceId = $tableId ?: $collectionId;
$channels = [];
- // sending legacy + tablesdb events to both legacy and tablesdb
- $channels = array_values(array_unique(array_merge(
- self::getDatabaseChannels('legacy', $database->getId(), $resourceId, $payload->getId(), 'databases'),
- self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId(), 'databases'),
- self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId())
- )));
+ switch ($parts[0]) {
+ case 'databases':
+ case 'tablesdb':
+ // sending legacy + tablesdb events to both legacy and tablesdb
+ $channels = array_values(array_unique(array_merge(
+ self::getDatabaseChannels('legacy', $database->getId(), $resourceId, $payload->getId(), 'databases'),
+ self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId(), 'databases'),
+ self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId())
+ )));
+ break;
+ default:
+ // only prefixed events
+ $channels = array_values(self::getDatabaseChannels($parts[0], $database->getId(), $resourceId, $payload->getId()));
+ }
$roles = $collection->getAttribute('documentSecurity', false)
? \array_merge($collection->getRead(), $payload->getRead())
@@ -564,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,
];
}
@@ -582,6 +849,7 @@ class Realtime extends MessagingAdapter
* @param string $resourceId The collection/table ID
* @param string $payloadId The document/row ID
* @param string $prefixOverride Override the channel prefix when different API types share the same terminology but need different prefixes
+ * (e.g., 'databases' and 'documentsdb' use same terminology but need different prefixes)
* @return array Array of channel names
*/
private static function getDatabaseChannels(
@@ -615,6 +883,13 @@ class Realtime extends MessagingAdapter
$channels[] = "{$basePrefix}.{$databaseId}.tables.{$resourceId}.rows.{$payloadId}";
break;
+ case 'documentsdb':
+ case 'vectorsdb':
+ $channels[] = 'documents';
+ $channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents";
+ $channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents.{$payloadId}";
+ break;
+
default:
$basePrefix = 'databases';
$channels[] = 'documents';
@@ -623,6 +898,7 @@ class Realtime extends MessagingAdapter
break;
}
+
return $channels;
}
}
diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php
index a4f73eb5f2..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
@@ -92,6 +93,11 @@ abstract class Migration
'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',
];
/**
@@ -204,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
index dc9ce9d196..a2d9d7907b 100644
--- a/src/Appwrite/Migration/Version/V24.php
+++ b/src/Appwrite/Migration/Version/V24.php
@@ -187,6 +187,20 @@ class V24 extends Migration
$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');
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 1cf5de91d1..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';
@@ -57,6 +47,36 @@ class Platform
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.
*
@@ -77,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;
}
@@ -120,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/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 06312d9cb2..a9cd1a8e2f 100644
--- a/src/Appwrite/Platform/Appwrite.php
+++ b/src/Appwrite/Platform/Appwrite.php
@@ -3,12 +3,14 @@
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;
@@ -39,6 +41,8 @@ class Appwrite extends Platform
$this->addModule(new Storage\Module());
$this->addModule(new VCS\Module());
$this->addModule(new Webhooks\Module());
+ $this->addModule(new Migrations\Module());
$this->addModule(new Project\Module());
+ $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
index 92a00651fe..69f7d4b072 100644
--- a/src/Appwrite/Platform/Installer/Http/Installer/Complete.php
+++ b/src/Appwrite/Platform/Installer/Http/Installer/Complete.php
@@ -48,9 +48,10 @@ class Complete extends Action
@touch(Server::INSTALLER_COMPLETE_FILE);
- if (!$sessionSecret && $installId !== '') {
- $data = $state->readProgressFile($installId);
- $details = $data['details'][Server::STEP_ACCOUNT_SETUP] ?? [];
+ $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'] ?? '');
@@ -68,8 +69,11 @@ class Complete extends Action
$expires = $timestamp;
}
}
- $response->addCookie('a_session_console', $sessionSecret, $expires, '/', '', $isHttps, true, $sameSite);
- $response->addCookie('a_session_console_legacy', $sessionSecret, $expires, '/', '', $isHttps, true, $sameSite);
+ $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);
}
@@ -79,4 +83,42 @@ class Complete extends Action
$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/Install.php b/src/Appwrite/Platform/Installer/Http/Installer/Install.php
index 0b2fa17c0d..e7e9008e3b 100644
--- a/src/Appwrite/Platform/Installer/Http/Installer/Install.php
+++ b/src/Appwrite/Platform/Installer/Http/Installer/Install.php
@@ -35,7 +35,7 @@ class Install extends Action
->param('appDomain', '', new AppDomain(), 'Application domain (hostname, IP, or bracket IPv6 with optional port)')
->param('httpPort', 80, new Range(1, 65535), 'HTTP port')
->param('httpsPort', 443, new Range(1, 65535), 'HTTPS port')
- ->param('emailCertificates', '', new Email(), 'Email for SSL certificates')
+ ->param('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)
@@ -43,6 +43,7 @@ class Install extends Action
->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')
@@ -64,6 +65,7 @@ class Install extends Action
string $database,
string $installId,
?string $retryStep,
+ bool $migrate,
Request $request,
Response $response,
SwooleResponse $swooleResponse,
@@ -90,6 +92,9 @@ class Install extends Action
$appDomain = trim($appDomain);
$emailCertificates = trim($emailCertificates);
+ if ($emailCertificates === '') {
+ $emailCertificates = trim($accountEmail);
+ }
$opensslKey = trim($opensslKey);
$assistantOpenAIKey = trim($assistantOpenAIKey);
@@ -140,6 +145,8 @@ class Install extends Action
@unlink(Server::INSTALLER_COMPLETE_FILE);
+ $state->clearStaleLockIfNeeded();
+
try {
$lockResult = $state->reserveGlobalLock($installId);
} catch (\Throwable $e) {
@@ -175,15 +182,23 @@ class Install extends Action
if (file_exists($existingPath)) {
$existing = $state->readProgressFile($installId);
if (!empty($existing['steps']) && $retryStep === null) {
- $state->updateGlobalLock($installId, Server::STATUS_ERROR);
- if ($wantsStream) {
- $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => 'Installation already started']);
- $swooleResponse->end();
+ $previousHadError = isset($existing['error']);
+ $allCompleted = !$previousHadError && $this->allStepsCompleted($existing['steps']);
+
+ if ($previousHadError || $allCompleted) {
+ @unlink($existingPath);
+ $existing = null;
} else {
- $response->setStatusCode(Response::STATUS_CODE_CONFLICT);
- $response->json(['success' => false, 'message' => 'Installation already started']);
+ $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;
}
- return;
}
}
@@ -207,7 +222,8 @@ class Install extends Action
'_APP_ASSISTANT_OPENAI_API_KEY' => $assistantOpenAIKey,
];
- if ($this->hasPayload($existing)) {
+ $previousHadError = is_array($existing) && isset($existing['error']);
+ if ($this->hasPayload($existing) && !$previousHadError) {
$stored = $existing['payload'];
$inputValues = [
'httpPort' => (string) $httpPort,
@@ -224,9 +240,7 @@ class Install extends Action
$inputValue = trim($inputValue);
}
if ($storedValue !== $inputValue) {
- if ($installId !== '') {
- $state->updateGlobalLock($installId, Server::STATUS_ERROR);
- }
+ $state->updateGlobalLock($installId, Server::STATUS_ERROR);
$this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch');
return;
}
@@ -246,16 +260,12 @@ class Install extends Action
$incomingHash = $state->hashSensitiveValue($incomingValue);
if (isset($stored[$hashField])) {
if (!hash_equals((string) $stored[$hashField], $incomingHash)) {
- if ($installId !== '') {
- $state->updateGlobalLock($installId, Server::STATUS_ERROR);
- }
+ $state->updateGlobalLock($installId, Server::STATUS_ERROR);
$this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch');
return;
}
} elseif (isset($stored[$field]) && $incomingValue !== '' && (string) $stored[$field] !== $incomingValue) {
- if ($installId !== '') {
- $state->updateGlobalLock($installId, Server::STATUS_ERROR);
- }
+ $state->updateGlobalLock($installId, Server::STATUS_ERROR);
$this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch');
return;
}
@@ -307,6 +317,28 @@ class Install extends Action
}
};
+ $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(),
@@ -317,23 +349,12 @@ class Install extends Action
$progress,
$retryStep,
$config->isUpgrade(),
- $account
+ $account,
+ $onComplete,
+ $migrate,
);
- if ($wantsStream) {
- $this->writeSseEvent($swooleResponse, 'done', ['installId' => $installId, 'success' => true]);
- usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS);
- $swooleResponse->write(": keepalive\n\n");
- usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS);
- $swooleResponse->end();
- } else {
- $response->json([
- 'success' => true,
- 'installId' => $installId,
- 'message' => 'Installation completed successfully',
- ]);
- }
- $state->updateGlobalLock($installId, Server::STATUS_COMPLETED);
+ $onComplete();
} catch (\Throwable $e) {
$this->handleInstallationError($e, $installId, $wantsStream, $response, $swooleResponse, $state);
}
@@ -368,8 +389,6 @@ class Install extends Action
$state->updateGlobalLock($installId, Server::STATUS_ERROR);
}
- @unlink(Server::INSTALLER_CONFIG_FILE);
-
if ($wantsStream) {
$this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, [
'message' => $e->getMessage(),
@@ -392,10 +411,20 @@ class Install extends Action
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] ?? '';
+ $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/Status.php b/src/Appwrite/Platform/Installer/Http/Installer/Status.php
index e53a501f4c..204ace077c 100644
--- a/src/Appwrite/Platform/Installer/Http/Installer/Status.php
+++ b/src/Appwrite/Platform/Installer/Http/Installer/Status.php
@@ -28,6 +28,8 @@ class Status extends 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);
@@ -43,7 +45,7 @@ class Status extends Action
}
$data = $state->readProgressFile($installId);
- if (is_array($data) && isset($data['payload']) && is_array($data['payload'])) {
+ if (isset($data['payload']) && is_array($data['payload'])) {
unset(
$data['payload']['opensslKey'],
$data['payload']['assistantOpenAIKey'],
@@ -52,7 +54,7 @@ class Status extends Action
);
}
// Strip sensitive data from step details
- if (is_array($data) && isset($data['details']) && is_array($data['details'])) {
+ if (isset($data['details']) && is_array($data['details'])) {
foreach ($data['details'] as $stepKey => &$stepDetails) {
if (is_array($stepDetails)) {
unset($stepDetails['sessionSecret'], $stepDetails['trace']);
diff --git a/src/Appwrite/Platform/Installer/Http/Installer/View.php b/src/Appwrite/Platform/Installer/Http/Installer/View.php
index ce308aa906..dea356eaaf 100644
--- a/src/Appwrite/Platform/Installer/Http/Installer/View.php
+++ b/src/Appwrite/Platform/Installer/Http/Installer/View.php
@@ -24,7 +24,7 @@ class View extends Action
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/')
->desc('Serve installer UI')
- ->param('step', 1, new Integer(true), 'Step number (1-5)', true)
+ ->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')
@@ -52,10 +52,13 @@ class View extends Action
$defaultEmailCertificates = 'walterobrien@example.com';
}
- $step = max(1, min(5, $step));
+ $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)) {
diff --git a/src/Appwrite/Platform/Installer/Runtime/Config.php b/src/Appwrite/Platform/Installer/Runtime/Config.php
index 99db12dfed..6142e47152 100644
--- a/src/Appwrite/Platform/Installer/Runtime/Config.php
+++ b/src/Appwrite/Platform/Installer/Runtime/Config.php
@@ -218,7 +218,7 @@ final class Config
}
/**
- * @param string[] $value
+ * @param array $value
*/
public function setEnabledDatabases(array $value): void
{
diff --git a/src/Appwrite/Platform/Installer/Runtime/State.php b/src/Appwrite/Platform/Installer/Runtime/State.php
index 5552eb5632..3cbcc51fa6 100644
--- a/src/Appwrite/Platform/Installer/Runtime/State.php
+++ b/src/Appwrite/Platform/Installer/Runtime/State.php
@@ -13,17 +13,17 @@ class State
private const string PATTERN_IPV6_WITH_PORT = '/^\[(.+)](?::(\d+))?$/';
private const int CONFIG_FILE_PERMISSION = 0600;
- private const int GLOBAL_LOCK_TIMEOUT_SECONDS = 3600;
+ private const int GLOBAL_LOCK_TIMEOUT_SECONDS = 300;
+ private const int STALE_LOCK_CHECK_INTERVAL_SECONDS = 30;
private const int PORT_MIN = 1;
private const int PORT_MAX = 65535;
- private array $paths;
private bool $bootstrapped = false;
+ private int $lastStaleLockClearAt = 0;
- public function __construct(array $paths)
+ public function __construct()
{
- $this->paths = $paths;
}
public function buildConfig(array $overrides = [], bool $useEnv = true): Config
@@ -178,7 +178,7 @@ class State
if (!preg_match(self::PATTERN_IPV6_WITH_PORT, $value, $matches)) {
return false;
}
- $host = $matches[1] ?? '';
+ $host = $matches[1];
$port = $matches[2] ?? null;
} else {
$parts = explode(':', $value);
@@ -254,6 +254,16 @@ class State
}
}
+ 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) {
diff --git a/src/Appwrite/Platform/Installer/Server.php b/src/Appwrite/Platform/Installer/Server.php
index f36c270553..26d82adf24 100644
--- a/src/Appwrite/Platform/Installer/Server.php
+++ b/src/Appwrite/Platform/Installer/Server.php
@@ -3,8 +3,10 @@
namespace Appwrite\Platform\Installer;
use Appwrite\Platform\Installer\Http\Installer\Error;
+use Appwrite\Platform\Installer\Runtime\Config;
use Appwrite\Platform\Installer\Runtime\State;
use Swoole\Http\Server as SwooleServer;
+use Swoole\Runtime;
use Utopia\Http\Adapter\Swoole\Request;
use Utopia\Http\Adapter\Swoole\Response;
use Utopia\Http\Adapter\Swoole\Server as SwooleAdapter;
@@ -27,6 +29,8 @@ class Server
public const string STEP_DOCKER_COMPOSE = 'docker-compose';
public const string STEP_DOCKER_CONTAINERS = 'docker-containers';
public const string STEP_ACCOUNT_SETUP = 'account-setup';
+ public const string STEP_MIGRATION = 'migration';
+ public const string STEP_SSL_CERTIFICATE = 'ssl-certificate';
public const string STATUS_IN_PROGRESS = 'in-progress';
public const string STATUS_COMPLETED = 'completed';
@@ -56,7 +60,7 @@ class Server
{
$this->initPaths();
- $this->state = new State($this->paths);
+ $this->state = new State();
if (PHP_SAPI === 'cli') {
$this->runCli();
@@ -127,6 +131,8 @@ class Server
private function startSwooleServer(string $host, int $port, ?string $readyFile = null): void
{
+ Runtime::enableCoroutine(SWOOLE_HOOK_ALL);
+
$this->state->clearStaleLock();
// Preload static files into memory
@@ -135,12 +141,24 @@ class Server
// Register resources for dependency injection into actions
$config = $this->state->buildConfig();
+ $this->autoDetectUpgrade($config);
$paths = $this->paths;
$state = $this->state;
- Http::setResource('installerState', fn () => $state);
- Http::setResource('installerConfig', fn () => $config);
- Http::setResource('installerPaths', fn () => $paths);
+ $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();
@@ -153,17 +171,6 @@ class Server
->inject('response')
->action($errorHandler->action(...));
- $adapter = new class ($host, $port, ['worker_num' => 1]) extends SwooleAdapter {
- public function getNativeServer(): SwooleServer
- {
- return $this->server;
- }
- };
-
- $nativeServer = $adapter->getNativeServer();
-
- Http::setResource('swooleServer', fn () => $nativeServer);
-
$nativeServer->on('start', function () use ($nativeServer, $port, $readyFile) {
\Swoole\Process::signal(SIGTERM, fn () => $nativeServer->shutdown());
\Swoole\Process::signal(SIGINT, fn () => $nativeServer->shutdown());
@@ -173,7 +180,7 @@ class Server
}
});
- $adapter->onRequest(function (Request $request, Response $response) use ($files) {
+ $adapter->onRequest(function (Request $request, Response $response) use ($adapter, $files) {
// Serve static files from memory
$uri = $request->getURI();
if ($files->isFileLoaded($uri)) {
@@ -183,13 +190,84 @@ class Server
return;
}
- $app = new Http('UTC');
+ $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);
diff --git a/src/Appwrite/Platform/Installer/Services/Http.php b/src/Appwrite/Platform/Installer/Services/Http.php
index bd0fc62cdc..b410e67a26 100644
--- a/src/Appwrite/Platform/Installer/Services/Http.php
+++ b/src/Appwrite/Platform/Installer/Services/Http.php
@@ -2,8 +2,10 @@
namespace Appwrite\Platform\Installer\Services;
+use Appwrite\Platform\Installer\Http\Installer\Certificate\Get as CertificateGet;
use Appwrite\Platform\Installer\Http\Installer\Complete;
use Appwrite\Platform\Installer\Http\Installer\Install;
+use Appwrite\Platform\Installer\Http\Installer\Reset;
use Appwrite\Platform\Installer\Http\Installer\Shutdown;
use Appwrite\Platform\Installer\Http\Installer\Status;
use Appwrite\Platform\Installer\Http\Installer\Validate;
@@ -21,6 +23,8 @@ class Http extends Service
$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
index f631015654..5d18b5214a 100644
--- a/src/Appwrite/Platform/Installer/Validator/AppDomain.php
+++ b/src/Appwrite/Platform/Installer/Validator/AppDomain.php
@@ -47,7 +47,7 @@ class AppDomain extends Validator
if (!preg_match(self::PATTERN_IPV6_WITH_PORT, $value, $matches)) {
return false;
}
- $host = $matches[1] ?? '';
+ $host = $matches[1];
$port = $matches[2] ?? null;
} else {
$parts = explode(':', $value);
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 20a6afed2e..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,8 +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\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;
@@ -101,8 +103,8 @@ class Create extends Action
->inject('platform')
->inject('request')
->inject('queueForEvents')
- ->inject('queueForMessaging')
- ->inject('queueForMails')
+ ->inject('publisherForMessaging')
+ ->inject('publisherForMails')
->inject('timelimit')
->inject('usage')
->inject('plan')
@@ -121,8 +123,8 @@ class Create extends Action
array $platform,
Request $request,
Event $queueForEvents,
- Messaging $queueForMessaging,
- Mail $queueForMails,
+ MessagingPublisher $publisherForMessaging,
+ MailPublisher $publisherForMails,
callable $timelimit,
Context $usage,
array $plan,
@@ -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,16 +182,18 @@ 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 {
@@ -223,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();
@@ -253,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'])) {
@@ -262,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'])) {
@@ -280,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 = [
@@ -318,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 2df12b17d1..c43c0fc4bf 100644
--- a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php
+++ b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php
@@ -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 = [];
}
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/Constants.php b/src/Appwrite/Platform/Modules/Databases/Constants.php
index cfc297c3f4..edc6b09cf0 100644
--- a/src/Appwrite/Platform/Modules/Databases/Constants.php
+++ b/src/Appwrite/Platform/Modules/Databases/Constants.php
@@ -22,3 +22,11 @@ const INDEX = 'index';
const DOCUMENTS = 'document';
const ATTRIBUTES = 'attribute';
const COLLECTIONS = 'collection';
+
+const LEGACY = 'legacy';
+const TABLESDB = 'tablesdb';
+const DOCUMENTSDB = 'documentsdb';
+const VECTORSDB = 'vectorsdb';
+
+const MIN_VECTOR_DIMENSION = 1;
+const MAX_VECTOR_DIMENSION = 16000;
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php
index 728e732cc5..7893a70753 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php
@@ -7,22 +7,33 @@ 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
{
- private string $context = 'legacy';
+ public const LIST_CACHE_FIELD_DOCUMENTS = 'documents';
+ public const LIST_CACHE_FIELD_TOTAL = 'total';
+
+ private string $context = DATABASE_TYPE_LEGACY;
public function getDatabaseType(): string
{
return $this->context;
}
- public function setHttpPath(string $path): AppwriteAction
+ public function setHttpPath(string $path): self
{
if (\str_contains($path, '/tablesdb')) {
- $this->context = 'tablesdb';
+ $this->context = DATABASE_TYPE_TABLESDB;
}
- return parent::setHttpPath($path);
+ if (\str_contains($path, '/documentsdb')) {
+ $this->context = DATABASE_TYPE_DOCUMENTSDB;
+ }
+ if (\str_contains($path, '/vectorsdb')) {
+ $this->context = DATABASE_TYPE_VECTORSDB;
+ }
+ parent::setHttpPath($path);
+ return $this;
}
/**
@@ -94,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 f49d07ec4c..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,29 +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 $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;
}
- return parent::setHttpPath($path);
+ parent::setHttpPath($path);
+ return $this;
}
/**
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/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php
index 216ec07e05..3a53a49579 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php
@@ -84,12 +84,13 @@ class Create extends Action
->param('indexes', [], new ArrayList(new JSON(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of index definitions to create. Each index should contain: key (string), type (string: key, fulltext, unique, spatial), attributes (array of attribute keys), orders (array of ASC/DESC, optional), and lengths (array of integers, optional).', true)
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $name, ?array $permissions, bool $documentSecurity, bool $enabled, array $attributes, array $indexes, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, string $name, ?array $permissions, bool $documentSecurity, bool $enabled, array $attributes, array $indexes, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void
{
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
@@ -121,12 +122,18 @@ class Create extends Action
throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]);
}
+ /**
+ * @var Database $dbForDatabases
+ */
+ $dbForDatabases = $getDatabasesDB($database);
+
$collectionKey = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence();
$databaseKey = 'database_' . $database->getSequence();
$attributesValidator = new AttributesValidator(
APP_LIMIT_ARRAY_PARAMS_SIZE,
- $dbForProject->getAdapter()->getSupportForSpatialAttributes()
+ $dbForDatabases->getAdapter()->getSupportForSpatialAttributes(),
+ $dbForDatabases->getAdapter()->getSupportForAttributes()
);
if (!$attributesValidator->isValid($attributes)) {
@@ -155,7 +162,7 @@ class Create extends Action
}
// Validate indexes
- $indexesValidator = new IndexesValidator($dbForProject->getLimitForIndexes());
+ $indexesValidator = new IndexesValidator($dbForDatabases->getLimitForIndexes());
if (!$indexesValidator->isValid($indexes)) {
$dbForProject->deleteDocument($databaseKey, $collection->getId());
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $indexesValidator->getDescription());
@@ -178,21 +185,23 @@ class Create extends Action
$indexValidator = new IndexValidator(
$collectionAttributes,
[],
- $dbForProject->getAdapter()->getMaxIndexLength(),
- $dbForProject->getAdapter()->getInternalIndexesKeys(),
- $dbForProject->getAdapter()->getSupportForIndexArray(),
- $dbForProject->getAdapter()->getSupportForSpatialIndexNull(),
- $dbForProject->getAdapter()->getSupportForSpatialIndexOrder(),
- $dbForProject->getAdapter()->getSupportForVectors(),
- $dbForProject->getAdapter()->getSupportForAttributes(),
- $dbForProject->getAdapter()->getSupportForMultipleFulltextIndexes(),
- $dbForProject->getAdapter()->getSupportForIdenticalIndexes(),
- $dbForProject->getAdapter()->getSupportForObjectIndexes(),
- $dbForProject->getAdapter()->getSupportForTrigramIndex(),
- $dbForProject->getAdapter()->getSupportForSpatialAttributes(),
- $dbForProject->getAdapter()->getSupportForIndex(),
- $dbForProject->getAdapter()->getSupportForUniqueIndex(),
- $dbForProject->getAdapter()->getSupportForFulltextIndex(),
+ $dbForDatabases->getAdapter()->getMaxIndexLength(),
+ $dbForDatabases->getAdapter()->getInternalIndexesKeys(),
+ $dbForDatabases->getAdapter()->getSupportForIndexArray(),
+ $dbForDatabases->getAdapter()->getSupportForSpatialIndexNull(),
+ $dbForDatabases->getAdapter()->getSupportForSpatialIndexOrder(),
+ $dbForDatabases->getAdapter()->getSupportForVectors(),
+ $dbForDatabases->getAdapter()->getSupportForAttributes(),
+ $dbForDatabases->getAdapter()->getSupportForMultipleFulltextIndexes(),
+ $dbForDatabases->getAdapter()->getSupportForIdenticalIndexes(),
+ $dbForDatabases->getAdapter()->getSupportForObjectIndexes(),
+ $dbForDatabases->getAdapter()->getSupportForTrigramIndex(),
+ $dbForDatabases->getAdapter()->getSupportForSpatialAttributes(),
+ $dbForDatabases->getAdapter()->getSupportForIndex(),
+ $dbForDatabases->getAdapter()->getSupportForUniqueIndex(),
+ $dbForDatabases->getAdapter()->getSupportForFulltextIndex(),
+ $dbForDatabases->getAdapter()->getSupportForTTLIndexes(),
+ $dbForDatabases->getAdapter()->getSupportForObject(),
);
foreach ($collectionIndexes as $indexDoc) {
@@ -203,7 +212,7 @@ class Create extends Action
}
try {
- $dbForProject->createCollection(
+ $dbForDatabases->createCollection(
id: $collectionKey,
attributes: $collectionAttributes,
indexes: $collectionIndexes,
@@ -281,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/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php
index 7f194aa93d..7a5b73f7db 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php
@@ -62,13 +62,14 @@ class Delete extends Action
->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
{
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty()) {
@@ -85,7 +86,8 @@ class Delete extends Action
throw new Exception(Exception::GENERAL_SERVER_ERROR, "Failed to remove $type from DB");
}
- $dbForProject->purgeCachedCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence());
+ $dbForDatabases = $getDatabasesDB($database);
+ $dbForDatabases->purgeCachedCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence());
$queueForDatabase
->setType(DATABASE_TYPE_DELETE_COLLECTION)
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 39146508fb..8100a2c51b 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
@@ -14,19 +14,24 @@ 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 $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;
+ } elseif (str_contains($path, '/documentsdb/')) {
+ $this->databaseType = DATABASE_TYPE_DOCUMENTSDB;
+ } elseif (str_contains($path, '/vectorsdb/')) {
+ $this->databaseType = DATABASE_TYPE_VECTORSDB;
}
$contextId = '$' . $this->getCollectionsEventsContext() . 'Id';
@@ -42,7 +47,41 @@ abstract class Action extends DatabasesAction
],
];
- return parent::setHttpPath($path);
+ parent::setHttpPath($path);
+ return $this;
+ }
+
+ protected function getDatabasesOperationReadMetric(): string
+ {
+ if ($this->databaseType === DATABASE_TYPE_LEGACY || $this->databaseType === DATABASE_TYPE_TABLESDB) {
+ return METRIC_DATABASES_OPERATIONS_READS;
+ }
+ return $this->databaseType.'.'.METRIC_DATABASES_OPERATIONS_READS;
+ }
+
+ protected function getDatabasesIdOperationReadMetric(): string
+ {
+ if ($this->databaseType === DATABASE_TYPE_LEGACY || $this->databaseType === DATABASE_TYPE_TABLESDB) {
+ return METRIC_DATABASE_ID_OPERATIONS_READS;
+ }
+ return $this->databaseType.'.'.METRIC_DATABASE_ID_OPERATIONS_READS;
+ }
+
+ protected function getDatabasesOperationWriteMetric(): string
+ {
+ if ($this->databaseType === DATABASE_TYPE_LEGACY || $this->databaseType === DATABASE_TYPE_TABLESDB) {
+ return METRIC_DATABASES_OPERATIONS_WRITES;
+ }
+ return $this->databaseType.'.'.METRIC_DATABASES_OPERATIONS_WRITES;
+
+ }
+
+ protected function getDatabasesIdOperationWriteMetric(): string
+ {
+ if ($this->databaseType === DATABASE_TYPE_LEGACY || $this->databaseType === DATABASE_TYPE_TABLESDB) {
+ return METRIC_DATABASE_ID_OPERATIONS_WRITES;
+ }
+ return $this->databaseType.'.'.METRIC_DATABASE_ID_OPERATIONS_WRITES;
}
/**
@@ -368,8 +407,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);
}
}
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 54557eaac0..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
@@ -82,17 +82,19 @@ class Decrement extends Action
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject'])
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForEvents')
->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, Event $queueForEvents, Context $usage, 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()) {
@@ -170,8 +172,9 @@ class Decrement extends Action
return;
}
+ $dbForDatabases = $getDatabasesDB($database);
try {
- $document = $dbForProject->decreaseDocumentAttribute(
+ $document = $dbForDatabases->decreaseDocumentAttribute(
collection: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(),
id: $documentId,
attribute: $attribute,
@@ -201,8 +204,10 @@ class Decrement extends Action
);
$usage
- ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, 1)
- ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), 1);
+ ->addMetric($this->getDatabasesOperationWriteMetric(), 1)
+ ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1);
+
+ $response->dynamic($document, $this->getResponseModel());
$queueForEvents
->setParam('databaseId', $databaseId)
@@ -213,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 b9c19b2d06..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
@@ -82,17 +82,19 @@ class Increment extends Action
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject'])
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForEvents')
->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, Event $queueForEvents, Context $usage, 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()) {
@@ -170,8 +172,9 @@ class Increment extends Action
return;
}
+ $dbForDatabases = $getDatabasesDB($database);
try {
- $document = $dbForProject->increaseDocumentAttribute(
+ $document = $dbForDatabases->increaseDocumentAttribute(
collection: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(),
id: $documentId,
attribute: $attribute,
@@ -201,8 +204,10 @@ class Increment extends Action
);
$usage
- ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, 1)
- ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), 1);
+ ->addMetric($this->getDatabasesOperationWriteMetric(), 1)
+ ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1);
+
+ $response->dynamic($document, $this->getResponseModel());
$queueForEvents
->setParam('databaseId', $databaseId)
@@ -213,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 f45b126f16..267a54adb0 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
@@ -76,6 +76,7 @@ class Delete extends Action
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject'])
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
@@ -86,7 +87,7 @@ class Delete extends Action
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Context $usage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void
+ public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void
{
$database = $dbForProject->getDocument('databases', $databaseId);
if ($database->isEmpty()) {
@@ -163,10 +164,11 @@ class Delete extends Action
return;
}
+ $dbForDatabases = $getDatabasesDB($database);
$documents = [];
try {
- $modified = $dbForProject->deleteDocuments(
+ $modified = $dbForDatabases->deleteDocuments(
'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(),
$queries,
onNext: function (Document $document) use ($plan, &$documents) {
@@ -189,12 +191,12 @@ class Delete extends Action
}
$usage
- ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $modified))
- ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $modified));
+ ->addMetric($this->getDatabasesOperationWriteMetric(), \max(1, $modified))
+ ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $modified));
$response->dynamic(new Document([
'total' => $modified,
- $this->getSDKGroup() => $documents,
+ $this->getSDKGroup() => $documents
]), $this->getResponseModel());
$this->triggerBulk(
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 000b59ff07..da3adf1192 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
@@ -80,6 +80,7 @@ class Update extends Action
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject'])
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
@@ -90,7 +91,7 @@ class Update extends Action
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string|array $data, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Context $usage, 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, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void
{
$data = \is_string($data)
? \json_decode($data, true)
@@ -189,11 +190,12 @@ class Update extends Action
return;
}
+ $dbForDatabases = $getDatabasesDB($database);
$documents = [];
try {
- $modified = $dbForProject->withPreserveDates(function () use ($plan, &$documents, $dbForProject, $database, $collection, $data, $queries) {
- return $dbForProject->updateDocuments(
+ $modified = $dbForDatabases->withPreserveDates(function () use ($plan, &$documents, $dbForDatabases, $database, $collection, $data, $queries) {
+ return $dbForDatabases->updateDocuments(
'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(),
new Document($data),
$queries,
@@ -220,8 +222,8 @@ class Update extends Action
}
$usage
- ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $modified))
- ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $modified));
+ ->addMetric($this->getDatabasesOperationWriteMetric(), \max(1, $modified))
+ ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $modified));
$response->dynamic(new Document([
'total' => $modified,
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php
index 564b5ee7b6..5a5ebf48ee 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
@@ -78,6 +78,7 @@ class Upsert extends Action
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject'])
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
@@ -88,7 +89,7 @@ class Upsert extends Action
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Context $usage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void
+ public function action(string $databaseId, string $collectionId, array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void
{
$database = $dbForProject->getDocument('databases', $databaseId);
if ($database->isEmpty()) {
@@ -165,11 +166,12 @@ class Upsert extends Action
return;
}
+ $dbForDatabases = $getDatabasesDB($database);
$upserted = [];
try {
- $modified = $dbForProject->withPreserveDates(function () use ($dbForProject, $database, $collection, $documents, $plan, &$upserted) {
- return $dbForProject->upsertDocuments(
+ $modified = $dbForDatabases->withPreserveDates(function () use ($dbForDatabases, $database, $collection, $documents, $plan, &$upserted) {
+ return $dbForDatabases->upsertDocuments(
'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(),
$documents,
onNext: function (Document $document) use ($plan, &$upserted) {
@@ -195,8 +197,8 @@ class Upsert extends Action
}
$usage
- ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $modified))
- ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $modified));
+ ->addMetric($this->getDatabasesOperationWriteMetric(), \max(1, $modified))
+ ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $modified));
$response->dynamic(new Document([
'total' => $modified,
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php
index 0bbe7c75cf..633a2bbc86 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
@@ -50,6 +50,11 @@ class Create extends Action
return UtopiaResponse::MODEL_DOCUMENT_LIST;
}
+ protected function getSupportForEmptyDocument()
+ {
+ return false;
+ }
+
public function __construct()
{
$this
@@ -85,7 +90,7 @@ class Create extends Action
new Parameter('documentId', optional: false),
new Parameter('data', optional: false),
new Parameter('permissions', optional: true),
- new Parameter('transactionId', optional: true),
+ new Parameter('transactionId', optional: true)
],
deprecated: new Deprecated(
since: '1.8.0',
@@ -110,7 +115,7 @@ class Create extends Action
new Parameter('databaseId', optional: false),
new Parameter('collectionId', optional: false),
new Parameter('documents', optional: false),
- new Parameter('transactionId', optional: true),
+ new Parameter('transactionId', optional: true)
],
deprecated: new Deprecated(
since: '1.8.0',
@@ -127,6 +132,7 @@ class Create extends Action
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject'])
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('user')
->inject('queueForEvents')
->inject('usage')
@@ -138,30 +144,42 @@ class Create extends Action
->inject('eventProcessor')
->callback($this->action(...));
}
- public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Document $user, Event $queueForEvents, Context $usage, 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, Event $queueForFunctions, 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(
@@ -169,21 +187,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);
@@ -208,7 +226,7 @@ class Create extends Action
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,
@@ -275,16 +293,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
@@ -447,11 +455,12 @@ class Create extends Action
return;
}
+ $dbForDatabases = $getDatabasesDB($database);
try {
$created = [];
- $dbForProject->withPreserveDates(
- function () use (&$created, $dbForProject, $database, $collection, $documents) {
- $dbForProject->createDocuments(
+ $dbForDatabases->withPreserveDates(
+ function () use (&$created, $dbForDatabases, $database, $collection, $documents) {
+ $dbForDatabases->createDocuments(
'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(),
$documents,
onNext: function ($doc) use (&$created) {
@@ -490,15 +499,15 @@ class Create extends Action
}
$usage
- ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $operations))
- ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $operations)); // per collection
+ ->addMetric($this->getDatabasesOperationWriteMetric(), \max(1, $operations))
+ ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $operations)); // per collection
$response->setStatusCode(SwooleResponse::STATUS_CODE_CREATED);
if ($isBulk) {
$response->dynamic(new Document([
'total' => count($created),
- $this->getSdkGroup() => $created
+ $this->getSDKGroup() => $created
]), $this->getBulkResponseModel());
$this->triggerBulk(
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 0996fa24ab..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
@@ -79,11 +79,13 @@ class Delete extends Action
->inject('requestTimestamp')
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForEvents')
->inject('usage')
->inject('transactionState')
->inject('plan')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
@@ -95,16 +97,18 @@ class Delete extends Action
?\DateTime $requestTimestamp,
UtopiaResponse $response,
Database $dbForProject,
+ callable $getDatabasesDB,
Event $queueForEvents,
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]);
@@ -116,14 +120,15 @@ class Delete extends Action
throw new Exception($this->getParentNotFoundException(), params: [$collectionId]);
}
+ $dbForDatabases = $getDatabasesDB($database);
// Read permission should not be required for delete
$collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence();
if ($transactionId !== null) {
// Use transaction-aware document retrieval to see changes from same transaction
- $document = $transactionState->getDocument($collectionTableId, $documentId, $transactionId);
+ $document = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId);
} else {
- $document = $authorization->skip(fn () => $dbForProject->getDocument($collectionTableId, $documentId));
+ $document = $authorization->skip(fn () => $dbForDatabases->getDocument($collectionTableId, $documentId));
}
if ($document->isEmpty()) {
@@ -187,8 +192,8 @@ class Delete extends Action
}
try {
- $dbForProject->withRequestTimestamp($requestTimestamp, function () use ($dbForProject, $database, $collection, $documentId) {
- $dbForProject->deleteDocument(
+ $dbForDatabases->withRequestTimestamp($requestTimestamp, function () use ($dbForDatabases, $database, $collection, $documentId) {
+ $dbForDatabases->deleteDocument(
'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(),
$documentId
);
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 10de481072..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
@@ -68,16 +68,18 @@ class Get extends Action
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject'])
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->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, Context $usage, 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)) {
@@ -86,6 +88,7 @@ class Get extends Action
$collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId));
+ $dbForDatabases = $getDatabasesDB($database);
if ($collection->isEmpty() || (!$collection->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
throw new Exception($this->getParentNotFoundException(), params: [$collectionId]);
}
@@ -97,16 +100,19 @@ 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();
// Use transaction-aware document retrieval if transactionId is provided
if ($transactionId !== null) {
- $document = $transactionState->getDocument($collectionTableId, $documentId, $transactionId, $queries);
- } elseif (!empty($selects)) {
- $document = $dbForProject->getDocument($collectionTableId, $documentId, $queries);
+ $document = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId, $queries);
+ } elseif (! empty($selects)) {
+ // has selects, allow relationship on documents!
+ $document = $dbForDatabases->getDocument($collectionTableId, $documentId, $queries);
} else {
- $document = $dbForProject->skipRelationships(fn () => $dbForProject->getDocument($collectionTableId, $documentId, $queries));
+ // has no selects, disable relationship looping on documents!
+ $document = $dbForDatabases->skipRelationships(fn () => $dbForDatabases->getDocument($collectionTableId, $documentId, $queries));
}
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
@@ -129,8 +135,8 @@ class Get extends Action
);
$usage
- ->addMetric(METRIC_DATABASES_OPERATIONS_READS, max($operations, 1))
- ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_READS), $operations);
+ ->addMetric($this->getDatabasesOperationReadMetric(), max($operations, 1))
+ ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), $operations);
$response->addHeader('X-Debug-Operations', $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 2e838329cb..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
@@ -70,6 +70,7 @@ class XList extends Action
->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')
@@ -77,7 +78,7 @@ class XList extends Action
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $documentId, array $queries, UtopiaResponse $response, Database $dbForProject, Locale $locale, Reader $geodb, Authorization $authorization, Audit $audit): void
+ public function action(string $databaseId, string $collectionId, string $documentId, array $queries, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Locale $locale, Reader $geodb, Authorization $authorization, Audit $audit): void
{
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
if ($database->isEmpty()) {
@@ -89,7 +90,8 @@ class XList extends Action
throw new Exception($this->getParentNotFoundException(), params: [$collectionId]);
}
- $document = $dbForProject->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId);
+ $dbForDatabases = $getDatabasesDB($database);
+ $document = $dbForDatabases->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId);
if ($document->isEmpty()) {
throw new Exception($this->getNotFoundException(), params: [$documentId]);
}
@@ -129,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 ca7935dfbd..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
@@ -83,15 +83,17 @@ class Update extends Action
->inject('requestTimestamp')
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForEvents')
->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, Event $queueForEvents, Context $usage, 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
@@ -101,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]);
@@ -118,15 +120,15 @@ class Update extends Action
$data = $this->parseOperators($data, $collection);
}
+ $dbForDatabases = $getDatabasesDB($database);
// Read permission should not be required for update
- /** @var Document $document */
$collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence();
if ($transactionId !== null) {
// Use transaction-aware document retrieval to see changes from same transaction
- $document = $transactionState->getDocument($collectionTableId, $documentId, $transactionId);
+ $document = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId);
} else {
- $document = $authorization->skip(fn () => $dbForProject->getDocument($collectionTableId, $documentId));
+ $document = $authorization->skip(fn () => $dbForDatabases->getDocument($collectionTableId, $documentId));
}
if ($document->isEmpty()) {
@@ -247,8 +249,8 @@ class Update extends Action
$setCollection($collection, $newDocument);
$usage
- ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, max($operations, 1))
- ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), $operations);
+ ->addMetric($this->getDatabasesOperationWriteMetric(), max($operations, 1))
+ ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), $operations);
// Handle transaction staging
if ($transactionId !== null) {
@@ -319,9 +321,9 @@ class Update extends Action
try {
- $document = $dbForProject->withRequestTimestamp(
+ $document = $dbForDatabases->withRequestTimestamp(
$requestTimestamp,
- fn () => $dbForProject->withPreserveDates(fn () => $dbForProject->updateDocument(
+ fn () => $dbForDatabases->withPreserveDates(fn () => $dbForDatabases->updateDocument(
'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(),
$document->getId(),
$newDocument
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 dc6655dfd3..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
@@ -87,6 +87,7 @@ class Upsert extends Action
->inject('response')
->inject('user')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForEvents')
->inject('usage')
->inject('transactionState')
@@ -95,7 +96,7 @@ class Upsert extends Action
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Document $user, Database $dbForProject, Event $queueForEvents, Context $usage, 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
@@ -107,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)) {
@@ -124,6 +125,7 @@ class Upsert extends Action
$data = $this->parseOperators($data, $collection);
}
+ $dbForDatabases = $getDatabasesDB($database);
$allowedPermissions = [
Database::PERMISSION_READ,
Database::PERMISSION_UPDATE,
@@ -134,13 +136,15 @@ class Upsert extends Action
$collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence();
+ $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence();
+
// If no permission, upsert permission from the old document if present (update scenario) else add default permission (create scenario)
if (\is_null($permissions)) {
if ($transactionId !== null) {
// Use transaction-aware document retrieval to see changes from same transaction
- $oldDocument = $transactionState->getDocument($collectionTableId, $documentId, $transactionId);
+ $oldDocument = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId);
} else {
- $oldDocument = $authorization->skip(fn () => $dbForProject->getDocument($collectionTableId, $documentId));
+ $oldDocument = $authorization->skip(fn () => $dbForDatabases->getDocument($collectionTableId, $documentId));
}
if ($oldDocument->isEmpty()) {
if (!empty($user->getId())) {
@@ -182,7 +186,7 @@ class Upsert extends Action
$newDocument = new Document($data);
$operations = 0;
- $setCollection = (function (Document $collection, Document $document) use ($isAPIKey, $isPrivilegedUser, &$setCollection, $dbForProject, $database, &$operations, $authorization) {
+ $setCollection = (function (Document $collection, Document $document) use ($isAPIKey, $isPrivilegedUser, &$setCollection, $dbForProject, $dbForDatabases, $database, &$operations, $authorization) {
$operations++;
$relationships = \array_filter(
@@ -226,7 +230,7 @@ class Upsert extends Action
if ($relation instanceof Document) {
$relation = $this->removeReadonlyAttributes($relation, $isAPIKey || $isPrivilegedUser);
- $oldDocument = $authorization->skip(fn () => $dbForProject->getDocument(
+ $oldDocument = $authorization->skip(fn () => $dbForDatabases->getDocument(
'database_' . $database->getSequence() . '_collection_' . $relatedCollection->getSequence(),
$relation->getId()
));
@@ -257,8 +261,8 @@ class Upsert extends Action
$setCollection($collection, $newDocument);
$usage
- ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $operations))
- ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $operations));
+ ->addMetric($this->getDatabasesOperationWriteMetric(), \max(1, $operations))
+ ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $operations));
// Handle transaction staging
if ($transactionId !== null) {
@@ -327,8 +331,8 @@ class Upsert extends Action
$upserted = [];
try {
- $dbForProject->withPreserveDates(function () use (&$upserted, $dbForProject, $database, $collection, $newDocument) {
- return $dbForProject->upsertDocuments(
+ $dbForDatabases->withPreserveDates(function () use (&$upserted, $dbForDatabases, $database, $collection, $newDocument) {
+ return $dbForDatabases->upsertDocuments(
'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(),
[$newDocument],
onNext: function (Document $document) use (&$upserted) {
@@ -349,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($collectionTableId, $documentId, $transactionId);
- } else {
- $upserted[0] = $dbForProject->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 a7d77d8a93..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
@@ -22,6 +22,7 @@ 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;
@@ -72,20 +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('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, Context $usage, 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)) {
@@ -103,6 +106,7 @@ class XList extends Action
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
+ $dbForDatabases = $getDatabasesDB($database);
$cursor = Query::getCursorQueries($queries, false);
$cursor = \reset($cursor);
@@ -114,7 +118,7 @@ class XList extends Action
$documentId = $cursor->getValue();
- $cursorDocument = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId));
+ $cursorDocument = $authorization->skip(fn () => $dbForDatabases->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId));
if ($cursorDocument->isEmpty()) {
$type = ucfirst($this->getContext());
@@ -124,87 +128,76 @@ 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($collectionTableId, $transactionId, $queries);
- $total = $includeTotal ? $transactionState->countDocuments($collectionTableId, $transactionId, $queries) : 0;
- } elseif (! empty($selectQueries)) {
+ $documents = $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries);
+ $total = $includeTotal ? $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries) : 0;
+ } 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 = $dbForProject->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 = $dbForProject->find($collectionTableId, $queries);
- $total = $includeTotal ? $dbForProject->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 = $dbForProject->skipRelationships(fn () => $dbForProject->find($collectionTableId, $queries));
- $total = $includeTotal ? $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0;
+ $documents = $find();
+ $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0;
}
} catch (OrderException $e) {
$documents = $this->isCollectionsAPI() ? 'documents' : 'rows';
@@ -217,6 +210,8 @@ class XList extends Action
throw new Exception(Exception::DATABASE_TIMEOUT);
}
+ $dbDurationMs = (\microtime(true) - $dbStart) * 1000;
+
$operations = 0;
$collectionsCache = [];
foreach ($documents as $document) {
@@ -232,13 +227,28 @@ class XList extends Action
}
$usage
- ->addMetric(METRIC_DATABASES_OPERATIONS_READS, max($operations, 1))
- ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_READS), $operations);
+ ->addMetric($this->getDatabasesOperationReadMetric(), max($operations, 1))
+ ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), $operations);
$response->dynamic(new Document([
'total' => $total,
// 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/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php
index fd785f3609..7e073c95d4 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php
@@ -77,13 +77,14 @@ class Create extends Action
->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)
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForDatabase')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, string $key, string $type, array $attributes, array $orders, array $lengths, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
+ public function action(string $databaseId, string $collectionId, string $key, string $type, array $attributes, array $orders, array $lengths, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
{
$db = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
@@ -103,7 +104,9 @@ class Create extends Action
Query::equal('databaseInternalId', [$db->getSequence()])
], 61);
- $limit = $dbForProject->getLimitForIndexes();
+ $dbForDatabases = $getDatabasesDB($db);
+
+ $limit = $dbForDatabases->getLimitForIndexes();
if ($count >= $limit) {
throw new Exception($this->getLimitException(), params: [$collectionId]);
@@ -145,32 +148,35 @@ class Create extends Action
];
$contextType = $this->getParentContext();
- foreach ($attributes as $i => $attribute) {
- $attributeIndex = \array_search($attribute, array_column($oldAttributes, 'key'));
+ if ($dbForDatabases->getAdapter()->getSupportForAttributes()) {
+ foreach ($attributes as $i => $attribute) {
+ // find attribute metadata in collection document
+ $attributeIndex = \array_search($attribute, array_column($oldAttributes, 'key'));
- if ($attributeIndex === false) {
- throw new Exception($this->getParentUnknownException(), params: [$attribute]);
- }
+ if ($attributeIndex === false) {
+ throw new Exception($this->getParentUnknownException(), params: [$attribute]);
+ }
- $attributeStatus = $oldAttributes[$attributeIndex]['status'];
- $attributeType = $oldAttributes[$attributeIndex]['type'];
- $attributeArray = $oldAttributes[$attributeIndex]['array'] ?? false;
+ $attributeStatus = $oldAttributes[$attributeIndex]['status'];
+ $attributeType = $oldAttributes[$attributeIndex]['type'];
+ $attributeArray = $oldAttributes[$attributeIndex]['array'] ?? false;
- if ($attributeType === Database::VAR_RELATIONSHIP) {
- throw new Exception($this->getParentInvalidTypeException(), "Cannot create an index for a relationship $contextType: " . $oldAttributes[$attributeIndex]['key']);
- }
+ if ($attributeType === Database::VAR_RELATIONSHIP) {
+ throw new Exception($this->getParentInvalidTypeException(), "Cannot create an index for a relationship $contextType: " . $oldAttributes[$attributeIndex]['key']);
+ }
- if ($attributeStatus !== 'available') {
- throw new Exception($this->getParentNotAvailableException(), params: [$oldAttributes[$attributeIndex]['key']]);
- }
+ if ($attributeStatus !== 'available') {
+ throw new Exception($this->getParentNotAvailableException(), params: [$oldAttributes[$attributeIndex]['key']]);
+ }
- if (empty($lengths[$i])) {
- $lengths[$i] = null;
- }
+ if (empty($lengths[$i])) {
+ $lengths[$i] = null;
+ }
- if ($attributeArray === true) {
- // Because of a bug in MySQL, we cannot create indexes on array attributes for now, otherwise queries break.
- throw new Exception(Exception::INDEX_INVALID, 'Creating indexes on array attributes is not currently supported.');
+ if ($attributeArray === true) {
+ // Because of a bug in MySQL, we cannot create indexes on array attributes for now, otherwise queries break.
+ throw new Exception(Exception::INDEX_INVALID, 'Creating indexes on array attributes is not currently supported.');
+ }
}
}
@@ -191,21 +197,23 @@ class Create extends Action
$validator = new IndexValidator(
$collection->getAttribute('attributes'),
$collection->getAttribute('indexes'),
- $dbForProject->getAdapter()->getMaxIndexLength(),
- $dbForProject->getAdapter()->getInternalIndexesKeys(),
- $dbForProject->getAdapter()->getSupportForIndexArray(),
- $dbForProject->getAdapter()->getSupportForSpatialIndexNull(),
- $dbForProject->getAdapter()->getSupportForSpatialIndexOrder(),
- $dbForProject->getAdapter()->getSupportForVectors(),
- $dbForProject->getAdapter()->getSupportForAttributes(),
- $dbForProject->getAdapter()->getSupportForMultipleFulltextIndexes(),
- $dbForProject->getAdapter()->getSupportForIdenticalIndexes(),
- $dbForProject->getAdapter()->getSupportForObjectIndexes(),
- $dbForProject->getAdapter()->getSupportForTrigramIndex(),
- $dbForProject->getAdapter()->getSupportForSpatialAttributes(),
- $dbForProject->getAdapter()->getSupportForIndex(),
- $dbForProject->getAdapter()->getSupportForUniqueIndex(),
- $dbForProject->getAdapter()->getSupportForFulltextIndex(),
+ $dbForDatabases->getAdapter()->getMaxIndexLength(),
+ $dbForDatabases->getAdapter()->getInternalIndexesKeys(),
+ $dbForDatabases->getAdapter()->getSupportForIndexArray(),
+ $dbForDatabases->getAdapter()->getSupportForSpatialIndexNull(),
+ $dbForDatabases->getAdapter()->getSupportForSpatialIndexOrder(),
+ $dbForDatabases->getAdapter()->getSupportForVectors(),
+ $dbForDatabases->getAdapter()->getSupportForAttributes(),
+ $dbForDatabases->getAdapter()->getSupportForMultipleFulltextIndexes(),
+ $dbForDatabases->getAdapter()->getSupportForIdenticalIndexes(),
+ $dbForDatabases->getAdapter()->getSupportForObjectIndexes(),
+ $dbForDatabases->getAdapter()->getSupportForTrigramIndex(),
+ $dbForDatabases->getAdapter()->getSupportForSpatialAttributes(),
+ $dbForDatabases->getAdapter()->getSupportForIndex(),
+ $dbForDatabases->getAdapter()->getSupportForUniqueIndex(),
+ $dbForDatabases->getAdapter()->getSupportForFulltextIndex(),
+ $dbForDatabases->getAdapter()->getSupportForTTLIndexes(),
+ $dbForDatabases->getAdapter()->getSupportForObject()
);
if (!$validator->isValid($index)) {
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 f34fd82997..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,14 +68,16 @@ 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')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
}
- public function action(string $databaseId, string $collectionId, ?string $name, ?array $permissions, bool $documentSecurity, bool $enabled, UtopiaResponse $response, Database $dbForProject, 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()) {
@@ -98,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,
@@ -110,13 +110,18 @@ class Update extends Action
->setAttribute('search', \implode(' ', [$collectionId, $searchName]))
);
- $dbForProject->updateCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $permissions, $documentSecurity);
+ $dbForDatabases = $getDatabasesDB($database);
+ $dbForDatabases->updateCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $permissions, $documentSecurity);
$queueForEvents
->setContext('database', $database)
->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 de20d058c4..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
@@ -31,6 +31,11 @@ class Get extends Action
return UtopiaResponse::MODEL_USAGE_COLLECTION;
}
+ protected function getMetric(): string
+ {
+ return METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS;
+ }
+
public function __construct()
{
$this
@@ -64,14 +69,16 @@ class Get extends Action
->inject('response')
->inject('dbForProject')
->inject('authorization')
+ ->inject('getDatabasesDB')
->callback($this->action(...));
}
- public function action(string $databaseId, string $range, string $collectionId, UtopiaResponse $response, Database $dbForProject, Authorization $authorization): void
+ public function action(string $databaseId, string $range, string $collectionId, UtopiaResponse $response, Database $dbForProject, Authorization $authorization, callable $getDatabasesDB): void
{
$database = $dbForProject->getDocument('databases', $databaseId);
$collectionDocument = $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId);
- $collection = $dbForProject->getCollection('database_' . $database->getSequence() . '_collection_' . $collectionDocument->getSequence());
+ $dbForDatabases = $getDatabasesDB($database);
+ $collection = $dbForDatabases->getCollection('database_' . $database->getSequence() . '_collection_' . $collectionDocument->getSequence());
if ($collection->isEmpty()) {
throw new Exception($this->getNotFoundException(), params: [$collectionId]);
@@ -81,7 +88,7 @@ class Get extends Action
$stats = $usage = [];
$days = $periods[$range];
$metrics = [
- str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$database->getSequence(), $collectionDocument->getSequence()], METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS),
+ str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$database->getSequence(), $collectionDocument->getSequence()], $this->getMetric()),
];
$authorization->skip(function () use ($dbForProject, $days, $metrics, &$stats) {
@@ -112,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 c2786b9f26..294a6712a9 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php
@@ -19,7 +19,9 @@ use Utopia\Database\Exception\Index as IndexException;
use Utopia\Database\Exception\Limit as LimitException;
use Utopia\Database\Exception\Structure as StructureException;
use Utopia\Database\Helpers\ID;
+use Utopia\DSN\DSN;
use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
+use Utopia\System\System;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
@@ -30,6 +32,109 @@ class Create extends Action
return 'createDatabase';
}
+ protected function getDatabaseDSN(Document $project): string
+ {
+ // TODO: use database worker for for creating the v2 schema if not present
+ // it is considered that the v2 metadata schema is already created during server start in the http.php
+ return $this->constructDatabaseDSNFromProjectDatabase($this->getDatabaseType(), $project->getAttribute('region'), $project->getAttribute('database'));
+ }
+
+ private function constructDatabaseDSNFromProjectDatabase(string $databasetype, $region, ?string $dsn = null): string
+ {
+ $databases = [];
+ $databaseKeys = [];
+ /**
+ * @var string|null $databaseOverride
+ */
+ $databaseOverride = '';
+ $dbScheme = '';
+ $databaseSharedTables = [];
+
+ switch ($databasetype) {
+ case DOCUMENTSDB:
+ $databases = Config::getParam('pools-documentsdb', []);
+ $databaseKeys = System::getEnv('_APP_DATABASE_DOCUMENTSDB_KEYS', '');
+ $databaseOverride = System::getEnv('_APP_DATABASE_DOCUMENTSDB_OVERRIDE');
+ $dbScheme = System::getEnv('_APP_DB_HOST_DOCUMENTSDB', 'mongodb');
+ $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 = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', '')));
+ break;
+ default:
+ // legacy/tablesdb
+ // it is already created during create project
+ return $dsn;
+ }
+
+ $isSharedTables = false;
+
+ if (!empty($dsn)) {
+ try {
+ $parsedDsn = new DSN($dsn);
+ $dsnHost = $parsedDsn->getHost();
+ } catch (\InvalidArgumentException) {
+ $dsnHost = $dsn;
+ }
+
+ $projectSharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
+ $isSharedTables = \in_array($dsnHost, $projectSharedTables);
+ }
+
+ if ($region !== 'default') {
+ $keys = explode(',', $databaseKeys);
+ $databases = array_filter($keys, function ($value) use ($region) {
+ return str_contains($value, $region);
+ });
+ }
+
+ $index = \array_search($databaseOverride, $databases);
+ if ($index !== false) {
+ $selectedDsn = $databases[$index];
+ } else {
+ 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));
+ }
+ }
+ 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)) {
+ $schema = 'appwrite';
+ $database = 'appwrite';
+ $namespace = System::getEnv('_APP_DATABASE_SHARED_NAMESPACE', '');
+ $selectedDsn = $schema . '://' . $selectedDsn . '?database=' . $database;
+
+ if (!empty($namespace)) {
+ $selectedDsn .= '&namespace=' . $namespace;
+ }
+ }
+ try {
+ new DSN($selectedDsn);
+ } catch (\InvalidArgumentException) {
+ $selectedDsn = $dbScheme.'://' . $selectedDsn;
+ }
+
+ return $selectedDsn;
+ }
+
+ protected function getDatabaseCollection()
+ {
+ return match ($this->getDatabaseType()) {
+ 'vectorsdb' => (Config::getParam('collections', [])['vectorsdb'] ?? [])['collections'] ?? [],
+ default => (Config::getParam('collections', [])['databases'] ?? [])['collections'] ?? [],
+ };
+ }
public function __construct()
{
$this
@@ -65,13 +170,15 @@ class Create extends Action
->param('databaseId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), '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('name', '', new Text(128), 'Database name. Max length: 128 chars.')
->param('enabled', true, new Boolean(), 'Is the database enabled? When set to \'disabled\', users cannot access the database but Server SDKs with an API key can still read and write to the database. No data is lost when this is toggled.', true)
+ ->inject('project')
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForEvents')
->callback($this->action(...));
}
- public function action(string $databaseId, string $name, bool $enabled, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents): void
+ public function action(string $databaseId, string $name, bool $enabled, Document $project, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents): void
{
$databaseId = $databaseId == 'unique()' ? ID::unique() : $databaseId;
@@ -82,6 +189,7 @@ class Create extends Action
'enabled' => $enabled,
'search' => implode(' ', [$databaseId, $name]),
'type' => $this->getDatabaseType(),
+ 'database' => $this->getDatabaseDSN($project)
]));
} catch (DuplicateException) {
throw new Exception(Exception::DATABASE_ALREADY_EXISTS, params: [$databaseId]);
@@ -91,7 +199,7 @@ class Create extends Action
$database = $dbForProject->getDocument('databases', $databaseId);
- $collections = (Config::getParam('collections', [])['databases'] ?? [])['collections'] ?? [];
+ $collections = $this->getDatabaseCollection();
if (empty($collections)) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'The "collections" collection is not configured.');
}
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 e2a4491736..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,14 +9,49 @@ abstract class Action extends DatabasesAction
/**
* The current API context (either 'table' or 'collection').
*/
- private ?string $context = COLLECTIONS;
+ private string $context = COLLECTIONS;
+ private string $databaseType = LEGACY;
- public function setHttpPath(string $path): DatabasesAction
+ public function getDatabaseType(): string
{
- if (\str_contains($path, '/tablesdb')) {
- $this->context = TABLES;
+ return $this->databaseType;
+ }
+
+ protected function getDatabasesOperationWriteMetric(): string
+ {
+ if ($this->databaseType === LEGACY || $this->databaseType === TABLESDB) {
+ return METRIC_DATABASES_OPERATIONS_WRITES;
}
- return parent::setHttpPath($path);
+ return $this->databaseType.'.'.METRIC_DATABASES_OPERATIONS_WRITES;
+
+ }
+ protected function getDatabasesIdOperationWriteMetric(): string
+ {
+ if ($this->databaseType === LEGACY || $this->databaseType === TABLESDB) {
+ return METRIC_DATABASE_ID_OPERATIONS_WRITES;
+ }
+ return $this->databaseType.'.'.METRIC_DATABASE_ID_OPERATIONS_WRITES;
+ }
+
+ public function setHttpPath(string $path): self
+ {
+ switch (true) {
+ case str_contains($path, '/tablesdb'):
+ $this->context = TABLES;
+ $this->databaseType = TABLESDB;
+ break;
+
+ case str_contains($path, '/documentsdb'):
+ $this->context = COLLECTIONS;
+ $this->databaseType = DOCUMENTSDB;
+ break;
+ case str_contains($path, '/vectorsdb'):
+ $this->context = COLLECTIONS;
+ $this->databaseType = VECTORSDB;
+ break;
+ }
+ 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 eebb3a77d5..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)
@@ -148,7 +149,7 @@ class Create extends Action
$collectionKey = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence();
$isDependant = isset($dependants[$collectionKey][$documentId]);
- $document = $transactionState->getDocument($collectionKey, $documentId, $transactionId);
+ $document = $transactionState->getDocument($database, $collectionKey, $documentId, $transactionId);
if ($document->isEmpty() && !$isDependant && $operation['action'] !== 'upsert') {
throw new Exception(Exception::DOCUMENT_NOT_FOUND, params: [$documentId]);
}
@@ -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 9a5a63ea91..c4d51e6c64 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php
@@ -67,8 +67,10 @@ class Update extends Action
->param('transactionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Transaction ID.', false, ['dbForProject'])
->param('commit', false, new Boolean(), 'Commit transaction?', true)
->param('rollback', false, new Boolean(), 'Rollback transaction?', true)
+ ->inject('project')
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('user')
->inject('transactionState')
->inject('queueForDeletes')
@@ -88,7 +90,8 @@ class Update extends Action
* @param bool $rollback
* @param UtopiaResponse $response
* @param Database $dbForProject
- * @param Document $user
+ * @param callable $getDatabasesDB
+ * @param User $user
* @param TransactionState $transactionState
* @param Delete $queueForDeletes
* @param Event $queueForEvents
@@ -102,11 +105,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, UtopiaResponse $response, Database $dbForProject, Document $user, TransactionState $transactionState, Delete $queueForDeletes, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, Authorization $authorization, EventProcessor $eventProcessor): void
+ 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, Event $queueForFunctions, Event $queueForWebhooks, Authorization $authorization, EventProcessor $eventProcessor): void
{
if (!$commit && !$rollback) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Either commit or rollback must be true');
@@ -115,8 +117,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))
@@ -135,26 +137,78 @@ class Update extends Action
}
if ($commit) {
-
$operations = [];
$totalOperations = 0;
$databaseOperations = [];
$currentDocumentId = null;
- try {
- $dbForProject->withTransaction(function () use ($dbForProject, $transactionState, $queueForDeletes, $transactionId, &$transaction, &$operations, &$totalOperations, &$databaseOperations, &$currentDocumentId, $queueForEvents, $usage, $queueForRealtime, $queueForFunctions, $queueForWebhooks, $authorization) {
- $authorization->skip(fn () => $dbForProject->updateDocument('transactions', $transactionId, new Document([
- 'status' => 'committing',
- ])));
+ $firstOperation = $authorization->skip(fn () => $dbForProject->findOne('transactionLogs', [
+ Query::equal('transactionInternalId', [$transaction->getSequence()]),
+ Query::orderAsc(),
+ ]));
- $operations = $authorization->skip(fn () => $dbForProject->find('transactionLogs', [
- Query::equal('transactionInternalId', [$transaction->getSequence()]),
- Query::orderAsc(),
- Query::limit(PHP_INT_MAX),
+ if ($firstOperation->isEmpty()) {
+ $transaction = $authorization->skip(fn () => $dbForProject->updateDocument(
+ 'transactions',
+ $transactionId,
+ new Document(['status' => 'committed'])
+ ));
+
+ $queueForDeletes
+ ->setType(DELETE_TYPE_DOCUMENT)
+ ->setDocument($transaction);
+
+ $response
+ ->setStatusCode(SwooleResponse::STATUS_CODE_OK)
+ ->dynamic($transaction, $this->getResponseModel());
+
+ return;
+ }
+
+ $databaseDoc = null;
+ switch ($this->getDatabaseType()) {
+ case DATABASE_TYPE_DOCUMENTSDB:
+ case DATABASE_TYPE_VECTORSDB:
+ $databaseDoc = $authorization->skip(fn () => $dbForProject->findOne('databases', [
+ Query::equal('$sequence', [$firstOperation['databaseInternalId']])
]));
+ break;
+ default:
+ // Legacy/tablesdb: use project-level database
+ $databaseDoc = new Document(['database' => $project->getAttribute('database')]);
+ break;
+ }
+ $dbForDatabases = $getDatabasesDB($databaseDoc);
+
+ try {
+ $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),
+ ]));
+
+ $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'];
@@ -170,11 +224,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)) {
@@ -182,7 +231,7 @@ class Update extends Action
}
if ($action === 'delete' && $documentId && empty($data)) {
- $doc = $dbForProject->getDocument($collectionId, $documentId);
+ $doc = $dbForDatabases->getDocument($collectionId, $documentId);
if (!$doc->isEmpty()) {
$operation['data'] = $doc->getArrayCopy();
$data = $operation['data'];
@@ -196,56 +245,57 @@ class Update extends Action
switch ($action) {
case 'create':
- $this->handleCreateOperation($dbForProject, $collectionId, $documentId, $data, $createdAt, $state);
+ $this->handleCreateOperation($dbForDatabases, $collectionId, $documentId, $data, $createdAt, $state);
break;
case 'update':
- $this->handleUpdateOperation($dbForProject, $collectionId, $documentId, $data, $createdAt, $state);
+ $this->handleUpdateOperation($dbForDatabases, $collectionId, $documentId, $data, $createdAt, $state);
break;
case 'upsert':
- $this->handleUpsertOperation($dbForProject, $collectionId, $documentId, $data, $createdAt, $state);
+ $this->handleUpsertOperation($dbForDatabases, $collectionId, $documentId, $data, $createdAt, $state);
break;
case 'delete':
- $this->handleDeleteOperation($dbForProject, $collectionId, $documentId, $createdAt, $state);
+ $this->handleDeleteOperation($dbForDatabases, $collectionId, $documentId, $createdAt, $state);
break;
case 'increment':
- $this->handleIncrementOperation($dbForProject, $collectionId, $documentId, $data, $createdAt, $state);
+ $this->handleIncrementOperation($dbForDatabases, $collectionId, $documentId, $data, $createdAt, $state);
break;
case 'decrement':
- $this->handleDecrementOperation($dbForProject, $collectionId, $documentId, $data, $createdAt, $state);
+ $this->handleDecrementOperation($dbForDatabases, $collectionId, $documentId, $data, $createdAt, $state);
break;
case 'bulkCreate':
- $count = $this->handleBulkCreateOperation($dbForProject, $collectionId, $data, $createdAt, $state);
+ $count = $this->handleBulkCreateOperation($dbForDatabases, $collectionId, $data, $createdAt, $state);
$totalOperations += $count;
$databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count;
break;
case 'bulkUpdate':
- $count = $this->handleBulkUpdateOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state);
+ $count = $this->handleBulkUpdateOperation($dbForDatabases, $transactionState, $collectionId, $data, $createdAt, $state);
$totalOperations += $count;
$databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count;
break;
case 'bulkUpsert':
- $count = $this->handleBulkUpsertOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state);
+ $count = $this->handleBulkUpsertOperation($dbForDatabases, $transactionState, $collectionId, $data, $createdAt, $state);
$totalOperations += $count;
$databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count;
break;
case 'bulkDelete':
- $count = $this->handleBulkDeleteOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state);
+ $count = $this->handleBulkDeleteOperation($dbForDatabases, $transactionState, $collectionId, $data, $createdAt, $state);
$totalOperations += $count;
$databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count;
break;
}
}
- $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',
@@ -279,15 +329,16 @@ class Update extends Action
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
- $usage->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, $totalOperations);
+ $usage->addMetric($this->getDatabasesOperationWriteMetric(), $totalOperations);
foreach ($databaseOperations as $sequence => $count) {
$usage->addMetric(
- str_replace('{databaseInternalId}', $sequence, METRIC_DATABASE_ID_OPERATIONS_WRITES),
+ str_replace('{databaseInternalId}', $sequence, $this->getDatabasesIdOperationWriteMetric()),
$count
);
}
+ $dbCache = [];
foreach ($operations as $operation) {
$databaseInternalId = $operation['databaseInternalId'];
$collectionInternalId = $operation['collectionInternalId'];
@@ -300,6 +351,16 @@ class Update extends Action
$data = $data->getArrayCopy();
}
+ // using a dbCache so only one time database is set with databaseInternalId
+ if (!isset($dbCache[$databaseInternalId])) {
+ $databaseDoc = $authorization->skip(fn () => $dbForProject->findOne('databases', [
+ Query::equal('$sequence', [$databaseInternalId])
+ ]));
+ $dbCache[$databaseInternalId] = $getDatabasesDB($databaseDoc);
+ }
+
+ $dbForDatabases = $dbCache[$databaseInternalId];
+
$database = $authorization->skip(fn () => $dbForProject->findOne('databases', [
Query::equal('$sequence', [$databaseInternalId])
]));
@@ -329,7 +390,7 @@ class Update extends Action
$eventAction = 'create';
$docId = $documentId ?? $data['$id'] ?? null;
if ($docId) {
- $doc = $dbForProject->getDocument($collectionId, $docId);
+ $doc = $dbForDatabases->getDocument($collectionId, $docId);
if (!$doc->isEmpty()) {
$documentsToTrigger[] = $doc;
}
@@ -340,7 +401,7 @@ class Update extends Action
case 'decrement':
$eventAction = 'update';
if ($documentId) {
- $doc = $dbForProject->getDocument($collectionId, $documentId);
+ $doc = $dbForDatabases->getDocument($collectionId, $documentId);
if (!$doc->isEmpty()) {
$documentsToTrigger[] = $doc;
}
@@ -356,7 +417,7 @@ class Update extends Action
$eventAction = 'update';
$docId = $documentId ?? $data['$id'] ?? null;
if ($docId) {
- $doc = $dbForProject->getDocument($collectionId, $docId);
+ $doc = $dbForDatabases->getDocument($collectionId, $docId);
if (!$doc->isEmpty()) {
$documentsToTrigger[] = $doc;
}
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 6f90e77e2b..240e7d400c 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php
@@ -26,6 +26,43 @@ class Get extends Action
return 'getDatabaseUsage';
}
+ protected $databaseType = DATABASE_TYPE_LEGACY;
+
+ public function setHttpPath(string $path): Action
+ {
+ $this->databaseType = match (true) {
+ str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB,
+ str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB,
+ default => DATABASE_TYPE_LEGACY,
+ };
+
+ return parent::setHttpPath($path);
+ }
+
+ protected function getMetrics(): array
+ {
+ $metrics = [
+ METRIC_DATABASE_ID_COLLECTIONS,
+ METRIC_DATABASE_ID_DOCUMENTS,
+ METRIC_DATABASE_ID_STORAGE,
+ METRIC_DATABASE_ID_OPERATIONS_READS,
+ METRIC_DATABASE_ID_OPERATIONS_WRITES
+ ];
+ if ($this->databaseType === DATABASE_TYPE_LEGACY || $this->databaseType === DATABASE_TYPE_TABLESDB) {
+ return $metrics;
+ }
+
+ return array_map(
+ fn ($metric) => "{$this->databaseType}.{$metric}",
+ $metrics
+ );
+ }
+
+ protected function getResponseModel(): string
+ {
+ return UtopiaResponse::MODEL_USAGE_DATABASE;
+ }
+
public function __construct()
{
$this
@@ -74,13 +111,10 @@ class Get extends Action
$periods = Config::getParam('usage', []);
$stats = $usage = [];
$days = $periods[$range];
- $metrics = [
- str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_COLLECTIONS),
- str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_DOCUMENTS),
- str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_STORAGE),
- str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_READS),
- str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES)
- ];
+ $metrics = array_map(
+ fn ($metric) => str_replace('{databaseInternalId}', $database->getSequence(), $metric),
+ $this->getMetrics()
+ );
$authorization->skip(function () use ($dbForProject, $days, $metrics, &$stats) {
foreach ($metrics as $metric) {
@@ -110,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) {
@@ -142,6 +177,6 @@ class Get extends Action
'storage' => $usage[$metrics[2]]['data'],
'databaseReads' => $usage[$metrics[3]]['data'],
'databaseWrites' => $usage[$metrics[4]]['data'],
- ]), UtopiaResponse::MODEL_USAGE_DATABASE);
+ ]), $this->getResponseModel());
}
}
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 db5ad21358..db73954e7f 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php
@@ -24,6 +24,43 @@ class XList extends Action
return 'listDatabaseUsage';
}
+ protected $databaseType = DATABASE_TYPE_LEGACY;
+
+ public function setHttpPath(string $path): Action
+ {
+ $this->databaseType = match (true) {
+ str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB,
+ str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB,
+ default => DATABASE_TYPE_LEGACY,
+ };
+
+ return parent::setHttpPath($path);
+ }
+
+ protected function getMetrics(): array
+ {
+ $metrics = [
+ METRIC_DATABASES,
+ METRIC_COLLECTIONS,
+ METRIC_DOCUMENTS,
+ METRIC_DATABASES_STORAGE,
+ METRIC_DATABASES_OPERATIONS_READS,
+ METRIC_DATABASES_OPERATIONS_WRITES,
+ ];
+ if ($this->databaseType === DATABASE_TYPE_LEGACY || $this->databaseType === DATABASE_TYPE_TABLESDB) {
+ return $metrics;
+ }
+ return array_map(
+ fn ($metric) => "{$this->databaseType}.{$metric}",
+ $metrics
+ );
+ }
+
+ protected function getResponseModel(): string
+ {
+ return UtopiaResponse::MODEL_USAGE_DATABASES;
+ }
+
public function __construct()
{
$this
@@ -66,14 +103,7 @@ class XList extends Action
$periods = Config::getParam('usage', []);
$stats = $usage = [];
$days = $periods[$range];
- $metrics = [
- METRIC_DATABASES,
- METRIC_COLLECTIONS,
- METRIC_DOCUMENTS,
- METRIC_DATABASES_STORAGE,
- METRIC_DATABASES_OPERATIONS_READS,
- METRIC_DATABASES_OPERATIONS_WRITES,
- ];
+ $metrics = $this->getMetrics();
$authorization->skip(function () use ($dbForProject, $days, $metrics, &$stats) {
foreach ($metrics as $metric) {
@@ -103,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) {
@@ -136,6 +167,6 @@ class XList extends Action
'storage' => $usage[$metrics[3]]['data'],
'databasesReads' => $usage[$metrics[4]]['data'],
'databasesWrites' => $usage[$metrics[5]]['data'],
- ]), UtopiaResponse::MODEL_USAGE_DATABASES);
+ ]), $this->getResponseModel());
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/XList.php
index 8627fa49c5..21dbc83edc 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/XList.php
@@ -17,7 +17,6 @@ use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
-use Utopia\Platform\Action;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
@@ -28,6 +27,11 @@ class XList extends Action
return 'listDatabases';
}
+ protected function getDatabaseTypeQueryFilters(): array
+ {
+ return [Query::equal('type', [$this->getDatabaseType()])];
+ }
+
public function __construct()
{
$this
@@ -93,6 +97,7 @@ class XList extends Action
}
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/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Create.php
new file mode 100644
index 0000000000..d1e91addf7
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Create.php
@@ -0,0 +1,75 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections')
+ ->desc('Create collection')
+ ->groups(['api', 'database'])
+ ->label('event', 'databases.[databaseId].collections.[collectionId].create')
+ ->label('scope', 'collections.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'collections.create')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: 'collections',
+ name: 'createCollection',
+ description: '/docs/references/documentsdb/create-collection.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_CREATED,
+ 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 CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), '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('name', '', new Text(128), 'Collection name. Max length: 128 chars.')
+ ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permissions strings. By default, no user is granted with any permissions. [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('attributes', [], new ArrayList(new JSON(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of attribute definitions to create. Each attribute should contain: key (string), type (string: string, integer, float, boolean, datetime, relationship), size (integer, required for string type), required (boolean, optional), default (mixed, optional), array (boolean, optional), and type-specific options.', true)
+ ->param('indexes', [], new ArrayList(new JSON(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of index definitions to create. Each index should contain: key (string), type (string: key, fulltext, unique, spatial), attributes (array of attribute keys), orders (array of ASC/DESC, optional), and lengths (array of integers, optional).', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('queueForEvents')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Delete.php
new file mode 100644
index 0000000000..d698b40203
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Delete.php
@@ -0,0 +1,62 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId')
+ ->desc('Delete collection')
+ ->groups(['api', 'database', 'schema'])
+ ->label('scope', 'collections.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].collections.[collectionId].delete')
+ ->label('audits.event', 'collection.delete')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: 'collections',
+ name: 'deleteCollection',
+ description: '/docs/references/documentsdb/delete-collection.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_NOCONTENT,
+ model: UtopiaResponse::MODEL_NONE,
+ )
+ ],
+ contentType: ContentType::NONE
+ ))
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->param('collectionId', '', new UID(), 'Collection ID.')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('queueForDatabase')
+ ->inject('queueForEvents')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
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
new file mode 100644
index 0000000000..6d986fc6b1
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Decrement.php
@@ -0,0 +1,74 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId/:attribute/decrement')
+ ->desc('Decrement document attribute')
+ ->groups(['api', 'database'])
+ ->label('event', 'documentsdb.[databaseId].collections.[collectionId].documents.[documentId].update')
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'documents.update')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: $this->getSdkGroup(),
+ name: 'decrementDocumentAttribute',
+ description: '/docs/references/documentsdb/decrement-document-attribute.md',
+ auth: [AuthType::SESSION, AuthType::JWT, AuthType::ADMIN, AuthType::KEY],
+ 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('attribute', '', new Key(), 'Attribute key.')
+ ->param('value', 1, new Numeric(), 'Value to decrement the attribute by. The value must be a number.', true)
+ ->param('min', null, new Numeric(), 'Minimum value for the attribute. If the current value is lesser than this value, an exception will be thrown.', true)
+ ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('queueForEvents')
+ ->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
new file mode 100644
index 0000000000..09def76941
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Increment.php
@@ -0,0 +1,74 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId/:attribute/increment')
+ ->desc('Increment document attribute')
+ ->groups(['api', 'database'])
+ ->label('event', 'documentsdb.[databaseId].collections.[collectionId].documents.[documentId].update')
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'documents.update')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: $this->getSdkGroup(),
+ name: 'incrementDocumentAttribute',
+ description: '/docs/references/documentsdb/increment-document-attribute.md',
+ auth: [AuthType::SESSION, AuthType::JWT, AuthType::ADMIN, AuthType::KEY],
+ 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('attribute', '', new Key(), 'Attribute key.')
+ ->param('value', 1, new Numeric(), 'Value to increment the attribute by. The value must be a number.', true)
+ ->param('max', null, new Numeric(), 'Maximum value for the attribute. If the current value is greater than this value, an error will be thrown.', true)
+ ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('queueForEvents')
+ ->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
new file mode 100644
index 0000000000..09ad9a5741
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Delete.php
@@ -0,0 +1,72 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents')
+ ->desc('Delete documents')
+ ->groups(['api', 'database'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'documents.delete')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: $this->getSdkGroup(),
+ name: 'deleteDocuments',
+ description: '/docs/references/documentsdb/delete-documents.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ 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. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).')
+ ->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, new UID(), 'Transaction ID for staging the operation.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('usage')
+ ->inject('queueForEvents')
+ ->inject('queueForRealtime')
+ ->inject('queueForFunctions')
+ ->inject('queueForWebhooks')
+ ->inject('plan')
+ ->inject('eventProcessor')
+ ->callback($this->action(...));
+ }
+}
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
new file mode 100644
index 0000000000..c723f1bc30
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Update.php
@@ -0,0 +1,74 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents')
+ ->desc('Update documents')
+ ->groups(['api', 'database'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'documents.update')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: $this->getSdkGroup(),
+ name: 'updateDocuments',
+ description: '/docs/references/documentsdb/update-documents.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ 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('data', [], new JSON(), 'Document data as JSON object. Include only attribute and value pairs to be updated.', true)
+ ->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, new UID(), 'Transaction ID for staging the operation.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('usage')
+ ->inject('queueForEvents')
+ ->inject('queueForRealtime')
+ ->inject('queueForFunctions')
+ ->inject('queueForWebhooks')
+ ->inject('plan')
+ ->inject('eventProcessor')
+ ->callback($this->action(...));
+ }
+}
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
new file mode 100644
index 0000000000..d5b62ec903
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Upsert.php
@@ -0,0 +1,74 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PUT)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents')
+ ->desc('Upsert documents')
+ ->groups(['api', 'database'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'document.create')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', [
+ new Method(
+ namespace: 'documentsDB',
+ group: $this->getSdkGroup(),
+ name: 'upsertDocuments',
+ description: '/docs/references/documentsdb/upsert-documents.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_CREATED,
+ model: $this->getResponseModel(),
+ )
+ ],
+ contentType: ContentType::JSON,
+ )
+ ])
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->param('collectionId', '', new UID(), 'Collection ID.')
+ ->param('documents', [], fn (array $plan) => new ArrayList(new JSON(), $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of document data as JSON objects. May contain partial documents.', false, ['plan'])
+ ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('usage')
+ ->inject('queueForEvents')
+ ->inject('queueForRealtime')
+ ->inject('queueForFunctions')
+ ->inject('queueForWebhooks')
+ ->inject('plan')
+ ->inject('eventProcessor')
+ ->callback($this->action(...));
+ }
+}
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
new file mode 100644
index 0000000000..532ae826e2
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php
@@ -0,0 +1,122 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents')
+ ->desc('Create document')
+ ->groups(['api', 'database'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'document.create')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', [
+ new Method(
+ namespace: 'documentsDB',
+ group: $this->getSdkGroup(),
+ name: 'createDocument',
+ desc: 'Create document',
+ description: '/docs/references/documentsdb/create-document.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_CREATED,
+ model: $this->getResponseModel(),
+ )
+ ],
+ contentType: ContentType::JSON,
+ parameters: [
+ new Parameter('databaseId', optional: false),
+ new Parameter('collectionId', optional: false),
+ new Parameter('documentId', optional: false),
+ new Parameter('data', optional: false),
+ new Parameter('permissions', optional: true),
+ ]
+ ),
+ new Method(
+ namespace: 'documentsDB',
+ group: $this->getSdkGroup(),
+ name: 'createDocuments',
+ desc: 'Create documents',
+ description: '/docs/references/documentsdb/create-documents.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_CREATED,
+ model: $this->getBulkResponseModel(),
+ )
+ ],
+ contentType: ContentType::JSON,
+ parameters: [
+ new Parameter('databaseId', optional: false),
+ new Parameter('collectionId', optional: false),
+ new Parameter('documents', optional: false),
+ ]
+ )
+ ])
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->param('documentId', '', new CustomId(), 'Document 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)
+ ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection). Make sure to define attributes before creating documents.')
+ ->param('data', [], new JSON(), 'Document data as JSON object.', true, example: '{"username":"walter.obrien","email":"walter.obrien@example.com","fullName":"Walter O\'Brien","age":30,"isAdmin":false}')
+ ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true)
+ ->param('documents', [], fn (array $plan) => new ArrayList(new JSON(), $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of documents data as JSON objects.', true, ['plan'])
+ ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('user')
+ ->inject('queueForEvents')
+ ->inject('usage')
+ ->inject('queueForRealtime')
+ ->inject('queueForFunctions')
+ ->inject('queueForWebhooks')
+ ->inject('plan')
+ ->inject('authorization')
+ ->inject('eventProcessor')
+ ->callback($this->action(...));
+ }
+}
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
new file mode 100644
index 0000000000..0253c287aa
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Delete.php
@@ -0,0 +1,77 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId')
+ ->desc('Delete document')
+ ->groups(['api', 'database'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].delete')
+ ->label('audits.event', 'document.delete')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{request.documentId}')
+ ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: $this->getSdkGroup(),
+ name: 'deleteDocument',
+ description: '/docs/references/documentsdb/delete-document.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_NOCONTENT,
+ model: UtopiaResponse::MODEL_NONE,
+ )
+ ],
+ contentType: ContentType::NONE
+ ))
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).')
+ ->param('documentId', '', new UID(), 'Document ID.')
+ ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true)
+ ->inject('requestTimestamp')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('queueForEvents')
+ ->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
new file mode 100644
index 0000000000..47d352bf98
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Get.php
@@ -0,0 +1,65 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId')
+ ->desc('Get document')
+ ->groups(['api', 'database'])
+ ->label('scope', 'documents.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: $this->getSdkGroup(),
+ name: 'getDocument',
+ description: '/docs/references/documentsdb/get-document.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ 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. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).')
+ ->param('documentId', '', new UID(), 'Document ID.')
+ ->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, new UID(), 'Transaction ID to read uncommitted changes within the transaction.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('usage')
+ ->inject('transactionState')
+ ->inject('authorization')
+ ->inject('user')
+ ->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
new file mode 100644
index 0000000000..9e79bb5464
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Update.php
@@ -0,0 +1,76 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId')
+ ->desc('Update document')
+ ->groups(['api', 'database'])
+ ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].update')
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'document.update')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{response.$id}')
+ ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: $this->getSdkGroup(),
+ name: 'updateDocument',
+ description: '/docs/references/documentsdb/update-document.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ 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('data', [], new JSON(), 'Document data as JSON object. Include only fields and value pairs to be updated.', true)
+ ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true)
+ ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true)
+ ->inject('requestTimestamp')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('queueForEvents')
+ ->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
new file mode 100644
index 0000000000..448c2d44bc
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Upsert.php
@@ -0,0 +1,78 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PUT)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId')
+ ->desc('Upsert a document')
+ ->groups(['api', 'database'])
+ ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].upsert')
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'document.upsert')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{response.$id}')
+ ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', [
+ new Method(
+ namespace: 'documentsDB',
+ group: $this->getSdkGroup(),
+ name: 'upsertDocument',
+ description: '/docs/references/documentsdb/upsert-document.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_CREATED,
+ 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('data', [], new JSON(), 'Document data as JSON object. Include all required fields of the document to be created or updated.', true)
+ ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true)
+ ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true)
+ ->inject('requestTimestamp')
+ ->inject('response')
+ ->inject('user')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('queueForEvents')
+ ->inject('usage')
+ ->inject('transactionState')
+ ->inject('plan')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
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
new file mode 100644
index 0000000000..51c0d67e8a
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php
@@ -0,0 +1,69 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents')
+ ->desc('List documents')
+ ->groups(['api', 'database'])
+ ->label('scope', 'documents.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: $this->getSdkGroup(),
+ name: 'listDocuments',
+ description: '/docs/references/documentsdb/list-documents.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ 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. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).')
+ ->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, new UID(), 'Transaction ID to read uncommitted changes within the transaction.', 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('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)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('user')
+ ->inject('getDatabasesDB')
+ ->inject('usage')
+ ->inject('transactionState')
+ ->inject('authorization')
+ ->inject('utopia')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Get.php
new file mode 100644
index 0000000000..53120dd636
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Get.php
@@ -0,0 +1,56 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId')
+ ->desc('Get collection')
+ ->groups(['api', 'database'])
+ ->label('scope', 'collections.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: 'collections',
+ name: 'getCollection',
+ description: '/docs/references/documentsdb/get-collection.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ 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.')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('authorization')
+ ->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
new file mode 100644
index 0000000000..dc3ce34605
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php
@@ -0,0 +1,73 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/indexes')
+ ->desc('Create index')
+ ->groups(['api', 'database'])
+ ->label('event', 'databases.[databaseId].collections.[collectionId].indexes.[indexId].create')
+ ->label('scope', 'collections.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'index.create')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: $this->getSdkGroup(),
+ name: 'createIndex',
+ description: '/docs/references/documentsdb/create-index.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_ACCEPTED,
+ 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. 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]), '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)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('queueForDatabase')
+ ->inject('queueForEvents')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Delete.php
new file mode 100644
index 0000000000..d4464f171d
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Delete.php
@@ -0,0 +1,67 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/indexes/:key')
+ ->desc('Delete index')
+ ->groups(['api', 'database'])
+ ->label('scope', 'collections.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].collections.[collectionId].indexes.[indexId].update')
+ ->label('audits.event', 'index.delete')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: $this->getSdkGroup(),
+ name: 'deleteIndex', // getName needs to be different from parent action to avoid conflict in path name
+ description: '/docs/references/documentsdb/delete-index.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_NOCONTENT,
+ model: UtopiaResponse::MODEL_NONE,
+ )
+ ],
+ contentType: ContentType::NONE
+ ))
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).')
+ ->param('key', '', new Key(), 'Index Key.')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForDatabase')
+ ->inject('queueForEvents')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Get.php
new file mode 100644
index 0000000000..7fa75b6ed9
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Get.php
@@ -0,0 +1,58 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/indexes/:key')
+ ->desc('Get index')
+ ->groups(['api', 'database'])
+ ->label('scope', 'collections.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: $this->getSdkGroup(),
+ name: 'getIndex', // getName needs to be different from parent action to avoid conflict in path name
+ description: '/docs/references/documentsdb/get-index.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ 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. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).')
+ ->param('key', null, new Key(), 'Index Key.')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/XList.php
new file mode 100644
index 0000000000..1e16155f76
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/XList.php
@@ -0,0 +1,60 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/indexes')
+ ->desc('List indexes')
+ ->groups(['api', 'database'])
+ ->label('scope', 'collections.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: $this->getSdkGroup(),
+ name: 'listIndexes', // getName needs to be different from parent action to avoid conflict in path name
+ description: '/docs/references/documentsdb/list-indexes.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ 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. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).')
+ ->param('queries', [], new Indexes(), '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(', ', Indexes::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')
+ ->inject('authorization')
+ ->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
new file mode 100644
index 0000000000..3acedc0379
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php
@@ -0,0 +1,69 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PUT)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId')
+ ->desc('Update collection')
+ ->groups(['api', 'database', 'schema'])
+ ->label('scope', 'collections.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].collections.[collectionId].update')
+ ->label('audits.event', 'collection.update')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: 'collections',
+ name: 'updateCollection',
+ description: '/docs/references/documentsdb/update-collection.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_COLLECTION,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->param('collectionId', '', new UID(), 'Collection ID.')
+ ->param('name', null, new Text(128), 'Collection name. Max length: 128 chars.')
+ ->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')
+ ->inject('queueForEvents')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Usage/Get.php
new file mode 100644
index 0000000000..51dd3c381d
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Usage/Get.php
@@ -0,0 +1,65 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/usage')
+ ->desc('Get collection usage stats')
+ ->groups(['api', 'database', 'usage'])
+ ->label('scope', 'collections.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: null,
+ name: 'getCollectionUsage',
+ description: '/docs/references/documentsdb/get-collection-usage.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('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true)
+ ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject'])
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('authorization')
+ ->inject('getDatabasesDB')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/XList.php
new file mode 100644
index 0000000000..638244145b
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/XList.php
@@ -0,0 +1,61 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/documentsdb/:databaseId/collections')
+ ->desc('List collections')
+ ->groups(['api', 'database'])
+ ->label('scope', 'collections.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: 'collections',
+ name: 'listCollections',
+ description: '/docs/references/documentsdb/list-collections.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: $this->getResponseModel(),
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->param('queries', [], new Collections(), '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(', ', Collections::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')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Create.php
new file mode 100644
index 0000000000..f9b425b3e6
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Create.php
@@ -0,0 +1,60 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/documentsdb')
+ ->desc('Create database')
+ ->groups(['api', 'database'])
+ ->label('event', 'databases.[databaseId].create')
+ ->label('scope', 'databases.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'database.create')
+ ->label('audits.resource', 'database/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: 'documentsdb',
+ name: 'create',
+ description: '/docs/references/documentsdb/create.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_CREATED,
+ model: UtopiaResponse::MODEL_DATABASE,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('databaseId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), '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('name', '', new Text(128), 'Database name. Max length: 128 chars.')
+ ->param('enabled', true, new Boolean(), 'Is the database enabled? When set to \'disabled\', users cannot access the database but Server SDKs with an API key can still read and write to the database. No data is lost when this is toggled.', true)
+ ->inject('project')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php
new file mode 100644
index 0000000000..1708656c98
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php
@@ -0,0 +1,56 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/documentsdb/:databaseId')
+ ->desc('Delete database')
+ ->groups(['api', 'database', 'schema'])
+ ->label('scope', 'databases.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].delete')
+ ->label('audits.event', 'database.delete')
+ ->label('audits.resource', 'database/{request.databaseId}')
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: 'documentsdb',
+ name: 'delete',
+ description: '/docs/references/documentsdb/delete.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_NOCONTENT,
+ model: UtopiaResponse::MODEL_NONE,
+ )
+ ],
+ contentType: ContentType::NONE
+ ))
+ ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForDatabase')
+ ->inject('queueForEvents')
+ ->inject('usage')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Get.php
new file mode 100644
index 0000000000..309a3b867e
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Get.php
@@ -0,0 +1,50 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/documentsdb/:databaseId')
+ ->desc('Get database')
+ ->groups(['api', 'database'])
+ ->label('scope', 'databases.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: 'documentsdb',
+ name: 'get',
+ description: '/docs/references/documentsdb/get.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_DATABASE,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
+ ->inject('response')
+ ->inject('dbForProject')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Logs/XList.php
new file mode 100644
index 0000000000..8afb0fd1ef
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Logs/XList.php
@@ -0,0 +1,60 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/documentsdb/:databaseId/logs')
+ ->desc('List database logs')
+ ->groups(['api', 'database'])
+ ->label('scope', 'databases.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', [
+ new Method(
+ namespace: 'documentsDB',
+ group: 'logs',
+ name: 'listDatabaseLogs',
+ description: '/docs/references/documentsdb/get-logs.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_LOG_LIST,
+ )
+ ],
+ contentType: ContentType::JSON
+ ),
+ ])
+ ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database 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/Transactions/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Create.php
new file mode 100644
index 0000000000..9341779dcd
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Create.php
@@ -0,0 +1,56 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/documentsdb/transactions')
+ ->desc('Create transaction')
+ ->groups(['api', 'database', 'transactions'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: 'transactions',
+ name: 'createTransaction',
+ description: '/docs/references/documentsdb/create-transaction.md',
+ auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_CREATED,
+ model: UtopiaResponse::MODEL_TRANSACTION,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('ttl', APP_DATABASE_TXN_TTL_DEFAULT, new Range(min: APP_DATABASE_TXN_TTL_MIN, max: APP_DATABASE_TXN_TTL_MAX), 'Seconds before the transaction expires.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('user')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Delete.php
new file mode 100644
index 0000000000..036f2e9600
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Delete.php
@@ -0,0 +1,55 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/documentsdb/transactions/:transactionId')
+ ->desc('Delete transaction')
+ ->groups(['api', 'database', 'transactions'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: 'transactions',
+ name: 'deleteTransaction',
+ description: '/docs/references/documentsdb/delete-transaction.md',
+ auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_NOCONTENT,
+ model: UtopiaResponse::MODEL_NONE,
+ )
+ ],
+ contentType: ContentType::NONE
+ ))
+ ->param('transactionId', '', new UID(), 'Transaction ID.')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForDeletes')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Get.php
new file mode 100644
index 0000000000..7def4f0b9a
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Get.php
@@ -0,0 +1,54 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/documentsdb/transactions/:transactionId')
+ ->desc('Get transaction')
+ ->groups(['api', 'database', 'transactions'])
+ ->label('scope', 'documents.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: 'transactions',
+ name: 'getTransaction',
+ description: '/docs/references/documentsdb/get-transaction.md',
+ auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_TRANSACTION,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('transactionId', '', new UID(), 'Transaction ID.')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->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
new file mode 100644
index 0000000000..963af2f43e
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Operations/Create.php
@@ -0,0 +1,61 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/documentsdb/transactions/:transactionId/operations')
+ ->desc('Create operations')
+ ->groups(['api', 'database', 'transactions'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: 'transactions',
+ name: 'createOperations',
+ description: '/docs/references/documentsdb/create-operations.md',
+ auth: [AuthType::KEY, AuthType::SESSION, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_CREATED,
+ model: UtopiaResponse::MODEL_TRANSACTION,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('transactionId', '', new UID(), 'Transaction ID.')
+ ->param('operations', [], new ArrayList(new Operation(type: 'documentsdb')), 'Array of staged operations.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->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
new file mode 100644
index 0000000000..b4c0c2ffab
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Update.php
@@ -0,0 +1,69 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/documentsdb/transactions/:transactionId')
+ ->desc('Update transaction')
+ ->groups(['api', 'database', 'transactions'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: 'transactions',
+ name: 'updateTransaction',
+ description: '/docs/references/documentsdb/update-transaction.md',
+ auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_TRANSACTION,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('transactionId', '', new UID(), 'Transaction ID.')
+ ->param('commit', false, new Boolean(), 'Commit transaction?', true)
+ ->param('rollback', false, new Boolean(), 'Rollback transaction?', true)
+ ->inject('project')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('user')
+ ->inject('transactionState')
+ ->inject('queueForDeletes')
+ ->inject('queueForEvents')
+ ->inject('usage')
+ ->inject('queueForRealtime')
+ ->inject('queueForFunctions')
+ ->inject('queueForWebhooks')
+ ->inject('authorization')
+ ->inject('eventProcessor')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/XList.php
new file mode 100644
index 0000000000..b216ce6a4a
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/XList.php
@@ -0,0 +1,54 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/documentsdb/transactions')
+ ->desc('List transactions')
+ ->groups(['api', 'database', 'transactions'])
+ ->label('scope', 'documents.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: 'transactions',
+ name: 'listTransactions',
+ description: '/docs/references/documentsdb/list-transactions.md',
+ auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_TRANSACTION_LIST,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('queries', [], new Transactions(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries).', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Update.php
new file mode 100644
index 0000000000..4bf5747b54
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Update.php
@@ -0,0 +1,58 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PUT)
+ ->setHttpPath('/v1/documentsdb/:databaseId')
+ ->desc('Update database')
+ ->groups(['api', 'database', 'schema'])
+ ->label('scope', 'databases.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].update')
+ ->label('audits.event', 'database.update')
+ ->label('audits.resource', 'database/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: 'documentsdb',
+ name: 'update',
+ description: '/docs/references/documentsdb/update.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_DATABASE,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
+ ->param('name', null, new Text(128), 'Database name. Max length: 128 chars.')
+ ->param('enabled', true, new Boolean(), 'Is database enabled? When set to \'disabled\', users cannot access the database but Server SDKs with an API key can still read and write to the database. No data is lost when this is toggled.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Usage/Get.php
new file mode 100644
index 0000000000..8373b6bc20
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Usage/Get.php
@@ -0,0 +1,60 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/documentsdb/:databaseId/usage')
+ ->desc('Get DocumentsDB usage stats')
+ ->groups(['api', 'database', 'usage'])
+ ->label('scope', 'collections.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', [
+ new Method(
+ namespace: 'documentsDB',
+ group: null,
+ name: 'getUsage',
+ description: '/docs/references/documentsdb/get-database-usage.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_USAGE_DOCUMENTSDB,
+ )
+ ],
+ contentType: ContentType::JSON,
+ ),
+ ])
+ ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
+ ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Usage/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Usage/XList.php
new file mode 100644
index 0000000000..16535765ca
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Usage/XList.php
@@ -0,0 +1,56 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/documentsdb/usage')
+ ->desc('Get DocumentsDB usage stats')
+ ->groups(['api', 'database', 'usage'])
+ ->label('scope', 'collections.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', [
+ new Method(
+ namespace: 'documentsDB',
+ group: null,
+ name: 'listUsage',
+ description: '/docs/references/documentsdb/list-usage.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_USAGE_DATABASES,
+ )
+ ],
+ contentType: ContentType::JSON
+ ),
+ ])
+ ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/XList.php
new file mode 100644
index 0000000000..13814b37e2
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/XList.php
@@ -0,0 +1,53 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/documentsdb')
+ ->desc('List databases')
+ ->groups(['api', 'database'])
+ ->label('scope', 'databases.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'documentsDB',
+ group: 'documentsdb',
+ name: 'list',
+ description: '/docs/references/documentsdb/list.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_DATABASE_LIST,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('queries', [], new Databases(), '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 columns: ' . implode(', ', Databases::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(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Create.php
index 8aa6e1e28b..eb2293dc28 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Create.php
@@ -50,8 +50,10 @@ class Create extends DatabaseCreate
->param('databaseId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), '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('name', '', new Text(128), 'Database name. Max length: 128 chars.')
->param('enabled', true, new Boolean(), 'Is the database enabled? When set to \'disabled\', users cannot access the database but Server SDKs with an API key can still read and write to the database. No data is lost when this is toggled.', true)
+ ->inject('project')
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForEvents')
->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 b0e81ed6b7..d0b2ed3e4b 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php
@@ -34,7 +34,7 @@ class Create extends EmailCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/email')
->desc('Create email column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php
index d1278376c1..c116d8c5b1 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php
@@ -35,7 +35,7 @@ class Update extends EmailUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/email/:key')
->desc('Update email column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php
index 9aeb9b2d4b..e58ae115fc 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php
@@ -35,7 +35,7 @@ class Create extends EnumCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/enum')
->desc('Create enum column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php
index 43503ee8ed..208fa9c8cf 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php
@@ -36,7 +36,7 @@ class Update extends EnumUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/enum/:key')
->desc('Update enum column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php
index 0dd0ef39e1..b8e81820aa 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php
@@ -34,7 +34,7 @@ class Create extends FloatCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/float')
->desc('Create float column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php
index 716923cc63..9ab61e642b 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php
@@ -35,7 +35,7 @@ class Update extends FloatUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/float/:key')
->desc('Update float column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php
index 0fe5fa062a..b0ef9e8a85 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php
@@ -42,7 +42,7 @@ class Get extends AttributesGet
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key')
->desc('Get column')
->groups(['api', 'database'])
- ->label('scope', ['tables.read', 'collections.read'])
+ ->label('scope', ['tables.read', 'collections.read', 'columns.read', 'attributes.read'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: $this->getSDKNamespace(),
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php
index c359feaab4..c2faec9aeb 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php
@@ -34,7 +34,7 @@ class Create extends IPCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/ip')
->desc('Create IP address column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php
index 0c7cc6644b..dcc4160580 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php
@@ -35,7 +35,7 @@ class Update extends IPUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/ip/:key')
->desc('Update IP address column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php
index bbb1710866..1a965c19dc 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php
@@ -34,7 +34,7 @@ class Create extends IntegerCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/integer')
->desc('Create integer column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php
index a9348f51e0..58dea7c848 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php
@@ -35,7 +35,7 @@ class Update extends IntegerUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/integer/:key')
->desc('Update integer column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php
index fb2c4fd1a8..c2f480d5d0 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php
@@ -35,7 +35,7 @@ class Create extends LineCreate
->desc('Create line column')
->groups(['api', 'database', 'schema'])
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'column.create')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php
index 564b743a2a..e2e8c59121 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php
@@ -35,7 +35,7 @@ class Update extends LineUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/line/:key')
->desc('Update line column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php
index da9471f37c..8e2dbd911d 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php
@@ -33,7 +33,7 @@ class Create extends LongtextCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/longtext')
->desc('Create longtext column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php
index fe93530cfb..9b90b745a2 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php
@@ -34,7 +34,7 @@ class Update extends LongtextUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/longtext/:key')
->desc('Update longtext column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php
index 585856cab9..f0b8099f02 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php
@@ -33,7 +33,7 @@ class Create extends MediumtextCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/mediumtext')
->desc('Create mediumtext column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php
index 733159d1d4..03009da25c 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php
@@ -34,7 +34,7 @@ class Update extends MediumtextUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/mediumtext/:key')
->desc('Update mediumtext column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php
index 9736e33158..138ee482c3 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php
@@ -35,7 +35,7 @@ class Create extends PointCreate
->desc('Create point column')
->groups(['api', 'database', 'schema'])
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'column.create')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php
index f104b170bd..66fb451a1f 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php
@@ -35,7 +35,7 @@ class Update extends PointUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/point/:key')
->desc('Update point column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php
index 177399396c..a03a34f310 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php
@@ -35,7 +35,7 @@ class Create extends PolygonCreate
->desc('Create polygon column')
->groups(['api', 'database', 'schema'])
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'column.create')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php
index e66e19a7b9..7a2fd8a5de 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php
@@ -35,7 +35,7 @@ class Update extends PolygonUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/polygon/:key')
->desc('Update polygon column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php
index 84ee3e6863..87544926fe 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php
@@ -34,7 +34,7 @@ class Create extends RelationshipCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/relationship')
->desc('Create relationship column')
->groups(['api', 'database'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php
index da5c8ca477..47884eda80 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php
@@ -34,7 +34,7 @@ class Update extends RelationshipUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key/relationship')
->desc('Update relationship column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php
index 122c8625f9..17f60f61c1 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php
@@ -37,7 +37,7 @@ class Create extends StringCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/string')
->desc('Create string column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php
index 0974a44d5d..2ec806d4fe 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php
@@ -37,7 +37,7 @@ class Update extends StringUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/string/:key')
->desc('Update string column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php
index 2c68431d8c..a8fde7d271 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php
@@ -33,7 +33,7 @@ class Create extends TextCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/text')
->desc('Create text column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php
index 599c93988d..4c1477fb9e 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php
@@ -34,7 +34,7 @@ class Update extends TextUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/text/:key')
->desc('Update text column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php
index 0b386c23f6..19b33594b7 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php
@@ -34,7 +34,7 @@ class Create extends URLCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/url')
->desc('Create URL column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php
index df6117ea77..d680389d9e 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php
@@ -35,7 +35,7 @@ class Update extends URLUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/url/:key')
->desc('Update URL column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php
index 0ee04f5f63..7595f16c45 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php
@@ -35,7 +35,7 @@ class Create extends VarcharCreate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/varchar')
->desc('Create varchar column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
->label('audits.event', 'column.create')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php
index 2b8eb9fbd7..dd170a0a19 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php
@@ -36,7 +36,7 @@ class Update extends VarcharUpdate
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/varchar/:key')
->desc('Update varchar column')
->groups(['api', 'database', 'schema'])
- ->label('scope', ['tables.write', 'collections.write'])
+ ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
->label('audits.event', 'column.update')
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php
index b38edf6218..56c436a13e 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php
@@ -33,7 +33,7 @@ class XList extends AttributesXList
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns')
->desc('List columns')
->groups(['api', 'database'])
- ->label('scope', ['tables.read', 'collections.read'])
+ ->label('scope', ['tables.read', 'collections.read', 'columns.read', 'attributes.read'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: $this->getSDKNamespace(),
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Create.php
index 9d32166a26..48f1136b09 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Create.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Create.php
@@ -67,6 +67,7 @@ class Create extends CollectionCreate
->param('indexes', [], new ArrayList(new JSON(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of index definitions to create. Each index should contain: key (string), type (string: key, fulltext, unique, spatial), attributes (array of column keys), orders (array of ASC/DESC, optional), and lengths (array of integers, optional).', true)
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php
index aa5b94c00f..97c5465fe3 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php
@@ -54,6 +54,7 @@ class Delete extends CollectionDelete
->param('tableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Table ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForDatabase')
->inject('queueForEvents')
->inject('authorization')
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 8186e07d61..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}')
@@ -64,6 +64,7 @@ class Create extends IndexCreate
->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)
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForDatabase')
->inject('queueForEvents')
->inject('authorization')
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 adaf83ccf1..37a3db01db 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php
@@ -61,6 +61,7 @@ class Delete extends DocumentsDelete
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject'])
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
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 d706d1f28b..bb839b752e 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Update.php
@@ -63,6 +63,7 @@ class Update extends DocumentsUpdate
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject'])
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
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 58da5064f9..364bf4a928 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Upsert.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Upsert.php
@@ -63,6 +63,7 @@ class Upsert extends DocumentsUpsert
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject'])
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('usage')
->inject('queueForEvents')
->inject('queueForRealtime')
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 e1e717e9b1..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
@@ -65,10 +65,12 @@ class Decrement extends DecrementDocumentAttribute
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject'])
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForEvents')
->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 0b20450254..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
@@ -65,10 +65,12 @@ class Increment extends IncrementDocumentAttribute
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject'])
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForEvents')
->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 fde8005d2b..26649accfb 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
@@ -104,6 +104,7 @@ class Create extends DocumentCreate
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject'])
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('user')
->inject('queueForEvents')
->inject('usage')
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 1845edc307..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
@@ -67,11 +67,13 @@ class Delete extends DocumentDelete
->inject('requestTimestamp')
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForEvents')
->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 43b799e5b1..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
@@ -57,9 +57,11 @@ class Get extends DocumentGet
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject'])
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('usage')
->inject('transactionState')
->inject('authorization')
+ ->inject('user')
->callback($this->action(...));
}
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Logs/XList.php
index a5f4787b05..e1d821130f 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Logs/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Logs/XList.php
@@ -50,6 +50,7 @@ class XList extends DocumentLogXList
->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')
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 c0d90f9531..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
@@ -65,11 +65,13 @@ class Update extends DocumentUpdate
->inject('requestTimestamp')
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForEvents')
->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 7f0aa0ad7d..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
@@ -68,6 +68,7 @@ class Upsert extends DocumentUpsert
->inject('response')
->inject('user')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForEvents')
->inject('usage')
->inject('transactionState')
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 6e5dcd9370..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,13 +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('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 c525f97715..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,8 +60,10 @@ 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')
->inject('queueForEvents')
->inject('authorization')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Usage/Get.php
index 4261ceaab6..6976be014c 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Usage/Get.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Usage/Get.php
@@ -54,6 +54,7 @@ class Get extends CollectionUsageGet
->inject('response')
->inject('dbForProject')
->inject('authorization')
+ ->inject('getDatabasesDB')
->callback($this->action(...));
}
}
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 68ea2b8901..872927d533 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php
@@ -51,8 +51,10 @@ class Update extends TransactionsUpdate
->param('transactionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Transaction ID.', false, ['dbForProject'])
->param('commit', false, new Boolean(), 'Commit transaction?', true)
->param('rollback', false, new Boolean(), 'Rollback transaction?', true)
+ ->inject('project')
->inject('response')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('user')
->inject('transactionState')
->inject('queueForDeletes')
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
new file mode 100644
index 0000000000..58433c7deb
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php
@@ -0,0 +1,225 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections')
+ ->desc('Create collection')
+ ->groups(['api', 'database'])
+ ->label('event', 'databases.[databaseId].collections.[collectionId].create')
+ ->label('scope', 'collections.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'collection.create')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: 'collections',
+ name: 'createCollection',
+ description: '/docs/references/vectorsdb/create-collection.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_CREATED,
+ 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 CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), '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('name', '', new Text(128), 'Collection name. Max length: 128 chars.')
+ ->param('dimension', null, new Range(MIN_VECTOR_DIMENSION, MAX_VECTOR_DIMENSION), 'Embedding dimension.')
+ ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permissions strings. By default, no user is granted with any permissions. [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)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('queueForEvents')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+
+ public function action(string $databaseId, string $collectionId, string $name, int $dimension, ?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()) {
+ throw new Exception(Exception::DATABASE_NOT_FOUND);
+ }
+
+ $collectionId = $collectionId === 'unique()' ? ID::unique() : $collectionId;
+
+ // Map aggregate permissions into the multiple permissions they represent.
+ $permissions = Permission::aggregate($permissions) ?? [];
+
+ try {
+ $collection = $dbForProject->createDocument('database_' . $database->getSequence(), new Document([
+ '$id' => $collectionId,
+ 'databaseInternalId' => $database->getSequence(),
+ 'databaseId' => $databaseId,
+ '$permissions' => $permissions,
+ 'documentSecurity' => $documentSecurity,
+ 'enabled' => $enabled,
+ 'name' => $name,
+ 'dimension' => $dimension,
+ 'search' => \implode(' ', [$collectionId, $name]),
+ ]));
+
+ } catch (DuplicateException) {
+ throw new Exception($this->getDuplicateException());
+ } catch (LimitException) {
+ throw new Exception($this->getLimitException());
+ } catch (NotFoundException) {
+ throw new Exception(Exception::DATABASE_NOT_FOUND);
+ }
+ /** @var Database $dbForDatabases */
+ $dbForDatabases = $getDatabasesDB($database);
+
+ $attributes = [];
+ $indexes = [];
+ $collections = (Config::getParam('collections', [])['vectorsdb'] ?? [])['collections'] ?? [];
+ foreach ($collections['defaultAttributes'] as $attribute) {
+ if ($attribute['$id'] === 'embeddings') {
+ $attribute['size'] = $dimension;
+ }
+ $attributes[] = new Document($attribute);
+ }
+ foreach ($collections['defaultIndexes'] as $index) {
+ $indexes[] = new Document($index);
+ }
+ try {
+ // 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(),
+ permissions: $permissions,
+ documentSecurity: $documentSecurity,
+ attributes:$attributes,
+ indexes:$indexes
+ );
+ // Create attribute and indexes metadata documents in the attributes and indexes collections
+ // needed for the get and list calls
+ $attributeDocs = array_map(function ($attributeConfig) use ($database, $collection, $databaseId, $collectionId, $dimension) {
+ $key = \is_string($attributeConfig['$id']) ? $attributeConfig['$id'] : (string) $attributeConfig['$id'];
+ return new Document([
+ '$id' => ID::custom($database->getSequence() . '_' . $collection->getSequence() . '_' . $key),
+ 'key' => $key,
+ 'databaseInternalId' => $database->getSequence(),
+ 'databaseId' => $databaseId,
+ 'collectionInternalId' => $collection->getSequence(),
+ 'collectionId' => $collectionId,
+ 'type' => $attributeConfig['type'],
+ 'status' => 'available',
+ 'size' => $dimension,
+ 'required' => $attributeConfig['required'] ?? false,
+ 'signed' => $attributeConfig['signed'] ?? false,
+ 'default' => $attributeConfig['default'] ?? null,
+ 'array' => $attributeConfig['array'] ?? false,
+ 'format' => $attributeConfig['format'] ?? '',
+ 'formatOptions' => $attributeConfig['formatOptions'] ?? [],
+ 'filters' => $attributeConfig['filters'] ?? [],
+ 'options' => $attributeConfig['options'] ?? [],
+ ]);
+ }, $collections['defaultAttributes']);
+ $dbForProject->createDocuments('attributes', $attributeDocs);
+
+ $indexDocs = array_map(function ($indexConfig) use ($database, $collection, $databaseId, $collectionId) {
+ $key = \is_string($indexConfig['$id']) ? $indexConfig['$id'] : (string) $indexConfig['$id'];
+
+ return new Document([
+ '$id' => ID::custom($database->getSequence() . '_' . $collection->getSequence() . '_' . $key),
+ 'key' => $key,
+ 'status' => 'available',
+ 'databaseInternalId' => $database->getSequence(),
+ 'databaseId' => $databaseId,
+ 'collectionInternalId' => $collection->getSequence(),
+ 'collectionId' => $collectionId,
+ 'type' => $indexConfig['type'],
+ 'attributes' => $indexConfig['attributes'] ?? [],
+ 'lengths' => $indexConfig['lengths'] ?? [],
+ 'orders' => $indexConfig['orders'] ?? [],
+ ]);
+ }, $collections['defaultIndexes']);
+
+ if (!empty($indexDocs)) {
+ $dbForProject->createDocuments('indexes', $indexDocs);
+ }
+ } catch (DuplicateException) {
+ throw new Exception($this->getDuplicateException());
+ } catch (IndexException) {
+ throw new Exception($this->getInvalidIndexException());
+ } catch (LimitException) {
+ throw new Exception($this->getLimitException());
+ }
+
+ $queueForEvents
+ ->setContext('database', $database)
+ ->setParam('databaseId', $databaseId)
+ ->setParam($this->getEventsParamKey(), $collection->getId());
+
+ $response
+ ->setStatusCode(SwooleResponse::STATUS_CODE_CREATED)
+ ->dynamic($collection, $this->getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Delete.php
new file mode 100644
index 0000000000..f1188868aa
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Delete.php
@@ -0,0 +1,62 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId')
+ ->desc('Delete collection')
+ ->groups(['api', 'database', 'schema'])
+ ->label('scope', 'collections.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].collections.[collectionId].delete')
+ ->label('audits.event', 'collection.delete')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: 'collections',
+ name: 'deleteCollection',
+ description: '/docs/references/vectorsdb/delete-collection.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_NOCONTENT,
+ model: UtopiaResponse::MODEL_NONE,
+ )
+ ],
+ contentType: ContentType::NONE
+ ))
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->param('collectionId', '', new UID(), 'Collection ID.')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('queueForDatabase')
+ ->inject('queueForEvents')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
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
new file mode 100644
index 0000000000..a4d640b423
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Delete.php
@@ -0,0 +1,72 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents')
+ ->desc('Delete documents')
+ ->groups(['api', 'database'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'documents.delete')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: $this->getSdkGroup(),
+ name: 'deleteDocuments',
+ description: '/docs/references/vectorsdb/delete-documents.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ 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. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).')
+ ->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, new UID(), 'Transaction ID for staging the operation.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('usage')
+ ->inject('queueForEvents')
+ ->inject('queueForRealtime')
+ ->inject('queueForFunctions')
+ ->inject('queueForWebhooks')
+ ->inject('plan')
+ ->inject('eventProcessor')
+ ->callback($this->action(...));
+ }
+}
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
new file mode 100644
index 0000000000..2784fa220a
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Update.php
@@ -0,0 +1,74 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents')
+ ->desc('Update documents')
+ ->groups(['api', 'database'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'documents.update')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: $this->getSdkGroup(),
+ name: 'updateDocuments',
+ description: '/docs/references/vectorsdb/update-documents.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ 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('data', [], new JSON(), 'Document data as JSON object. Include only attribute and value pairs to be updated.', true)
+ ->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, new UID(), 'Transaction ID for staging the operation.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('usage')
+ ->inject('queueForEvents')
+ ->inject('queueForRealtime')
+ ->inject('queueForFunctions')
+ ->inject('queueForWebhooks')
+ ->inject('plan')
+ ->inject('eventProcessor')
+ ->callback($this->action(...));
+ }
+}
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
new file mode 100644
index 0000000000..cfbf6c9158
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Upsert.php
@@ -0,0 +1,74 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PUT)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents')
+ ->desc('Upsert documents')
+ ->groups(['api', 'database'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'document.create')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', [
+ new Method(
+ namespace: 'vectorsDB',
+ group: $this->getSdkGroup(),
+ name: 'upsertDocuments',
+ description: '/docs/references/vectorsdb/upsert-documents.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_CREATED,
+ model: $this->getResponseModel(),
+ )
+ ],
+ contentType: ContentType::JSON,
+ )
+ ])
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->param('collectionId', '', new UID(), 'Collection ID.')
+ ->param('documents', [], fn (array $plan) => new ArrayList(new JSON(), $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of document data as JSON objects. May contain partial documents.', false, ['plan'])
+ ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('usage')
+ ->inject('queueForEvents')
+ ->inject('queueForRealtime')
+ ->inject('queueForFunctions')
+ ->inject('queueForWebhooks')
+ ->inject('plan')
+ ->inject('eventProcessor')
+ ->callback($this->action(...));
+ }
+}
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
new file mode 100644
index 0000000000..563b5f60ef
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Create.php
@@ -0,0 +1,116 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents')
+ ->desc('Create document')
+ ->groups(['api', 'database'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'document.create')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', [
+ new Method(
+ namespace: 'vectorsDB',
+ group: $this->getSdkGroup(),
+ name: 'createDocument',
+ desc: 'Create document',
+ description: '/docs/references/vectorsdb/create-document.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_CREATED,
+ model: $this->getResponseModel(),
+ )
+ ],
+ contentType: ContentType::JSON,
+ parameters: [
+ new Parameter('databaseId', optional: false),
+ new Parameter('collectionId', optional: false),
+ new Parameter('documentId', optional: false),
+ new Parameter('data', optional: false),
+ new Parameter('permissions', optional: true),
+ ]
+ ),
+ new Method(
+ namespace: 'vectorsDB',
+ group: $this->getSdkGroup(),
+ name: 'createDocuments',
+ desc: 'Create documents',
+ description: '/docs/references/vectorsdb/create-documents.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_CREATED,
+ model: $this->getBulkResponseModel(),
+ )
+ ],
+ contentType: ContentType::JSON,
+ parameters: [
+ new Parameter('databaseId', optional: false),
+ new Parameter('collectionId', optional: false),
+ new Parameter('documents', optional: false),
+ ]
+ )
+ ])
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->param('documentId', '', new CustomId(), 'Document 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)
+ ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection). Make sure to define attributes before creating documents.')
+ ->param('data', [], new JSON(), 'Document data as JSON object.', true, example: '{"embeddings": [0.12, -0.55, 0.88, 1.02], "metadata": {"key":"value"} }')
+ ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true)
+ ->param('documents', [], fn (array $plan) => new ArrayList(new JSON(), $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of documents data as JSON objects.', true, ['plan'])
+ ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('user')
+ ->inject('queueForEvents')
+ ->inject('usage')
+ ->inject('queueForRealtime')
+ ->inject('queueForFunctions')
+ ->inject('queueForWebhooks')
+ ->inject('plan')
+ ->inject('authorization')
+ ->inject('eventProcessor')
+ ->callback($this->action(...));
+ }
+}
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
new file mode 100644
index 0000000000..e81e34e1e5
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Delete.php
@@ -0,0 +1,77 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId')
+ ->desc('Delete document')
+ ->groups(['api', 'database'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].delete')
+ ->label('audits.event', 'document.delete')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{request.documentId}')
+ ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: $this->getSdkGroup(),
+ name: 'deleteDocument',
+ description: '/docs/references/vectorsdb/delete-document.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_NOCONTENT,
+ model: UtopiaResponse::MODEL_NONE,
+ )
+ ],
+ contentType: ContentType::NONE
+ ))
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).')
+ ->param('documentId', '', new UID(), 'Document ID.')
+ ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true)
+ ->inject('requestTimestamp')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('queueForEvents')
+ ->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
new file mode 100644
index 0000000000..73f7a55026
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Get.php
@@ -0,0 +1,65 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId')
+ ->desc('Get document')
+ ->groups(['api', 'database'])
+ ->label('scope', 'documents.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: $this->getSdkGroup(),
+ name: 'getDocument',
+ description: '/docs/references/vectorsdb/get-document.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ 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. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).')
+ ->param('documentId', '', new UID(), 'Document ID.')
+ ->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, new UID(), 'Transaction ID to read uncommitted changes within the transaction.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('usage')
+ ->inject('transactionState')
+ ->inject('authorization')
+ ->inject('user')
+ ->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
new file mode 100644
index 0000000000..8a8b9d89ae
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Update.php
@@ -0,0 +1,76 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId')
+ ->desc('Update document')
+ ->groups(['api', 'database'])
+ ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].update')
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'document.update')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{response.$id}')
+ ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: $this->getSdkGroup(),
+ name: 'updateDocument',
+ description: '/docs/references/vectorsdb/update-document.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ 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('data', [], new JSON(), 'Document data as JSON object. Include only fields and value pairs to be updated.', true)
+ ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true)
+ ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true)
+ ->inject('requestTimestamp')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('queueForEvents')
+ ->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
new file mode 100644
index 0000000000..f8f17d33d9
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Upsert.php
@@ -0,0 +1,79 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PUT)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId')
+ ->desc('Upsert a document')
+ ->groups(['api', 'database'])
+ ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].upsert')
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'document.upsert')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{response.$id}')
+ ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', [
+ new Method(
+ namespace: 'vectorsDB',
+ group: $this->getSdkGroup(),
+ name: 'upsertDocument',
+ description: '/docs/references/vectorsdb/upsert-document.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_CREATED,
+ model: $this->getResponseModel(),
+ )
+ ],
+ contentType: ContentType::JSON
+ ),
+ ])
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->param('collectionId', '', new UID(), 'Collection ID.')
+ ->param('documentId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Document ID.', false, ['dbForProject'])
+ ->param('data', [], new JSON(), 'Document data as JSON object. Include all required fields of the document to be created or updated.', true)
+ ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true)
+ ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true)
+ ->inject('requestTimestamp')
+ ->inject('response')
+ ->inject('user')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('queueForEvents')
+ ->inject('usage')
+ ->inject('transactionState')
+ ->inject('plan')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
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
new file mode 100644
index 0000000000..c9ed05ac02
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/XList.php
@@ -0,0 +1,68 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents')
+ ->desc('List documents')
+ ->groups(['api', 'database'])
+ ->label('scope', 'documents.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: $this->getSdkGroup(),
+ name: 'listDocuments',
+ description: '/docs/references/vectorsdb/list-documents.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ 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. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).')
+ ->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, new UID(), 'Transaction ID to read uncommitted changes within the transaction.', 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('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)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('user')
+ ->inject('getDatabasesDB')
+ ->inject('usage')
+ ->inject('transactionState')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Get.php
new file mode 100644
index 0000000000..9619bb5048
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Get.php
@@ -0,0 +1,56 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId')
+ ->desc('Get collection')
+ ->groups(['api', 'database'])
+ ->label('scope', 'collections.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: 'collections',
+ name: 'getCollection',
+ description: '/docs/references/vectorsdb/get-collection.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ 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.')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Create.php
new file mode 100644
index 0000000000..a535dd5724
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Create.php
@@ -0,0 +1,73 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/indexes')
+ ->desc('Create index')
+ ->groups(['api', 'database'])
+ ->label('event', 'databases.[databaseId].tables.[tableId].indexes.[indexId].create')
+ ->label('scope', 'collections.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'index.create')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.tableId}')
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: $this->getSdkGroup(),
+ name: 'createIndex',
+ description: '/docs/references/vectorsdb/create-index.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_ACCEPTED,
+ model: $this->getResponseModel(),
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).')
+ ->param('key', null, new Key(), 'Index Key.')
+ ->param('type', null, new WhiteList([Database::INDEX_HNSW_EUCLIDEAN,Database::INDEX_HNSW_DOT, Database::INDEX_HNSW_COSINE, Database::INDEX_OBJECT, Database::INDEX_KEY, Database::INDEX_UNIQUE]), 'Index type.')
+ ->param('attributes', null, new ArrayList(new Key(true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of attributes to index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' attributes are allowed, each 32 characters long.')
+ ->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)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('queueForDatabase')
+ ->inject('queueForEvents')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Delete.php
new file mode 100644
index 0000000000..5c7fc47ee0
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Delete.php
@@ -0,0 +1,67 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/indexes/:key')
+ ->desc('Delete index')
+ ->groups(['api', 'database'])
+ ->label('scope', 'collections.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].collections.[collectionId].indexes.[indexId].update')
+ ->label('audits.event', 'index.delete')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: $this->getSdkGroup(),
+ name: 'deleteIndex', // getName needs to be different from parent action to avoid conflict in path name
+ description: '/docs/references/vectorsdb/delete-index.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_NOCONTENT,
+ model: UtopiaResponse::MODEL_NONE,
+ )
+ ],
+ contentType: ContentType::NONE
+ ))
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).')
+ ->param('key', '', new Key(), 'Index Key.')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForDatabase')
+ ->inject('queueForEvents')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Get.php
new file mode 100644
index 0000000000..4cf646acba
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Get.php
@@ -0,0 +1,58 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/indexes/:key')
+ ->desc('Get index')
+ ->groups(['api', 'database'])
+ ->label('scope', 'collections.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: $this->getSdkGroup(),
+ name: 'getIndex', // getName needs to be different from parent action to avoid conflict in path name
+ description: '/docs/references/vectorsdb/get-index.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ 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. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).')
+ ->param('key', null, new Key(), 'Index Key.')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/XList.php
new file mode 100644
index 0000000000..acc46fb570
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/XList.php
@@ -0,0 +1,60 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/indexes')
+ ->desc('List indexes')
+ ->groups(['api', 'database'])
+ ->label('scope', 'collections.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: $this->getSdkGroup(),
+ name: 'listIndexes', // getName needs to be different from parent action to avoid conflict in path name
+ description: '/docs/references/vectorsdb/list-indexes.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ 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. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).')
+ ->param('queries', [], new Indexes(), '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(', ', Indexes::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')
+ ->inject('authorization')
+ ->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
new file mode 100644
index 0000000000..f8ba767e7e
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Update.php
@@ -0,0 +1,117 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PUT)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId')
+ ->desc('Update collection')
+ ->groups(['api', 'database', 'schema'])
+ ->label('scope', 'collections.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].collections.[collectionId].update')
+ ->label('audits.event', 'collection.update')
+ ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: 'collections',
+ name: 'updateCollection',
+ description: '/docs/references/vectorsdb/update-collection.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_VECTORSDB_COLLECTION,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->param('collectionId', '', new UID(), 'Collection ID.')
+ ->param('name', null, new Text(128), 'Collection name. Max length: 128 chars.')
+ ->param('dimension', null, new Range(MIN_VECTOR_DIMENSION, MAX_VECTOR_DIMENSION), 'Embedding dimensions.', true)
+ ->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)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('queueForEvents')
+ ->inject('authorization')
+ ->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
+ {
+ $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
+ if ($database->isEmpty()) {
+ throw new Exception(Exception::DATABASE_NOT_FOUND);
+ }
+
+ $collection = $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId);
+ if ($collection->isEmpty()) {
+ throw new Exception($this->getNotFoundException());
+ }
+
+ $permissions ??= $collection->getPermissions();
+
+ // Map aggregate permissions into the multiple permissions they represent.
+ $permissions = Permission::aggregate($permissions);
+
+ $enabled ??= $collection->getAttribute('enabled', true);
+
+ $updated = $dbForProject->updateDocument(
+ 'database_' . $database->getSequence(),
+ $collectionId,
+ $collection
+ ->setAttribute('name', $name ?? $collection->getAttribute('name'))
+ ->setAttribute('dimension', $dimensions ?? $collection->getAttribute('dimension'))
+ ->setAttribute('$permissions', $permissions)
+ ->setAttribute('documentSecurity', $documentSecurity)
+ ->setAttribute('enabled', $enabled)
+ ->setAttribute('search', \implode(' ', [$collectionId, $name ?? $collection->getAttribute('name')]))
+ );
+
+ $dbForDatabases = $getDatabasesDB($database);
+ $dbForDatabases->updateCollection('database_' . $database->getSequence() . '_collection_' . $updated->getSequence(), $permissions, $documentSecurity);
+
+ $queueForEvents
+ ->setContext('database', $database)
+ ->setParam('databaseId', $databaseId)
+ ->setParam($this->getEventsParamKey(), $updated->getId());
+
+ $response->dynamic($updated, $this->getResponseModel());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Usage/Get.php
new file mode 100644
index 0000000000..7e0f79a9f1
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Usage/Get.php
@@ -0,0 +1,64 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/usage')
+ ->desc('Get collection usage stats')
+ ->groups(['api', 'database', 'usage'])
+ ->label('scope', 'collections.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: null,
+ name: 'getCollectionUsage',
+ description: '/docs/references/vectorsdb/get-collection-usage.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('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true)
+ ->param('collectionId', '', new UID(), 'Collection ID.')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('authorization')
+ ->inject('getDatabasesDB')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/XList.php
new file mode 100644
index 0000000000..7ba26b8b6a
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/XList.php
@@ -0,0 +1,61 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/collections')
+ ->desc('List collections')
+ ->groups(['api', 'database'])
+ ->label('scope', 'collections.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: 'collections',
+ name: 'listCollections',
+ description: '/docs/references/vectorsdb/list-collections.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: $this->getResponseModel(),
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->param('queries', [], new Collections(), '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(', ', Collections::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')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Create.php
new file mode 100644
index 0000000000..cc2914fc10
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Create.php
@@ -0,0 +1,59 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/vectorsdb')
+ ->desc('Create database')
+ ->groups(['api', 'database'])
+ ->label('event', 'databases.[databaseId].create')
+ ->label('scope', 'databases.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('audits.event', 'database.create')
+ ->label('audits.resource', 'database/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: 'vectorsdb',
+ name: 'create',
+ description: '/docs/references/vectorsdb/create.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_CREATED,
+ model: UtopiaResponse::MODEL_DATABASE,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('databaseId', '', new CustomId(), '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.')
+ ->param('name', '', new Text(128), 'Database name. Max length: 128 chars.')
+ ->param('enabled', true, new Boolean(), 'Is the database enabled? When set to \'disabled\', users cannot access the database but Server SDKs with an API key can still read and write to the database. No data is lost when this is toggled.', true)
+ ->inject('project')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php
new file mode 100644
index 0000000000..c9d36904a9
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php
@@ -0,0 +1,55 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/vectorsdb/:databaseId')
+ ->desc('Delete database')
+ ->groups(['api', 'database', 'schema'])
+ ->label('scope', 'databases.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].delete')
+ ->label('audits.event', 'database.delete')
+ ->label('audits.resource', 'database/{request.databaseId}')
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: 'vectorsdb',
+ name: 'delete',
+ description: '/docs/references/vectorsdb/delete.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_NOCONTENT,
+ model: UtopiaResponse::MODEL_NONE,
+ )
+ ],
+ contentType: ContentType::NONE
+ ))
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForDatabase')
+ ->inject('queueForEvents')
+ ->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
new file mode 100644
index 0000000000..8a7137e38b
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php
@@ -0,0 +1,152 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/vectorsdb/embeddings/text')
+ ->desc('Create Text Embeddings')
+ ->groups(['api', 'database'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_EMBEDDINGS_TEXT)
+ ->label('audits.event', 'embedding.create')
+ ->label('audits.resource', 'vectorsdb/embeddings/text')
+ ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', [
+ new Method(
+ namespace: 'vectorsDB',
+ group: $this->getSdkGroup(),
+ name: 'createTextEmbeddings',
+ desc: 'Create Text Embedding',
+ description: '/docs/references/vectorsdb/create-document.md',
+ auth: [AuthType::ADMIN, AuthType::KEY, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: $this->getBulkResponseModel(),
+ )
+ ],
+ contentType: ContentType::JSON,
+ parameters: [
+ new Parameter('texts', optional: false),
+ new Parameter('model', optional: true),
+ ]
+ )
+ ])
+ ->param('texts', [], fn (array $plan) => new ArrayList(new Text(0), $plan['databasesMaxEmbeddingTexts'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of text to generate embeddings.', false, ['plan'])
+ ->param('model', Ollama::MODEL_EMBEDDING_GEMMA, new WhiteList(Ollama::MODELS), 'The embedding model to use for generating vector embeddings.', true)
+ ->inject('response')
+ ->inject('project')
+ ->inject('embeddingAgent')
+ ->inject('usage')
+ ->inject('log')
+ ->inject('logger')
+ ->callback($this->action(...));
+ }
+
+ 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);
+ $dimension = $embeddingAgent->getAdapter()->getEmbeddingDimension();
+
+ $totalDuration = 0;
+ $totalTokens = 0;
+ $totalErrors = 0;
+ foreach ($texts as $text) {
+ $embedding = [];
+ $error = '';
+ try {
+ $embedResult = $embeddingAgent->embed($text);
+ $embedding = $embedResult['embedding'];
+ $totalDuration += $embedResult['totalDuration'] ?? 0;
+ $totalTokens += $embedResult['tokensProcessed'] ?? 0;
+ } catch (\Exception $e) {
+ $error = 'Error while generating embedding';
+ $totalErrors += 1;
+ if ($logger) {
+ $log->setNamespace("http");
+ $log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
+ $log->setVersion(System::getEnv('_APP_VERSION', 'UNKNOWN'));
+ $log->setType(Log::TYPE_ERROR);
+ $log->setMessage($e->getMessage());
+
+ $log->addTag('embeddingModel', $model);
+ $log->addTag('code', $e->getCode());
+ $log->addTag('projectId', $project->getId());
+
+ $log->addExtra('file', $e->getFile());
+ $log->addExtra('line', $e->getLine());
+ $log->addExtra('trace', $e->getTraceAsString());
+
+ $logger->addLog($log);
+ }
+ }
+
+ $results[] = new Document([
+ 'model' => $model,
+ 'dimension' => $dimension,
+ 'embedding' => $embedding,
+ 'error' => $error
+ ]);
+ }
+ $embeddings = new Document([
+ 'embeddings' => $results,
+ 'total' => \count($results),
+ ]);
+
+ $response
+ ->setStatusCode(SwooleResponse::STATUS_CODE_OK)
+ ->dynamic($embeddings, $this->getBulkResponseModel());
+
+ $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)
+ ->addMetric(\str_replace('{embeddingModel}', $model, METRIC_EMBEDDINGS_MODEL_TEXT_TOTAL_TOKENS), $totalTokens)
+ ->addMetric(METRIC_EMBEDDINGS_TEXT_TOTAL_DURATION, $totalDuration)
+ ->addMetric(\str_replace('{embeddingModel}', $model, METRIC_EMBEDDINGS_MODEL_TEXT_TOTAL_DURATION), $totalDuration)
+ ->addMetric(METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR, $totalErrors)
+ ->addMetric(\str_replace('{embeddingModel}', $model, METRIC_EMBEDDINGS_MODEL_TEXT_TOTAL_ERROR), $totalErrors);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Get.php
new file mode 100644
index 0000000000..a79632b105
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Get.php
@@ -0,0 +1,49 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/vectorsdb/:databaseId')
+ ->desc('Get database')
+ ->groups(['api', 'database'])
+ ->label('scope', 'databases.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: 'vectorsdb',
+ name: 'get',
+ description: '/docs/references/vectorsdb/get.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_DATABASE,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Logs/XList.php
new file mode 100644
index 0000000000..d8c1df5f04
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Logs/XList.php
@@ -0,0 +1,59 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/logs')
+ ->desc('List database logs')
+ ->groups(['api', 'database'])
+ ->label('scope', 'databases.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', [
+ new Method(
+ namespace: 'vectorsDB',
+ group: 'logs',
+ name: 'listDatabaseLogs',
+ description: '/docs/references/vectorsdb/get-logs.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_LOG_LIST,
+ )
+ ],
+ contentType: ContentType::JSON
+ ),
+ ])
+ ->param('databaseId', '', new UID(), 'Database 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/Transactions/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Create.php
new file mode 100644
index 0000000000..cb67d3f7f1
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Create.php
@@ -0,0 +1,56 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/vectorsdb/transactions')
+ ->desc('Create transaction')
+ ->groups(['api', 'database', 'transactions'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: 'transactions',
+ name: 'createTransaction',
+ description: '/docs/references/vectorsdb/create-transaction.md',
+ auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_CREATED,
+ model: UtopiaResponse::MODEL_TRANSACTION,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('ttl', APP_DATABASE_TXN_TTL_DEFAULT, new Range(min: APP_DATABASE_TXN_TTL_MIN, max: APP_DATABASE_TXN_TTL_MAX), 'Seconds before the transaction expires.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('user')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Delete.php
new file mode 100644
index 0000000000..0ac2caecba
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Delete.php
@@ -0,0 +1,55 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/vectorsdb/transactions/:transactionId')
+ ->desc('Delete transaction')
+ ->groups(['api', 'database', 'transactions'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: 'transactions',
+ name: 'deleteTransaction',
+ description: '/docs/references/vectorsdb/delete-transaction.md',
+ auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_NOCONTENT,
+ model: UtopiaResponse::MODEL_NONE,
+ )
+ ],
+ contentType: ContentType::NONE
+ ))
+ ->param('transactionId', '', new UID(), 'Transaction ID.')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForDeletes')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Get.php
new file mode 100644
index 0000000000..fa4cc86cdd
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Get.php
@@ -0,0 +1,54 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/vectorsdb/transactions/:transactionId')
+ ->desc('Get transaction')
+ ->groups(['api', 'database', 'transactions'])
+ ->label('scope', 'documents.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: 'transactions',
+ name: 'getTransaction',
+ description: '/docs/references/vectorsdb/get-transaction.md',
+ auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_TRANSACTION,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('transactionId', '', new UID(), 'Transaction ID.')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->callback($this->action(...));
+ }
+}
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
new file mode 100644
index 0000000000..27283cda49
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Operations/Create.php
@@ -0,0 +1,61 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/v1/vectorsdb/transactions/:transactionId/operations')
+ ->desc('Create operations')
+ ->groups(['api', 'database', 'transactions'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: 'transactions',
+ name: 'createOperations',
+ description: '/docs/references/vectorsdb/create-operations.md',
+ auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_CREATED,
+ model: UtopiaResponse::MODEL_TRANSACTION,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('transactionId', '', new UID(), 'Transaction ID.')
+ ->param('operations', [], new ArrayList(new Operation(type: 'documentsdb')), 'Array of staged operations.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->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
new file mode 100644
index 0000000000..f4bd4d67f5
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Update.php
@@ -0,0 +1,69 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/vectorsdb/transactions/:transactionId')
+ ->desc('Update transaction')
+ ->groups(['api', 'database', 'transactions'])
+ ->label('scope', 'documents.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: 'transactions',
+ name: 'updateTransaction',
+ description: '/docs/references/vectorsdb/update-transaction.md',
+ auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_TRANSACTION,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('transactionId', '', new UID(), 'Transaction ID.')
+ ->param('commit', false, new Boolean(), 'Commit transaction?', true)
+ ->param('rollback', false, new Boolean(), 'Rollback transaction?', true)
+ ->inject('project')
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('getDatabasesDB')
+ ->inject('user')
+ ->inject('transactionState')
+ ->inject('queueForDeletes')
+ ->inject('queueForEvents')
+ ->inject('usage')
+ ->inject('queueForRealtime')
+ ->inject('queueForFunctions')
+ ->inject('queueForWebhooks')
+ ->inject('authorization')
+ ->inject('eventProcessor')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/XList.php
new file mode 100644
index 0000000000..fb95667ffb
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/XList.php
@@ -0,0 +1,54 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/vectorsdb/transactions')
+ ->desc('List transactions')
+ ->groups(['api', 'database', 'transactions'])
+ ->label('scope', 'documents.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: 'transactions',
+ name: 'listTransactions',
+ description: '/docs/references/vectorsdb/list-transactions.md',
+ auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_TRANSACTION_LIST,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('queries', [], new Transactions(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries).', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Update.php
new file mode 100644
index 0000000000..0b10d6d98b
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Update.php
@@ -0,0 +1,57 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_PUT)
+ ->setHttpPath('/v1/vectorsdb/:databaseId')
+ ->desc('Update database')
+ ->groups(['api', 'database', 'schema'])
+ ->label('scope', 'databases.write')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('event', 'databases.[databaseId].update')
+ ->label('audits.event', 'database.update')
+ ->label('audits.resource', 'database/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: 'vectorsdb',
+ name: 'update',
+ description: '/docs/references/vectorsdb/update.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_DATABASE,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->param('name', null, new Text(128), 'Database name. Max length: 128 chars.')
+ ->param('enabled', true, new Boolean(), 'Is database enabled? When set to \'disabled\', users cannot access the database but Server SDKs with an API key can still read and write to the database. No data is lost when this is toggled.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Usage/Get.php
new file mode 100644
index 0000000000..051e2e39fa
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Usage/Get.php
@@ -0,0 +1,59 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/vectorsdb/:databaseId/usage')
+ ->desc('Get VectorsDB usage stats')
+ ->groups(['api', 'database', 'usage'])
+ ->label('scope', 'collections.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', [
+ new Method(
+ namespace: 'vectorsDB',
+ group: null,
+ name: 'getUsage',
+ description: '/docs/references/vectorsdb/get-database-usage.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_USAGE_VECTORSDB,
+ )
+ ],
+ contentType: ContentType::JSON,
+ ),
+ ])
+ ->param('databaseId', '', new UID(), 'Database ID.')
+ ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->inject('authorization')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Usage/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Usage/XList.php
new file mode 100644
index 0000000000..d91a5963c4
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Usage/XList.php
@@ -0,0 +1,56 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/vectorsdb/usage')
+ ->desc('Get VectorsDB usage stats')
+ ->groups(['api', 'database', 'usage'])
+ ->label('scope', 'collections.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', [
+ new Method(
+ namespace: 'vectorsDB',
+ group: null,
+ name: 'listUsage',
+ description: '/docs/references/vectorsdb/list-usage.md',
+ auth: [AuthType::ADMIN],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_USAGE_VECTORSDBS,
+ )
+ ],
+ contentType: ContentType::JSON
+ ),
+ ])
+ ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true)
+ ->inject('response')
+ ->inject('dbForProject')
+ ->callback($this->action(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/XList.php
new file mode 100644
index 0000000000..e18a89c6a4
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/XList.php
@@ -0,0 +1,53 @@
+setHttpMethod(self::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/vectorsdb')
+ ->desc('List databases')
+ ->groups(['api', 'database'])
+ ->label('scope', 'databases.read')
+ ->label('resourceType', RESOURCE_TYPE_DATABASES)
+ ->label('sdk', new Method(
+ namespace: 'vectorsDB',
+ group: 'vectorsdb',
+ name: 'list',
+ description: '/docs/references/vectorsdb/list.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: SwooleResponse::STATUS_CODE_OK,
+ model: UtopiaResponse::MODEL_DATABASE_LIST,
+ )
+ ],
+ contentType: ContentType::JSON
+ ))
+ ->param('queries', [], new Databases(), '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 columns: ' . implode(', ', Databases::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(...));
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Http.php b/src/Appwrite/Platform/Modules/Databases/Services/Http.php
index f683f537bc..5146382b56 100644
--- a/src/Appwrite/Platform/Modules/Databases/Services/Http.php
+++ b/src/Appwrite/Platform/Modules/Databases/Services/Http.php
@@ -3,8 +3,10 @@
namespace Appwrite\Platform\Modules\Databases\Services;
use Appwrite\Platform\Modules\Databases\Http\Init\Timeout;
+use Appwrite\Platform\Modules\Databases\Services\Registry\DocumentsDB as DocumentsDBRegistry;
use Appwrite\Platform\Modules\Databases\Services\Registry\Legacy as LegacyRegistry;
-use Appwrite\Platform\Modules\Databases\Services\Registry\TablesDB as TablesDBRegistry;
+use Appwrite\Platform\Modules\Databases\Services\Registry\TablesDB as TablesDBDBRegistry;
+use Appwrite\Platform\Modules\Databases\Services\Registry\VectorsDB as VectorsDBRegistry;
use Utopia\Platform\Service;
class Http extends Service
@@ -17,7 +19,9 @@ class Http extends Service
foreach ([
LegacyRegistry::class,
- TablesDBRegistry::class,
+ TablesDBDBRegistry::class,
+ DocumentsDBRegistry::class,
+ VectorsDBRegistry::class
] as $registrar) {
new $registrar($this);
}
diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php
new file mode 100644
index 0000000000..5d41ed3e2b
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php
@@ -0,0 +1,105 @@
+registerDatabaseActions($service);
+ $this->registerTableActions($service);
+ $this->registerIndexActions($service);
+ $this->registerRowActions($service);
+ $this->registerTransactionActions($service);
+ }
+
+ private function registerDatabaseActions(Service $service): void
+ {
+ $service->addAction(CreateTablesDatabase::getName(), new CreateTablesDatabase());
+ $service->addAction(GetTablesDatabase::getName(), new GetTablesDatabase());
+ $service->addAction(UpdateTablesDatabase::getName(), new UpdateTablesDatabase());
+ $service->addAction(DeleteTablesDatabase::getName(), new DeleteTablesDatabase());
+ $service->addAction(ListTablesDatabase::getName(), new ListTablesDatabase());
+ $service->addAction(GetTablesDatabaseUsage::getName(), new GetTablesDatabaseUsage());
+ $service->addAction(ListTablesDatabaseUsage::getName(), new ListTablesDatabaseUsage());
+ }
+
+ private function registerTableActions(Service $service): void
+ {
+ $service->addAction(CreateTable::getName(), new CreateTable());
+ $service->addAction(GetTable::getName(), new GetTable());
+ $service->addAction(UpdateTable::getName(), new UpdateTable());
+ $service->addAction(DeleteTable::getName(), new DeleteTable());
+ $service->addAction(ListTables::getName(), new ListTables());
+ $service->addAction(GetTableUsage::getName(), new GetTableUsage());
+ }
+
+ private function registerIndexActions(Service $service): void
+ {
+ $service->addAction(CreateColumnIndex::getName(), new CreateColumnIndex());
+ $service->addAction(GetColumnIndex::getName(), new GetColumnIndex());
+ $service->addAction(DeleteColumnIndex::getName(), new DeleteColumnIndex());
+ $service->addAction(ListColumnIndexes::getName(), new ListColumnIndexes());
+ }
+
+ private function registerRowActions(Service $service): void
+ {
+ $service->addAction(CreateRow::getName(), new CreateRow());
+ $service->addAction(GetRow::getName(), new GetRow());
+ $service->addAction(UpdateRow::getName(), new UpdateRow());
+ $service->addAction(UpdateRows::getName(), new UpdateRows());
+ $service->addAction(UpsertRow::getName(), new UpsertRow());
+ $service->addAction(UpsertRows::getName(), new UpsertRows());
+ $service->addAction(DeleteRow::getName(), new DeleteRow());
+ $service->addAction(DeleteRows::getName(), new DeleteRows());
+ $service->addAction(ListRows::getName(), new ListRows());
+ $service->addAction(IncrementRowColumn::getName(), new IncrementRowColumn());
+ $service->addAction(DecrementRowColumn::getName(), new DecrementRowColumn());
+ }
+
+ private function registerTransactionActions(Service $service): void
+ {
+ $service->addAction(CreateTransaction::getName(), new CreateTransaction());
+ $service->addAction(GetTransaction::getName(), new GetTransaction());
+ $service->addAction(UpdateTransaction::getName(), new UpdateTransaction());
+ $service->addAction(DeleteTransaction::getName(), new DeleteTransaction());
+ $service->addAction(ListTransactions::getName(), new ListTransactions());
+ $service->addAction(CreateOperations::getName(), new CreateOperations());
+ }
+}
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
new file mode 100644
index 0000000000..fe96d51d20
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php
@@ -0,0 +1,108 @@
+registerDatabaseActions($service);
+ $this->registerCollectionActions($service);
+ $this->registerIndexActions($service);
+ $this->registerDocumentActions($service);
+ $this->registerEmbeddingActions($service);
+ $this->registerTransactionActions($service);
+ }
+
+ private function registerDatabaseActions(Service $service): void
+ {
+ $service->addAction(CreateVectorDatabase::getName(), new CreateVectorDatabase());
+ $service->addAction(GetVectorDatabase::getName(), new GetVectorDatabase());
+ $service->addAction(UpdateVectorDatabase::getName(), new UpdateVectorDatabase());
+ $service->addAction(DeleteVectorDatabase::getName(), new DeleteVectorDatabase());
+ $service->addAction(ListVectorDatabases::getName(), new ListVectorDatabases());
+ $service->addAction(GetVectorDatabaseUsage::getName(), new GetVectorDatabaseUsage());
+ $service->addAction(ListVectorDatabaseUsage::getName(), new ListVectorDatabaseUsage());
+ }
+
+ private function registerCollectionActions(Service $service): void
+ {
+ $service->addAction(CreateCollection::getName(), new CreateCollection());
+ $service->addAction(GetCollection::getName(), new GetCollection());
+ $service->addAction(UpdateCollection::getName(), new UpdateCollection());
+ $service->addAction(DeleteCollection::getName(), new DeleteCollection());
+ $service->addAction(ListCollections::getName(), new ListCollections());
+ $service->addAction(GetCollectionUsage::getName(), new GetCollectionUsage());
+ }
+
+ private function registerIndexActions(Service $service): void
+ {
+ $service->addAction(CreateIndex::getName(), new CreateIndex());
+ $service->addAction(GetIndex::getName(), new GetIndex());
+ $service->addAction(DeleteIndex::getName(), new DeleteIndex());
+ $service->addAction(ListIndexes::getName(), new ListIndexes());
+ }
+
+ private function registerDocumentActions(Service $service): void
+ {
+ $service->addAction(CreateDocument::getName(), new CreateDocument());
+ $service->addAction(UpdateDocument::getName(), new UpdateDocument());
+ $service->addAction(UpsertDocument::getName(), new UpsertDocument());
+ $service->addAction(GetDocument::getName(), new GetDocument());
+ $service->addAction(ListDocuments::getName(), new ListDocuments());
+ $service->addAction(DeleteDocument::getName(), new DeleteDocument());
+ $service->addAction(UpdateDocuments::getName(), new UpdateDocuments());
+ $service->addAction(UpsertDocuments::getName(), new UpsertDocuments());
+ $service->addAction(DeleteDocuments::getName(), new DeleteDocuments());
+ }
+
+ private function registerTransactionActions(Service $service): void
+ {
+ $service->addAction(CreateTransaction::getName(), new CreateTransaction());
+ $service->addAction(GetTransaction::getName(), new GetTransaction());
+ $service->addAction(UpdateTransaction::getName(), new UpdateTransaction());
+ $service->addAction(DeleteTransaction::getName(), new DeleteTransaction());
+ $service->addAction(ListTransactions::getName(), new ListTransactions());
+ $service->addAction(CreateOperations::getName(), new CreateOperations());
+ }
+
+ private function registerEmbeddingActions(Service $service): void
+ {
+ $service->addAction(CreateTextEmbeddings::getName(), new CreateTextEmbeddings());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php
index 60d70b7942..39902aea53 100644
--- a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php
+++ b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php
@@ -36,6 +36,7 @@ class Databases extends Action
->inject('project')
->inject('dbForPlatform')
->inject('dbForProject')
+ ->inject('getDatabasesDB')
->inject('queueForRealtime')
->inject('log')
->callback($this->action(...));
@@ -51,9 +52,9 @@ class Databases extends Action
* @return void
* @throws \Exception
*/
- public function action(Message $message, Document $project, Database $dbForPlatform, Database $dbForProject, Realtime $queueForRealtime, Log $log): void
+ 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');
@@ -63,7 +64,10 @@ class Databases extends Action
$document = new Document($payload['row'] ?? $payload['document'] ?? []);
$collection = new Document($payload['table'] ?? $payload['collection'] ?? []);
$database = new Document($payload['database'] ?? []);
-
+ /**
+ * @var Database $dbForDatabases
+ */
+ $dbForDatabases = $getDatabasesDB($database);
$log->addTag('projectId', $project->getId());
$log->addTag('type', $type);
@@ -74,12 +78,12 @@ class Databases extends Action
$log->addTag('databaseId', $database->getId());
match (\strval($type)) {
- DATABASE_TYPE_DELETE_DATABASE => $this->deleteDatabase($database, $dbForProject),
- DATABASE_TYPE_DELETE_COLLECTION => $this->deleteCollection($database, $collection, $dbForProject),
+ DATABASE_TYPE_DELETE_DATABASE => $this->deleteDatabase($database, $dbForProject, $dbForDatabases),
+ DATABASE_TYPE_DELETE_COLLECTION => $this->deleteCollection($database, $collection, $dbForProject, $dbForDatabases),
DATABASE_TYPE_CREATE_ATTRIBUTE => $this->createAttribute($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $queueForRealtime),
- DATABASE_TYPE_DELETE_ATTRIBUTE => $this->deleteAttribute($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $queueForRealtime),
- DATABASE_TYPE_CREATE_INDEX => $this->createIndex($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $queueForRealtime),
- DATABASE_TYPE_DELETE_INDEX => $this->deleteIndex($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $queueForRealtime),
+ DATABASE_TYPE_DELETE_ATTRIBUTE => $this->deleteAttribute($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $dbForDatabases, $queueForRealtime),
+ DATABASE_TYPE_CREATE_INDEX => $this->createIndex($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $dbForDatabases, $queueForRealtime),
+ DATABASE_TYPE_DELETE_INDEX => $this->deleteIndex($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $dbForDatabases, $queueForRealtime),
default => throw new Exception('No database operation for type: ' . \strval($type)),
};
@@ -244,6 +248,7 @@ class Databases extends Action
* @param Document $project
* @param Database $dbForPlatform
* @param Database $dbForProject
+ * @param Database $dbForDatabases
* @param Realtime $queueForRealtime
* @return void
* @throws Authorization
@@ -251,7 +256,7 @@ class Databases extends Action
* @throws \Exception
* @throws \Throwable
**/
- private function deleteAttribute(Document $database, Document $collection, Document $attribute, Document $project, Database $dbForPlatform, Database $dbForProject, Realtime $queueForRealtime): void
+ private function deleteAttribute(Document $database, Document $collection, Document $attribute, Document $project, Database $dbForPlatform, Database $dbForDatabases, Database $dbForProject, Realtime $queueForRealtime): void
{
if ($collection->isEmpty()) {
throw new Exception('Missing collection/table');
@@ -386,7 +391,7 @@ class Databases extends Action
}
if ($exists) { // Delete the duplicate if created, else update in db
- $this->deleteIndex($database, $collection, $index, $project, $dbForPlatform, $dbForProject, $queueForRealtime);
+ $this->deleteIndex($database, $collection, $index, $project, $dbForPlatform, $dbForProject, $dbForDatabases, $queueForRealtime);
} else {
$dbForProject->updateDocument('indexes', $index->getId(), new Document([
'attributes' => $index->getAttribute('attributes'),
@@ -415,6 +420,7 @@ class Databases extends Action
* @param Document $project
* @param Database $dbForPlatform
* @param Database $dbForProject
+ * @param Database $dbForDatabases
* @param Realtime $queueForRealtime
* @return void
* @throws Authorization
@@ -423,7 +429,7 @@ class Databases extends Action
* @throws DatabaseException
* @throws \Throwable
*/
- private function createIndex(Document $database, Document $collection, Document $index, Document $project, Database $dbForPlatform, Database $dbForProject, Realtime $queueForRealtime): void
+ private function createIndex(Document $database, Document $collection, Document $index, Document $project, Database $dbForPlatform, Database $dbForProject, Database $dbForDatabases, Realtime $queueForRealtime): void
{
if ($collection->isEmpty()) {
throw new Exception('Missing collection/table');
@@ -443,7 +449,7 @@ class Databases extends Action
$project = $dbForPlatform->getDocument('projects', $projectId);
try {
- if (!$dbForProject->createIndex('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $key, $type, $attributes, $lengths, $orders)) {
+ if (!$dbForDatabases->createIndex('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $key, $type, $attributes, $lengths, $orders)) {
throw new DatabaseException('Failed to create Index');
}
$dbForProject->updateDocument('indexes', $index->getId(), $index->setAttribute('status', 'available'));
@@ -473,6 +479,7 @@ class Databases extends Action
* @param Document $project
* @param Database $dbForPlatform
* @param Database $dbForProject
+ * @param Database $dbForDatabases
* @param Realtime $queueForRealtime
* @return void
* @throws Authorization
@@ -481,7 +488,7 @@ class Databases extends Action
* @throws DatabaseException
* @throws \Throwable
*/
- private function deleteIndex(Document $database, Document $collection, Document $index, Document $project, Database $dbForPlatform, Database $dbForProject, Realtime $queueForRealtime): void
+ private function deleteIndex(Document $database, Document $collection, Document $index, Document $project, Database $dbForPlatform, Database $dbForProject, Database $dbForDatabases, Realtime $queueForRealtime): void
{
if ($collection->isEmpty()) {
throw new Exception('Missing collection/table');
@@ -497,7 +504,7 @@ class Databases extends Action
$project = $dbForPlatform->getDocument('projects', $projectId);
try {
- if ($status !== 'failed' && !$dbForProject->deleteIndex('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $key)) {
+ if ($status !== 'failed' && !$dbForDatabases->deleteIndex('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $key)) {
throw new DatabaseException('Failed to delete index');
}
$dbForProject->deleteDocument('indexes', $index->getId());
@@ -525,14 +532,15 @@ class Databases extends Action
/**
* @param Document $database
- * @param $dbForProject
+ * @param Database $dbForProject
+ * @param Database $dbForDatabases
* @return void
* @throws Exception
*/
- protected function deleteDatabase(Document $database, $dbForProject): void
+ protected function deleteDatabase(Document $database, Database $dbForProject, Database $dbForDatabases): void
{
- $this->deleteByGroup('database_' . $database->getSequence(), [], $dbForProject, function ($collection) use ($database, $dbForProject) {
- $this->deleteCollection($database, $collection, $dbForProject);
+ $this->deleteByGroup('database_' . $database->getSequence(), [], $dbForProject, function ($collection) use ($database, $dbForProject, $dbForDatabases) {
+ $this->deleteCollection($database, $collection, $dbForProject, $dbForDatabases);
});
$dbForProject->deleteCollection('database_' . $database->getSequence());
@@ -542,6 +550,7 @@ class Databases extends Action
* @param Document $database
* @param Document $collection
* @param Database $dbForProject
+ * @param Database $dbForDatabases
* @return void
* @throws Authorization
* @throws Conflict
@@ -550,7 +559,7 @@ class Databases extends Action
* @throws Structure
* @throws Exception
*/
- protected function deleteCollection(Document $database, Document $collection, Database $dbForProject): void
+ protected function deleteCollection(Document $database, Document $collection, Database $dbForProject, Database $dbForDatabases): void
{
if ($collection->isEmpty()) {
throw new Exception('Missing collection/table');
@@ -560,7 +569,7 @@ class Databases extends Action
$collectionInternalId = $collection->getSequence();
$databaseInternalId = $database->getSequence();
- $dbForProject->deleteCollection('database_' . $databaseInternalId . '_collection_' . $collection->getSequence());
+ $dbForDatabases->deleteCollection('database_' . $databaseInternalId . '_collection_' . $collection->getSequence());
/**
* Related collections relating to current collection
@@ -641,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 ee33abe9e1..02dd76294e 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php
@@ -17,6 +17,7 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Response;
+use Executor\Exception\Timeout as ExecutorTimeout;
use Executor\Executor;
use MaxMind\Db\Reader;
use Utopia\Auth\Proofs\Token;
@@ -60,7 +61,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(
@@ -119,7 +120,7 @@ class Create extends Base
Document $project,
Database $dbForProject,
Database $dbForPlatform,
- Document $user,
+ User $user,
Event $queueForEvents,
Context $usage,
Func $queueForFunctions,
@@ -145,21 +146,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 +159,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 +201,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 +228,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,9 +293,7 @@ class Create extends Base
if ($async) {
if (is_null($scheduledAt)) {
- if ($project->getId() != '6862e6a6000cce69f9da') {
- $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution));
- }
+ $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution));
$queueForFunctions
->setType('http')
->setExecution($execution)
@@ -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) {
@@ -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..00a91141fb 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php
@@ -2,9 +2,10 @@
namespace Appwrite\Platform\Modules\Functions\Http\Functions;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Validator\FunctionEvent;
use Appwrite\Event\Webhook;
@@ -115,7 +116,7 @@ class Create extends Base
->inject('timelimit')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('queueForRealtime')
->inject('queueForWebhooks')
->inject('queueForFunctions')
@@ -157,7 +158,7 @@ class Create extends Base
callable $timelimit,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
Realtime $queueForRealtime,
Webhook $queueForWebhooks,
Func $queueForFunctions,
@@ -326,10 +327,11 @@ class Create extends Base
project: $project,
installation: $installation,
dbForProject: $dbForProject,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: true,
+ platform: $platform,
reference: $providerBranch,
referenceType: 'branch'
);
@@ -367,15 +369,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 +396,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(),
@@ -424,8 +429,8 @@ class Create extends Base
/** 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 bcedeee764..e120da4e3d 100644
--- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
+++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php
@@ -6,15 +6,17 @@ use Ahc\Jwt\JWT;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
use Appwrite\Event\Message\Usage as UsageMessage;
+use Appwrite\Event\Publisher\Screenshot;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Realtime;
-use Appwrite\Event\Screenshot;
use Appwrite\Event\Webhook;
+use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Response\Model\Deployment;
use Appwrite\Vcs\Comment;
use Exception;
+use Executor\Exception\Timeout as ExecutorTimeout;
use Executor\Executor;
use Swoole\Coroutine as Co;
use Utopia\Cache\Cache;
@@ -34,6 +36,7 @@ use Utopia\Detector\Detector\Rendering;
use Utopia\Logger\Log;
use Utopia\Platform\Action;
use Utopia\Queue\Message;
+use Utopia\Span\Span;
use Utopia\Storage\Device;
use Utopia\Storage\Device\Local;
use Utopia\System\System;
@@ -58,7 +61,7 @@ class Builds extends Action
->inject('project')
->inject('dbForPlatform')
->inject('queueForEvents')
- ->inject('queueForScreenshots')
+ ->inject('publisherForScreenshots')
->inject('queueForWebhooks')
->inject('queueForFunctions')
->inject('queueForRealtime')
@@ -84,7 +87,7 @@ class Builds extends Action
Document $project,
Database $dbForPlatform,
Event $queueForEvents,
- Screenshot $queueForScreenshots,
+ Screenshot $publisherForScreenshots,
Webhook $queueForWebhooks,
Func $queueForFunctions,
Realtime $queueForRealtime,
@@ -102,7 +105,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');
@@ -126,7 +129,7 @@ class Builds extends Action
$deviceForFunctions,
$deviceForSites,
$deviceForFiles,
- $queueForScreenshots,
+ $publisherForScreenshots,
$queueForWebhooks,
$queueForFunctions,
$queueForRealtime,
@@ -144,7 +147,8 @@ class Builds extends Action
$log,
$executor,
$plan,
- $platform
+ $platform,
+ (int) ($payload['timeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900))
);
break;
@@ -161,7 +165,7 @@ class Builds extends Action
Device $deviceForFunctions,
Device $deviceForSites,
Device $deviceForFiles,
- Screenshot $queueForScreenshots,
+ Screenshot $publisherForScreenshots,
Webhook $queueForWebhooks,
Func $queueForFunctions,
Realtime $queueForRealtime,
@@ -179,8 +183,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();
@@ -204,7 +215,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');
}
@@ -221,12 +232,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";
@@ -450,7 +461,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");
@@ -592,10 +603,7 @@ 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);
+ $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $timeout, 0);
$apiKey = $jwtObj->encode([
'projectId' => $project->getId(),
@@ -629,7 +637,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(),
@@ -644,7 +652,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(),
@@ -725,6 +733,9 @@ class Builds extends Action
);
Console::log('createRuntime finished');
+ } catch (ExecutorTimeout $error) {
+ Console::warning('createRuntime timed out');
+ $err = new AppwriteException(AppwriteException::BUILD_TIMEOUT, previous: $error);
} catch (\Throwable $error) {
Console::warning('createRuntime failed');
$err = $error;
@@ -830,7 +841,8 @@ 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;
@@ -862,7 +874,7 @@ 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);
@@ -1120,10 +1132,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');
}
@@ -1151,13 +1163,11 @@ class Builds extends Action
$message = \str_replace('{APPWRITE_DETECTION_SEPARATOR_START}', '', $message);
$message = \str_replace('{APPWRITE_DETECTION_SEPARATOR_END}', '', $message);
- // Combine with previous logs if deployment got past build process
- $previousLogs = '';
- if (! is_null($deployment->getAttribute('buildSize', null))) {
- $previousLogs = $deployment->getAttribute('buildLogs', '');
- if (! empty($previousLogs)) {
- $message = $previousLogs . "\n" . $message;
- }
+ // Append error to whatever build logs were already streamed
+ $deployment = $dbForProject->getDocument('deployments', $deploymentId);
+ $previousLogs = $deployment->getAttribute('buildLogs', '');
+ if (! empty($previousLogs)) {
+ $message = $previousLogs . "\n" . $message;
}
$endTime = DateTime::now();
@@ -1203,6 +1213,8 @@ class Builds extends Action
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':
@@ -1258,21 +1270,6 @@ class Builds extends Action
*/
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');
- }
}
/**
@@ -1282,13 +1279,6 @@ class Builds extends Action
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
@@ -1312,6 +1302,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() . '".'),
};
}
@@ -1364,6 +1355,8 @@ class Builds extends Action
Realtime $queueForRealtime,
array $platform
): void {
+ $deployment = new Document();
+
try {
if ($resource->getAttribute('providerSilentMode', false) === true) {
return;
@@ -1442,11 +1435,10 @@ class Builds extends Action
]);
$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));
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 cb3640746f..70d7713280 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\Publisher\Audit;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
+use Appwrite\Event\Publisher\Certificate;
+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\Screenshot;
-use Appwrite\Event\StatsResources;
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('publisherForAudits')
+ ->inject('publisherForMails')
->inject('queueForFunctions')
- ->inject('queueForStatsResources')
+ ->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,
+ Audit $publisherForAudits,
+ MailPublisher $publisherForMails,
Func $queueForFunctions,
- StatsResources $queueForStatsResources,
+ 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_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) => $queueForFunctions,
- System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME) => $queueForStatsResources,
+ 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/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/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/Project/AuthMethods/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php
new file mode 100644
index 0000000000..0d1cd83203
--- /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. Use this endpoint to enable or disable a given auth method for this project.')
+ ->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
index acc39bb68d..8c76ed2a8e 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php
@@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\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;
@@ -19,7 +18,7 @@ use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
-class Create extends Base
+class Create extends Action
{
use HTTP;
@@ -54,7 +53,7 @@ class Create extends Base
)
],
))
- ->param('variableId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject'])
+ ->param('variableId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject'])
->param('key', null, new Text(Database::LENGTH_KEY), 'Variable key. Max length: ' . Database::LENGTH_KEY . ' chars.')
->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.')
->param('secret', true, new Boolean(), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true)
@@ -73,7 +72,7 @@ class Create extends Base
QueueEvent $queueForEvents,
Database $dbForProject,
) {
- $variableId = ($variableId == 'unique()') ? ID::unique() : $variableId;
+ $variableId = ($variableId === 'unique()') ? ID::unique() : $variableId;
$variable = new Document([
'$id' => $variableId,
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php
index ac47ec3dbb..553fb09e54 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php
@@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Variables;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
-use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
@@ -16,7 +15,7 @@ use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
-class Delete extends Base
+class Delete extends Action
{
use HTTP;
@@ -35,7 +34,7 @@ class Delete extends Base
->label('scope', 'project.write')
->label('event', 'variables.[variableId].delete')
->label('audits.event', 'project.variable.delete')
- ->label('audits.resource', 'project.variable/{response.$id}')
+ ->label('audits.resource', 'project.variable/{request.variableId}')
->label('sdk', new Method(
namespace: 'project',
group: 'variables',
@@ -52,7 +51,7 @@ class Delete extends Base
],
contentType: ContentType::NONE
))
- ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID.', false, ['dbForProject'])
+ ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php
index 6de51dacaf..d9030421d7 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php
@@ -3,7 +3,6 @@
namespace Appwrite\Platform\Modules\Project\Http\Project\Variables;
use Appwrite\Extend\Exception;
-use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
@@ -13,7 +12,7 @@ use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
-class Get extends Base
+class Get extends Action
{
use HTTP;
@@ -45,7 +44,7 @@ class Get extends Base
)
]
))
- ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID.', false, ['dbForProject'])
+ ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php
index 61a943b618..6b05e19a78 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php
@@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\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;
@@ -19,7 +18,7 @@ use Utopia\Validator\Boolean;
use Utopia\Validator\Nullable;
use Utopia\Validator\Text;
-class Update extends Base
+class Update extends Action
{
use HTTP;
@@ -53,7 +52,7 @@ class Update extends Base
)
]
))
- ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID.', false, ['dbForProject'])
+ ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject'])
->param('key', null, new Nullable(new Text(255, 0)), 'Variable key. Max length: 255 chars.', true)
->param('value', null, new Nullable(new Text(8192, 0)), 'Variable value. Max length: 8192 chars.', true)
->param('secret', null, new Nullable(new Boolean()), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true)
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php
index cd11fe68c6..bd391ea3b4 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php
@@ -3,7 +3,6 @@
namespace Appwrite\Platform\Modules\Project\Http\Project\Variables;
use Appwrite\Extend\Exception;
-use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
@@ -19,7 +18,7 @@ use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
-class XList extends Base
+class XList extends Action
{
use HTTP;
diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php
index 949fb2bcd9..d36d0e9005 100644
--- a/src/Appwrite/Platform/Modules/Project/Services/Http.php
+++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php
@@ -3,6 +3,96 @@
namespace Appwrite\Platform\Modules\Project\Services;
use Appwrite\Platform\Modules\Project\Http\Init;
+use Appwrite\Platform\Modules\Project\Http\Project\AuthMethods\Update as UpdateAuthMethod;
+use Appwrite\Platform\Modules\Project\Http\Project\Delete as DeleteProject;
+use Appwrite\Platform\Modules\Project\Http\Project\Get as GetProject;
+use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey;
+use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey;
+use Appwrite\Platform\Modules\Project\Http\Project\Keys\Ephemeral\Create as CreateEphemeralKey;
+use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey;
+use Appwrite\Platform\Modules\Project\Http\Project\Keys\Update as UpdateKey;
+use Appwrite\Platform\Modules\Project\Http\Project\Keys\XList as ListKeys;
+use Appwrite\Platform\Modules\Project\Http\Project\Labels\Update as UpdateProjectLabels;
+use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Create as CreateMockPhone;
+use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Delete as DeleteMockPhone;
+use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Get as GetMockPhone;
+use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Update as UpdateMockPhone;
+use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\XList as ListMockPhones;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Amazon\Update as UpdateOAuth2Amazon;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Apple\Update as UpdateOAuth2Apple;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Auth0\Update as UpdateOAuth2Auth0;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Authentik\Update as UpdateOAuth2Authentik;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Autodesk\Update as UpdateOAuth2Autodesk;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Bitbucket\Update as UpdateOAuth2Bitbucket;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Bitly\Update as UpdateOAuth2Bitly;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Box\Update as UpdateOAuth2Box;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Dailymotion\Update as UpdateOAuth2Dailymotion;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Discord\Update as UpdateOAuth2Discord;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Disqus\Update as UpdateOAuth2Disqus;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Dropbox\Update as UpdateOAuth2Dropbox;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Etsy\Update as UpdateOAuth2Etsy;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Facebook\Update as UpdateOAuth2Facebook;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Figma\Update as UpdateOAuth2Figma;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\FusionAuth\Update as UpdateOAuth2FusionAuth;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Get as GetOAuth2Provider;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\GitHub\Update as UpdateOAuth2GitHub;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Gitlab\Update as UpdateOAuth2Gitlab;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Google\Update as UpdateOAuth2Google;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Keycloak\Update as UpdateOAuth2Keycloak;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Kick\Update as UpdateOAuth2Kick;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Linkedin\Update as UpdateOAuth2Linkedin;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Microsoft\Update as UpdateOAuth2Microsoft;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Notion\Update as UpdateOAuth2Notion;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Oidc\Update as UpdateOAuth2Oidc;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Okta\Update as UpdateOAuth2Okta;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Paypal\Update as UpdateOAuth2Paypal;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\PaypalSandbox\Update as UpdateOAuth2PaypalSandbox;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Podio\Update as UpdateOAuth2Podio;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Salesforce\Update as UpdateOAuth2Salesforce;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Slack\Update as UpdateOAuth2Slack;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Spotify\Update as UpdateOAuth2Spotify;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Stripe\Update as UpdateOAuth2Stripe;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Tradeshift\Update as UpdateOAuth2Tradeshift;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\TradeshiftSandbox\Update as UpdateOAuth2TradeshiftSandbox;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Twitch\Update as UpdateOAuth2Twitch;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\WordPress\Update as UpdateOAuth2WordPress;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\X\Update as UpdateOAuth2X;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\XList as ListOAuth2Providers;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Yahoo\Update as UpdateOAuth2Yahoo;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Yandex\Update as UpdateOAuth2Yandex;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Zoho\Update as UpdateOAuth2Zoho;
+use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Zoom\Update as UpdateOAuth2Zoom;
+use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Create as CreateAndroidPlatform;
+use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Update as UpdateAndroidPlatform;
+use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Apple\Create as CreateApplePlatform;
+use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Apple\Update as UpdateApplePlatform;
+use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Delete as DeletePlatform;
+use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Get as GetPlatform;
+use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Linux\Create as CreateLinuxPlatform;
+use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Linux\Update as UpdateLinuxPlatform;
+use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Create as CreateWebPlatform;
+use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Update as UpdateWebPlatform;
+use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as CreateWindowsPlatform;
+use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform;
+use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\Get as GetPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\MembershipPrivacy\Update as UpdateMembershipPrivacyPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordDictionary\Update as UpdatePasswordDictionaryPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordHistory\Update as UpdatePasswordHistoryPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordPersonalData\Update as UpdatePasswordPersonalDataPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionAlert\Update as UpdateSessionAlertPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionDuration\Update as UpdateSessionDurationPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionInvalidation\Update as UpdateSessionInvalidationPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionLimit\Update as UpdateSessionLimitPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\UserLimit\Update as UpdateUserLimitPolicy;
+use Appwrite\Platform\Modules\Project\Http\Project\Policies\XList as ListPolicies;
+use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdateProjectProtocol;
+use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService;
+use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest;
+use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP;
+use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Get as GetTemplate;
+use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Update as UpdateTemplate;
+use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\XList as ListTemplates;
use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable;
use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable;
use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable;
@@ -20,10 +110,118 @@ class Http extends Service
$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(DeletePlatform::getName(), new DeletePlatform());
+ $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(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(GetPlatform::getName(), new GetPlatform());
+ $this->addAction(ListPlatforms::getName(), new ListPlatforms());
+
+ // 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 eb71e5a02f..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,32 +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) {
- $tenant = null;
- if ($sharedTablesV1) {
- $tenant = $project->getSequence();
- }
- $dbForProject
- ->setSharedTables(true)
- ->setTenant($tenant)
- ->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;
@@ -241,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'] ?? [];
@@ -276,37 +228,7 @@ class Create extends Action
try {
$dbForProject->createCollection($key, $attributes, $indexes);
} catch (Duplicate) {
- try {
- $dbForProject->createDocument(Database::METADATA, new Document([
- '$id' => ID::custom($key),
- '$permissions' => [Permission::create(Role::any())],
- 'name' => $key,
- 'attributes' => $attributes,
- 'indexes' => $indexes,
- 'documentSecurity' => true
- ]));
- } catch (Duplicate) {
- // Metadata already exists from concurrent creation
- }
- } catch (\Throwable $e) {
- // PostgreSQL adapter may throw a non-Duplicate exception when
- // a table or index already exists during concurrent project
- // creation in shared mode. Treat as duplicate if metadata
- // can be created successfully.
- try {
- $dbForProject->createDocument(Database::METADATA, new Document([
- '$id' => ID::custom($key),
- '$permissions' => [Permission::create(Role::any())],
- 'name' => $key,
- 'attributes' => $attributes,
- 'indexes' => $indexes,
- 'documentSecurity' => true
- ]));
- } catch (Duplicate) {
- // Metadata already exists from concurrent creation
- } catch (\Throwable) {
- throw $e; // Rethrow original if metadata creation also fails
- }
+ // 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 ba99cefb42..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 0632aea3dd..5500a56cbc 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
@@ -4,8 +4,10 @@ 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\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\Platform\Action;
use Appwrite\SDK\AuthType;
@@ -87,21 +89,24 @@ class Create extends Action
->inject('dbForProject')
->inject('authorization')
->inject('locale')
- ->inject('queueForMails')
- ->inject('queueForMessaging')
+ ->inject('publisherForMails')
+ ->inject('publisherForMessaging')
->inject('queueForEvents')
->inject('timelimit')
->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, Context $usage, 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) {
@@ -145,9 +150,6 @@ class Create extends Action
}
} 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
@@ -169,14 +171,41 @@ class Create extends Action
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 {
@@ -298,7 +327,9 @@ class Create extends Action
$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] ?? [];
+ $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
@@ -315,7 +346,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'])) {
@@ -324,16 +357,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'])) {
@@ -342,18 +373,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 = [
@@ -366,14 +409,17 @@ class Create extends Action
'project' => $projectName,
];
- $queueForMails
- ->setSubject($subject)
- ->setBody($body)
- ->setPreview($preview)
- ->setRecipient($invitee->getAttribute('email'))
- ->setName($invitee->getAttribute('name', ''))
- ->appendVariables($emailVariables)
- ->trigger();
+ $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');
@@ -381,11 +427,6 @@ class Create extends Action
$message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/sms-base.tpl');
- $customTemplate = $project->getAttribute('templates', [])['sms.invitation-' . $locale->default] ?? [];
- if (! empty($customTemplate)) {
- $message = $customTemplate['message'];
- }
-
$message = $message->setParam('{{token}}', $url);
$message = $message->render();
@@ -396,11 +437,13 @@ 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 {
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 638ceab59e..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', '');
@@ -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/Webhooks/Create.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php
index 91daf33b2b..e192dff2a0 100644
--- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php
+++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php
@@ -5,7 +5,6 @@ namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks;
use Appwrite\Event\Event as QueueEvent;
use Appwrite\Event\Validator\Event;
use Appwrite\Extend\Exception;
-use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
@@ -22,10 +21,11 @@ use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Multiple;
+use Utopia\Validator\Nullable;
use Utopia\Validator\Text;
use Utopia\Validator\URL;
-class Create extends Base
+class Create extends Action
{
use HTTP;
@@ -66,9 +66,10 @@ class Create extends Base
->param('name', null, new Text(128), 'Webhook name. Max length: 128 chars.')
->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.')
->param('enabled', true, new Boolean(), 'Enable or disable a webhook.', true)
- ->param('security', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true)
- ->param('httpUser', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true)
- ->param('httpPass', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true)
+ ->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')
@@ -86,9 +87,10 @@ class Create extends Base
string $name,
array $events,
bool $enabled,
- bool $security,
- string $httpUser,
- string $httpPass,
+ bool $tls,
+ string $authUsername,
+ string $authPassword,
+ ?string $secret,
Response $response,
Document $project,
QueueEvent $queueForEvents,
@@ -105,10 +107,10 @@ class Create extends Base
'name' => $name,
'events' => $events,
'url' => $url,
- 'security' => $security,
- 'httpUser' => $httpUser,
- 'httpPass' => $httpPass,
- 'signatureKey' => \bin2hex(\random_bytes(64)),
+ 'security' => $tls,
+ 'httpUser' => $authUsername,
+ 'httpPass' => $authPassword,
+ 'signatureKey' => $secret ?? \bin2hex(\random_bytes(64)),
'enabled' => $enabled,
]);
diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php
index 7730e9fc2c..cd05b6210c 100644
--- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php
+++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php
@@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
-use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
@@ -18,7 +17,7 @@ use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
-class Delete extends Base
+class Delete extends Action
{
use HTTP;
diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php
index 52ac455fc9..a42500ca46 100644
--- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php
+++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php
@@ -3,7 +3,6 @@
namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks;
use Appwrite\Extend\Exception;
-use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
@@ -16,7 +15,7 @@ use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
-class Get extends Base
+class Get extends Action
{
use HTTP;
@@ -73,6 +72,8 @@ class Get extends Base
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/Signature/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php
similarity index 76%
rename from src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php
rename to src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php
index 9b2612863f..fbff94735e 100644
--- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php
+++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php
@@ -1,10 +1,9 @@
setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
- ->setHttpPath('/v1/webhooks/:webhookId/signature')
+ ->setHttpPath('/v1/webhooks/:webhookId/secret')
->httpAlias('/v1/projects/:projectId/webhooks/:webhookId/signature')
- ->desc('Update webhook signature key')
+ ->desc('Update webhook secret key')
->groups(['api', 'webhooks'])
->label('scope', 'webhooks.write')
->label('event', 'webhooks.[webhookId].update')
@@ -40,9 +41,9 @@ class Update extends Base
->label('sdk', new Method(
namespace: 'webhooks',
group: null,
- name: 'updateSignature',
+ 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')
@@ -63,6 +65,7 @@ class Update extends Base
public function action(
string $webhookId,
+ ?string $secret,
Response $response,
Document $project,
QueueEvent $queueForEvents,
@@ -79,7 +82,7 @@ class Update extends Base
}
$updates = new Document([
- 'signatureKey' => \bin2hex(\random_bytes(64)),
+ 'signatureKey' => $secret ?? \bin2hex(\random_bytes(64)),
]);
$webhook = $authorization->skip(fn () => $dbForPlatform->updateDocument('webhooks', $webhook->getId(), $updates));
diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php
index a1387c356c..e7b516449e 100644
--- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php
+++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php
@@ -5,7 +5,6 @@ namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks;
use Appwrite\Event\Event as QueueEvent;
use Appwrite\Event\Validator\Event;
use Appwrite\Extend\Exception;
-use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
@@ -24,7 +23,7 @@ use Utopia\Validator\Multiple;
use Utopia\Validator\Text;
use Utopia\Validator\URL;
-class Update extends Base
+class Update extends Action
{
use HTTP;
@@ -64,9 +63,9 @@ class Update extends Base
->param('url', '', fn () => new Multiple([new URL(['http', 'https']), new PublicDomain()], Multiple::TYPE_STRING), 'Webhook URL.')
->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.')
->param('enabled', true, new Boolean(), 'Enable or disable a webhook.', true)
- ->param('security', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true)
- ->param('httpUser', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true)
- ->param('httpPass', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true)
+ ->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')
@@ -81,9 +80,9 @@ class Update extends Base
string $url,
array $events,
bool $enabled,
- bool $security,
- string $httpUser,
- string $httpPass,
+ bool $tls,
+ string $authUsername,
+ string $authPassword,
Response $response,
Document $project,
QueueEvent $queueForEvents,
@@ -103,9 +102,9 @@ class Update extends Base
'name' => $name,
'events' => $events,
'url' => $url,
- 'security' => $security,
- 'httpUser' => $httpUser,
- 'httpPass' => $httpPass,
+ 'security' => $tls,
+ 'httpUser' => $authUsername,
+ 'httpPass' => $authPassword,
'enabled' => $enabled,
]);
@@ -119,6 +118,8 @@ class Update extends Base
$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
index fae95d7c5d..f0961b541c 100644
--- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php
+++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php
@@ -3,7 +3,6 @@
namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks;
use Appwrite\Extend\Exception;
-use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
@@ -20,7 +19,7 @@ use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
-class XList extends Base
+class XList extends Action
{
use HTTP;
@@ -79,6 +78,15 @@ class XList extends Base
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);
@@ -112,6 +120,10 @@ class XList extends Base
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,
diff --git a/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php b/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php
index 4805de6ebc..0e9c39a762 100644
--- a/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php
+++ b/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php
@@ -6,7 +6,7 @@ use Appwrite\Platform\Modules\Webhooks\Http\Init;
use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Create as CreateWebhook;
use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Delete as DeleteWebhook;
use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Get as GetWebhook;
-use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Signature\Update as UpdateWebhookSignature;
+use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Secret\Update as UpdateWebhookSecret;
use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Update as UpdateWebhook;
use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\XList as ListWebhooks;
use Utopia\Platform\Service;
@@ -26,6 +26,6 @@ class Http extends Service
$this->addAction(GetWebhook::getName(), new GetWebhook());
$this->addAction(DeleteWebhook::getName(), new DeleteWebhook());
$this->addAction(UpdateWebhook::getName(), new UpdateWebhook());
- $this->addAction(UpdateWebhookSignature::getName(), new UpdateWebhookSignature());
+ $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 af768444f2..3e11a4060c 100644
--- a/src/Appwrite/Platform/Tasks/Install.php
+++ b/src/Appwrite/Platform/Tasks/Install.php
@@ -7,6 +7,7 @@ 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;
@@ -25,6 +26,7 @@ class Install extends Action
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$/';
@@ -34,6 +36,7 @@ class Install extends Action
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;
@@ -106,7 +109,7 @@ class Install extends Action
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) {
@@ -119,10 +122,6 @@ class Install extends Action
if ($oldVersion) {
foreach ($compose->getServices() as $service) {
- if (!$service) {
- continue;
- }
-
$env = $service->getEnvironment()->list();
foreach ($env as $key => $value) {
@@ -169,14 +168,11 @@ class Install extends Action
}
}
- // Block database type changes on existing installations.
- // Only enforce if the existing config explicitly set _APP_DB_ADAPTER
- // (pre-1.9.0 installs never had this variable).
+ // 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) {
- if (!$service) {
- continue;
- }
$svcEnv = $service->getEnvironment()->list();
if (isset($svcEnv['_APP_DB_ADAPTER'])) {
$existingDatabase = $svcEnv['_APP_DB_ADAPTER'];
@@ -190,10 +186,15 @@ class Install extends Action
$existingDatabase = (new Env($rawEnv))->list()['_APP_DB_ADAPTER'] ?? null;
}
}
- if ($existingDatabase !== null && $existingDatabase !== $database) {
- Console::error("Cannot change database type from '{$existingDatabase}' to '{$database}'.");
- Console::error('Changing database types on an existing installation is not supported.');
- Console::exit(1);
+ 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;
}
}
@@ -205,22 +206,24 @@ class Install extends Action
}
// If interactive and web mode enabled, start web server
- if ($interactive === 'Y' && Console::isInteractive()) {
+ // 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');
- $this->startWebServer($defaultHttpPort, $defaultHttpsPort, $organization, $image, $noStart, $vars);
+ $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 ($existingInstallation && isset($compose)) {
+ if ($existingInstallation) {
try {
- $assistantService = $compose->getService('appwrite-assistant');
- $assistantExistsInOldCompose = $assistantService !== null;
+ $compose->getService('appwrite-assistant');
+ $assistantExistsInOldCompose = true;
} catch (\Throwable) {
/* ignore */
}
@@ -280,7 +283,7 @@ class Install extends Action
continue;
}
- if ($var['name'] === '_APP_DB_ADAPTER' && $data !== false) {
+ if ($var['name'] === '_APP_DB_ADAPTER' && $data !== '') {
$userInput[$var['name']] = $database;
continue;
}
@@ -314,7 +317,7 @@ class Install extends Action
$shouldGenerateSecrets = !$existingInstallation && !$isUpgrade;
$input = $this->prepareEnvironmentVariables($userInput, $vars, $shouldGenerateSecrets);
- $this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart, null, null, $isUpgrade);
+ $this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart, null, null, $isUpgrade, migrate: $this->migrate);
}
@@ -324,7 +327,7 @@ class Install extends Action
@unlink(InstallerServer::INSTALLER_COMPLETE_FILE);
- $state = new State([]);
+ $state = new State();
$state->clearStaleLock();
$installerConfig = $this->readInstallerConfig();
@@ -503,7 +506,9 @@ class Install extends Action
?callable $progress = null,
?string $resumeFromStep = null,
bool $isUpgrade = false,
- array $account = []
+ array $account = [],
+ ?callable $onComplete = null,
+ bool $migrate = false,
): void {
$isLocalInstall = $this->isLocalInstall();
$this->applyLocalPaths($isLocalInstall, false);
@@ -596,10 +601,15 @@ class Install extends Action
$this->copyMongoEntrypointIfNeeded();
}
- if (!$noStart && $startIndex <= 2) {
+ 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);
+ $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();
@@ -607,17 +617,48 @@ class Install extends Action
$domain = $input['_APP_DOMAIN'] ?? 'localhost';
- // Wait for Appwrite API to be healthy before marking containers as ready
- $apiUrl = $this->waitForApiReady($domain, $httpPort, $isLocalInstall, $progress, InstallerServer::STEP_DOCKER_CONTAINERS);
+ $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);
- $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages);
+ if ($isUpgrade) {
+ $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages);
+ }
if (!$isUpgrade) {
$this->createInitialAdminAccount($account, $progress, $apiUrl, $domain);
}
- // Track installs
- $this->trackSelfHostedInstall($input, $isUpgrade, $version, $account);
+ if ($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');
@@ -658,8 +699,9 @@ class Install extends Action
messageOverride: 'Creating Appwrite account'
);
- // Create the account — tolerate "already exists" so we can still
- // create a session (common when re-running the installer).
+ // 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', [
@@ -669,7 +711,10 @@ class Install extends Action
'name' => $name
], false, $apiUrl, $domain);
} catch (\Throwable $e) {
- if (\stripos($e->getMessage(), 'already exists') === false) {
+ $message = $e->getMessage();
+ $accountExists = \stripos($message, 'already exists') !== false
+ || \stripos($message, 'console is restricted') !== false;
+ if (!$accountExists) {
throw $e;
}
}
@@ -705,6 +750,46 @@ class Install extends Action
}
}
+ 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()) {
@@ -732,6 +817,8 @@ class Install extends Action
$name = $account['name'] ?? 'Admin';
$email = $account['email'] ?? 'admin@selfhosted.local';
+ $hostIp = @gethostbyname($domain);
+
$payload = [
'action' => $type,
'account' => 'self-hosted',
@@ -744,12 +831,19 @@ class Install extends Action
'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) {
@@ -766,7 +860,7 @@ class Install extends Action
* - host.docker.internal:{port} — reaches host-published ports from inside a container
* - localhost:{port} — works when running directly on the host (local dev)
*/
- private function waitForApiReady(string $domain, string $httpPort, bool $isLocalInstall, ?callable $progress, string $step = InstallerServer::STEP_DOCKER_CONTAINERS): string
+ private function waitForApiReady(string $domain, string $httpPort, bool $isLocalInstall, ?callable $progress, string $step = InstallerServer::STEP_ACCOUNT_SETUP): string
{
$client = new Client();
$client
@@ -776,12 +870,16 @@ class Install extends Action
$healthPath = '/v1/health/version';
- // Local dev: reach Traefik via localhost on the host.
- // Docker: reach Appwrite directly via Docker internal DNS (network connect is guaranteed).
- $candidate = $isLocalInstall
- ? 'http://localhost:' . $httpPort . $healthPath
- : self::APPWRITE_API_URL . $healthPath;
- $candidates = [$candidate];
+ if ($isLocalInstall) {
+ $candidates = [
+ 'http://localhost:' . $httpPort . $healthPath,
+ ];
+ } else {
+ $candidates = [
+ self::APPWRITE_API_URL . $healthPath,
+ 'http://host.docker.internal:' . $httpPort . $healthPath,
+ ];
+ }
$lastErrors = [];
@@ -803,7 +901,7 @@ class Install extends Action
$progress(
$step,
InstallerServer::STATUS_IN_PROGRESS,
- 'Waiting for Appwrite to be ready (' . ($i + 1) . '/' . self::HEALTH_CHECK_ATTEMPTS . ')',
+ 'Waiting for Appwrite to be ready...',
[]
);
} catch (\Throwable) {
@@ -964,7 +1062,7 @@ class Install extends Action
}
}
- protected function runDockerCompose(array $input, bool $isLocalInstall, bool $useExistingConfig, bool $isCLI): void
+ protected function runDockerCompose(array $input, bool $isLocalInstall, bool $useExistingConfig, bool $isCLI, ?callable $progress = null, bool $isUpgrade = false): void
{
$env = '';
if (!$useExistingConfig) {
@@ -1004,8 +1102,28 @@ class Install extends Action
$command[] = '-d';
$command[] = '--remove-orphans';
$command[] = '--renew-anon-volumes';
- $commandLine = $env . implode(' ', array_map(escapeshellarg(...), $command)) . ' 2>&1';
- \exec($commandLine, $output, $exit);
+ $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 = trim(implode("\n", $output));
@@ -1017,6 +1135,126 @@ class Install extends Action
}
}
+ 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) {
@@ -1089,6 +1327,47 @@ class Install extends Action
$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();
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 b952808998..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;
@@ -32,6 +31,7 @@ class Migrate extends Action
->inject('getProjectDB')
->inject('register')
->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)) {
@@ -125,8 +126,6 @@ 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);
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/ScheduleFunctions.php b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php
index 88725a190a..6dd9cd0351 100644
--- a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php
+++ b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php
@@ -7,6 +7,7 @@ use Cron\CronExpression;
use Utopia\Console;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
+use Utopia\Span\Span;
/**
* ScheduleFunctions
@@ -19,8 +20,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 +40,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,7 +90,7 @@ 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];
@@ -102,19 +104,26 @@ class ScheduleFunctions extends ScheduleBase
->setFunction($schedule['resource'])
->setMethod('POST')
->setPath('/')
- ->setProject($schedule['project'])
- ->trigger();
+ ->setProject($schedule['project']);
- $this->recordEnqueueDelay($delayConfig['nextDate']);
+ 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'] ?? '');
+
+ $queueForFunctions->trigger();
+
+ $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 606c03bf10..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;
@@ -160,6 +163,12 @@ class Specs extends Action
'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',
@@ -216,6 +225,18 @@ 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',
@@ -269,7 +290,19 @@ 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' => [
@@ -294,6 +327,150 @@ class Specs extends Action
];
}
+ 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 = [];
@@ -336,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 = [];
@@ -431,7 +621,7 @@ class Specs extends Action
}
$arguments = [
- new Http('UTC'),
+ $specsContainer,
$services,
$routes,
$models,
@@ -443,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)
@@ -463,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 1d61180963..bde73fd05c 100644
--- a/src/Appwrite/Platform/Tasks/Upgrade.php
+++ b/src/Appwrite/Platform/Tasks/Upgrade.php
@@ -30,6 +30,7 @@ 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(...));
}
@@ -40,9 +41,11 @@ class Upgrade extends Install
string $image,
string $interactive,
bool $noStart,
- string $database
+ string $database,
+ bool $migrate = false,
): void {
$this->isUpgrade = true;
+ $this->migrate = $migrate;
$isLocalInstall = $this->isLocalInstall();
$this->applyLocalPaths($isLocalInstall, true);
@@ -62,9 +65,6 @@ class Upgrade extends Install
$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'];
diff --git a/src/Appwrite/Platform/Workers/Audits.php b/src/Appwrite/Platform/Workers/Audits.php
index 55ec39026b..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,36 +50,35 @@ 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;
$impersonatorUserId = $user->getAttribute('impersonatorUserId');
$actorUserId = $impersonatorUserId ?: $user->getId();
@@ -129,14 +127,14 @@ class Audits extends Action
];
}
- if (isset($this->logs[$project->getSequence()])) {
- $this->logs[$project->getSequence()]['logs'][] = $eventData;
+ 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..af3d145f85 100644
--- a/src/Appwrite/Platform/Workers/Certificates.php
+++ b/src/Appwrite/Platform/Workers/Certificates.php
@@ -3,10 +3,11 @@
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\Mail as MailMessage;
+use Appwrite\Event\Publisher\Certificate;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception as AppwriteException;
@@ -50,12 +51,12 @@ class Certificates extends Action
->desc('Certificates worker')
->inject('message')
->inject('dbForPlatform')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('queueForEvents')
->inject('queueForWebhooks')
->inject('queueForFunctions')
->inject('queueForRealtime')
- ->inject('queueForCertificates')
+ ->inject('publisherForCertificates')
->inject('log')
->inject('certificates')
->inject('plan')
@@ -66,12 +67,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 Realtime $queueForRealtime
- * @param Certificate $queueForCertificates
+ * @param Certificate $publisherForCertificates
* @param Log $log
* @param CertificatesAdapter $certificates
* @param array $plan
@@ -83,39 +84,40 @@ class Certificates extends Action
public function action(
Message $message,
Database $dbForPlatform,
- Mail $queueForMails,
+ MailPublisher $publisherForMails,
Event $queueForEvents,
Webhook $queueForWebhooks,
Func $queueForFunctions,
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, $queueForFunctions, $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, $queueForFunctions, $queueForRealtime, $log, $certificates, $authorization, $skipRenewCheck, $plan, $validationDomain);
break;
default:
@@ -130,7 +132,7 @@ class Certificates extends Action
* @param Webhook $queueForWebhooks
* @param Func $queueForFunctions
* @param Realtime $queueForRealtime
- * @param Certificate $queueForCertificates
+ * @param Certificate $publisherForCertificates
* @param Log $log
* @param ValidatorAuthorization $authorization
* @param string|null $validationDomain
@@ -146,7 +148,7 @@ class Certificates extends Action
Webhook $queueForWebhooks,
Func $queueForFunctions,
Realtime $queueForRealtime,
- Certificate $queueForCertificates,
+ Certificate $publisherForCertificates,
Log $log,
ValidatorAuthorization $authorization,
?string $validationDomain = null
@@ -188,13 +190,17 @@ class Certificates extends Action
// 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,7 +210,7 @@ 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
@@ -228,7 +234,7 @@ class Certificates extends Action
Domain $domain,
?string $domainType,
Database $dbForPlatform,
- Mail $queueForMails,
+ MailPublisher $publisherForMails,
Event $queueForEvents,
Webhook $queueForWebhooks,
Func $queueForFunctions,
@@ -353,7 +359,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 {
@@ -519,12 +525,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 +561,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 3065b2377f..3a865bf6a9 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;
@@ -54,6 +53,7 @@ class Deletes extends Action
->inject('project')
->inject('dbForPlatform')
->inject('getProjectDB')
+ ->inject('getDatabasesDB')
->inject('getLogsDB')
->inject('deviceForFiles')
->inject('deviceForFunctions')
@@ -80,6 +80,7 @@ class Deletes extends Action
Document $project,
Database $dbForPlatform,
callable $getProjectDB,
+ callable $getDatabasesDB,
callable $getLogsDB,
Device $deviceForFiles,
Device $deviceForFunctions,
@@ -95,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');
@@ -115,7 +116,7 @@ class Deletes extends Action
case DELETE_TYPE_DOCUMENT:
switch ($document->getCollection()) {
case DELETE_TYPE_PROJECTS:
- $this->deleteProject($dbForPlatform, $getProjectDB, $deviceForFiles, $deviceForSites, $deviceForFunctions, $deviceForBuilds, $deviceForCache, $certificates, $document);
+ $this->deleteProject($dbForPlatform, $getProjectDB, $getDatabasesDB, $deviceForFiles, $deviceForSites, $deviceForFunctions, $deviceForBuilds, $deviceForCache, $certificates, $document);
break;
case DELETE_TYPE_SITES:
$this->deleteSite($dbForPlatform, $getProjectDB, $deviceForSites, $deviceForBuilds, $deviceForFiles, $document, $certificates, $project);
@@ -150,7 +151,7 @@ class Deletes extends Action
}
break;
case DELETE_TYPE_TEAM_PROJECTS:
- $this->deleteProjectsByTeam($dbForPlatform, $getProjectDB, $certificates, $document);
+ $this->deleteProjectsByTeam($dbForPlatform, $getProjectDB, $getDatabasesDB, $certificates, $document);
break;
case DELETE_TYPE_EXECUTIONS:
$this->deleteExecutionLogs($project, $getProjectDB, $executionRetention);
@@ -215,11 +216,69 @@ 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,
+ bool $projectTables,
+ array $projectCollectionIds
+ ): void {
+ $executionActionPerDatabase(
+ $databaseDoc,
+ fn (Database $dbForDatabases) => $this->cleanDatabaseCollections(
+ $dbForDatabases,
+ $projectTables,
+ $projectCollectionIds
+ )
+ );
+ }
+
+ private function cleanDatabaseCollections(
+ Database $dbForDatabases,
+ bool $projectTables,
+ array $projectCollectionIds
+ ): void {
+ $dbForDatabases->foreach(
+ Database::METADATA,
+ function (Document $collection) use ($dbForDatabases, $projectTables, $projectCollectionIds) {
+ $collectionId = $collection->getId();
+
+ try {
+ if ($projectTables || !\in_array($collectionId, $projectCollectionIds, true)) {
+ $dbForDatabases->deleteCollection($collectionId);
+ return;
+ }
+
+ $this->deleteByGroup(
+ $collectionId,
+ [Query::orderAsc()],
+ database: $dbForDatabases
+ );
+ } catch (Throwable $e) {
+ Console::error('Error deleting ' . $collectionId . ' ' . $e->getMessage());
+ }
+ }
+ );
+ }
+
/**
* @param Database $dbForPlatform
* @param callable $getProjectDB
@@ -259,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 {
@@ -317,7 +377,6 @@ class Deletes extends Action
/**
* @param Document $project
* @param callable $getProjectDB
- * @param Document $target
* @return void
* @throws Exception
*/
@@ -392,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
@@ -472,7 +530,6 @@ class Deletes extends Action
}
/**
- * @param Database $dbForPlatform
* @param callable $getProjectDB
* @param string $hourlyUsageRetentionDatetime
* @return void
@@ -540,14 +597,13 @@ class Deletes extends Action
* @param Database $dbForPlatform
* @param Document $document
* @return void
- * @throws Authorization
* @throws DatabaseException
* @throws Conflict
* @throws Restricted
* @throws Structure
* @throws Exception
*/
- protected function deleteProjectsByTeam(Database $dbForPlatform, callable $getProjectDB, CertificatesAdapter $certificates, Document $document): void
+ protected function deleteProjectsByTeam(Database $dbForPlatform, callable $getProjectDB, callable $getDatabasesDB, CertificatesAdapter $certificates, Document $document): void
{
$projects = $dbForPlatform->find('projects', [
@@ -562,7 +618,7 @@ class Deletes extends Action
$deviceForBuilds = getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId());
$deviceForCache = getDevice(APP_STORAGE_CACHE . '/app-' . $project->getId());
- $this->deleteProject($dbForPlatform, $getProjectDB, $deviceForFiles, $deviceForSites, $deviceForFunctions, $deviceForBuilds, $deviceForCache, $certificates, $project);
+ $this->deleteProject($dbForPlatform, $getProjectDB, $getDatabasesDB, $deviceForFiles, $deviceForSites, $deviceForFunctions, $deviceForBuilds, $deviceForCache, $certificates, $project);
$dbForPlatform->deleteDocument('projects', $project->getId());
}
}
@@ -577,10 +633,9 @@ class Deletes extends Action
* @param Document $document
* @return void
* @throws Exception
- * @throws Authorization
* @throws DatabaseException
*/
- protected function deleteProject(Database $dbForPlatform, callable $getProjectDB, Device $deviceForFiles, Device $deviceForSites, Device $deviceForFunctions, Device $deviceForBuilds, Device $deviceForCache, CertificatesAdapter $certificates, Document $document): void
+ protected function deleteProject(Database $dbForPlatform, callable $getProjectDB, callable $getDatabasesDB, Device $deviceForFiles, Device $deviceForSites, Device $deviceForFunctions, Device $deviceForBuilds, Device $deviceForCache, CertificatesAdapter $certificates, Document $document): void
{
$projectInternalId = $document->getSequence();
$projectId = $document->getId();
@@ -592,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
*/
@@ -611,93 +769,71 @@ 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;
- $dbForProject->foreach(Database::METADATA, function (Document $collection) use ($dbForProject, $projectTables, $projectCollectionIds) {
- try {
- if ($projectTables || !\in_array($collection->getId(), $projectCollectionIds)) {
- $dbForProject->deleteCollection($collection->getId());
- } else {
- $this->deleteByGroup(
- $collection->getId(),
- [
- Query::orderAsc()
- ],
- database: $dbForProject
- );
- }
- } catch (Throwable $e) {
- Console::error('Error deleting ' . $collection->getId() . ' ' . $e->getMessage());
+ $allDatabases = [
+ new Document([
+ 'database' => $document->getAttribute('database')
+ ]),
+ ...$dbForProject->find('databases', [
+ Query::equal('type', [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]),
+ Query::limit(5000),
+ ]),
+ ];
+ $databasesToClean = [];
+
+ foreach ($allDatabases as $db) {
+ $key = $db->getAttribute('database');
+
+ if ($key) {
+ $databasesToClean[$key] ??= $db;
}
- });
+ }
- // Delete Platforms
- $this->deleteByGroup('platforms', [
- Query::equal('projectInternalId', [$projectInternalId]),
- Query::orderAsc()
- ], $dbForPlatform);
+ $databasesToClean = array_values($databasesToClean);
- // 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);
- });
+ $executionActionPerDatabase = function (Document $databaseDoc, $callback) use ($getDatabasesDB, $document) {
+ /**
+ * @var Database $dbForDatabases
+ */
+ $dbForDatabases = $getDatabasesDB($databaseDoc, $document);
+ $callback($dbForDatabases);
+ };
- // 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);
+ batch(array_map(
+ 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 metadata table
if ($projectTables) {
- $dbForProject->deleteCollection(Database::METADATA);
- } elseif ($sharedTablesV1) {
- $this->deleteByGroup(
- Database::METADATA,
- [
- Query::orderAsc()
- ],
- $dbForProject
- );
- } elseif ($sharedTablesV2) {
+ batch(array_map(
+ fn ($databaseDoc) => function () use ($databaseDoc, $executionActionPerDatabase) {
+ try {
+ $executionActionPerDatabase(
+ $databaseDoc,
+ fn (Database $dbForDatabases) =>
+ $dbForDatabases->deleteCollection(Database::METADATA)
+ );
+ } catch (Throwable $th) {
+ Console::error('Failed to delete metadata table for database ' . $databaseDoc->getAttribute('database') . ': ' . $th->getMessage());
+ }
+ },
+ $databasesToClean
+ ));
+ } else {
$queries = \array_map(
fn ($id) => Query::notEqual('$id', $id),
$projectCollectionIds
@@ -705,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();
@@ -877,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'])],
@@ -1034,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);
@@ -1097,7 +1261,7 @@ class Deletes extends Action
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);
});
@@ -1121,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);
@@ -1246,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
@@ -1556,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) {
@@ -1571,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 3f19abdf22..93b500b98b 100644
--- a/src/Appwrite/Platform/Workers/Functions.php
+++ b/src/Appwrite/Platform/Workers/Functions.php
@@ -10,6 +10,7 @@ use Appwrite\Event\Realtime;
use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Utopia\Response\Model\Execution;
+use Executor\Exception\Timeout as ExecutorTimeout;
use Executor\Executor;
use Utopia\Bus\Bus;
use Utopia\Config\Config;
@@ -23,6 +24,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 +35,7 @@ class Functions extends Action
}
/**
- * @throws Exception
+ * @throws \Exception
*/
public function __construct()
{
@@ -67,7 +69,7 @@ class Functions extends Action
Executor $executor,
callable $isResourceBlocked
): void {
- $payload = $message->getPayload() ?? [];
+ $payload = $message->getPayload();
if (empty($payload)) {
throw new AppwriteException(
@@ -78,6 +80,12 @@ class Functions extends Action
$type = $payload['type'] ?? '';
+ 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 = $payload['events'] ?? [];
$data = $payload['body'] ?? '';
$eventData = $payload['payload'] ?? '';
@@ -115,6 +123,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;
@@ -241,7 +253,7 @@ class Functions extends Action
jwt: $jwt,
event: null,
eventData: null,
- executionId: $execution->getId() ?? null
+ executionId: $execution->getId()
);
break;
}
@@ -256,7 +268,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 +283,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 +316,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(),
@@ -359,6 +377,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 +425,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 +440,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 +481,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,6 +532,9 @@ class Functions extends Action
]);
/** Execute function */
+ $error = null;
+ $errorCode = 0;
+
try {
$version = $function->getAttribute('version', 'v2');
$command = $runtime['startCommand'];
@@ -519,24 +546,28 @@ class Functions extends Action
$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 +622,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(),
@@ -629,7 +662,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('/