Merge remote-tracking branch 'origin/1.9.x' into feat-platform-db-access

# Conflicts:
#	app/controllers/api/migrations.php
#	composer.json
#	composer.lock
#	src/Appwrite/Platform/Workers/Migrations.php
This commit is contained in:
Prem Palanisamy
2026-05-13 16:36:39 +01:00
1141 changed files with 87058 additions and 19549 deletions
@@ -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.
@@ -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: <?php echo $organization; ?>/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
+14 -1
View File
@@ -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=
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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
+349
View File
@@ -0,0 +1,349 @@
const fs = require('fs');
const marker = '<!-- appwrite-benchmark-results -->';
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),
'',
'<details>',
'<summary><strong>Top API waits</strong></summary>',
'',
'<br>',
'',
'| API request | Max wait (ms) |',
'| --- | ---: |',
...topWaitRows(topWaits),
'',
'</details>',
);
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 ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' })[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;
}
+330 -179
View File
@@ -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
+32 -2
View File
@@ -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 }}
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 }}
+4 -4
View File
@@ -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
+10 -6
View File
@@ -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'
+6 -6
View File
@@ -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
+6 -6
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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."
+1
View File
@@ -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
+105 -80
View File
@@ -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 <file>` | Format a specific file |
| `composer lint <file>` | 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.
+1 -1
View File
@@ -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
+7 -2
View File
@@ -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
+43 -63
View File
@@ -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)
<img width="1920" height="1080" alt="image" src="https://github.com/user-attachments/assets/55a81268-4ecc-46cd-bdf5-73f7e8662fee" />
<br />
<p align="center">
<a href="https://appwrite.io" target="_blank"><img src="./public/images/banner.png" alt="Appwrite banner, with logo and text saying "The Developer's Cloud"></a>
<br />
<br />
<b>Appwrite is a best-in-class, developer-first platform that gives builders everything they need to create scalable, stable, and production-ready software, fast.</b>
<h1>Appwrite</h1>
<b>Appwrite is an open-source, all-in-one development platform. Use built-in backend infrastructure and web hosting, all from a single place.</b>
<br />
<br />
</p>
<!-- [![Build Status](https://img.shields.io/travis/com/appwrite/appwrite?style=flat-square)](https://travis-ci.com/appwrite/appwrite) -->
[![We're Hiring label](https://img.shields.io/static/v1?label=We're&message=Hiring&color=blue&style=flat-square)](https://appwrite.io/company/careers)
[![Hacktoberfest label](https://img.shields.io/static/v1?label=hacktoberfest&message=ready&color=191120&style=flat-square)](https://hacktoberfest.appwrite.io)
[![Discord label](https://img.shields.io/discord/564160730845151244?label=discord&style=flat-square)](https://appwrite.io/discord?r=Github)
[![Build Status label](https://img.shields.io/github/actions/workflow/status/appwrite/appwrite/tests.yml?branch=master&label=tests&style=flat-square)](https://github.com/appwrite/appwrite/actions)
[![X Account label](https://img.shields.io/twitter/follow/appwrite?color=00acee&label=twitter&style=flat-square)](https://twitter.com/appwrite)
<!-- [![Docker Pulls](https://img.shields.io/docker/pulls/appwrite/appwrite?color=f02e65&style=flat-square)](https://hub.docker.com/r/appwrite/appwrite) -->
<!-- [![Translate](https://img.shields.io/badge/translate-f02e65?style=flat-square)](docs/tutorials/add-translations.md) -->
<!-- [![Swag Store](https://img.shields.io/badge/swag%20store-f02e65?style=flat-square)](https://store.appwrite.io) -->
[![Discord](https://img.shields.io/badge/chat-5865F2?style=flat-square&logo=discord&logoColor=white)](https://appwrite.io/discord)
[![X](https://img.shields.io/badge/follow-000000?style=flat-square&logo=x&logoColor=white)](https://x.com/appwrite)
[![Appwrite Cloud](https://img.shields.io/badge/Cloud-F02E65?style=flat-square&logo=icloud&logoColor=white)](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.
![Appwrite project dashboard showing various Appwrite features](public/images/github.png)
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: &nbsp; [Web](https://github.com/appwrite/sdk-for-web) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Flutter](https://github.com/appwrite/sdk-for-flutter) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Apple](https://github.com/appwrite/sdk-for-apple) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Android](https://github.com/appwrite/sdk-for-android) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [React Native](https://github.com/appwrite/sdk-for-react-native) - **Beta** (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Web](https://github.com/appwrite/sdk-for-web)
- :white_check_mark: &nbsp; [Flutter](https://github.com/appwrite/sdk-for-flutter)
- :white_check_mark: &nbsp; [Apple](https://github.com/appwrite/sdk-for-apple)
- :white_check_mark: &nbsp; [Android](https://github.com/appwrite/sdk-for-android)
- :white_check_mark: &nbsp; [React Native](https://github.com/appwrite/sdk-for-react-native)
#### Server
- :white_check_mark: &nbsp; [NodeJS](https://github.com/appwrite/sdk-for-node) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [PHP](https://github.com/appwrite/sdk-for-php) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Dart](https://github.com/appwrite/sdk-for-dart) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Deno](https://github.com/appwrite/sdk-for-deno) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Ruby](https://github.com/appwrite/sdk-for-ruby) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Python](https://github.com/appwrite/sdk-for-python) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Kotlin](https://github.com/appwrite/sdk-for-kotlin) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [Swift](https://github.com/appwrite/sdk-for-swift) (Maintained by the Appwrite Team)
- :white_check_mark: &nbsp; [.NET](https://github.com/appwrite/sdk-for-dotnet) - **Beta** (Maintained by the Appwrite Team)
#### Community
- :white_check_mark: &nbsp; [Appcelerator Titanium](https://github.com/m1ga/ti.appwrite) (Maintained by [Michael Gangolf](https://github.com/m1ga/))
- :white_check_mark: &nbsp; [Godot Engine](https://github.com/GodotNuts/appwrite-sdk) (Maintained by [fenix-hub @GodotNuts](https://github.com/fenix-hub))
- :white_check_mark: &nbsp; [NodeJS](https://github.com/appwrite/sdk-for-node)
- :white_check_mark: &nbsp; [PHP](https://github.com/appwrite/sdk-for-php)
- :white_check_mark: &nbsp; [Dart](https://github.com/appwrite/sdk-for-dart)
- :white_check_mark: &nbsp; [Deno](https://github.com/appwrite/sdk-for-deno)
- :white_check_mark: &nbsp; [Ruby](https://github.com/appwrite/sdk-for-ruby)
- :white_check_mark: &nbsp; [Python](https://github.com/appwrite/sdk-for-python)
- :white_check_mark: &nbsp; [Kotlin](https://github.com/appwrite/sdk-for-kotlin)
- :white_check_mark: &nbsp; [Swift](https://github.com/appwrite/sdk-for-swift)
- :white_check_mark: &nbsp; [.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)!
+67 -45
View File
@@ -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);
+2
View File
@@ -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,
+7
View File
@@ -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' => [],
],
],
],
+459 -4
View File
@@ -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
+9
View File
@@ -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' => [
[
+165
View File
@@ -0,0 +1,165 @@
<?php
use Utopia\Database\Database;
use Utopia\Database\Helpers\ID;
return [
'collections' => [
'$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' => [],
],
]
]
];
+9
View File
@@ -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)) : [],
+100 -1
View File
@@ -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,
],
];
+29 -1
View File
@@ -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.',
],
],
],
];
-6
View File
@@ -9,11 +9,5 @@ return [
'mfaChallenge',
'sessionAlert',
'otpSession'
],
'sms' => [
'verification',
'login',
'invitation',
'mfaChallenge'
]
];
+15 -15
View File
@@ -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}},",
+10
View File
@@ -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",
+44
View File
@@ -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/',
@@ -9,8 +9,8 @@ return [
'key' => 'graphql',
'name' => 'GraphQL',
],
'realtime' => [
'key' => 'realtime',
'name' => 'Realtime',
'websocket' => [
'key' => 'websocket',
'name' => 'Websocket',
],
];
+17 -5
View File
@@ -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 => [
-7
View File
@@ -1,7 +0,0 @@
<?php
// Resource types available for console project migration keys
return [
'platforms' => 'platforms.read',
];
-14
View File
@@ -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',
],
+371 -180
View File
@@ -1,191 +1,382 @@
<?php
return [ // List of publicly visible scopes
'sessions.write' => [
'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',
],
];
+75 -24
View File
@@ -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',
+20 -6
View File
@@ -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'],
],
];
+3 -5
View File
@@ -31,9 +31,6 @@ class FunctionUseCases
public const DEV_TOOLS = 'dev-tools';
public const AUTH = 'auth';
/**
* @var array<string>
*/
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 <a target="_blank" rel="noopener noreferrer" class="link" href="https://github.com/appwrite/templates/tree/main/node/starter">file</a>.',
'instructions' => 'For documentation and instructions check out the <a target="_blank" rel="noopener noreferrer" class="link" href="https://github.com/appwrite/templates">templates repository</a>.',
'vcsProvider' => 'github',
'providerRepositoryId' => 'templates',
'providerOwner' => 'appwrite',
'providerVersion' => '0.2.*',
'providerVersion' => '0.3.*',
'variables' => [],
'scopes' => ['users.read']
],
+9 -12
View File
@@ -25,9 +25,6 @@ class SiteUseCases
public const FORMS = 'forms';
public const DASHBOARD = 'dashboard';
/**
* @var array<string>
*/
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',
+13 -4
View File
@@ -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.',
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -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') {
+1
View File
@@ -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([
+50 -46
View File
@@ -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
}
}
File diff suppressed because it is too large Load Diff
+62 -1
View File
@@ -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);
});
File diff suppressed because it is too large Load Diff
+115 -55
View File
@@ -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) {
+156 -112
View File
@@ -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;
}
+30 -28
View File
@@ -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(),
]);
+129 -101
View File
@@ -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,
+4 -3
View File
@@ -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;
+76 -71
View File
@@ -1,14 +1,13 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/init.php';
require_once __DIR__ . '/init/span.php';
$setRequestContext = require __DIR__ . '/init/resources/request.php';
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Swoole\Constant;
use Swoole\Http\Request as SwooleRequest;
use Swoole\Http\Response as SwooleResponse;
use Swoole\Http\Server;
use Swoole\Process;
use Swoole\Table;
use Swoole\Timer;
@@ -27,11 +26,12 @@ use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\DI\Container;
use Utopia\Http\Adapter\Swoole\Server;
use Utopia\Http\Files;
use Utopia\Http\Http;
use Utopia\Logger\Log;
use Utopia\Logger\Log\User;
use Utopia\Pools\Group;
use Utopia\Span\Span;
use Utopia\System\System;
@@ -48,18 +48,33 @@ $certifiedDomains = new Table(100_000);
$certifiedDomains->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();
+1 -2
View File
@@ -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
+121 -11
View File
@@ -1,6 +1,13 @@
<?php
use Appwrite\Platform\Modules\Advisor\Enums\InsightCTAMethod;
use Appwrite\Platform\Modules\Advisor\Enums\InsightCTAService;
use Appwrite\Platform\Modules\Advisor\Enums\InsightSeverity;
use Appwrite\Platform\Modules\Advisor\Enums\InsightStatus;
use Appwrite\Platform\Modules\Advisor\Enums\InsightType;
use Appwrite\Platform\Modules\Advisor\Enums\ReportType;
use Appwrite\Platform\Modules\Compute\Specification;
use Utopia\System\System;
const APP_NAME = 'Appwrite';
const APP_DOMAIN = 'appwrite.io';
@@ -24,9 +31,6 @@ const APP_MODE_ADMIN = 'admin';
const APP_PAGING_LIMIT = 12;
const APP_LIMIT_COUNT = 5000;
const APP_LIMIT_USERS = 10_000;
const APP_LIMIT_USER_PASSWORD_HISTORY = 20;
const APP_LIMIT_USER_SESSIONS_MAX = 100;
const APP_LIMIT_USER_SESSIONS_DEFAULT = 10;
const APP_LIMIT_ANTIVIRUS = 20_000_000; //20MB
const APP_LIMIT_ENCRYPTION = 20_000_000; //20MB
const APP_LIMIT_COMPRESSION = 20_000_000; //20MB
@@ -41,20 +45,20 @@ const APP_LIMIT_LIST_DEFAULT = 25; // Default maximum number of items to return
const APP_LIMIT_DATABASE_BATCH = 100; // Default maximum batch size for database operations
const APP_LIMIT_DATABASE_TRANSACTION = 100; // Default maximum operations per transaction
const APP_KEY_ACCESS = 24 * 60 * 60; // 24 hours
const APP_CONSOLE_KEY_TTL = 120; // 2 minutes
const APP_USER_ACCESS = 24 * 60 * 60; // 24 hours
const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours
const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours
const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours
const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours
const APP_CACHE_BUSTER = 4321;
const APP_VERSION_STABLE = '1.9.0';
const APP_CACHE_BUSTER = 4326;
const APP_VERSION_STABLE = '1.9.5';
const APP_DATABASE_ATTRIBUTE_EMAIL = 'email';
const APP_DATABASE_ATTRIBUTE_ENUM = 'enum';
const APP_DATABASE_ATTRIBUTE_IP = 'ip';
const APP_DATABASE_ATTRIBUTE_DATETIME = 'datetime';
const APP_DATABASE_ATTRIBUTE_URL = 'url';
const APP_DATABASE_ATTRIBUTE_INT_RANGE = 'intRange';
const APP_DATABASE_ATTRIBUTE_BIGINT_RANGE = 'bigintRange';
const APP_DATABASE_ATTRIBUTE_FLOAT_RANGE = 'floatRange';
const APP_DATABASE_ATTRIBUTE_POINT = 'point';
const APP_DATABASE_ATTRIBUTE_LINE = 'line';
@@ -98,6 +102,7 @@ const APP_COMPUTE_DEPLOYMENT_MAX_RETENTION = 100 * 365; // 100 years
const APP_SDK_PLATFORM_SERVER = 'server';
const APP_SDK_PLATFORM_CLIENT = 'client';
const APP_SDK_PLATFORM_CONSOLE = 'console';
const APP_SDK_PLATFORM_STATIC = 'static';
const APP_VCS_GITHUB_USERNAME = 'Appwrite';
const APP_VCS_GITHUB_EMAIL = 'team@appwrite.io';
const APP_VCS_GITHUB_URL = 'https://github.com/TeamAppwrite';
@@ -156,9 +161,12 @@ const SESSION_PROVIDER_SERVER = 'server';
/**
* Activity associated with user or the app.
*/
const ACTIVITY_TYPE_APP = 'app';
const ACTIVITY_TYPE_USER = 'user';
const ACTIVITY_TYPE_ADMIN = 'admin';
const ACTIVITY_TYPE_GUEST = 'guest';
const ACTIVITY_TYPE_KEY_PROJECT = 'keyProject';
const ACTIVITY_TYPE_KEY_ACCOUNT = 'keyAccount';
const ACTIVITY_TYPE_KEY_ORGANIZATION = 'keyOrganization';
/**
* MFA
@@ -187,7 +195,7 @@ const BUILD_TYPE_RETRY = 'retry';
// Deletion Types
const ENABLE_EXECUTIONS_LIMIT_ON_ROUTE = false;
\define('ENABLE_EXECUTIONS_LIMIT_ON_ROUTE', System::getEnv('_APP_EXECUTIONS_LIMIT_ON_ROUTE', 'disabled') === 'enabled');
const DELETE_TYPE_DATABASES = 'databases';
const DELETE_TYPE_DOCUMENT = 'document';
@@ -220,6 +228,7 @@ const DELETE_TYPE_EXPIRED_TARGETS = 'invalid_targets';
const DELETE_TYPE_SESSION_TARGETS = 'session_targets';
const DELETE_TYPE_CSV_EXPORTS = 'csv_exports';
const DELETE_TYPE_MAINTENANCE = 'maintenance';
const DELETE_TYPE_REPORT = 'report';
// Rule statuses
const RULE_STATUS_CREATED = 'created'; // This is also the status when domain DNS verification fails.
@@ -243,6 +252,7 @@ const APP_AUTH_TYPE_KEY = 'Key';
const APP_AUTH_TYPE_ADMIN = 'Admin';
// Response related
const MAX_OUTPUT_CHUNK_SIZE = 10 * 1024 * 1024; // 10MB
const APP_LIMIT_UPLOAD_CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
const APP_FUNCTION_LOG_LENGTH_LIMIT = 1000000;
const APP_FUNCTION_ERROR_LENGTH_LIMIT = 1000000;
// Function headers
@@ -254,11 +264,9 @@ const MESSAGE_TYPE_SMS = 'sms';
const MESSAGE_TYPE_PUSH = 'push';
// API key types
const API_KEY_STANDARD = 'standard';
const API_KEY_DYNAMIC = 'dynamic';
const API_KEY_EPHEMERAL = 'ephemeral';
const API_KEY_ORGANIZATION = 'organization';
const API_KEY_ACCOUNT = 'account';
// API key source identifiers
const KEY_SOURCE_MIGRATION = 'migration';
// Usage metrics
const METRIC_TEAMS = 'teams';
const METRIC_USERS = 'users';
@@ -291,6 +299,45 @@ const METRIC_DATABASES_OPERATIONS_READS = 'databases.operations.reads';
const METRIC_DATABASE_ID_OPERATIONS_READS = '{databaseInternalId}.databases.operations.reads';
const METRIC_DATABASES_OPERATIONS_WRITES = 'databases.operations.writes';
const METRIC_DATABASE_ID_OPERATIONS_WRITES = '{databaseInternalId}.databases.operations.writes';
// documentsdb
const METRIC_DATABASES_DOCUMENTSDB = 'documentsdb.databases';
const METRIC_COLLECTIONS_DOCUMENTSDB = 'documentsdb.collections';
const METRIC_DATABASES_STORAGE_DOCUMENTSDB = 'documentsdb.databases.storage';
const METRIC_DATABASE_ID_COLLECTIONS_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.collections';
const METRIC_DATABASE_ID_STORAGE_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.databases.storage';
const METRIC_DOCUMENTS_DOCUMENTSDB = 'documentsdb.documents';
const METRIC_DATABASE_ID_DOCUMENTS_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.documents';
const METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.{collectionInternalId}.documents';
const METRIC_DATABASE_ID_COLLECTION_ID_STORAGE_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.{collectionInternalId}.databases.storage';
const METRIC_DATABASES_OPERATIONS_READS_DOCUMENTSDB = 'documentsdb.databases.operations.reads';
const METRIC_DATABASE_ID_OPERATIONS_READS_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.databases.operations.reads';
const METRIC_DATABASES_OPERATIONS_WRITES_DOCUMENTSDB = 'documentsdb.databases.operations.writes';
const METRIC_DATABASE_ID_OPERATIONS_WRITES_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.databases.operations.writes';
// vectorsdb
const METRIC_DATABASES_VECTORSDB = 'vectorsdb.databases';
const METRIC_COLLECTIONS_VECTORSDB = 'vectorsdb.collections';
const METRIC_DATABASES_STORAGE_VECTORSDB = 'vectorsdb.databases.storage';
const METRIC_DATABASE_ID_COLLECTIONS_VECTORSDB = 'vectorsdb.{databaseInternalId}.collections';
const METRIC_DATABASE_ID_STORAGE_VECTORSDB = 'vectorsdb.{databaseInternalId}.databases.storage';
const METRIC_DOCUMENTS_VECTORSDB = 'vectorsdb.documents';
const METRIC_DATABASE_ID_DOCUMENTS_VECTORSDB = 'vectorsdb.{databaseInternalId}.documents';
const METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS_VECTORSDB = 'vectorsdb.{databaseInternalId}.{collectionInternalId}.documents';
const METRIC_DATABASE_ID_COLLECTION_ID_STORAGE_VECTORSDB = 'vectorsdb.{databaseInternalId}.{collectionInternalId}.databases.storage';
const METRIC_DATABASES_OPERATIONS_READS_VECTORSDB = 'vectorsdb.databases.operations.reads';
const METRIC_DATABASE_ID_OPERATIONS_READS_VECTORSDB = 'vectorsdb.{databaseInternalId}.databases.operations.reads';
const METRIC_DATABASES_OPERATIONS_WRITES_VECTORSDB = 'vectorsdb.databases.operations.writes';
const METRIC_DATABASE_ID_OPERATIONS_WRITES_VECTORSDB = 'vectorsdb.{databaseInternalId}.databases.operations.writes';
const METRIC_EMBEDDINGS_TEXT = 'embeddings.text';
const METRIC_EMBEDDINGS_MODEL_TEXT = 'embeddings.text.{embeddingModel}';
const METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR = 'embeddings.text.totalErrors';
const METRIC_EMBEDDINGS_MODEL_TEXT_TOTAL_ERROR = 'embeddings.text.{embeddingModel}.totalErrors';
const METRIC_EMBEDDINGS_TEXT_TOTAL_DURATION = 'embeddings.text.totalDuration';
const METRIC_EMBEDDINGS_MODEL_TEXT_TOTAL_DURATION = 'embeddings.text.{embeddingModel}.totalDuration';
const METRIC_EMBEDDINGS_TEXT_TOTAL_TOKENS = 'embeddings.text.totalTokens';
const METRIC_EMBEDDINGS_MODEL_TEXT_TOTAL_TOKENS = 'embeddings.text.{embeddingModel}.totalTokens';
const METRIC_BUCKETS = 'buckets';
const METRIC_FILES = 'files';
const METRIC_FILES_STORAGE = 'files.storage';
@@ -383,6 +430,56 @@ const RESOURCE_TYPE_SUBSCRIBERS = 'subscribers';
const RESOURCE_TYPE_MESSAGES = 'messages';
const RESOURCE_TYPE_EXECUTIONS = 'executions';
const RESOURCE_TYPE_VCS = 'vcs';
const RESOURCE_TYPE_EMBEDDINGS_TEXT = 'embeddingsText';
const RESOURCE_TYPE_INSIGHTS = 'insights';
const RESOURCE_TYPE_REPORTS = 'reports';
// Insight types — engine-specific so the CTA action can reference the right public API.
const ADVISOR_INSIGHT_TYPES = [
InsightType::DATABASE_INDEX->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
];
+22 -1
View File
@@ -1,5 +1,6 @@
<?php
use Appwrite\Network\Platform;
use Appwrite\OpenSSL\OpenSSL;
use Utopia\Database\Database;
use Utopia\Database\Document;
@@ -123,11 +124,17 @@ Database::addFilter(
return;
},
function (mixed $value, Document $document, Database $database) {
return $database->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),
]));
}
);
+7
View File
@@ -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;
+172 -9
View File
@@ -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());
+368
View File
@@ -0,0 +1,368 @@
<?php
use Ahc\Jwt\JWT;
use Ahc\Jwt\JWTException;
use Appwrite\Extend\Exception;
use Appwrite\Network\Platform;
use Appwrite\Network\Validator\Origin;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Request;
use Utopia\Auth\Hashes\Sha;
use Utopia\Auth\Proofs\Token;
use Utopia\Auth\Store;
use Utopia\Database\DateTime as DatabaseDateTime;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\DI\Container;
use Utopia\System\System;
use Utopia\Validator\URL;
use Utopia\Validator\WhiteList;
/**
* Register the minimal per-connection resources required by realtime.
*/
return function (Container $container): void {
$getProjectId = static function (Request $request): string {
$projectId = $request->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']);
};
+60 -59
View File
@@ -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');
+95 -1199
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+20 -1
View File
@@ -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;
});
+459
View File
@@ -0,0 +1,459 @@
<?php
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
use Appwrite\Event\Realtime;
use Appwrite\Event\Webhook;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Database\Documents\User;
use Utopia\Audit\Adapter\Database as AdapterDatabase;
use Utopia\Audit\Audit as UtopiaAudit;
use Utopia\Cache\Cache;
use Utopia\Config\Config;
use Utopia\Console;
use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\DI\Container;
use Utopia\DSN\DSN;
use Utopia\Logger\Log;
use Utopia\Pools\Group;
use Utopia\Queue\Publisher;
use Utopia\Registry\Registry;
use Utopia\Storage\Device\Telemetry as TelemetryDevice;
use Utopia\System\System;
use Utopia\Telemetry\Adapter as Telemetry;
/**
* Register per-job resources on the given container.
* These resources depend on the queue message or keep mutable state and
* must be fresh for each worker job.
*/
return function (Container $container): void {
$container->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']);
};
+2
View File
@@ -1,9 +1,11 @@
<?php
use Appwrite\Bus\Listeners\Log;
use Appwrite\Bus\Listeners\Mails;
use Appwrite\Bus\Listeners\Usage;
return [
new Log(),
new Mails(),
new Usage(),
];
+468 -61
View File
@@ -33,21 +33,28 @@ use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\DI\Container;
use Utopia\DSN\DSN;
use Utopia\Http\Http;
use Utopia\Logger\Log;
use Utopia\Pools\Group;
use Utopia\Registry\Registry;
use Utopia\Span\Span;
use Utopia\System\System;
use Utopia\Telemetry\Adapter\None as NoTelemetry;
use Utopia\WebSocket\Adapter;
use Utopia\WebSocket\Server;
/**
* @var Registry $register
*/
require_once __DIR__ . '/init.php';
if (System::getEnv('_APP_EDITION', 'self-hosted') === 'self-hosted') {
require_once __DIR__ . '/init/span.php';
}
/** @var Registry $register */
$register = $GLOBALS['register'] ?? throw new \RuntimeException('Registry not initialized');
$registerConnectionResources ??= require __DIR__ . '/init/realtime/connection.php';
Runtime::enableCoroutine(SWOOLE_HOOK_ALL);
// Log uncaught exceptions in one line instead of relying on Swoole's full backtrace dump
@@ -123,8 +130,14 @@ if (!function_exists('getProjectDB')) {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->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);
});
+46 -24
View File
@@ -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: <?php echo $organization; ?>/console:7.6.4
image: <?php echo $organization; ?>/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: <?php echo $organization; ?>/<?php echo $image; ?>:<?php echo $version."\n"; ?>
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: <?php echo $organization; ?>/<?php echo $image; ?>:<?php echo $version."\n"; ?>
@@ -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: <?php echo $organization; ?>/<?php echo $image; ?>:<?php echo $version."\n"; ?>
@@ -936,7 +961,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
<?php if ($enableAssistant): ?>
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:
<?php elseif ($dbService === 'mongodb'): ?>
appwrite-mongodb:
appwrite-mongodb-keyfile:
appwrite-mongodb-config:
<?php endif; ?>
appwrite-redis:
appwrite-cache:
+1 -1
View File
@@ -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";
+117
View File
@@ -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;
}
+13 -6
View File
@@ -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);
@@ -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({
@@ -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();
}
};
+39 -11
View File
@@ -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 = {
@@ -240,6 +240,9 @@
if (key === 'database') {
value = toDatabaseLabel(formState?.database);
}
if (key === 'emailCertificates' && !value) {
value = formState?.accountEmail;
}
if (value) {
node.textContent = value;
}
@@ -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
};
})();
+26 -4
View File
@@ -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;
}
@@ -62,12 +62,14 @@ $badgeClass = $defaultSecretKey !== '' ? 'badge-success' : 'badge-warning';
<span class="badge badge-neutral typography-text-xs-400" data-review-assistant-badge>Disabled</span>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Appwrite Assistant</div>
</div>
<?php if (!$isUpgrade) { ?>
<div class="review-row">
<span class="badge <?php echo $badgeClass; ?> typography-text-xs-400" data-review-badge>
<?php echo htmlspecialchars((string) $badgeLabel, ENT_QUOTES, 'UTF-8'); ?>
</span>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Secret API key</div>
</div>
<?php } ?>
</div>
</div>
</div>
@@ -6,7 +6,7 @@ $isUpgrade = $isUpgrade ?? false;
<div class="install-panel">
<div class="install-header">
<div class="typography-text-m-400 text-neutral-primary">
<?php echo $isUpgrade ? 'Updating your app…' : 'Installing your app…'; ?>
<?php echo $isUpgrade ? 'Updating Appwrite…' : 'Installing Appwrite…'; ?>
</div>
</div>
<div class="install-list" data-install-list></div>
@@ -30,6 +30,7 @@ $isUpgrade = $isUpgrade ?? false;
</span>
<span class="install-text typography-text-m-400 text-neutral-primary" data-install-text></span>
</div>
<span class="install-counter typography-text-xs-400" data-install-counter></span>
<button type="button" class="install-row-toggle" aria-expanded="false" data-install-toggle>
<?php include __DIR__ . '/../../icons/chevron-down.svg'; ?>
</button>
@@ -50,4 +51,13 @@ $isUpgrade = $isUpgrade ?? false;
</div>
</div>
</template>
<div class="install-global-actions is-hidden" data-install-global-actions>
<button type="button" class="button secondary" data-install-start-over>
<span class="button-text typography-text-m-500">Start Over</span>
</button>
<button type="button" class="button secondary" data-install-hard-reset>
<span class="button-text typography-text-m-500">Reset Everything</span>
</button>
</div>
</div>
@@ -0,0 +1,37 @@
<?php
$isUpgrade = $isUpgrade ?? false;
?>
<div class="step-layout" data-step="6">
<div class="stack-xl">
<div class="stack-xxxs">
<h1 class="typography-title-s text-neutral-primary">Database migration</h1>
<p class="typography-text-m-400 text-neutral-secondary">
Run database migration after the update to apply schema changes.
</p>
</div>
<div class="stack-xl">
<label class="migration-option" for="run-migration">
<span class="migration-option-content">
<span class="typography-text-m-500 text-neutral-primary">Run migration automatically</span>
<span class="typography-text-xs-400 text-neutral-tertiary">Recommended when upgrading to a new version</span>
</span>
<span class="migration-switch">
<input type="checkbox" id="run-migration" name="migrate" class="sr-only" checked>
<span class="migration-switch-track" aria-hidden="true">
<span class="migration-switch-thumb"></span>
</span>
</span>
</label>
<div class="migration-hint">
<span class="migration-hint-icon">
<?php include __DIR__ . '/../../icons/info.svg'; ?>
</span>
<span class="typography-text-xs-400 text-neutral-tertiary">
To run manually later: <code class="migration-code">docker compose exec appwrite migrate</code>
</span>
</div>
</div>
</div>
</div>
+50 -477
View File
@@ -1,511 +1,70 @@
<?php
require_once __DIR__ . '/init.php';
$registerWorkerMessageResources = require __DIR__ . '/init/worker/message.php';
use Appwrite\Certificates\LetsEncrypt;
use Appwrite\Event\Audit;
use Appwrite\Event\Build;
use Appwrite\Event\Certificate;
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
use Appwrite\Event\Migration;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Screenshot;
use Appwrite\Event\Webhook;
use Appwrite\Platform\Appwrite;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Database\Documents\User;
use Executor\Executor;
use Swoole\Runtime;
use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis;
use Utopia\Audit\Adapter\Database as AdapterDatabase;
use Utopia\Audit\Audit as UtopiaAudit;
use Utopia\Cache\Adapter\Pool as CachePool;
use Utopia\Cache\Adapter\Sharding;
use Utopia\Cache\Cache;
use Utopia\Config\Config;
use Utopia\Console;
use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\DSN\DSN;
use Utopia\Logger\Log;
use Utopia\Logger\Logger;
use Utopia\Platform\Service;
use Utopia\Pools\Group;
use Utopia\Queue\Adapter\Swoole;
use Utopia\Queue\Broker\Pool as BrokerPool;
use Utopia\Queue\Message;
use Utopia\Queue\Publisher;
use Utopia\Queue\Queue;
use Utopia\Queue\Server;
use Utopia\Registry\Registry;
use Utopia\Storage\Device\Telemetry as TelemetryDevice;
use Utopia\Span\Span;
use Utopia\System\System;
use Utopia\Telemetry\Adapter as Telemetry;
use Utopia\Telemetry\Adapter\None as NoTelemetry;
Runtime::enableCoroutine();
require_once __DIR__ . '/init/span.php';
global $register;
Server::setResource('register', fn () => $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());
+20 -33
View File
@@ -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,
Generated
+520 -475
View File
File diff suppressed because it is too large Load Diff
+73 -16
View File
@@ -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:
@@ -1 +0,0 @@
Initialize an MFA challenge of the specified factor. The factor must be available on the account.
@@ -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.
+1
View File
@@ -0,0 +1 @@
Delete an analyzer report by its unique ID. Nested insights and CTA metadata are removed asynchronously by the deletes worker.
+1
View File
@@ -0,0 +1 @@
Get an insight by its unique ID, scoped to its parent report.
+1
View File
@@ -0,0 +1 @@
Get an analyzer report by its unique ID. The response includes the report's metadata and the nested insights it produced.
+1
View File
@@ -0,0 +1 @@
List the insights produced under a single analyzer report. You can use the query params to filter your results further.
+1
View File
@@ -0,0 +1 @@
Get a list of all the project's analyzer reports. You can use the query params to filter your results.
-1
View File
@@ -1 +0,0 @@
Get all Environment Variables that are relevant for the console.
@@ -0,0 +1 @@
Create a bigint attribute. Optionally, minimum and maximum values can be provided.
@@ -0,0 +1 @@
Update a bigint attribute. Changing the `default` value will not update already existing documents.
@@ -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.
@@ -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.
@@ -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.
@@ -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`.

Some files were not shown because too many files have changed in this diff Show More