mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Compare commits
471
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6da8c1cb12 | ||
|
|
d303d6f807 | ||
|
|
07973dee2d | ||
|
|
5cfaa0807d | ||
|
|
e181954dd1 | ||
|
|
4b05a6cf8f | ||
|
|
29bbc7299a | ||
|
|
21ba13a076 | ||
|
|
399f75e8e7 | ||
|
|
7917506b58 | ||
|
|
706459e314 | ||
|
|
7e9d6f33c8 | ||
|
|
3b2a01f470 | ||
|
|
e621701cbc | ||
|
|
c89bf3084a | ||
|
|
9387c480f5 | ||
|
|
2e9841c9c2 | ||
|
|
8201fea9ef | ||
|
|
bf52b1ba31 | ||
|
|
e834a95213 | ||
|
|
a9dd957a7a | ||
|
|
d2b551cd12 | ||
|
|
bc0501aaf2 | ||
|
|
a65c6136f4 | ||
|
|
78133a3c25 | ||
|
|
140cbb633d | ||
|
|
389146c625 | ||
|
|
ec927104fe | ||
|
|
dcef7ef559 | ||
|
|
ff518a055f | ||
|
|
00ee9c6b0e | ||
|
|
f9c5f41a1a | ||
|
|
d27b578888 | ||
|
|
1ccf058c14 | ||
|
|
1273bcd33a | ||
|
|
add2b7b64a | ||
|
|
a0d77ba1b2 | ||
|
|
63ac3bedca | ||
|
|
a286b78a0b | ||
|
|
e63f9fd6a5 | ||
|
|
fc918d8b3c | ||
|
|
93ce542d31 | ||
|
|
e6a9c68534 | ||
|
|
03f7b62ff1 | ||
|
|
ea4a31c6c6 | ||
|
|
c9ad685e11 | ||
|
|
83d56a2f36 | ||
|
|
94c968e941 | ||
|
|
8918cc75c3 | ||
|
|
f90f618bff | ||
|
|
b382a9c3e5 | ||
|
|
3fd79e3045 | ||
|
|
3e58500e13 | ||
|
|
1975ab2513 | ||
|
|
15f21daa2b | ||
|
|
48fbb59113 | ||
|
|
9b120e9db7 | ||
|
|
305e8e1ec7 | ||
|
|
93515fcc1d | ||
|
|
fd1a4542e7 | ||
|
|
7ccf2b1a1e | ||
|
|
57dd7cf952 | ||
|
|
90e681a906 | ||
|
|
bca876acaf | ||
|
|
2fc044a7c9 | ||
|
|
fef4cbf3b0 | ||
|
|
32d30dfd9d | ||
|
|
b0220292a7 | ||
|
|
9ab546d743 | ||
|
|
67ab29ffeb | ||
|
|
1a41a8ea66 | ||
|
|
538fa94c79 | ||
|
|
1a410c43d7 | ||
|
|
4d5bb30917 | ||
|
|
c35d5e348b | ||
|
|
d2922e7d5d | ||
|
|
b1c3fc0908 | ||
|
|
d18f64d526 | ||
|
|
e81ae67745 | ||
|
|
1e9b364f58 | ||
|
|
d7d0ecb106 | ||
|
|
879dc6873e | ||
|
|
2263632f7d | ||
|
|
2231830826 | ||
|
|
cd6f5c64f0 | ||
|
|
d8349544d2 | ||
|
|
ec75f101bb | ||
|
|
9c9bde2ce6 | ||
|
|
554fdf5472 | ||
|
|
737c85822d | ||
|
|
1fc3a8803c | ||
|
|
44dd0e8c32 | ||
|
|
c32294743a | ||
|
|
f27172018f | ||
|
|
f5a7cfd2ea | ||
|
|
76e6239d32 | ||
|
|
1da5b549af | ||
|
|
92eceba218 | ||
|
|
8f68a59a79 | ||
|
|
6051b8150c | ||
|
|
4e20e382d2 | ||
|
|
c96922422f | ||
|
|
7f27851dab | ||
|
|
f396064c49 | ||
|
|
413930a15e | ||
|
|
f672891c42 | ||
|
|
65f926b4c5 | ||
|
|
b79637eef5 | ||
|
|
b1906e5eb9 | ||
|
|
e14db61e30 | ||
|
|
08ad7d7f71 | ||
|
|
5ffb7c82e7 | ||
|
|
71300383b2 | ||
|
|
d4e32af792 | ||
|
|
8785aa9877 | ||
|
|
62b7d5558f | ||
|
|
fbbab0f7e1 | ||
|
|
86b9599a57 | ||
|
|
526b390c15 | ||
|
|
b73ba68bfb | ||
|
|
d98bd8c972 | ||
|
|
81c580bf50 | ||
|
|
c0bba74eee | ||
|
|
337d47b1d9 | ||
|
|
8ffe48d948 | ||
|
|
4050b9ded1 | ||
|
|
d099167d18 | ||
|
|
9d3255f5cd | ||
|
|
f03cc847f8 | ||
|
|
6088fd55c8 | ||
|
|
1aeee8c407 | ||
|
|
18b9769672 | ||
|
|
8eed06678b | ||
|
|
7a9a2899ff | ||
|
|
36486ccc93 | ||
|
|
794d8eac5b | ||
|
|
32ebfc6cb8 | ||
|
|
e1b8f5bf98 | ||
|
|
4d86e67006 | ||
|
|
b3e3b2a330 | ||
|
|
e010bf25d5 | ||
|
|
aaf91f3816 | ||
|
|
bae61e8a05 | ||
|
|
dfbf45f4cc | ||
|
|
c264db6146 | ||
|
|
cccafeff0c | ||
|
|
360d08f087 | ||
|
|
d8e97ae47d | ||
|
|
d13e6d75f0 | ||
|
|
4447396859 | ||
|
|
4d1f229ec7 | ||
|
|
4f733f5984 | ||
|
|
701f557755 | ||
|
|
8ab26aab44 | ||
|
|
ec3aa2b54f | ||
|
|
2d636ff7ec | ||
|
|
fd42b8fa64 | ||
|
|
9d7df34590 | ||
|
|
e75fc5b859 | ||
|
|
57b8305144 | ||
|
|
a429fb5860 | ||
|
|
aca11ed073 | ||
|
|
86123c9e93 | ||
|
|
a58ea1123b | ||
|
|
dae9cbcf45 | ||
|
|
05f2d2b9cf | ||
|
|
c1f61b22aa | ||
|
|
980762fc3e | ||
|
|
c96836b1c0 | ||
|
|
15917ac7ba | ||
|
|
cd851bff24 | ||
|
|
3f5dcc81fd | ||
|
|
f5a732d231 | ||
|
|
72dfd8a7bc | ||
|
|
11f80fc2ed | ||
|
|
547709a1d8 | ||
|
|
ccb0ddd578 | ||
|
|
b2ce95a0cd | ||
|
|
ed9b47f6ce | ||
|
|
2a357511ea | ||
|
|
67d24d3ef1 | ||
|
|
87ed7c3817 | ||
|
|
8f176166c9 | ||
|
|
a0ef145b92 | ||
|
|
3d3f5934c6 | ||
|
|
9e1f8af103 | ||
|
|
b055ff1066 | ||
|
|
f0cbfbbbe4 | ||
|
|
cb4cff120b | ||
|
|
49e6a38e7f | ||
|
|
dfa3ae5274 | ||
|
|
543765a22a | ||
|
|
e2bb9a9161 | ||
|
|
bda823ac0e | ||
|
|
3dd5a51ba4 | ||
|
|
5afc8f462d | ||
|
|
ed0c7b4e12 | ||
|
|
d25707346f | ||
|
|
a3f6cf4645 | ||
|
|
9a175c5098 | ||
|
|
8f32d01686 | ||
|
|
5465be6301 | ||
|
|
e1209614c5 | ||
|
|
46a457bfa3 | ||
|
|
4c989f99c3 | ||
|
|
8f1d73a6cb | ||
|
|
01b5fa8ecb | ||
|
|
d73b7a70d8 | ||
|
|
40aa8cb1bd | ||
|
|
4ae4531355 | ||
|
|
f71a2dfddc | ||
|
|
cefd063c55 | ||
|
|
443f5cfb0e | ||
|
|
c924cbcc59 | ||
|
|
5a928f2c0f | ||
|
|
81321e82d1 | ||
|
|
f5730e8eed | ||
|
|
30a511692b | ||
|
|
9637409831 | ||
|
|
c4f6b11706 | ||
|
|
b6afef6efc | ||
|
|
3fba7afd2e | ||
|
|
ac46ff802a | ||
|
|
8cccc79935 | ||
|
|
b03c901fa7 | ||
|
|
ad4178aa42 | ||
|
|
7b32fd0196 | ||
|
|
1f16b0d9e7 | ||
|
|
015aee087a | ||
|
|
50d86c5b5d | ||
|
|
3d43530225 | ||
|
|
d0d536a2dd | ||
|
|
4b620bb31a | ||
|
|
ca7f36a9b8 | ||
|
|
ec3c7f1ad6 | ||
|
|
ecba11eba5 | ||
|
|
7a96b024b3 | ||
|
|
4ba413fcc0 | ||
|
|
af95e71244 | ||
|
|
ee1eea5c0c | ||
|
|
b28b851bb2 | ||
|
|
2f2da98cca | ||
|
|
c80c747e48 | ||
|
|
54997638e8 | ||
|
|
70b9c60e2c | ||
|
|
49d2db65e6 | ||
|
|
1fdcca9592 | ||
|
|
dfde1be035 | ||
|
|
cb8640b56f | ||
|
|
a781325679 | ||
|
|
3f12062259 | ||
|
|
2e57500d7e | ||
|
|
a1a88ae57e | ||
|
|
9553f8a9f8 | ||
|
|
15f94d99ca | ||
|
|
ca105ff9bc | ||
|
|
7e3114d733 | ||
|
|
ef4b9c4934 | ||
|
|
2e960b90df | ||
|
|
9be0ed8af0 | ||
|
|
5f8a32798d | ||
|
|
1928605bd9 | ||
|
|
340ce9d56b | ||
|
|
e6d5c216eb | ||
|
|
d25ccb784d | ||
|
|
8ce7aa2abe | ||
|
|
78715e4a1a | ||
|
|
557fef2c7b | ||
|
|
df57ee2a32 | ||
|
|
6d4a66fbb3 | ||
|
|
3aee54747c | ||
|
|
d2423a5bb5 | ||
|
|
c0c053ff20 | ||
|
|
ece468d1f7 | ||
|
|
e4bfb38a57 | ||
|
|
0a7b7de197 | ||
|
|
51c0767be2 | ||
|
|
d25dac7d60 | ||
|
|
1f18e16310 | ||
|
|
d0f6daa67a | ||
|
|
184399023c | ||
|
|
a588a62277 | ||
|
|
ffd0dbd406 | ||
|
|
8200d079c6 | ||
|
|
d9d87f813f | ||
|
|
dc9f48c948 | ||
|
|
db7acd4b8b | ||
|
|
a62ca8612d | ||
|
|
975da667f5 | ||
|
|
6cfb12c48b | ||
|
|
8cdcd379c8 | ||
|
|
1b8123bf62 | ||
|
|
1ca75c73df | ||
|
|
fe08978851 | ||
|
|
0633662695 | ||
|
|
89819db775 | ||
|
|
faf09ed7c5 | ||
|
|
c097d9fcdd | ||
|
|
dac184b281 | ||
|
|
335b1c2f6c | ||
|
|
5fbe6cba79 | ||
|
|
36435d940d | ||
|
|
93f7a0d902 | ||
|
|
7fbfb6266b | ||
|
|
29b700d1ec | ||
|
|
478a2a6e86 | ||
|
|
8292f7493d | ||
|
|
e728265d21 | ||
|
|
84b6dfa9d5 | ||
|
|
e3231393b9 | ||
|
|
5beeca5a99 | ||
|
|
4de3009f67 | ||
|
|
4b3963512c | ||
|
|
8c634a95e4 | ||
|
|
7a3c001452 | ||
|
|
a48fd13ced | ||
|
|
9c6ed9565e | ||
|
|
bdbc5b92df | ||
|
|
c246fb0f83 | ||
|
|
a0a3849b16 | ||
|
|
b99139661e | ||
|
|
6d86b8fd0d | ||
|
|
cef7a5197f | ||
|
|
c1dfeae323 | ||
|
|
51fa0770a6 | ||
|
|
d46403507c | ||
|
|
83724ce96f | ||
|
|
34930e6d67 | ||
|
|
096f8041fd | ||
|
|
9dad7cef9e | ||
|
|
9e23867f0a | ||
|
|
48353faa9b | ||
|
|
c36b8fbabf | ||
|
|
5a5cb1e74e | ||
|
|
0a26608155 | ||
|
|
18694e0a28 | ||
|
|
07307c6131 | ||
|
|
0134832fb6 | ||
|
|
44b7b26adf | ||
|
|
a8e6b1b683 | ||
|
|
c1506643e9 | ||
|
|
2ad95e5113 | ||
|
|
715bd40b4a | ||
|
|
3d66078fe9 | ||
|
|
9a6a597710 | ||
|
|
c15e8d0126 | ||
|
|
7b25d778d4 | ||
|
|
3283b0bec0 | ||
|
|
49f8d6e89c | ||
|
|
277a09847e | ||
|
|
cdc5654c26 | ||
|
|
c2e5bbe0f7 | ||
|
|
b2d24080b9 | ||
|
|
b006858d0c | ||
|
|
a0f30f608d | ||
|
|
6d1def7716 | ||
|
|
f0f1e1c412 | ||
|
|
11f85e2477 | ||
|
|
dbb1d1139e | ||
|
|
46e778ea90 | ||
|
|
fd9fe5d9ce | ||
|
|
59e0383264 | ||
|
|
0f81bc2da9 | ||
|
|
17e3d03b40 | ||
|
|
c97435d95c | ||
|
|
b2ad7237ab | ||
|
|
57d777f80a | ||
|
|
d1962dbc62 | ||
|
|
6648a1987b | ||
|
|
c60e85ca84 | ||
|
|
ca1cf1982f | ||
|
|
f50ca0281b | ||
|
|
f934259c31 | ||
|
|
d1ade3872e | ||
|
|
0f64f54221 | ||
|
|
9065d9ada4 | ||
|
|
e0fec8f550 | ||
|
|
205c283935 | ||
|
|
355d4323fc | ||
|
|
7578b5644c | ||
|
|
240cdf43e5 | ||
|
|
f75a7269c9 | ||
|
|
f770277ea5 | ||
|
|
eeadba3b59 | ||
|
|
2e42633e12 | ||
|
|
dfd39d3946 | ||
|
|
7d7fcea8c0 | ||
|
|
33d3f82a58 | ||
|
|
73a77b8dcc | ||
|
|
4b1b2972e9 | ||
|
|
b3f305f9a8 | ||
|
|
32508e7251 | ||
|
|
a98b9f2319 | ||
|
|
a0ef5968fb | ||
|
|
bc637ad25f | ||
|
|
3b9c604eb8 | ||
|
|
7c486ddcef | ||
|
|
cb7f2ec693 | ||
|
|
3cc7b833db | ||
|
|
9ca84a56c9 | ||
|
|
211ac32080 | ||
|
|
30bf9deae0 | ||
|
|
196b04a39c | ||
|
|
b9d01617a4 | ||
|
|
9c65609d73 | ||
|
|
63b2a1fb7f | ||
|
|
30cfbb2d99 | ||
|
|
ef08d5a04c | ||
|
|
566eebfaec | ||
|
|
51bc3dc1d5 | ||
|
|
83f182b444 | ||
|
|
dcd01a8fb0 | ||
|
|
6aeb2d2be0 | ||
|
|
2cfe40e98e | ||
|
|
15e45df81e | ||
|
|
e4f74a3fb1 | ||
|
|
774a0d7022 | ||
|
|
19f02a5129 | ||
|
|
d0603c4d28 | ||
|
|
187d0c0cf4 | ||
|
|
fc6bd7232e | ||
|
|
e878b0b403 | ||
|
|
f3c2502a8c | ||
|
|
18f9dfb64e | ||
|
|
8fa28257be | ||
|
|
e7072574ad | ||
|
|
d84f71e728 | ||
|
|
5f41f7fb86 | ||
|
|
c5fe71684a | ||
|
|
d44820e00c | ||
|
|
b3c6e0e67f | ||
|
|
5c0c0b44c0 | ||
|
|
4f599fc75c | ||
|
|
928aa9c909 | ||
|
|
0ea8379065 | ||
|
|
0c734f8f12 | ||
|
|
229ab28427 | ||
|
|
45dc259df3 | ||
|
|
71b28eae5a | ||
|
|
9cb2ea1135 | ||
|
|
d38c921781 | ||
|
|
3c20ea47f5 | ||
|
|
32728ae4a0 | ||
|
|
ad7f83db11 | ||
|
|
c4b3956bef | ||
|
|
5fa4551400 | ||
|
|
2fb54e0846 | ||
|
|
dfbf22cf3c | ||
|
|
a5b2021d1e | ||
|
|
fb28b31906 | ||
|
|
737bfc599a | ||
|
|
819863559e | ||
|
|
f6cdcfb823 | ||
|
|
e5d818ba44 | ||
|
|
19487cf56b | ||
|
|
5741041f33 | ||
|
|
89eb5c2254 | ||
|
|
77d195f0c3 | ||
|
|
a9ed74c6a8 | ||
|
|
01db1efbc4 | ||
|
|
ffa3f741ed | ||
|
|
03c73ac0aa | ||
|
|
28882a4414 | ||
|
|
d993214810 | ||
|
|
2838642bae | ||
|
|
bd03ec7a50 | ||
|
|
6128a049f2 | ||
|
|
9ba6bfba62 | ||
|
|
6f73602dd7 | ||
|
|
b1e97b4539 | ||
|
|
065a0fddae |
@@ -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.
|
||||
@@ -47,6 +47,8 @@ _APP_DB_SCHEMA=appwrite
|
||||
_APP_DB_USER=user
|
||||
_APP_DB_PASS=password
|
||||
_APP_DB_ROOT_PASS=rootsecretpassword
|
||||
_APP_DATABASE_SHARED_TABLES=
|
||||
_APP_DATABASE_SHARED_NAMESPACE=
|
||||
_APP_DB_ADAPTER_DOCUMENTSDB=mongodb
|
||||
_APP_DB_HOST_DOCUMENTSDB=mongodb
|
||||
_APP_DB_PORT_DOCUMENTSDB=27017
|
||||
@@ -146,3 +148,5 @@ _APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main
|
||||
_APP_TRUSTED_HEADERS=x-forwarded-for
|
||||
_APP_POOL_ADAPTER=stack
|
||||
_APP_WORKER_SCREENSHOTS_ROUTER=http://appwrite
|
||||
_TESTS_OAUTH2_GITHUB_CLIENT_ID=
|
||||
_TESTS_OAUTH2_GITHUB_CLIENT_SECRET=
|
||||
|
||||
@@ -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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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;
|
||||
}
|
||||
+241
-116
@@ -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
|
||||
@@ -257,32 +263,30 @@ jobs:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build Appwrite
|
||||
- name: Build and push Appwrite
|
||||
uses: docker/build-push-action@v6
|
||||
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
|
||||
@@ -290,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
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v4
|
||||
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
|
||||
|
||||
@@ -337,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
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v4
|
||||
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
|
||||
|
||||
@@ -395,6 +411,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
packages: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -429,6 +446,8 @@ jobs:
|
||||
include:
|
||||
- service: Databases
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
paratest_processes: 3
|
||||
timeout_minutes: 30
|
||||
- service: Sites
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- service: Functions
|
||||
@@ -439,18 +458,18 @@ jobs:
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- service: TablesDB
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
paratest_processes: 3
|
||||
timeout_minutes: 30
|
||||
- service: Migrations
|
||||
paratest_processes: 1
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download Docker Image
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ${{ env.IMAGE }}
|
||||
path: /tmp
|
||||
|
||||
- name: Set database environment
|
||||
- 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
|
||||
@@ -474,6 +493,18 @@ jobs:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v4
|
||||
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:
|
||||
@@ -482,7 +513,6 @@ jobs:
|
||||
_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
|
||||
|
||||
@@ -499,7 +529,7 @@ jobs:
|
||||
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 }}
|
||||
@@ -512,9 +542,16 @@ jobs:
|
||||
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()
|
||||
@@ -529,6 +566,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
packages: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -536,12 +574,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download Docker Image
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ${{ env.IMAGE }}
|
||||
path: /tmp
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
@@ -549,6 +583,18 @@ jobs:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v4
|
||||
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:
|
||||
@@ -557,7 +603,6 @@ jobs:
|
||||
_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
|
||||
|
||||
@@ -588,6 +633,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
packages: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -596,18 +642,24 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download Docker Image
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ${{ env.IMAGE }}
|
||||
path: /tmp
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v4
|
||||
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:
|
||||
@@ -615,7 +667,6 @@ jobs:
|
||||
_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
|
||||
|
||||
@@ -649,19 +700,20 @@ 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
|
||||
with:
|
||||
name: ${{ env.IMAGE }}
|
||||
path: /tmp
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
@@ -669,80 +721,153 @@ jobs:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Load and Start Appwrite
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Pull Appwrite image
|
||||
run: |
|
||||
sed -i 's/traefik/localhost/g' .env
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
docker compose up -d
|
||||
sleep 10
|
||||
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: Install Oha
|
||||
- name: Setup k6
|
||||
uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2
|
||||
with:
|
||||
k6-version: ${{ env.K6_VERSION }}
|
||||
|
||||
- name: Prepare benchmark before
|
||||
id: benchmark_before_prepare
|
||||
continue-on-error: true
|
||||
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
|
||||
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: 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
|
||||
- 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: |
|
||||
rm .env
|
||||
LATEST_TAG=$(curl -fsSL -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/appwrite/appwrite/releases/latest | jq -r .tag_name)
|
||||
echo "Latest release tag: $LATEST_TAG"
|
||||
curl -fsSL "https://raw.githubusercontent.com/appwrite/appwrite/${LATEST_TAG}/docker-compose.yml" -o docker-compose.yml
|
||||
curl -fsSL "https://raw.githubusercontent.com/appwrite/appwrite/${LATEST_TAG}/.env" -o .env
|
||||
sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env
|
||||
docker compose up -d
|
||||
sleep 10
|
||||
docker tag ${{ env.IMAGE }}:before ${{ env.IMAGE }}
|
||||
docker compose up -d --wait --no-build
|
||||
|
||||
- name: Benchmark Latest
|
||||
run: oha -z 180s http://localhost/v1/health/version --output-format json > benchmark-latest.json
|
||||
- name: Prepare benchmark files
|
||||
run: rm -f benchmark-before-summary.json benchmark-after-summary.json benchmark-before-samples.json benchmark-after-samples.json
|
||||
|
||||
- name: Prepare comment
|
||||
- name: Benchmark before
|
||||
if: steps.benchmark_before_start.outcome == 'success'
|
||||
continue-on-error: true
|
||||
uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d
|
||||
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: |
|
||||
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
|
||||
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@a15e2072ede004e8d46141e33d7f7dad8ad08d9d
|
||||
env:
|
||||
APPWRITE_ENDPOINT: 'http://localhost/v1'
|
||||
APPWRITE_BENCHMARK_ITERATIONS: '5'
|
||||
APPWRITE_BENCHMARK_VUS: '1'
|
||||
APPWRITE_WORKER_TIMEOUT_MS: '120000'
|
||||
APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH: '../../benchmark-before-summary.json'
|
||||
APPWRITE_BENCHMARK_SUMMARY_PATH: 'benchmark-after-summary.json'
|
||||
with:
|
||||
path: tests/benchmarks/http.js
|
||||
flags: --quiet --out json=benchmark-after-samples.json
|
||||
cloud-comment-on-pr: false
|
||||
debug: true
|
||||
|
||||
- name: Stop after Appwrite
|
||||
if: always()
|
||||
run: docker compose down -v || true
|
||||
|
||||
- name: Comment on PR
|
||||
if: always()
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
BENCHMARK_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
BENCHMARK_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
with:
|
||||
script: |
|
||||
const comment = require('./.github/workflows/benchmark-comment.js');
|
||||
await comment({ github, context, core });
|
||||
|
||||
- name: Save results
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: benchmark.json
|
||||
path: benchmark.json
|
||||
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: Find Comment
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: peter-evans/find-comment@v3
|
||||
id: fc
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-author: 'github-actions[bot]'
|
||||
body-includes: Benchmark results
|
||||
|
||||
- name: Comment on PR
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
with:
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body-path: benchmark.txt
|
||||
edit-mode: replace
|
||||
- name: Fail benchmark
|
||||
if: always() && steps.benchmark_after.outcome != 'success'
|
||||
run: exit 1
|
||||
|
||||
@@ -5,6 +5,11 @@ on:
|
||||
types:
|
||||
- closed
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -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 }}
|
||||
|
||||
@@ -24,9 +24,11 @@ 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@v4
|
||||
if: always() && hashFiles('trivy-image-results.sarif') != ''
|
||||
with:
|
||||
sarif_file: 'trivy-image-results.sarif'
|
||||
category: 'trivy-image'
|
||||
|
||||
scan-code:
|
||||
name: Scan Code
|
||||
@@ -42,6 +44,8 @@ jobs:
|
||||
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@v4
|
||||
if: always() && hashFiles('trivy-fs-results.sarif') != ''
|
||||
with:
|
||||
sarif_file: 'trivy-fs-results.sarif'
|
||||
category: 'trivy-source'
|
||||
|
||||
+7
-2
@@ -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
|
||||
|
||||
+20
@@ -157,12 +157,19 @@ $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform,
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -182,9 +189,16 @@ $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform,
|
||||
$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
|
||||
@@ -212,6 +226,11 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
|
||||
return $database;
|
||||
}
|
||||
|
||||
/** @var array $collections */
|
||||
$collections = Config::getParam('collections', []);
|
||||
$logsCollections = $collections['logs'] ?? [];
|
||||
$logsCollections = array_keys($logsCollections);
|
||||
|
||||
$adapter = new DatabasePool($pools->get('logs'));
|
||||
$database = new Database($adapter, $cache);
|
||||
|
||||
@@ -220,6 +239,7 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
|
||||
->setAuthorization($authorization)
|
||||
->setSharedTables(true)
|
||||
->setNamespace('logsV1')
|
||||
->setGlobalCollections($logsCollections)
|
||||
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_TASK)
|
||||
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
|
||||
|
||||
|
||||
@@ -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' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@ $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
|
||||
|
||||
+26
-1
@@ -384,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,
|
||||
],
|
||||
|
||||
@@ -623,6 +623,11 @@ return [
|
||||
'description' => 'Synchronous function execution timed out. Use asynchronous execution instead, or ensure the execution duration doesn\'t exceed 30 seconds.',
|
||||
'code' => 408,
|
||||
],
|
||||
Exception::FUNCTION_ASYNCHRONOUS_TIMEOUT => [
|
||||
'name' => Exception::FUNCTION_ASYNCHRONOUS_TIMEOUT,
|
||||
'description' => 'Asynchronous function execution timed out. Ensure the execution duration doesn\'t exceed the configured function timeout.',
|
||||
'code' => 408,
|
||||
],
|
||||
Exception::FUNCTION_TEMPLATE_NOT_FOUND => [
|
||||
'name' => Exception::FUNCTION_TEMPLATE_NOT_FOUND,
|
||||
'description' => 'Function Template with the requested ID could not be found.',
|
||||
@@ -687,6 +692,11 @@ return [
|
||||
'description' => 'Build with the requested ID failed. Please check the logs for more information.',
|
||||
'code' => 400,
|
||||
],
|
||||
Exception::BUILD_TIMEOUT => [
|
||||
'name' => Exception::BUILD_TIMEOUT,
|
||||
'description' => 'Build timed out. Increase the build timeout via the `_APP_COMPUTE_BUILD_TIMEOUT` environment variable, or simplify the build to complete within the limit.',
|
||||
'code' => 408,
|
||||
],
|
||||
|
||||
/** Deployments */
|
||||
Exception::DEPLOYMENT_NOT_FOUND => [
|
||||
@@ -1408,4 +1418,19 @@ 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,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/',
|
||||
|
||||
+11
-6
@@ -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,7 +55,12 @@ $admins = [
|
||||
'tables.write',
|
||||
'platforms.read',
|
||||
'platforms.write',
|
||||
'policies.write',
|
||||
'oauth2.read',
|
||||
'oauth2.write',
|
||||
'mocks.read',
|
||||
'mocks.write',
|
||||
'project.policies.read',
|
||||
'project.policies.write',
|
||||
'templates.read',
|
||||
'templates.write',
|
||||
'projects.write',
|
||||
@@ -76,8 +81,8 @@ $admins = [
|
||||
'sites.write',
|
||||
'log.read',
|
||||
'log.write',
|
||||
'execution.read',
|
||||
'execution.write',
|
||||
'executions.read',
|
||||
'executions.write',
|
||||
'rules.read',
|
||||
'rules.write',
|
||||
'migrations.read',
|
||||
@@ -118,7 +123,7 @@ return [
|
||||
'files.write',
|
||||
'locale.read',
|
||||
'avatars.read',
|
||||
'execution.write',
|
||||
'executions.write',
|
||||
],
|
||||
],
|
||||
User::ROLE_USERS => [
|
||||
|
||||
+325
-180
@@ -1,219 +1,364 @@
|
||||
<?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',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -286,7 +286,7 @@ 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',
|
||||
|
||||
@@ -79,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']
|
||||
],
|
||||
|
||||
@@ -830,11 +830,11 @@ Http::patch('/v1/account/sessions/:sessionId')
|
||||
$refreshToken = $session->getAttribute('providerRefreshToken', '');
|
||||
$oAuthProviders = Config::getParam('oAuthProviders') ?? [];
|
||||
$className = $oAuthProviders[$provider]['class'] ?? null;
|
||||
if (!empty($provider) && ($className === null || !\class_exists($className))) {
|
||||
if (!empty($refreshToken) && ($className === null || !\class_exists($className))) {
|
||||
throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED);
|
||||
}
|
||||
|
||||
if (!empty($provider) && \class_exists($className)) {
|
||||
if ($className !== null && \class_exists($className)) {
|
||||
$appId = $project->getAttribute('oAuthProviders', [])[$provider . 'Appid'] ?? '';
|
||||
$appSecret = $project->getAttribute('oAuthProviders', [])[$provider . 'Secret'] ?? '{}';
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,10 @@
|
||||
<?php
|
||||
|
||||
use Ahc\Jwt\JWT;
|
||||
use Appwrite\Auth\Validator\MockNumber;
|
||||
use Appwrite\Event\Delete;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\ContentType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Database\Validator\Queries\Keys;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Config\Config;
|
||||
use Utopia\Database\Database;
|
||||
@@ -19,7 +15,6 @@ use Utopia\System\System;
|
||||
use Utopia\Validator\ArrayList;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\Nullable;
|
||||
use Utopia\Validator\Range;
|
||||
use Utopia\Validator\Text;
|
||||
use Utopia\Validator\WhiteList;
|
||||
|
||||
@@ -63,39 +58,11 @@ Http::get('/v1/projects/:projectId')
|
||||
$response->dynamic($project, Response::MODEL_PROJECT);
|
||||
});
|
||||
|
||||
Http::patch('/v1/projects/:projectId/service/all')
|
||||
->desc('Update all service status')
|
||||
->groups(['api', 'projects'])
|
||||
->label('scope', 'projects.write')
|
||||
->action(function () {
|
||||
throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.');
|
||||
});
|
||||
|
||||
Http::patch('/v1/projects/:projectId/api/all')
|
||||
->desc('Update all API status')
|
||||
->groups(['api', 'projects'])
|
||||
->label('scope', 'projects.write')
|
||||
->action(function () {
|
||||
throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.');
|
||||
});
|
||||
|
||||
// Backwards compatibility
|
||||
Http::patch('/v1/projects/:projectId/oauth2')
|
||||
->desc('Update project OAuth2')
|
||||
->groups(['api', 'projects'])
|
||||
->label('scope', 'projects.write')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'projects',
|
||||
group: 'auth',
|
||||
name: 'updateOAuth2',
|
||||
description: '/docs/references/projects/update-oauth2.md',
|
||||
auth: [AuthType::ADMIN],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_PROJECT,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
|
||||
->param('provider', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders')), true), 'Provider Name')
|
||||
->param('appId', null, new Nullable(new Text(256)), 'Provider app ID. Max length: 256 chars.', true)
|
||||
@@ -130,23 +97,11 @@ Http::patch('/v1/projects/:projectId/oauth2')
|
||||
$response->dynamic($project, Response::MODEL_PROJECT);
|
||||
});
|
||||
|
||||
// Backwards compatibility
|
||||
Http::patch('/v1/projects/:projectId/auth/mock-numbers')
|
||||
->desc('Update the mock numbers for the project')
|
||||
->groups(['api', 'projects'])
|
||||
->label('scope', 'projects.write')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'projects',
|
||||
group: 'auth',
|
||||
name: 'updateMockNumbers',
|
||||
description: '/docs/references/projects/update-mock-numbers.md',
|
||||
auth: [AuthType::ADMIN],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_PROJECT,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
|
||||
->param('numbers', '', new ArrayList(new MockNumber(), 10), 'An array of mock numbers and their corresponding verification codes (OTPs). Each number should be a valid E.164 formatted phone number. Maximum of 10 numbers are allowed.')
|
||||
->inject('response')
|
||||
@@ -176,92 +131,6 @@ Http::patch('/v1/projects/:projectId/auth/mock-numbers')
|
||||
$response->dynamic($project, Response::MODEL_PROJECT);
|
||||
});
|
||||
|
||||
Http::delete('/v1/projects/:projectId')
|
||||
->desc('Delete project')
|
||||
->groups(['api', 'projects'])
|
||||
->label('audits.event', 'projects.delete')
|
||||
->label('audits.resource', 'project/{request.projectId}')
|
||||
->label('scope', 'projects.write')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'projects',
|
||||
group: 'projects',
|
||||
name: 'delete',
|
||||
description: '/docs/references/projects/delete.md',
|
||||
auth: [AuthType::ADMIN],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_NOCONTENT,
|
||||
model: Response::MODEL_NONE,
|
||||
)
|
||||
],
|
||||
contentType: ContentType::NONE
|
||||
))
|
||||
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
|
||||
->inject('response')
|
||||
->inject('user')
|
||||
->inject('dbForPlatform')
|
||||
->inject('queueForDeletes')
|
||||
->action(function (string $projectId, Response $response, Document $user, Database $dbForPlatform, Delete $queueForDeletes) {
|
||||
$project = $dbForPlatform->getDocument('projects', $projectId);
|
||||
|
||||
if ($project->isEmpty()) {
|
||||
throw new Exception(Exception::PROJECT_NOT_FOUND);
|
||||
}
|
||||
|
||||
$queueForDeletes
|
||||
->setProject($project)
|
||||
->setType(DELETE_TYPE_DOCUMENT)
|
||||
->setDocument($project);
|
||||
|
||||
if (!$dbForPlatform->deleteDocument('projects', $projectId)) {
|
||||
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove project from DB');
|
||||
}
|
||||
|
||||
$response->noContent();
|
||||
});
|
||||
|
||||
// JWT Keys
|
||||
|
||||
Http::post('/v1/projects/:projectId/jwts')
|
||||
->groups(['api', 'projects'])
|
||||
->desc('Create JWT')
|
||||
->label('scope', 'projects.write')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'projects',
|
||||
group: 'auth',
|
||||
name: 'createJWT',
|
||||
description: '/docs/references/projects/create-jwt.md',
|
||||
auth: [AuthType::ADMIN],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_CREATED,
|
||||
model: Response::MODEL_JWT,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
|
||||
->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for JWT key. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.')
|
||||
->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true)
|
||||
->inject('response')
|
||||
->inject('dbForPlatform')
|
||||
->action(function (string $projectId, array $scopes, int $duration, Response $response, Database $dbForPlatform) {
|
||||
|
||||
$project = $dbForPlatform->getDocument('projects', $projectId);
|
||||
|
||||
if ($project->isEmpty()) {
|
||||
throw new Exception(Exception::PROJECT_NOT_FOUND);
|
||||
}
|
||||
|
||||
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0);
|
||||
|
||||
$response
|
||||
->setStatusCode(Response::STATUS_CODE_CREATED)
|
||||
->dynamic(new Document(['jwt' => API_KEY_DYNAMIC . '_' . $jwt->encode([
|
||||
'projectId' => $project->getId(),
|
||||
'scopes' => $scopes
|
||||
])]), Response::MODEL_JWT);
|
||||
});
|
||||
|
||||
// Backwards compatibility
|
||||
Http::delete('/v1/projects/:projectId/templates/email')
|
||||
->alias('/v1/projects/:projectId/templates/email/:type/:locale')
|
||||
|
||||
@@ -856,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',
|
||||
@@ -2314,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')
|
||||
@@ -2470,7 +2470,7 @@ 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(
|
||||
@@ -2521,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(
|
||||
|
||||
+43
-22
@@ -27,6 +27,8 @@ 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\Response;
|
||||
use Appwrite\Utopia\Response\Filters\V16 as ResponseV16;
|
||||
use Appwrite\Utopia\Response\Filters\V17 as ResponseV17;
|
||||
@@ -36,7 +38,10 @@ 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\View;
|
||||
use Executor\Exception\Timeout as ExecutorTimeout;
|
||||
use Executor\Executor;
|
||||
use MaxMind\Db\Reader;
|
||||
use Swoole\Http\Request as SwooleRequest;
|
||||
@@ -120,7 +125,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);
|
||||
}
|
||||
|
||||
@@ -397,7 +402,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'] = '';
|
||||
|
||||
@@ -577,26 +582,30 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
|
||||
'site' => '',
|
||||
};
|
||||
|
||||
$executionResponse = $executor->createExecution(
|
||||
projectId: $project->getId(),
|
||||
deploymentId: $deployment->getId(),
|
||||
body: \strlen($body) > 0 ? $body : null,
|
||||
variables: $vars,
|
||||
timeout: $resource->getAttribute('timeout', 30),
|
||||
image: $runtime['image'],
|
||||
source: $source,
|
||||
entrypoint: $entrypoint,
|
||||
version: $version,
|
||||
path: $path,
|
||||
method: $method,
|
||||
headers: $headers,
|
||||
runtimeEntrypoint: $runtimeEntrypoint,
|
||||
cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT,
|
||||
memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT,
|
||||
logging: $resource->getAttribute('logging', true),
|
||||
requestTimeout: 30,
|
||||
responseFormat: Executor::RESPONSE_FORMAT_ARRAY_HEADERS
|
||||
);
|
||||
try {
|
||||
$executionResponse = $executor->createExecution(
|
||||
projectId: $project->getId(),
|
||||
deploymentId: $deployment->getId(),
|
||||
body: \strlen($body) > 0 ? $body : null,
|
||||
variables: $vars,
|
||||
timeout: $resource->getAttribute('timeout', 30),
|
||||
image: $runtime['image'],
|
||||
source: $source,
|
||||
entrypoint: $entrypoint,
|
||||
version: $version,
|
||||
path: $path,
|
||||
method: $method,
|
||||
headers: $headers,
|
||||
runtimeEntrypoint: $runtimeEntrypoint,
|
||||
cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT,
|
||||
memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT,
|
||||
logging: $resource->getAttribute('logging', true),
|
||||
requestTimeout: 30,
|
||||
responseFormat: Executor::RESPONSE_FORMAT_ARRAY_HEADERS
|
||||
);
|
||||
} catch (ExecutorTimeout $th) {
|
||||
throw new AppwriteException(AppwriteException::FUNCTION_SYNCHRONOUS_TIMEOUT, previous: $th);
|
||||
}
|
||||
|
||||
$headerOverrides = [];
|
||||
|
||||
@@ -899,6 +908,12 @@ Http::init()
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
$localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', ''));
|
||||
@@ -923,6 +938,12 @@ Http::init()
|
||||
*/
|
||||
$responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', ''));
|
||||
if ($responseFormat) {
|
||||
if (version_compare($responseFormat, '1.9.4', '<')) {
|
||||
$response->addFilter(new ResponseV25());
|
||||
}
|
||||
if (version_compare($responseFormat, '1.9.3', '<')) {
|
||||
$response->addFilter(new ResponseV24());
|
||||
}
|
||||
if (version_compare($responseFormat, '1.9.2', '<')) {
|
||||
$response->addFilter(new ResponseV23());
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ 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) {
|
||||
$find = $matches[0][$pos];
|
||||
@@ -59,6 +59,7 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar
|
||||
|
||||
$params = match ($namespace) {
|
||||
'user' => (array) $user,
|
||||
'project' => $project->getArrayCopy(),
|
||||
'request' => $requestParams,
|
||||
default => $responsePayload,
|
||||
};
|
||||
@@ -182,7 +183,8 @@ 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);
|
||||
}
|
||||
|
||||
@@ -903,7 +905,7 @@ 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) {
|
||||
$auditContext->resource = $resource;
|
||||
}
|
||||
@@ -976,12 +978,12 @@ Http::shutdown()
|
||||
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(
|
||||
|
||||
@@ -44,14 +44,15 @@ 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 = 4323;
|
||||
const APP_VERSION_STABLE = '1.9.2';
|
||||
const APP_CACHE_BUSTER = 4325;
|
||||
const APP_VERSION_STABLE = '1.9.4';
|
||||
const APP_DATABASE_ATTRIBUTE_EMAIL = 'email';
|
||||
const APP_DATABASE_ATTRIBUTE_ENUM = 'enum';
|
||||
const APP_DATABASE_ATTRIBUTE_IP = 'ip';
|
||||
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';
|
||||
@@ -244,6 +245,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
|
||||
@@ -255,7 +257,7 @@ 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';
|
||||
// Usage metrics
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -37,6 +38,7 @@ 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;
|
||||
@@ -56,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;
|
||||
@@ -68,6 +75,7 @@ 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;
|
||||
@@ -105,6 +113,47 @@ 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\PlatformAndroid;
|
||||
use Appwrite\Utopia\Response\Model\PlatformApple;
|
||||
@@ -112,6 +161,16 @@ 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\Provider;
|
||||
@@ -210,6 +269,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));
|
||||
@@ -237,6 +299,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());
|
||||
@@ -270,6 +333,7 @@ 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());
|
||||
@@ -335,8 +399,59 @@ Response::setModel(new Execution());
|
||||
Response::setModel(new Project());
|
||||
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 PlatformWeb());
|
||||
Response::setModel(new PlatformApple());
|
||||
@@ -376,6 +491,11 @@ Response::setModel(new Rule());
|
||||
Response::setModel(new Schedule());
|
||||
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());
|
||||
|
||||
@@ -327,9 +327,11 @@ return function (Container $container): void {
|
||||
}
|
||||
}
|
||||
|
||||
$impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', '');
|
||||
$impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', '');
|
||||
$impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', '');
|
||||
// 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;
|
||||
|
||||
@@ -159,10 +159,16 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
|
||||
$adapter = new DatabasePool($pools->get('logs'));
|
||||
$database = new Database($adapter, $cache);
|
||||
|
||||
/** @var array $collections */
|
||||
$collections = Config::getParam('collections', []);
|
||||
$logsCollections = $collections['logs'] ?? [];
|
||||
$logsCollections = array_keys($logsCollections);
|
||||
|
||||
$database
|
||||
->setDatabase(APP_DATABASE)
|
||||
->setAuthorization($authorization)
|
||||
->setSharedTables(true)
|
||||
->setGlobalCollections($logsCollections)
|
||||
->setNamespace('logsV1')
|
||||
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
|
||||
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
|
||||
|
||||
@@ -204,9 +204,16 @@ return function (Container $container): void {
|
||||
$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
|
||||
@@ -223,6 +230,11 @@ return function (Container $container): void {
|
||||
$adapter = null;
|
||||
|
||||
return function (?Document $project = null) use ($pools, $cache, $authorization, &$adapter) {
|
||||
/** @var array $collections */
|
||||
$collections = Config::getParam('collections', []);
|
||||
$logsCollections = $collections['logs'] ?? [];
|
||||
$logsCollections = array_keys($logsCollections);
|
||||
|
||||
$adapter ??= new DatabasePool($pools->get('logs'));
|
||||
$database = new Database($adapter, $cache);
|
||||
|
||||
@@ -230,6 +242,7 @@ return function (Container $container): void {
|
||||
->setDatabase(APP_DATABASE)
|
||||
->setAuthorization($authorization)
|
||||
->setSharedTables(true)
|
||||
->setGlobalCollections($logsCollections)
|
||||
->setNamespace('logsV1')
|
||||
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
|
||||
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
|
||||
@@ -571,10 +584,12 @@ return function (Container $container): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Impersonation: if current user has impersonator capability and headers are set, act as another user
|
||||
$impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', '');
|
||||
$impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', '');
|
||||
$impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', '');
|
||||
// Impersonation: if current user has impersonator capability and headers/params are set, act as another user
|
||||
// Query params mirror the header fallback pattern used by ?project= and ?devKey=,
|
||||
// allowing Console to embed impersonation in direct file/image URLs where headers cannot be set.
|
||||
$impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', ''));
|
||||
$impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', (string)$request->getParam('impersonateEmail', ''));
|
||||
$impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', (string)$request->getParam('impersonatePhone', ''));
|
||||
if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) {
|
||||
$userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject;
|
||||
$targetUser = null;
|
||||
@@ -688,8 +703,15 @@ return function (Container $container): void {
|
||||
$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 {
|
||||
@@ -1290,6 +1312,12 @@ return function (Container $container): void {
|
||||
$database = new Database($adapter, $cache);
|
||||
$sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')));
|
||||
|
||||
/** @var array $collections */
|
||||
$collections = Config::getParam('collections', []);
|
||||
$projectCollections = $collections['projects'] ?? [];
|
||||
$projectsGlobalCollections = array_keys($projectCollections);
|
||||
$projectsGlobalCollections[] = 'audit';
|
||||
|
||||
$database
|
||||
->setDatabase(APP_DATABASE)
|
||||
->setAuthorization($authorization)
|
||||
@@ -1312,6 +1340,7 @@ return function (Container $container): void {
|
||||
if (\in_array($databaseHost, $dbTypeSharedTables)) {
|
||||
$database
|
||||
->setSharedTables(true)
|
||||
->setGlobalCollections($projectsGlobalCollections)
|
||||
->setTenant($project->getSequence())
|
||||
->setNamespace($databaseDSN->getParam('namespace'));
|
||||
} else {
|
||||
@@ -1323,6 +1352,7 @@ return function (Container $container): void {
|
||||
} elseif (\in_array($dsn->getHost(), $sharedTables)) {
|
||||
$database
|
||||
->setSharedTables(true)
|
||||
->setGlobalCollections($projectsGlobalCollections)
|
||||
->setTenant($project->getSequence())
|
||||
->setNamespace($dsn->getParam('namespace'));
|
||||
} else {
|
||||
|
||||
@@ -14,6 +14,7 @@ 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;
|
||||
@@ -90,8 +91,15 @@ return function (Container $container): void {
|
||||
$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 {
|
||||
@@ -130,8 +138,15 @@ return function (Container $container): void {
|
||||
$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 {
|
||||
@@ -152,8 +167,15 @@ return function (Container $container): void {
|
||||
$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 {
|
||||
@@ -210,6 +232,14 @@ return function (Container $container): void {
|
||||
|
||||
$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()) {
|
||||
@@ -222,6 +252,7 @@ return function (Container $container): void {
|
||||
if (\in_array($databaseHost, $dbTypeSharedTables)) {
|
||||
$database
|
||||
->setSharedTables(true)
|
||||
->setGlobalCollections($projectsGlobalCollections)
|
||||
->setTenant($projectDocument->getSequence())
|
||||
->setNamespace($databaseDSN->getParam('namespace'));
|
||||
} else {
|
||||
@@ -233,6 +264,7 @@ return function (Container $container): void {
|
||||
} elseif (\in_array($dsn->getHost(), $sharedTables, true)) {
|
||||
$database
|
||||
->setSharedTables(true)
|
||||
->setGlobalCollections($projectsGlobalCollections)
|
||||
->setTenant($projectDocument->getSequence())
|
||||
->setNamespace($dsn->getParam('namespace'));
|
||||
} else {
|
||||
@@ -257,6 +289,11 @@ return function (Container $container): void {
|
||||
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);
|
||||
|
||||
@@ -264,6 +301,7 @@ return function (Container $container): void {
|
||||
->setDatabase(APP_DATABASE)
|
||||
->setAuthorization($authorization)
|
||||
->setSharedTables(true)
|
||||
->setGlobalCollections($logsCollections)
|
||||
->setNamespace('logsV1')
|
||||
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER)
|
||||
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES_WORKER);
|
||||
|
||||
+155
-13
@@ -38,6 +38,7 @@ use Utopia\DSN\DSN;
|
||||
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;
|
||||
@@ -45,6 +46,10 @@ use Utopia\WebSocket\Server;
|
||||
|
||||
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');
|
||||
|
||||
@@ -125,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 {
|
||||
@@ -564,6 +575,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
|
||||
|
||||
$roles = $user->getRoles($database->getAuthorization());
|
||||
$authorization = $realtime->connections[$connection]['authorization'] ?? null;
|
||||
$previousUserId = $realtime->connections[$connection]['userId'] ?? '';
|
||||
|
||||
$meta = $realtime->getSubscriptionMetadata($connection);
|
||||
|
||||
@@ -571,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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -701,6 +719,20 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
$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.connectionId', $connection);
|
||||
Span::add('realtime.inboundBytes', $rawSize);
|
||||
if (!empty($request->getOrigin())) {
|
||||
Span::add('realtime.origin', $request->getOrigin());
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var Document $project */
|
||||
@@ -750,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());
|
||||
@@ -771,6 +801,7 @@ $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);
|
||||
@@ -808,11 +839,15 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
$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(
|
||||
@@ -839,6 +874,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
|
||||
$mapping[$index] = $subscriptionId;
|
||||
}
|
||||
$subscriptionCount = \count($subscriptions);
|
||||
if (!empty($subscriptions)) {
|
||||
$register->get('telemetry.workerSubscriptionCounter')->add(\count($subscriptions), $register->get('telemetry.workerAttributes'));
|
||||
}
|
||||
@@ -857,8 +893,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
]);
|
||||
|
||||
$server->send([$connection], $connectedPayloadJson);
|
||||
$outboundBytes += \strlen($connectedPayloadJson);
|
||||
$updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson);
|
||||
|
||||
$success = true;
|
||||
|
||||
} catch (Throwable $th) {
|
||||
logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization);
|
||||
@@ -868,6 +905,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
if (!\is_int($code)) {
|
||||
$code = 500;
|
||||
}
|
||||
$responseCode = $code;
|
||||
|
||||
$message = $th->getMessage();
|
||||
|
||||
@@ -885,7 +923,9 @@ $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 (System::getEnv('_APP_ENV', 'production') === 'development') {
|
||||
@@ -893,16 +933,44 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
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.responseCode', $responseCode);
|
||||
Span::add('realtime.subscriptionMode', $subscriptionMode);
|
||||
Span::add('realtime.channelCount', $channelCount);
|
||||
Span::add('realtime.subscriptionCount', $subscriptionCount);
|
||||
Span::add('realtime.outboundBytes', $outboundBytes);
|
||||
if (!empty($project?->getId())) {
|
||||
Span::add('realtime.projectId', $project->getId());
|
||||
}
|
||||
if (!empty($logUser?->getId())) {
|
||||
Span::add('realtime.userId', $logUser->getId());
|
||||
}
|
||||
Span::current()?->finish();
|
||||
}
|
||||
});
|
||||
|
||||
$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.connectionId', $connection);
|
||||
Span::add('realtime.inboundBytes', $rawSize);
|
||||
Span::add('realtime.containerId', $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;
|
||||
@@ -952,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.');
|
||||
@@ -964,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);
|
||||
@@ -1009,6 +1084,11 @@ $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);
|
||||
@@ -1018,13 +1098,18 @@ $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'] ?? [],
|
||||
$channels,
|
||||
$queries,
|
||||
$user->getId()
|
||||
);
|
||||
@@ -1053,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);
|
||||
@@ -1114,21 +1200,25 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
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 = \array_keys(Realtime::convertChannels($parsedPayload['channels'], $userId));
|
||||
$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'));
|
||||
}
|
||||
@@ -1141,7 +1231,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
'subscriptions' => \array_map(function (array $parsedPayload) {
|
||||
return [
|
||||
'subscriptionId' => $parsedPayload['subscriptionId'],
|
||||
'channels' => $parsedPayload['channels'],
|
||||
'channels' => $parsedPayload['convertedChannels'],
|
||||
'queries' => \array_map(fn ($q) => $q->toString(), $parsedPayload['queries']),
|
||||
];
|
||||
}, $parsedPayloads),
|
||||
@@ -1149,6 +1239,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
]);
|
||||
|
||||
$server->send([$connection], $responsePayload);
|
||||
$outboundBytes += \strlen($responsePayload);
|
||||
|
||||
if ($project !== null && !$project->isEmpty()) {
|
||||
$subscribeOutboundBytes = \strlen($responsePayload);
|
||||
@@ -1194,6 +1285,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
}
|
||||
$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'));
|
||||
}
|
||||
@@ -1208,6 +1301,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
]);
|
||||
|
||||
$server->send([$connection], $unsubscribeResponsePayload);
|
||||
$outboundBytes += \strlen($unsubscribeResponsePayload);
|
||||
|
||||
if ($project !== null && !$project->isEmpty()) {
|
||||
$unsubscribeOutboundBytes = \strlen($unsubscribeResponsePayload);
|
||||
@@ -1224,12 +1318,14 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
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();
|
||||
|
||||
@@ -1246,15 +1342,43 @@ $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.responseCode', $responseCode);
|
||||
Span::add('realtime.subscriptionDelta', $subscriptionDelta);
|
||||
Span::add('realtime.subscriptionsRequested', $subscriptionsRequested);
|
||||
Span::add('realtime.subscriptionsRemoved', $subscriptionsRemoved);
|
||||
Span::add('realtime.subscribe.subscriptionsCount', $subscriptionsRequested);
|
||||
Span::add('realtime.outboundBytes', $outboundBytes);
|
||||
Span::add('realtime.projectId', $project?->getId() ?? $projectId);
|
||||
Span::add('realtime.userId', $realtime->connections[$connection]['userId'] ?? null);
|
||||
Span::add('realtime.messageType', $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.connectionId', $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');
|
||||
@@ -1271,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('realtime.projectId', $projectId);
|
||||
}
|
||||
if (!empty($userId)) {
|
||||
Span::add('realtime.userId', $userId);
|
||||
}
|
||||
Span::add('realtime.subscriptionsBeforeClose', $subscriptionsBeforeClose);
|
||||
Span::current()?->finish();
|
||||
}
|
||||
$realtime->unsubscribe($connection);
|
||||
|
||||
Console::info('Connection close: ' . $connection);
|
||||
});
|
||||
|
||||
+7
-6
@@ -49,7 +49,7 @@
|
||||
"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/agents": "1.2.*",
|
||||
@@ -69,19 +69,20 @@
|
||||
"utopia-php/dsn": "0.2.1",
|
||||
"utopia-php/http": "0.34.*",
|
||||
"utopia-php/fetch": "0.5.*",
|
||||
"utopia-php/validators": "0.2.*",
|
||||
"utopia-php/image": "0.8.*",
|
||||
"utopia-php/locale": "0.8.*",
|
||||
"utopia-php/logger": "0.6.*",
|
||||
"utopia-php/messaging": "0.22.*",
|
||||
"utopia-php/migration": "1.9.*",
|
||||
"utopia-php/migration": "1.*",
|
||||
"utopia-php/platform": "0.13.*",
|
||||
"utopia-php/pools": "1.*",
|
||||
"utopia-php/span": "1.1.*",
|
||||
"utopia-php/preloader": "0.2.*",
|
||||
"utopia-php/queue": "0.17.*",
|
||||
"utopia-php/servers": "0.3.*",
|
||||
"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": "3.*",
|
||||
@@ -92,7 +93,7 @@
|
||||
"chillerlan/php-qrcode": "4.3.*",
|
||||
"adhocore/jwt": "1.1.*",
|
||||
"spomky-labs/otphp": "11.*",
|
||||
"webonyx/graphql-php": "15.31.*",
|
||||
"webonyx/graphql-php": "15.32.*",
|
||||
"league/csv": "9.14.*",
|
||||
"enshrined/svg-sanitize": "0.22.*"
|
||||
},
|
||||
|
||||
Generated
+189
-187
File diff suppressed because it is too large
Load Diff
@@ -247,6 +247,8 @@ services:
|
||||
- _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"
|
||||
|
||||
|
||||
@@ -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 bigint column. Optionally, minimum and maximum values can be provided.
|
||||
@@ -0,0 +1 @@
|
||||
Update a bigint column. Changing the `default` value will not update already existing rows.
|
||||
@@ -105,7 +105,7 @@ class Key
|
||||
|
||||
/**
|
||||
* Decode the given secret key into a Key object, containing the project ID, type, role, scopes, and name.
|
||||
* Can be a stored API key or a dynamic key (JWT).
|
||||
* Can be a stored API key or an ephemeral key (JWT).
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -138,7 +138,9 @@ class Key
|
||||
);
|
||||
|
||||
switch ($type) {
|
||||
case API_KEY_DYNAMIC:
|
||||
// Dynamic supported for backwards compatibility
|
||||
case API_KEY_EPHEMERAL:
|
||||
case 'dynamic':
|
||||
$jwtObj = new JWT(
|
||||
key: System::getEnv('_APP_OPENSSL_KEY_V1'),
|
||||
algo: 'HS256',
|
||||
@@ -153,7 +155,7 @@ class Key
|
||||
$expired = true;
|
||||
}
|
||||
|
||||
$name = $payload['name'] ?? 'Dynamic Key';
|
||||
$name = $payload['name'] ?? 'Ephemeral Key';
|
||||
$projectId = $payload['projectId'] ?? '';
|
||||
$disabledMetrics = $payload['disabledMetrics'] ?? [];
|
||||
$hostnameOverride = $payload['hostnameOverride'] ?? false;
|
||||
|
||||
@@ -206,8 +206,6 @@ abstract class OAuth2
|
||||
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
\curl_close($ch);
|
||||
|
||||
if ($code >= 400) {
|
||||
throw new Exception($response, $code);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,13 @@ class Authentik extends OAuth2
|
||||
return 'authentik';
|
||||
}
|
||||
|
||||
public function verifyCredentials(): void
|
||||
{
|
||||
if (empty($this->getAuthentikDomain())) {
|
||||
throw new \Exception('Authentik endpoint is required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Auth\OAuth2;
|
||||
|
||||
use Appwrite\Auth\OAuth2;
|
||||
|
||||
// Reference Material
|
||||
// https://fusionauth.io/docs/lifecycle/authenticate-users/oauth/endpoints
|
||||
|
||||
class FusionAuth extends OAuth2
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected array $scopes = [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
'offline_access'
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected array $user = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected array $tokens = [];
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return 'fusionauth';
|
||||
}
|
||||
|
||||
public function verifyCredentials(): void
|
||||
{
|
||||
if (empty($this->getFusionAuthDomain())) {
|
||||
throw new \Exception('FusionAuth endpoint is required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getLoginURL(): string
|
||||
{
|
||||
return 'https://' . $this->getFusionAuthDomain() . '/oauth2/authorize?' . \http_build_query([
|
||||
'client_id' => $this->appID,
|
||||
'redirect_uri' => $this->callback,
|
||||
'state' => \json_encode($this->state),
|
||||
'scope' => \implode(' ', $this->getScopes()),
|
||||
'response_type' => 'code'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $code
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function getTokens(string $code): array
|
||||
{
|
||||
if (empty($this->tokens)) {
|
||||
$headers = ['Content-Type: application/x-www-form-urlencoded'];
|
||||
$this->tokens = \json_decode($this->request(
|
||||
'POST',
|
||||
'https://' . $this->getFusionAuthDomain() . '/oauth2/token',
|
||||
$headers,
|
||||
\http_build_query([
|
||||
'code' => $code,
|
||||
'client_id' => $this->appID,
|
||||
'client_secret' => $this->getClientSecret(),
|
||||
'redirect_uri' => $this->callback,
|
||||
'scope' => \implode(' ', $this->getScopes()),
|
||||
'grant_type' => 'authorization_code'
|
||||
])
|
||||
), true);
|
||||
}
|
||||
return $this->tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $refreshToken
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function refreshTokens(string $refreshToken): array
|
||||
{
|
||||
$headers = ['Content-Type: application/x-www-form-urlencoded'];
|
||||
$this->tokens = \json_decode($this->request(
|
||||
'POST',
|
||||
'https://' . $this->getFusionAuthDomain() . '/oauth2/token',
|
||||
$headers,
|
||||
\http_build_query([
|
||||
'refresh_token' => $refreshToken,
|
||||
'client_id' => $this->appID,
|
||||
'client_secret' => $this->getClientSecret(),
|
||||
'grant_type' => 'refresh_token'
|
||||
])
|
||||
), true);
|
||||
|
||||
if (empty($this->tokens['refresh_token'])) {
|
||||
$this->tokens['refresh_token'] = $refreshToken;
|
||||
}
|
||||
|
||||
return $this->tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $accessToken
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getUserID(string $accessToken): string
|
||||
{
|
||||
$user = $this->getUser($accessToken);
|
||||
|
||||
if (isset($user['sub'])) {
|
||||
return $user['sub'];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $accessToken
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getUserEmail(string $accessToken): string
|
||||
{
|
||||
$user = $this->getUser($accessToken);
|
||||
|
||||
if (isset($user['email'])) {
|
||||
return $user['email'];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the User email is verified
|
||||
*
|
||||
* @param string $accessToken
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isEmailVerified(string $accessToken): bool
|
||||
{
|
||||
$user = $this->getUser($accessToken);
|
||||
|
||||
if ($user['email_verified'] ?? false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $accessToken
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getUserName(string $accessToken): string
|
||||
{
|
||||
$user = $this->getUser($accessToken);
|
||||
|
||||
if (isset($user['name'])) {
|
||||
return $user['name'];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $accessToken
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function getUser(string $accessToken): array
|
||||
{
|
||||
if (empty($this->user)) {
|
||||
$headers = ['Authorization: Bearer ' . \urlencode($accessToken)];
|
||||
$user = $this->request('GET', 'https://' . $this->getFusionAuthDomain() . '/oauth2/userinfo', $headers);
|
||||
$this->user = \json_decode($user, true);
|
||||
}
|
||||
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the Client Secret from the JSON stored in appSecret
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getClientSecret(): string
|
||||
{
|
||||
$secret = $this->getAppSecret();
|
||||
|
||||
return $secret['clientSecret'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the FusionAuth Domain from the JSON stored in appSecret
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getFusionAuthDomain(): string
|
||||
{
|
||||
$secret = $this->getAppSecret();
|
||||
return $secret['fusionAuthDomain'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the JSON stored in appSecret
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function getAppSecret(): array
|
||||
{
|
||||
try {
|
||||
$secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\Throwable $th) {
|
||||
throw new \Exception('Invalid secret');
|
||||
}
|
||||
return $secret;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Appwrite\Auth\OAuth2;
|
||||
|
||||
use Appwrite\Auth\OAuth2;
|
||||
use Utopia\Fetch\Client as FetchClient;
|
||||
|
||||
class Github extends OAuth2
|
||||
{
|
||||
@@ -219,4 +220,34 @@ class Github extends OAuth2
|
||||
$repository = \json_decode($repository, true);
|
||||
return $repository;
|
||||
}
|
||||
|
||||
public function verifyCredentials(): void
|
||||
{
|
||||
$client = new FetchClient();
|
||||
$client->addHeader('Accept', 'application/json');
|
||||
|
||||
$response = $client->fetch(
|
||||
url: 'https://github.com/login/oauth/access_token',
|
||||
method: FetchClient::METHOD_POST,
|
||||
query: [
|
||||
'client_id' => $this->appID,
|
||||
'client_secret' => $this->appSecret,
|
||||
'code' => 'intentionally-invalid-code',
|
||||
'redirect_uri' => 'intentionally-invalid-redirect',
|
||||
]
|
||||
);
|
||||
|
||||
$json = \json_decode($response->getBody(), true);
|
||||
|
||||
if (isset($json['error']) && $json['error'] === "Not Found") {
|
||||
throw new \Exception('GitHub application with provided Client ID is does not exist.');
|
||||
}
|
||||
|
||||
if (isset($json['error']) && $json['error'] === "incorrect_client_credentials") {
|
||||
throw new \Exception('GitHub application with provided Client ID is valid, but the provided Client Secret is incorrect.');
|
||||
}
|
||||
|
||||
// We still expect error, like redirect_uri_mismatch or bad_verification_code,
|
||||
// but that indicates valid credentials
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Auth\OAuth2;
|
||||
|
||||
use Appwrite\Auth\OAuth2;
|
||||
|
||||
// Reference Material
|
||||
// https://www.keycloak.org/docs/latest/securing_apps/#_oidc
|
||||
|
||||
class Keycloak extends OAuth2
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected array $scopes = [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
'offline_access'
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected array $user = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected array $tokens = [];
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return 'keycloak';
|
||||
}
|
||||
|
||||
public function verifyCredentials(): void
|
||||
{
|
||||
if (empty($this->getKeycloakDomain())) {
|
||||
throw new \Exception('Keycloak endpoint is required.');
|
||||
}
|
||||
|
||||
if (empty($this->getKeycloakRealm())) {
|
||||
throw new \Exception('Keycloak realm name is required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getLoginURL(): string
|
||||
{
|
||||
return $this->getRealmBaseURL() . '/protocol/openid-connect/auth?' . \http_build_query([
|
||||
'client_id' => $this->appID,
|
||||
'redirect_uri' => $this->callback,
|
||||
'state' => \json_encode($this->state),
|
||||
'scope' => \implode(' ', $this->getScopes()),
|
||||
'response_type' => 'code'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $code
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function getTokens(string $code): array
|
||||
{
|
||||
if (empty($this->tokens)) {
|
||||
$headers = ['Content-Type: application/x-www-form-urlencoded'];
|
||||
$this->tokens = \json_decode($this->request(
|
||||
'POST',
|
||||
$this->getRealmBaseURL() . '/protocol/openid-connect/token',
|
||||
$headers,
|
||||
\http_build_query([
|
||||
'code' => $code,
|
||||
'client_id' => $this->appID,
|
||||
'client_secret' => $this->getClientSecret(),
|
||||
'redirect_uri' => $this->callback,
|
||||
'scope' => \implode(' ', $this->getScopes()),
|
||||
'grant_type' => 'authorization_code'
|
||||
])
|
||||
), true);
|
||||
}
|
||||
return $this->tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $refreshToken
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function refreshTokens(string $refreshToken): array
|
||||
{
|
||||
$headers = ['Content-Type: application/x-www-form-urlencoded'];
|
||||
$this->tokens = \json_decode($this->request(
|
||||
'POST',
|
||||
$this->getRealmBaseURL() . '/protocol/openid-connect/token',
|
||||
$headers,
|
||||
\http_build_query([
|
||||
'refresh_token' => $refreshToken,
|
||||
'client_id' => $this->appID,
|
||||
'client_secret' => $this->getClientSecret(),
|
||||
'grant_type' => 'refresh_token'
|
||||
])
|
||||
), true);
|
||||
|
||||
if (empty($this->tokens['refresh_token'])) {
|
||||
$this->tokens['refresh_token'] = $refreshToken;
|
||||
}
|
||||
|
||||
return $this->tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $accessToken
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getUserID(string $accessToken): string
|
||||
{
|
||||
$user = $this->getUser($accessToken);
|
||||
|
||||
if (isset($user['sub'])) {
|
||||
return $user['sub'];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $accessToken
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getUserEmail(string $accessToken): string
|
||||
{
|
||||
$user = $this->getUser($accessToken);
|
||||
|
||||
if (isset($user['email'])) {
|
||||
return $user['email'];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the User email is verified
|
||||
*
|
||||
* @param string $accessToken
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isEmailVerified(string $accessToken): bool
|
||||
{
|
||||
$user = $this->getUser($accessToken);
|
||||
|
||||
if ($user['email_verified'] ?? false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $accessToken
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getUserName(string $accessToken): string
|
||||
{
|
||||
$user = $this->getUser($accessToken);
|
||||
|
||||
if (isset($user['name'])) {
|
||||
return $user['name'];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $accessToken
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function getUser(string $accessToken): array
|
||||
{
|
||||
if (empty($this->user)) {
|
||||
$headers = ['Authorization: Bearer ' . \urlencode($accessToken)];
|
||||
$user = $this->request('GET', $this->getRealmBaseURL() . '/protocol/openid-connect/userinfo', $headers);
|
||||
$this->user = \json_decode($user, true);
|
||||
}
|
||||
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the Client Secret from the JSON stored in appSecret
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getClientSecret(): string
|
||||
{
|
||||
$secret = $this->getAppSecret();
|
||||
|
||||
return $secret['clientSecret'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the Keycloak Domain from the JSON stored in appSecret
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getKeycloakDomain(): string
|
||||
{
|
||||
$secret = $this->getAppSecret();
|
||||
return $secret['keycloakDomain'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the Keycloak Realm from the JSON stored in appSecret
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getKeycloakRealm(): string
|
||||
{
|
||||
$secret = $this->getAppSecret();
|
||||
return $secret['keycloakRealm'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the realm-scoped base URL: `https://{domain}/realms/{realm}`.
|
||||
* Keycloak realm names allow spaces and other characters that must be
|
||||
* percent-encoded in URLs (e.g. `my realm` → `my%20realm`).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getRealmBaseURL(): string
|
||||
{
|
||||
return 'https://' . $this->getKeycloakDomain() . '/realms/' . \rawurlencode($this->getKeycloakRealm());
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the JSON stored in appSecret
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function getAppSecret(): array
|
||||
{
|
||||
try {
|
||||
$secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\Throwable $th) {
|
||||
throw new \Exception('Invalid secret');
|
||||
}
|
||||
return $secret;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Auth\OAuth2;
|
||||
|
||||
use Appwrite\Auth\OAuth2;
|
||||
|
||||
// Reference Material
|
||||
// https://docs.kick.com/getting-started/generating-tokens-oauth2-flow
|
||||
// https://docs.kick.com/getting-started/scopes
|
||||
|
||||
class Kick extends OAuth2
|
||||
{
|
||||
private const PKCE_STATE_KEY = '_pkce';
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected array $user = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected array $tokens = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected array $scopes = [
|
||||
'user:read',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private string $pkceVerifier = '';
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return 'kick';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getLoginURL(): string
|
||||
{
|
||||
$state = $this->state;
|
||||
$state[self::PKCE_STATE_KEY] = $this->getPKCEVerifier();
|
||||
|
||||
return 'https://id.kick.com/oauth/authorize?' . \http_build_query([
|
||||
'response_type' => 'code',
|
||||
'client_id' => $this->appID,
|
||||
'redirect_uri' => $this->callback,
|
||||
'scope' => \implode(' ', $this->getScopes()),
|
||||
'state' => \json_encode($state),
|
||||
'code_challenge' => $this->getPKCEChallenge(),
|
||||
'code_challenge_method' => 'S256',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $code
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function getTokens(string $code): array
|
||||
{
|
||||
if (empty($this->tokens)) {
|
||||
$headers = ['Content-Type: application/x-www-form-urlencoded'];
|
||||
$this->tokens = \json_decode($this->request(
|
||||
'POST',
|
||||
'https://id.kick.com/oauth/token',
|
||||
$headers,
|
||||
\http_build_query([
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $this->appID,
|
||||
'client_secret' => $this->appSecret,
|
||||
'redirect_uri' => $this->callback,
|
||||
'code_verifier' => $this->getPKCEVerifier(),
|
||||
'code' => $code,
|
||||
])
|
||||
), true);
|
||||
}
|
||||
|
||||
return $this->tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $refreshToken
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function refreshTokens(string $refreshToken): array
|
||||
{
|
||||
$headers = ['Content-Type: application/x-www-form-urlencoded'];
|
||||
$this->tokens = \json_decode($this->request(
|
||||
'POST',
|
||||
'https://id.kick.com/oauth/token',
|
||||
$headers,
|
||||
\http_build_query([
|
||||
'grant_type' => 'refresh_token',
|
||||
'client_id' => $this->appID,
|
||||
'client_secret' => $this->appSecret,
|
||||
'refresh_token' => $refreshToken,
|
||||
])
|
||||
), true);
|
||||
|
||||
if (empty($this->tokens['refresh_token'])) {
|
||||
$this->tokens['refresh_token'] = $refreshToken;
|
||||
}
|
||||
|
||||
return $this->tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $accessToken
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getUserID(string $accessToken): string
|
||||
{
|
||||
$user = $this->getUser($accessToken);
|
||||
|
||||
return isset($user['user_id']) ? (string)$user['user_id'] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $accessToken
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getUserEmail(string $accessToken): string
|
||||
{
|
||||
$user = $this->getUser($accessToken);
|
||||
|
||||
return $user['email'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the OAuth email is verified.
|
||||
*
|
||||
* Kick only returns an email when the user has granted the `user:read`
|
||||
* scope and the account email is verified, so a non-empty email is
|
||||
* treated as verified.
|
||||
*
|
||||
* @param string $accessToken
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isEmailVerified(string $accessToken): bool
|
||||
{
|
||||
return !empty($this->getUserEmail($accessToken));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $accessToken
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getUserName(string $accessToken): string
|
||||
{
|
||||
$user = $this->getUser($accessToken);
|
||||
|
||||
return $user['name'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $accessToken
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function getUser(string $accessToken): array
|
||||
{
|
||||
if (empty($this->user)) {
|
||||
$headers = ['Authorization: Bearer ' . $accessToken];
|
||||
$response = \json_decode($this->request(
|
||||
'GET',
|
||||
'https://api.kick.com/public/v1/users',
|
||||
$headers
|
||||
), true);
|
||||
|
||||
$this->user = $response['data'][0] ?? [];
|
||||
}
|
||||
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the PKCE verifier from the state on the callback so the same
|
||||
* value generated in getLoginURL() can be sent to the token endpoint.
|
||||
*
|
||||
* @param string $state
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function parseState(string $state): ?array
|
||||
{
|
||||
$parsed = \json_decode($state, true);
|
||||
|
||||
if (!\is_array($parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$verifier = $parsed[self::PKCE_STATE_KEY] ?? null;
|
||||
if (\is_string($verifier)) {
|
||||
$this->pkceVerifier = $verifier;
|
||||
}
|
||||
|
||||
unset($parsed[self::PKCE_STATE_KEY]);
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function getPKCEVerifier(): string
|
||||
{
|
||||
if ($this->pkceVerifier === '') {
|
||||
$this->pkceVerifier = \rtrim(\strtr(\base64_encode(\random_bytes(64)), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
return $this->pkceVerifier;
|
||||
}
|
||||
|
||||
private function getPKCEChallenge(): string
|
||||
{
|
||||
return \rtrim(\strtr(\base64_encode(\hash('sha256', $this->getPKCEVerifier(), true)), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,13 @@ class Microsoft extends OAuth2
|
||||
return 'microsoft';
|
||||
}
|
||||
|
||||
public function verifyCredentials(): void
|
||||
{
|
||||
if (empty($this->getTenantID())) {
|
||||
throw new \Exception('Microsoft tenant is required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
@@ -201,7 +208,7 @@ class Microsoft extends OAuth2
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the Tenant Id from the JSON stored in appSecret. Defaults to 'common' as a fallback
|
||||
* Extracts the Tenant Id from the JSON stored in appSecret.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
@@ -209,6 +216,6 @@ class Microsoft extends OAuth2
|
||||
{
|
||||
$secret = $this->getAppSecret();
|
||||
|
||||
return $secret['tenantID'] ?? 'common';
|
||||
return $secret['tenantID'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@ class Exception extends \Exception
|
||||
public const string FUNCTION_RUNTIME_UNSUPPORTED = 'function_runtime_unsupported';
|
||||
public const string FUNCTION_ENTRYPOINT_MISSING = 'function_entrypoint_missing';
|
||||
public const string FUNCTION_SYNCHRONOUS_TIMEOUT = 'function_synchronous_timeout';
|
||||
public const string FUNCTION_ASYNCHRONOUS_TIMEOUT = 'function_asynchronous_timeout';
|
||||
public const string FUNCTION_TEMPLATE_NOT_FOUND = 'function_template_not_found';
|
||||
public const string FUNCTION_RUNTIME_NOT_DETECTED = 'function_runtime_not_detected';
|
||||
public const string FUNCTION_EXECUTE_PERMISSION_MISSING = 'function_execute_permission_missing';
|
||||
@@ -192,6 +193,7 @@ class Exception extends \Exception
|
||||
public const string BUILD_ALREADY_COMPLETED = 'build_already_completed';
|
||||
public const string BUILD_CANCELED = 'build_canceled';
|
||||
public const string BUILD_FAILED = 'build_failed';
|
||||
public const string BUILD_TIMEOUT = 'build_timeout';
|
||||
|
||||
/** Execution */
|
||||
public const string EXECUTION_NOT_FOUND = 'execution_not_found';
|
||||
@@ -384,6 +386,11 @@ class Exception extends \Exception
|
||||
public const string MESSAGE_TARGET_NOT_PUSH = 'message_target_not_push';
|
||||
public const string MESSAGE_MISSING_SCHEDULE = 'message_missing_schedule';
|
||||
|
||||
/** Mocks */
|
||||
public const string MOCK_NUMBER_ALREADY_EXISTS = 'mock_number_already_exists';
|
||||
public const string MOCK_NUMBER_NOT_FOUND = 'mock_number_not_found';
|
||||
public const string MOCK_NUMBER_LIMIT_EXCEEDED = 'mock_number_limit_exceeded';
|
||||
|
||||
/** Targets */
|
||||
public const string TARGET_PROVIDER_INVALID_TYPE = 'target_provider_invalid_type';
|
||||
|
||||
|
||||
@@ -14,6 +14,28 @@ use Utopia\Database\Query;
|
||||
|
||||
class Realtime extends MessagingAdapter
|
||||
{
|
||||
public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert', 'delete'];
|
||||
|
||||
// Resources whose channels receive an action-suffixed sibling at publish time.
|
||||
// The suffix loop in fromPayload() treats any channel whose last OR second-to-last
|
||||
// segment matches an entry here as a candidate for `.{action}` suffixing.
|
||||
//
|
||||
// `functions` is intentionally a parent-only entry: fromPayload publishes
|
||||
// `functions.{functionId}` (suffixed to `functions.{functionId}.{action}`) but
|
||||
// never emits a bare `functions` channel — so subscribing to bare
|
||||
// `functions.{action}` is a silent no-op. Per-function filters
|
||||
// (`functions.{functionId}.{action}`) are the supported form.
|
||||
private const RESOURCE_LEAF_NAMES = [
|
||||
'documents',
|
||||
'rows',
|
||||
'files',
|
||||
'executions',
|
||||
'functions',
|
||||
'account',
|
||||
'teams',
|
||||
'memberships',
|
||||
];
|
||||
|
||||
/**
|
||||
* Connection Tree
|
||||
*
|
||||
@@ -45,8 +67,6 @@ class Realtime extends MessagingAdapter
|
||||
/**
|
||||
* Get the PubSubPool instance, initializing it lazily if needed.
|
||||
* This allows unit tests to work without requiring the global $register.
|
||||
*
|
||||
* @return PubSubPool
|
||||
*/
|
||||
private function getPubSubPool(): PubSubPool
|
||||
{
|
||||
@@ -54,6 +74,7 @@ class Realtime extends MessagingAdapter
|
||||
global $register;
|
||||
$this->pubSubPool = new PubSubPool($register->get('pools')->get('pubsub'));
|
||||
}
|
||||
|
||||
return $this->pubSubPool;
|
||||
}
|
||||
|
||||
@@ -147,7 +168,7 @@ class Realtime extends MessagingAdapter
|
||||
$roles = $this->connections[$connection]['roles'] ?? [];
|
||||
$channels = $this->connections[$connection]['channels'] ?? [];
|
||||
|
||||
if (!$projectId || empty($roles) || empty($channels)) {
|
||||
if (! $projectId || empty($roles) || empty($channels)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -168,7 +189,7 @@ class Realtime extends MessagingAdapter
|
||||
if (!isset($subscriptions[$subscriptionId])) {
|
||||
$subscriptions[$subscriptionId] = [
|
||||
'channels' => [],
|
||||
'queries' => $data['strings'] ?? []
|
||||
'queries' => $data['strings'] ?? [],
|
||||
];
|
||||
}
|
||||
if (!\in_array($channel, $subscriptions[$subscriptionId]['channels'])) {
|
||||
@@ -306,7 +327,7 @@ class Realtime extends MessagingAdapter
|
||||
*/
|
||||
public function hasSubscriber(string $projectId, string $role, string $channel = ''): bool
|
||||
{
|
||||
//TODO: look into moving it to an abstract class in the parent class
|
||||
// TODO: look into moving it to an abstract class in the parent class
|
||||
if (empty($channel)) {
|
||||
return array_key_exists($projectId, $this->subscriptions)
|
||||
&& array_key_exists($role, $this->subscriptions[$projectId]);
|
||||
@@ -327,6 +348,7 @@ class Realtime extends MessagingAdapter
|
||||
* @param array $roles
|
||||
* @param array $options
|
||||
* @return void
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function send(string $projectId, array $payload, array $events, array $channels, array $roles, array $options = []): void
|
||||
@@ -347,8 +369,8 @@ class Realtime extends MessagingAdapter
|
||||
'events' => $events,
|
||||
'channels' => $channels,
|
||||
'timestamp' => DateTime::formatTz(DateTime::now()),
|
||||
'payload' => $payload
|
||||
]
|
||||
'payload' => $payload,
|
||||
],
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -361,7 +383,6 @@ class Realtime extends MessagingAdapter
|
||||
* - 1.5 ms | 1,000 Connections / 10,000 Subscriptions
|
||||
* - 15 ms | 10,000 Connections / 100,000 Subscriptions
|
||||
*
|
||||
* @param array $event
|
||||
* @return array<int|string, array> Map of connection IDs to matched query groups
|
||||
*/
|
||||
public function getSubscribers(array $event): array
|
||||
@@ -377,7 +398,7 @@ class Realtime extends MessagingAdapter
|
||||
foreach ($this->subscriptions[$event['project']] as $role => $subscriptionsByChannel) {
|
||||
foreach ($event['data']['channels'] as $channel) {
|
||||
if (
|
||||
!\array_key_exists($channel, $subscriptionsByChannel)
|
||||
! \array_key_exists($channel, $subscriptionsByChannel)
|
||||
|| (!\in_array($role, $event['roles']) && !\in_array(Role::any()->toString(), $event['roles']))
|
||||
) {
|
||||
continue;
|
||||
@@ -410,6 +431,12 @@ class Realtime extends MessagingAdapter
|
||||
|
||||
/**
|
||||
* Converts the channels from the Query Params into an array.
|
||||
* Also renames the account channel to account.USER_ID, rewrites action-suffixed
|
||||
* account variants (`account.create`, `account.update`, `account.upsert`,
|
||||
* `account.delete`) to `account.USER_ID.{action}` so they match the channels
|
||||
* fromPayload() publishes for top-level user events, and removes all other
|
||||
* illegal account channel variations (e.g. another user's `account.{otherId}`).
|
||||
*
|
||||
* Also renames the account channel to account.USER_ID and removes all illegal account channel variations.
|
||||
* @param array $channels
|
||||
* @param string $userId
|
||||
@@ -421,27 +448,94 @@ class Realtime extends MessagingAdapter
|
||||
|
||||
foreach ($channels as $key => $value) {
|
||||
switch (true) {
|
||||
case str_starts_with($key, 'account.'):
|
||||
unset($channels[$key]);
|
||||
break;
|
||||
|
||||
case $key === 'account':
|
||||
if (!empty($userId)) {
|
||||
$channels['account.' . $userId] = $value;
|
||||
$channels['account.'.$userId] = $value;
|
||||
}
|
||||
break;
|
||||
|
||||
case \in_array(\substr($key, \strlen('account.')), self::SUPPORTED_ACTIONS, true) && str_starts_with($key, 'account.'):
|
||||
// Authenticated: rewrite `account.{action}` → `account.{userId}.{action}`
|
||||
// so the subscriber only receives their own account events.
|
||||
// Guest: keep the literal `account.{action}` so the action filter
|
||||
// applies to the broadcast `account.{action}` channel that fromPayload
|
||||
// emits for top-level user events. On in-band auth, rebindAccountChannels
|
||||
// rewrites the literal to the user-scoped form.
|
||||
if (!empty($userId)) {
|
||||
unset($channels[$key]);
|
||||
$action = \substr($key, \strlen('account.'));
|
||||
$channels['account.'.$userId.'.'.$action] = $value;
|
||||
}
|
||||
break;
|
||||
|
||||
case str_starts_with($key, 'account.'):
|
||||
unset($channels[$key]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites stored account channels to match a new userId. Used when in-band
|
||||
* authentication changes the connection's user identity:
|
||||
*
|
||||
* - guest → authenticated: rewrites the literal `account.{action}` form
|
||||
* that convertChannels preserves for guests into `account.{userId}.{action}`.
|
||||
* - reauth as a different user: rewrites `account.{oldUserId}` and
|
||||
* `account.{oldUserId}.{action}` to the new userId.
|
||||
*
|
||||
* Returns channels unchanged when there's nothing to do — same user, or an
|
||||
* empty target (defensive: avoids producing malformed `account.` strings if
|
||||
* a caller ever passes `$newUserId = ''`, e.g. an in-band logout flow).
|
||||
*/
|
||||
public static function rebindAccountChannels(array $channels, string $oldUserId, string $newUserId): array
|
||||
{
|
||||
if ($newUserId === '' || $oldUserId === $newUserId) {
|
||||
return $channels;
|
||||
}
|
||||
|
||||
return \array_map(function (string $channel) use ($oldUserId, $newUserId) {
|
||||
if (!\str_starts_with($channel, 'account.')) {
|
||||
return $channel;
|
||||
}
|
||||
|
||||
// Guest origin: literal `account.{action}` (preserved by convertChannels
|
||||
// for unauthenticated connections) becomes `account.{newUserId}.{action}`.
|
||||
if ($oldUserId === '') {
|
||||
$suffix = \substr($channel, \strlen('account.'));
|
||||
if (\in_array($suffix, self::SUPPORTED_ACTIONS, true)) {
|
||||
return 'account.'.$newUserId.'.'.$suffix;
|
||||
}
|
||||
|
||||
return $channel;
|
||||
}
|
||||
|
||||
// Authenticated → different user.
|
||||
if ($channel === 'account.'.$oldUserId) {
|
||||
return 'account.'.$newUserId;
|
||||
}
|
||||
|
||||
$oldPrefix = 'account.'.$oldUserId.'.';
|
||||
if (\str_starts_with($channel, $oldPrefix)) {
|
||||
$action = \substr($channel, \strlen($oldPrefix));
|
||||
if (\in_array($action, self::SUPPORTED_ACTIONS, true)) {
|
||||
return 'account.'.$newUserId.'.'.$action;
|
||||
}
|
||||
}
|
||||
|
||||
return $channel;
|
||||
}, $channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs subscriptions from query parameters.
|
||||
*
|
||||
* @param array $channelNames
|
||||
* @param callable $getQueryParam
|
||||
* @return array [index => ['channels' => string[], 'queries' => Query[]]]
|
||||
*
|
||||
* @throws QueryException
|
||||
*/
|
||||
public static function constructSubscriptions(array $channelNames, callable $getQueryParam): array
|
||||
@@ -482,10 +576,11 @@ class Realtime extends MessagingAdapter
|
||||
if (empty($subscriptions[0]['queries'])) {
|
||||
$subscriptions[0]['queries'] = [Query::select(['*'])];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!\is_array($params)) {
|
||||
if (! \is_array($params)) {
|
||||
$params = [$params];
|
||||
}
|
||||
|
||||
@@ -512,6 +607,7 @@ class Realtime extends MessagingAdapter
|
||||
* Converts the queries from the Query Params into an array.
|
||||
* @param array|string $queries
|
||||
* @return array
|
||||
*
|
||||
* @throws QueryException
|
||||
*/
|
||||
public static function convertQueries(mixed $queries): array
|
||||
@@ -524,7 +620,7 @@ class Realtime extends MessagingAdapter
|
||||
$query = array_pop($stack);
|
||||
$method = $query->getMethod();
|
||||
|
||||
if (!in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) {
|
||||
if (! in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) {
|
||||
throw new QueryException(
|
||||
"Query method '{$method}' is not supported in Realtime queries. Allowed: {$allowed}"
|
||||
);
|
||||
@@ -680,11 +776,53 @@ class Realtime extends MessagingAdapter
|
||||
break;
|
||||
}
|
||||
|
||||
// Action is the last segment for plain CRUD events (e.g. `documents.X.create`),
|
||||
// and the second-to-last segment for attribute-trailing events
|
||||
// (e.g. `users.U.update.email`, `teams.T.update.prefs`,
|
||||
// `teams.T.memberships.M.update.status`). Without the second-to-last fallback
|
||||
$count = \count($parts);
|
||||
$action = null;
|
||||
if (\in_array($parts[$count - 1], self::SUPPORTED_ACTIONS, true)) {
|
||||
$action = $parts[$count - 1];
|
||||
} elseif ($count >= 2 && \in_array($parts[$count - 2], self::SUPPORTED_ACTIONS, true)) {
|
||||
$action = $parts[$count - 2];
|
||||
}
|
||||
|
||||
// The `users` branch emits only user-level account channels
|
||||
// (`account`, `account.{userId}`) regardless of event depth, so nested events
|
||||
// like `users.U.sessions.S.create` or `users.U.challenges.C.create` would
|
||||
// otherwise be suffixed as `account.create` — making a subscription to
|
||||
// `account.create` receive unrelated session/challenge/recovery/verification
|
||||
// events. Restrict suffixing to top-level user events where the action sits
|
||||
// at parts[2] (`users.U.create`, `users.U.update.email`, etc.).
|
||||
if (
|
||||
$action !== null
|
||||
&& $parts[0] === 'users'
|
||||
&& ($parts[2] ?? null) !== $action
|
||||
) {
|
||||
$action = null;
|
||||
}
|
||||
|
||||
if ($action !== null && !empty($channels)) {
|
||||
$augmented = $channels;
|
||||
foreach ($channels as $channel) {
|
||||
$segments = \explode('.', $channel);
|
||||
$segCount = \count($segments);
|
||||
$leafIsResource = \in_array($segments[$segCount - 1], self::RESOURCE_LEAF_NAMES, true);
|
||||
$parentIsResource = $segCount >= 2 && \in_array($segments[$segCount - 2], self::RESOURCE_LEAF_NAMES, true);
|
||||
|
||||
if ($leafIsResource || $parentIsResource) {
|
||||
$augmented[] = $channel. '.' .$action;
|
||||
}
|
||||
}
|
||||
$channels = \array_values(\array_unique($augmented));
|
||||
}
|
||||
|
||||
return [
|
||||
'channels' => $channels,
|
||||
'roles' => $roles,
|
||||
'permissionsChanged' => $permissionsChanged,
|
||||
'projectId' => $projectId
|
||||
'projectId' => $projectId,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,8 @@ abstract class Migration
|
||||
'1.9.0' => 'V24',
|
||||
'1.9.1' => 'V24',
|
||||
'1.9.2' => 'V24',
|
||||
'1.9.3' => 'V24',
|
||||
'1.9.4' => 'V24',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ use Appwrite\Platform\Modules\Core;
|
||||
use Appwrite\Platform\Modules\Databases;
|
||||
use Appwrite\Platform\Modules\Functions;
|
||||
use Appwrite\Platform\Modules\Health;
|
||||
use Appwrite\Platform\Modules\Migrations;
|
||||
use Appwrite\Platform\Modules\Project;
|
||||
use Appwrite\Platform\Modules\Projects;
|
||||
use Appwrite\Platform\Modules\Proxy;
|
||||
@@ -39,6 +40,7 @@ class Appwrite extends Platform
|
||||
$this->addModule(new Storage\Module());
|
||||
$this->addModule(new VCS\Module());
|
||||
$this->addModule(new Webhooks\Module());
|
||||
$this->addModule(new Migrations\Module());
|
||||
$this->addModule(new Project\Module());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ class Get extends Action
|
||||
curl_setopt_array($ch, $options);
|
||||
curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return $errno === 0;
|
||||
}
|
||||
|
||||
@@ -94,9 +94,12 @@ class Get extends Action
|
||||
throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED);
|
||||
}
|
||||
|
||||
$body = $res->getBody();
|
||||
$doc = new DOMDocument();
|
||||
$doc->strictErrorChecking = false;
|
||||
@$doc->loadHTML($res->getBody());
|
||||
if (!empty($body)) {
|
||||
@$doc->loadHTML($body);
|
||||
}
|
||||
|
||||
$links = $doc->getElementsByTagName('link');
|
||||
$outputHref = '';
|
||||
|
||||
@@ -85,8 +85,6 @@ class Create extends Action
|
||||
|
||||
curl_exec($ch);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
$response->chunk('', true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Console\Http\OAuth2Providers;
|
||||
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Base as OAuth2Base;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\ContentType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Config\Config;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Platform\Action;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
|
||||
class XList extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'listOAuth2Providers';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
|
||||
->setHttpPath('/v1/console/oauth2-providers')
|
||||
->desc('List OAuth2 providers')
|
||||
->groups(['api'])
|
||||
->label('scope', 'public')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'console',
|
||||
group: 'console',
|
||||
name: 'listOAuth2Providers',
|
||||
description: 'List all OAuth2 providers supported by the Appwrite server, along with the parameters required to configure each provider. The response excludes mock providers but includes sandbox providers.',
|
||||
auth: [AuthType::ADMIN],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_CONSOLE_OAUTH2_PROVIDER_LIST,
|
||||
)
|
||||
],
|
||||
contentType: ContentType::JSON
|
||||
))
|
||||
->inject('response')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(Response $response): void
|
||||
{
|
||||
$providersConfig = Config::getParam('oAuthProviders', []);
|
||||
$actions = OAuth2Base::getProviderActions();
|
||||
|
||||
$providers = [];
|
||||
foreach ($providersConfig as $providerId => $config) {
|
||||
$updateClass = $actions[$providerId] ?? null;
|
||||
if ($updateClass === null) {
|
||||
continue;
|
||||
}
|
||||
if (!($config['enabled'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
if ($config['mock'] ?? false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$providers[] = new Document([
|
||||
'$id' => $providerId,
|
||||
'parameters' => $updateClass::getParameters(),
|
||||
]);
|
||||
}
|
||||
|
||||
$response->dynamic(new Document([
|
||||
'total' => \count($providers),
|
||||
'oAuth2Providers' => $providers,
|
||||
]), Response::MODEL_CONSOLE_OAUTH2_PROVIDER_LIST);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Console\Http\Scopes\Key;
|
||||
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\ContentType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Config\Config;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Platform\Action;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
|
||||
class XList extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'listConsoleProjectScopes';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
|
||||
->setHttpPath('/v1/console/scopes/project')
|
||||
->desc('List project scopes')
|
||||
->groups(['api'])
|
||||
->label('scope', 'public')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'console',
|
||||
group: 'console',
|
||||
name: 'listProjectScopes',
|
||||
description: 'List all scopes available for project API keys, along with a description for each scope.',
|
||||
auth: [AuthType::ADMIN],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_CONSOLE_KEY_SCOPE_LIST,
|
||||
)
|
||||
],
|
||||
contentType: ContentType::JSON
|
||||
))
|
||||
->inject('response')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(Response $response): void
|
||||
{
|
||||
$scopesConfig = Config::getParam('projectScopes', []);
|
||||
|
||||
$scopes = [];
|
||||
foreach ($scopesConfig as $scopeId => $scope) {
|
||||
$scopes[] = new Document([
|
||||
'$id' => $scopeId,
|
||||
'description' => $scope['description'] ?? '',
|
||||
'category' => $scope['category'] ?? '',
|
||||
'deprecated' => $scope['deprecated'] ?? false,
|
||||
]);
|
||||
}
|
||||
|
||||
$response->dynamic(new Document([
|
||||
'total' => \count($scopes),
|
||||
'scopes' => $scopes,
|
||||
]), Response::MODEL_CONSOLE_KEY_SCOPE_LIST);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ class Get extends Action
|
||||
namespace: 'console',
|
||||
group: 'console',
|
||||
name: 'variables',
|
||||
description: '/docs/references/console/variables.md',
|
||||
description: 'Get all Environment Variables that are relevant for the console.',
|
||||
auth: [AuthType::ADMIN],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Appwrite\Platform\Modules\Console\Services;
|
||||
use Appwrite\Platform\Modules\Console\Http\Assistant\Create as CreateAssistantQuery;
|
||||
use Appwrite\Platform\Modules\Console\Http\Init\API;
|
||||
use Appwrite\Platform\Modules\Console\Http\Init\Web;
|
||||
use Appwrite\Platform\Modules\Console\Http\OAuth2Providers\XList as ListOAuth2Providers;
|
||||
use Appwrite\Platform\Modules\Console\Http\Redirects\Auth\Get as RedirectAuth;
|
||||
use Appwrite\Platform\Modules\Console\Http\Redirects\Card\Get as RedirectCard;
|
||||
use Appwrite\Platform\Modules\Console\Http\Redirects\Invite\Get as RedirectInvite;
|
||||
@@ -14,6 +15,7 @@ use Appwrite\Platform\Modules\Console\Http\Redirects\Recover\Get as RedirectReco
|
||||
use Appwrite\Platform\Modules\Console\Http\Redirects\Register\Get as RedirectRegister;
|
||||
use Appwrite\Platform\Modules\Console\Http\Redirects\Root\Get as RedirectRoot;
|
||||
use Appwrite\Platform\Modules\Console\Http\Resources\Get as GetResourceAvailability;
|
||||
use Appwrite\Platform\Modules\Console\Http\Scopes\Key\XList as ListKeyScopes;
|
||||
use Appwrite\Platform\Modules\Console\Http\Variables\Get as GetVariables;
|
||||
use Utopia\Platform\Service;
|
||||
|
||||
@@ -28,6 +30,8 @@ class Http extends Service
|
||||
$this->addAction(Web::getName(), new Web());
|
||||
|
||||
$this->addAction(GetVariables::getName(), new GetVariables());
|
||||
$this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers());
|
||||
$this->addAction(ListKeyScopes::getName(), new ListKeyScopes());
|
||||
$this->addAction(CreateAssistantQuery::getName(), new CreateAssistantQuery());
|
||||
$this->addAction(GetResourceAvailability::getName(), new GetResourceAvailability());
|
||||
|
||||
|
||||
+9
-3
@@ -241,6 +241,10 @@ abstract class Action extends UtopiaAction
|
||||
? UtopiaResponse::MODEL_ATTRIBUTE_INTEGER
|
||||
: UtopiaResponse::MODEL_COLUMN_INTEGER,
|
||||
|
||||
Database::VAR_BIGINT => $isCollections
|
||||
? UtopiaResponse::MODEL_ATTRIBUTE_BIGINT
|
||||
: UtopiaResponse::MODEL_COLUMN_BIGINT,
|
||||
|
||||
Database::VAR_FLOAT => $isCollections
|
||||
? UtopiaResponse::MODEL_ATTRIBUTE_FLOAT
|
||||
: UtopiaResponse::MODEL_COLUMN_FLOAT,
|
||||
@@ -540,6 +544,7 @@ abstract class Action extends UtopiaAction
|
||||
|
||||
switch ($attribute->getAttribute('format')) {
|
||||
case APP_DATABASE_ATTRIBUTE_INT_RANGE:
|
||||
case APP_DATABASE_ATTRIBUTE_BIGINT_RANGE:
|
||||
case APP_DATABASE_ATTRIBUTE_FLOAT_RANGE:
|
||||
$min ??= $attribute->getAttribute('formatOptions')['min'];
|
||||
$max ??= $attribute->getAttribute('formatOptions')['max'];
|
||||
@@ -548,14 +553,15 @@ abstract class Action extends UtopiaAction
|
||||
throw new Exception($this->getInvalidValueException(), 'Minimum value must be lesser than maximum value');
|
||||
}
|
||||
|
||||
if ($attribute->getAttribute('format') === APP_DATABASE_ATTRIBUTE_INT_RANGE) {
|
||||
$validator = new Range($min, $max, Database::VAR_INTEGER);
|
||||
} else {
|
||||
if ($attribute->getAttribute('format') === APP_DATABASE_ATTRIBUTE_FLOAT_RANGE) {
|
||||
$validator = new Range($min, $max, Database::VAR_FLOAT);
|
||||
|
||||
if (!is_null($default)) {
|
||||
$default = \floatval($default);
|
||||
}
|
||||
} else {
|
||||
// intRange and bigintRange share the same integer range semantics
|
||||
$validator = new Range($min, $max, Range::TYPE_INTEGER);
|
||||
}
|
||||
|
||||
if (!is_null($default) && !$validator->isValid($default)) {
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\BigInt;
|
||||
|
||||
use Appwrite\Event\Database as EventDatabase;
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Deprecated;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response as UtopiaResponse;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
use Utopia\Database\Validator\Key;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\Integer;
|
||||
use Utopia\Validator\Nullable;
|
||||
use Utopia\Validator\Range;
|
||||
|
||||
class Create extends Action
|
||||
{
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'createBigIntAttribute';
|
||||
}
|
||||
|
||||
protected function getResponseModel(): string|array
|
||||
{
|
||||
return UtopiaResponse::MODEL_ATTRIBUTE_BIGINT;
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
|
||||
->setHttpPath('/v1/databases/:databaseId/collections/:collectionId/attributes/bigint')
|
||||
->desc('Create bigint attribute')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', 'collections.write')
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].collections.[collectionId].attributes.[attributeId].create')
|
||||
->label('audits.event', 'attribute.create')
|
||||
->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
|
||||
->label('sdk', new Method(
|
||||
namespace: $this->getSDKNamespace(),
|
||||
group: $this->getSDKGroup(),
|
||||
name: self::getName(),
|
||||
description: '/docs/references/databases/create-bigint-attribute.md',
|
||||
auth: [AuthType::ADMIN, AuthType::KEY],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: SwooleResponse::STATUS_CODE_ACCEPTED,
|
||||
model: $this->getResponseModel(),
|
||||
)
|
||||
],
|
||||
deprecated: new Deprecated(
|
||||
since: '1.8.0',
|
||||
replaceWith: 'tablesDB.createBigIntColumn',
|
||||
),
|
||||
))
|
||||
->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
|
||||
->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject'])
|
||||
->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Attribute Key.', false, ['dbForProject'])
|
||||
->param('required', null, new Boolean(), 'Is attribute required?')
|
||||
->param('min', null, new Nullable(new Integer(false, 64)), 'Minimum value', true)
|
||||
->param('max', null, new Nullable(new Integer(false, 64)), 'Maximum value', true)
|
||||
->param('default', null, new Nullable(new Integer(false, 64)), 'Default value. Cannot be set when attribute is required.', true)
|
||||
->param('array', false, new Boolean(), 'Is attribute an array?', true)
|
||||
->inject('response')
|
||||
->inject('dbForProject')
|
||||
->inject('queueForDatabase')
|
||||
->inject('queueForEvents')
|
||||
->inject('authorization')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?int $min, ?int $max, ?int $default, bool $array, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void
|
||||
{
|
||||
$min ??= \PHP_INT_MIN;
|
||||
$max ??= \PHP_INT_MAX;
|
||||
|
||||
if ($min > $max) {
|
||||
throw new Exception($this->getInvalidValueException(), 'Minimum value must be lesser than maximum value');
|
||||
}
|
||||
|
||||
$validator = new Range($min, $max, Range::TYPE_INTEGER);
|
||||
if (!\is_null($default) && !$validator->isValid($default)) {
|
||||
throw new Exception($this->getInvalidValueException(), $validator->getDescription());
|
||||
}
|
||||
|
||||
$attribute = $this->createAttribute($databaseId, $collectionId, new Document([
|
||||
'key' => $key,
|
||||
'type' => Database::VAR_BIGINT,
|
||||
'size' => 8,
|
||||
'required' => $required,
|
||||
'default' => $default,
|
||||
'array' => $array,
|
||||
'format' => APP_DATABASE_ATTRIBUTE_BIGINT_RANGE,
|
||||
'formatOptions' => ['min' => $min, 'max' => $max],
|
||||
]), $response, $dbForProject, $queueForDatabase, $queueForEvents, $authorization);
|
||||
|
||||
$formatOptions = $attribute->getAttribute('formatOptions', []);
|
||||
if (!empty($formatOptions)) {
|
||||
$attribute->setAttribute('min', \intval($formatOptions['min']));
|
||||
$attribute->setAttribute('max', \intval($formatOptions['max']));
|
||||
}
|
||||
|
||||
$response
|
||||
->setStatusCode(SwooleResponse::STATUS_CODE_ACCEPTED)
|
||||
->dynamic($attribute, $this->getResponseModel());
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\BigInt;
|
||||
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\Action;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\ContentType;
|
||||
use Appwrite\SDK\Deprecated;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response as UtopiaResponse;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
use Utopia\Database\Validator\Key;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\Integer;
|
||||
use Utopia\Validator\Nullable;
|
||||
|
||||
class Update extends Action
|
||||
{
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'updateBigIntAttribute';
|
||||
}
|
||||
|
||||
protected function getResponseModel(): string|array
|
||||
{
|
||||
return UtopiaResponse::MODEL_ATTRIBUTE_BIGINT;
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH)
|
||||
->setHttpPath('/v1/databases/:databaseId/collections/:collectionId/attributes/bigint/:key')
|
||||
->desc('Update bigint attribute')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', 'collections.write')
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].collections.[collectionId].attributes.[attributeId].update')
|
||||
->label('audits.event', 'attribute.update')
|
||||
->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}')
|
||||
->label('sdk', new Method(
|
||||
namespace: $this->getSDKNamespace(),
|
||||
group: $this->getSDKGroup(),
|
||||
name: self::getName(),
|
||||
description: '/docs/references/databases/update-bigint-attribute.md',
|
||||
auth: [AuthType::ADMIN, AuthType::KEY],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: SwooleResponse::STATUS_CODE_OK,
|
||||
model: $this->getResponseModel(),
|
||||
)
|
||||
],
|
||||
contentType: ContentType::JSON,
|
||||
deprecated: new Deprecated(
|
||||
since: '1.8.0',
|
||||
replaceWith: 'tablesDB.updateBigIntColumn',
|
||||
),
|
||||
))
|
||||
->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
|
||||
->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject'])
|
||||
->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Attribute Key.', false, ['dbForProject'])
|
||||
->param('required', null, new Boolean(), 'Is attribute required?')
|
||||
->param('min', null, new Nullable(new Integer(false, 64)), 'Minimum value', true)
|
||||
->param('max', null, new Nullable(new Integer(false, 64)), 'Maximum value', true)
|
||||
->param('default', null, new Nullable(new Integer(false, 64)), 'Default value. Cannot be set when attribute is required.')
|
||||
->param('newKey', null, fn (Database $dbForProject) => new Nullable(new Key(false, $dbForProject->getAdapter()->getMaxUIDLength())), 'New Attribute Key.', true, ['dbForProject'])
|
||||
->inject('response')
|
||||
->inject('dbForProject')
|
||||
->inject('queueForEvents')
|
||||
->inject('authorization')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(string $databaseId, string $collectionId, string $key, ?bool $required, ?int $min, ?int $max, ?int $default, ?string $newKey, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, Authorization $authorization): void
|
||||
{
|
||||
$attribute = $this->updateAttribute(
|
||||
databaseId: $databaseId,
|
||||
collectionId: $collectionId,
|
||||
key: $key,
|
||||
dbForProject: $dbForProject,
|
||||
queueForEvents: $queueForEvents,
|
||||
authorization: $authorization,
|
||||
type: Database::VAR_BIGINT,
|
||||
default: $default,
|
||||
required: $required,
|
||||
min: $min,
|
||||
max: $max,
|
||||
newKey: $newKey
|
||||
);
|
||||
|
||||
$formatOptions = $attribute->getAttribute('formatOptions', []);
|
||||
if (!empty($formatOptions)) {
|
||||
$attribute->setAttribute('min', \intval($formatOptions['min']));
|
||||
$attribute->setAttribute('max', \intval($formatOptions['max']));
|
||||
}
|
||||
|
||||
$response
|
||||
->setStatusCode(SwooleResponse::STATUS_CODE_OK)
|
||||
->dynamic($attribute, $this->getResponseModel());
|
||||
}
|
||||
}
|
||||
@@ -290,13 +290,15 @@ class Create extends Action
|
||||
}
|
||||
|
||||
if (isset($attribute['min']) || isset($attribute['max'])) {
|
||||
$format = $type === Database::VAR_INTEGER
|
||||
? APP_DATABASE_ATTRIBUTE_INT_RANGE
|
||||
: APP_DATABASE_ATTRIBUTE_FLOAT_RANGE;
|
||||
$format = match($type) {
|
||||
Database::VAR_INTEGER => APP_DATABASE_ATTRIBUTE_INT_RANGE,
|
||||
Database::VAR_BIGINT => APP_DATABASE_ATTRIBUTE_BIGINT_RANGE,
|
||||
default => APP_DATABASE_ATTRIBUTE_FLOAT_RANGE,
|
||||
};
|
||||
|
||||
$formatOptions = [
|
||||
'min' => $attribute['min'] ?? ($type === Database::VAR_INTEGER ? \PHP_INT_MIN : -\PHP_FLOAT_MAX),
|
||||
'max' => $attribute['max'] ?? ($type === Database::VAR_INTEGER ? \PHP_INT_MAX : \PHP_FLOAT_MAX),
|
||||
'min' => $attribute['min'] ?? ($type === Database::VAR_INTEGER || $type === Database::VAR_BIGINT ? \PHP_INT_MIN : -\PHP_FLOAT_MAX),
|
||||
'max' => $attribute['max'] ?? ($type === Database::VAR_INTEGER || $type === Database::VAR_BIGINT ? \PHP_INT_MAX : \PHP_FLOAT_MAX),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Columns\BigInt;
|
||||
|
||||
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\BigInt\Create as BigIntCreate;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response as UtopiaResponse;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Validator\Key;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\Integer;
|
||||
use Utopia\Validator\Nullable;
|
||||
|
||||
class Create extends BigIntCreate
|
||||
{
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'createBigIntColumn';
|
||||
}
|
||||
|
||||
protected function getResponseModel(): string|array
|
||||
{
|
||||
return UtopiaResponse::MODEL_COLUMN_BIGINT;
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/bigint')
|
||||
->desc('Create bigint column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
|
||||
->label('audits.event', 'column.create')
|
||||
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
|
||||
->label('sdk', new Method(
|
||||
namespace: $this->getSDKNamespace(),
|
||||
group: $this->getSDKGroup(),
|
||||
name: self::getName(),
|
||||
description: '/docs/references/tablesdb/create-bigint-column.md',
|
||||
auth: [AuthType::ADMIN, AuthType::KEY],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: SwooleResponse::STATUS_CODE_ACCEPTED,
|
||||
model: $this->getResponseModel(),
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
|
||||
->param('tableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Table ID.', false, ['dbForProject'])
|
||||
->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Column Key.', false, ['dbForProject'])
|
||||
->param('required', null, new Boolean(), 'Is column required?')
|
||||
->param('min', null, new Nullable(new Integer(false, 64)), 'Minimum value', true)
|
||||
->param('max', null, new Nullable(new Integer(false, 64)), 'Maximum value', true)
|
||||
->param('default', null, new Nullable(new Integer(false, 64)), 'Default value. Cannot be set when column is required.', true)
|
||||
->param('array', false, new Boolean(), 'Is column an array?', true)
|
||||
->inject('response')
|
||||
->inject('dbForProject')
|
||||
->inject('queueForDatabase')
|
||||
->inject('queueForEvents')
|
||||
->inject('authorization')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Columns\BigInt;
|
||||
|
||||
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Attributes\BigInt\Update as BigIntUpdate;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\ContentType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response as UtopiaResponse;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Validator\Key;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\Integer;
|
||||
use Utopia\Validator\Nullable;
|
||||
|
||||
class Update extends BigIntUpdate
|
||||
{
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'updateBigIntColumn';
|
||||
}
|
||||
|
||||
protected function getResponseModel(): string|array
|
||||
{
|
||||
return UtopiaResponse::MODEL_COLUMN_BIGINT;
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH)
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/bigint/:key')
|
||||
->desc('Update bigint column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
|
||||
->label('audits.event', 'column.update')
|
||||
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
|
||||
->label('sdk', new Method(
|
||||
namespace: $this->getSDKNamespace(),
|
||||
group: $this->getSDKGroup(),
|
||||
name: self::getName(),
|
||||
description: '/docs/references/tablesdb/update-bigint-column.md',
|
||||
auth: [AuthType::ADMIN, AuthType::KEY],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: SwooleResponse::STATUS_CODE_OK,
|
||||
model: $this->getResponseModel(),
|
||||
)
|
||||
],
|
||||
contentType: ContentType::JSON
|
||||
))
|
||||
->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject'])
|
||||
->param('tableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Table ID.', false, ['dbForProject'])
|
||||
->param('key', '', fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Column Key.', false, ['dbForProject'])
|
||||
->param('required', null, new Boolean(), 'Is column required?')
|
||||
->param('min', null, new Nullable(new Integer(false, 64)), 'Minimum value', true)
|
||||
->param('max', null, new Nullable(new Integer(false, 64)), 'Maximum value', true)
|
||||
->param('default', null, new Nullable(new Integer(false, 64)), 'Default value. Cannot be set when column is required.')
|
||||
->param('newKey', null, fn (Database $dbForProject) => new Nullable(new Key(false, $dbForProject->getAdapter()->getMaxUIDLength())), 'New Column Key.', true, ['dbForProject'])
|
||||
->inject('response')
|
||||
->inject('dbForProject')
|
||||
->inject('queueForEvents')
|
||||
->inject('authorization')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -34,7 +34,7 @@ class Create extends BooleanCreate
|
||||
->desc('Create boolean column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('audits.event', 'column.create')
|
||||
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class Update extends BooleanUpdate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/boolean/:key')
|
||||
->desc('Update boolean column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
|
||||
->label('audits.event', 'column.update')
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class Create extends DatetimeCreate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/datetime')
|
||||
->desc('Create datetime column')
|
||||
->groups(['api', 'database'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
|
||||
->label('audits.event', 'column.create')
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class Update extends DatetimeUpdate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/datetime/:key')
|
||||
->desc('Update dateTime column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
|
||||
->label('audits.event', 'column.update')
|
||||
|
||||
@@ -33,7 +33,7 @@ class Delete extends AttributesDelete
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key')
|
||||
->desc('Delete column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
|
||||
->label('audits.event', 'column.delete')
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class Create extends EmailCreate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/email')
|
||||
->desc('Create email column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
|
||||
->label('audits.event', 'column.create')
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class Update extends EmailUpdate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/email/:key')
|
||||
->desc('Update email column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
|
||||
->label('audits.event', 'column.update')
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class Create extends EnumCreate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/enum')
|
||||
->desc('Create enum column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
|
||||
->label('audits.event', 'column.create')
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ class Update extends EnumUpdate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/enum/:key')
|
||||
->desc('Update enum column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
|
||||
->label('audits.event', 'column.update')
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class Create extends FloatCreate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/float')
|
||||
->desc('Create float column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
|
||||
->label('audits.event', 'column.create')
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class Update extends FloatUpdate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/float/:key')
|
||||
->desc('Update float column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
|
||||
->label('audits.event', 'column.update')
|
||||
|
||||
@@ -42,7 +42,7 @@ class Get extends AttributesGet
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key')
|
||||
->desc('Get column')
|
||||
->groups(['api', 'database'])
|
||||
->label('scope', ['tables.read', 'collections.read'])
|
||||
->label('scope', ['tables.read', 'collections.read', 'columns.read', 'attributes.read'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('sdk', new Method(
|
||||
namespace: $this->getSDKNamespace(),
|
||||
|
||||
@@ -34,7 +34,7 @@ class Create extends IPCreate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/ip')
|
||||
->desc('Create IP address column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
|
||||
->label('audits.event', 'column.create')
|
||||
|
||||
@@ -35,7 +35,7 @@ class Update extends IPUpdate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/ip/:key')
|
||||
->desc('Update IP address column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
|
||||
->label('audits.event', 'column.update')
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class Create extends IntegerCreate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/integer')
|
||||
->desc('Create integer column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
|
||||
->label('audits.event', 'column.create')
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class Update extends IntegerUpdate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/integer/:key')
|
||||
->desc('Update integer column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
|
||||
->label('audits.event', 'column.update')
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class Create extends LineCreate
|
||||
->desc('Create line column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('audits.event', 'column.create')
|
||||
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class Update extends LineUpdate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/line/:key')
|
||||
->desc('Update line column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
|
||||
->label('audits.event', 'column.update')
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ class Create extends LongtextCreate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/longtext')
|
||||
->desc('Create longtext column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
|
||||
->label('audits.event', 'column.create')
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class Update extends LongtextUpdate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/longtext/:key')
|
||||
->desc('Update longtext column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
|
||||
->label('audits.event', 'column.update')
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ class Create extends MediumtextCreate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/mediumtext')
|
||||
->desc('Create mediumtext column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
|
||||
->label('audits.event', 'column.create')
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class Update extends MediumtextUpdate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/mediumtext/:key')
|
||||
->desc('Update mediumtext column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
|
||||
->label('audits.event', 'column.update')
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class Create extends PointCreate
|
||||
->desc('Create point column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('audits.event', 'column.create')
|
||||
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class Update extends PointUpdate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/point/:key')
|
||||
->desc('Update point column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
|
||||
->label('audits.event', 'column.update')
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class Create extends PolygonCreate
|
||||
->desc('Create polygon column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('audits.event', 'column.create')
|
||||
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}')
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class Update extends PolygonUpdate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/polygon/:key')
|
||||
->desc('Update polygon column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
|
||||
->label('audits.event', 'column.update')
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class Create extends RelationshipCreate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/relationship')
|
||||
->desc('Create relationship column')
|
||||
->groups(['api', 'database'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
|
||||
->label('audits.event', 'column.create')
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class Update extends RelationshipUpdate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key/relationship')
|
||||
->desc('Update relationship column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
|
||||
->label('audits.event', 'column.update')
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ class Create extends StringCreate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/string')
|
||||
->desc('Create string column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
|
||||
->label('audits.event', 'column.create')
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ class Update extends StringUpdate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/string/:key')
|
||||
->desc('Update string column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
|
||||
->label('audits.event', 'column.update')
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ class Create extends TextCreate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/text')
|
||||
->desc('Create text column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
|
||||
->label('audits.event', 'column.create')
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class Update extends TextUpdate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/text/:key')
|
||||
->desc('Update text column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
|
||||
->label('audits.event', 'column.update')
|
||||
|
||||
@@ -34,7 +34,7 @@ class Create extends URLCreate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/url')
|
||||
->desc('Create URL column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
|
||||
->label('audits.event', 'column.create')
|
||||
|
||||
@@ -35,7 +35,7 @@ class Update extends URLUpdate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/url/:key')
|
||||
->desc('Update URL column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update')
|
||||
->label('audits.event', 'column.update')
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class Create extends VarcharCreate
|
||||
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/varchar')
|
||||
->desc('Create varchar column')
|
||||
->groups(['api', 'database', 'schema'])
|
||||
->label('scope', ['tables.write', 'collections.write'])
|
||||
->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write'])
|
||||
->label('resourceType', RESOURCE_TYPE_DATABASES)
|
||||
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
|
||||
->label('audits.event', 'column.create')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user