Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d8bf6c0dd | ||
|
|
bf375d1d40 | ||
|
|
fd05827089 | ||
|
|
ac2b35004d | ||
|
|
6b6092c9ec | ||
|
|
677bb048cc | ||
|
|
b093a3fb24 | ||
|
|
3dc9a10fab | ||
|
|
2142905409 | ||
|
|
af401eb950 | ||
|
|
392926bc1a | ||
|
|
06c5a2845f | ||
|
|
e6272f7a35 | ||
|
|
e8790c405b | ||
|
|
83a8e2811d | ||
|
|
41017bb0af | ||
|
|
5478d3d601 | ||
|
|
176a29d608 | ||
|
|
ee732581d8 | ||
|
|
a94649fb58 | ||
|
|
366da19b61 | ||
|
|
5a56a2fc35 | ||
|
|
5e3f698b57 | ||
|
|
a86217bf4b | ||
|
|
4863b29754 | ||
|
|
e0a2b5c1ef | ||
|
|
bc2a0d573c | ||
|
|
e1bb4fc2cc | ||
|
|
79f907ff55 | ||
|
|
937d94972f | ||
|
|
aefa51f261 | ||
|
|
f9ecd28dd0 | ||
|
|
7e6db9a99b | ||
|
|
c618372d37 | ||
|
|
975a38bf43 | ||
|
|
6b21968413 | ||
|
|
286e0e5802 | ||
|
|
ac05c9e9ce | ||
|
|
153e660274 | ||
|
|
ad97feaf6e | ||
|
|
ef618b831d | ||
|
|
daaf1d7f77 | ||
|
|
1209a2e344 | ||
|
|
04bbc30e7e | ||
|
|
48c9ecb53f | ||
|
|
eb082cdd86 | ||
|
|
0c7a9290bd | ||
|
|
341aabb942 | ||
|
|
09c963abda | ||
|
|
eb2f0b1cea | ||
|
|
01a9340eaf | ||
|
|
021c5fcad6 | ||
|
|
4309bd075e | ||
|
|
236dceae2c | ||
|
|
8eb309e92c | ||
|
|
6fee0cb73e | ||
|
|
840b1fbef3 | ||
|
|
884b8f10bb | ||
|
|
03ebc4857a | ||
|
|
1db4c40c6a | ||
|
|
99f08b9f95 | ||
|
|
530d681c48 | ||
|
|
cdbb8ff7e4 | ||
|
|
99f21b8af6 | ||
|
|
871795efa9 | ||
|
|
dba8c28741 | ||
|
|
526aa590e1 | ||
|
|
d91e3543c8 | ||
|
|
f15f63c0c1 | ||
|
|
fa406adab9 | ||
|
|
aa1012ffb6 | ||
|
|
c192a154f5 | ||
|
|
0295e27b5d | ||
|
|
a804cba999 | ||
|
|
d8f733787f | ||
|
|
1abbca9318 | ||
|
|
e99f682cd6 | ||
|
|
26326d05e9 | ||
|
|
e67ed2660a | ||
|
|
8d0a4d7f92 | ||
|
|
aecca2f503 | ||
|
|
7e4ec9eae0 | ||
|
|
6319d961eb | ||
|
|
5260577872 | ||
|
|
254c670c1d | ||
|
|
edd948557e | ||
|
|
09317f290a | ||
|
|
e5f0c2df12 | ||
|
|
2afec80a25 | ||
|
|
20c65bac37 | ||
|
|
2c6f37ecad | ||
|
|
f09b9a994e | ||
|
|
9f4ba3a4a2 | ||
|
|
8cb36835cc | ||
|
|
89419344c2 | ||
|
|
7dfe44cb36 | ||
|
|
4cbe50193a | ||
|
|
16929bc420 | ||
|
|
fd28ad8a66 | ||
|
|
38798c8993 | ||
|
|
33ce469ab0 | ||
|
|
0bbcc3f570 | ||
|
|
ecca0d8036 | ||
|
|
534dc55f17 | ||
|
|
d30df31b82 | ||
|
|
dc9a1c03d1 | ||
|
|
3613a645d0 | ||
|
|
6aac7ea6ad | ||
|
|
d22642590f | ||
|
|
0b416e4475 | ||
|
|
4e1b710503 | ||
|
|
e557a5fc6e | ||
|
|
7d15d7e1f5 | ||
|
|
ab1d88a44a | ||
|
|
af54ff83dc | ||
|
|
4b1c6fdf83 | ||
|
|
e42656d51a | ||
|
|
a56c2c11d3 | ||
|
|
0ebda89adf | ||
|
|
4bf3c72196 | ||
|
|
2f01965014 | ||
|
|
36bd7a4667 | ||
|
|
c23945ebc6 | ||
|
|
0984daf37e | ||
|
|
1113bffb78 | ||
|
|
33cafdfc0e | ||
|
|
1be142e4be | ||
|
|
f4b8992cde | ||
|
|
ee2e41fa45 | ||
|
|
94c6f6e07d | ||
|
|
f23411f549 | ||
|
|
299778cd50 | ||
|
|
b677586925 | ||
|
|
fda8aee280 | ||
|
|
7f4cba276e | ||
|
|
e2900d4349 | ||
|
|
eccc39a466 | ||
|
|
8b632eb428 | ||
|
|
d19f14b60f | ||
|
|
c4c2534f9a | ||
|
|
91dffddf3b | ||
|
|
a10595f501 | ||
|
|
06e3e98cec | ||
|
|
e42812283b | ||
|
|
4d7aa6d8ab | ||
|
|
95b3db0228 | ||
|
|
a0167d6c6c | ||
|
|
39f3bc7b9d | ||
|
|
ab0fa70bca | ||
|
|
74ab41f2b5 | ||
|
|
d8954f424c | ||
|
|
38493094db | ||
|
|
58e5e69fec | ||
|
|
895ec7fd20 | ||
|
|
d3442d86c1 | ||
|
|
c20dfc7063 | ||
|
|
591cc70e76 | ||
|
|
2de28cfcb7 | ||
|
|
c6d476b978 | ||
|
|
d80aacd706 | ||
|
|
4cd1dcc6f3 | ||
|
|
576435c24b | ||
|
|
5dca3ad875 | ||
|
|
b6793dc0b5 | ||
|
|
a2ad25a00a | ||
|
|
16ad05792d | ||
|
|
e5f0bc2df6 | ||
|
|
0d3d14d9e1 | ||
|
|
f2826189c6 | ||
|
|
8b026d3459 | ||
|
|
0ea196d21c | ||
|
|
ece5b81732 | ||
|
|
2d92e56795 | ||
|
|
2306072dda | ||
|
|
91edf82060 | ||
|
|
3fc50e4d8d | ||
|
|
71dff441ed | ||
|
|
65780d75f9 | ||
|
|
12ae61b915 | ||
|
|
03d7bccde2 | ||
|
|
b5c2cc9716 | ||
|
|
ee107d30b3 | ||
|
|
499ca71c28 | ||
|
|
cfe60dd2a2 | ||
|
|
ab85942276 | ||
|
|
0214f22d20 | ||
|
|
e0269e268f | ||
|
|
a4844599c6 | ||
|
|
ce876adb3a | ||
|
|
59edcf1934 | ||
|
|
5821832da6 | ||
|
|
d4df2c51de | ||
|
|
dbbc57bc37 | ||
|
|
d772220414 | ||
|
|
9d2036024a | ||
|
|
cf9637288c | ||
|
|
9fdd7c1c6e | ||
|
|
5510231b8e | ||
|
|
152de6c584 | ||
|
|
d1ffa5daf3 | ||
|
|
161c5af66f | ||
|
|
c497a68613 | ||
|
|
50f9c67862 | ||
|
|
198f9a64a3 | ||
|
|
248b3c8b32 | ||
|
|
4574385b31 | ||
|
|
f8b31e7db7 | ||
|
|
cf16e3e2a3 | ||
|
|
ff0f132984 | ||
|
|
300aaeb251 | ||
|
|
f989ccde57 | ||
|
|
63756ddf1d | ||
|
|
1f5fd919c9 | ||
|
|
718d3377b0 | ||
|
|
c81029d8aa | ||
|
|
321528cac0 | ||
|
|
00d10e62b5 | ||
|
|
cd5f3c3eb3 | ||
|
|
0291f8f943 | ||
|
|
da30142b4d | ||
|
|
f5047afec9 | ||
|
|
fc88d4b4ab | ||
|
|
df604cb2ef | ||
|
|
a6d58a847d | ||
|
|
fdd3a58d81 | ||
|
|
cf7710b580 | ||
|
|
ccaaffed48 | ||
|
|
c073743989 | ||
|
|
a0854e0591 | ||
|
|
20f248a6ae | ||
|
|
c171e0c3a2 | ||
|
|
2081c4c42c | ||
|
|
b45ff6b646 | ||
|
|
82db411517 | ||
|
|
5999db295d | ||
|
|
7644e0fe48 | ||
|
|
b6b44efdab | ||
|
|
f4125b8859 | ||
|
|
1ccd61ece5 | ||
|
|
34dd5006bb | ||
|
|
3cc2375b15 | ||
|
|
5ee58dcc81 | ||
|
|
1dfee199c0 | ||
|
|
598c71fb11 | ||
|
|
40fc4edb25 | ||
|
|
feedd0eb4a | ||
|
|
f41c19ed3e | ||
|
|
94bd9661b3 | ||
|
|
e31843be4b | ||
|
|
e7424e70aa | ||
|
|
4fda4d4ac3 | ||
|
|
9c7cef2971 | ||
|
|
029cd695dd | ||
|
|
5dadb9a998 | ||
|
|
164c372b21 | ||
|
|
cb260fa3d8 | ||
|
|
05a2f56945 | ||
|
|
349bc63618 | ||
|
|
82a5d138e0 | ||
|
|
dcfc33c65d | ||
|
|
4c76ddc2a3 | ||
|
|
3d4f37c482 | ||
|
|
b03ad6d523 | ||
|
|
f540df50b1 | ||
|
|
adda327509 | ||
|
|
a5fa09b4ce | ||
|
|
5c2b1be581 | ||
|
|
b3b2fe3ba2 | ||
|
|
9667ec2434 | ||
|
|
1790f56d99 | ||
|
|
3c4c78d086 | ||
|
|
98aab7b34d | ||
|
|
81e828a200 | ||
|
|
218d25ba1b | ||
|
|
908a74f69d | ||
|
|
18d7b12dff | ||
|
|
118199fd4b | ||
|
|
8368a28ff5 | ||
|
|
abe21d51cb | ||
|
|
339ecc418d | ||
|
|
7a27cf4ac7 | ||
|
|
5ff7e2af24 | ||
|
|
f8d8997ae3 | ||
|
|
9aa47c267d | ||
|
|
12dca64b60 | ||
|
|
952f48c72a | ||
|
|
4e80f7eb9a | ||
|
|
9db037722c | ||
|
|
ded05ebea2 | ||
|
|
a71f3555ae | ||
|
|
4500349fda | ||
|
|
611495ec28 | ||
|
|
347436d370 | ||
|
|
dd925e335b | ||
|
|
0e8b5f1d04 | ||
|
|
8dfaf4a9a9 | ||
|
|
d5b2f97252 | ||
|
|
a7046dbe3c | ||
|
|
47a81d165e | ||
|
|
d7c8b9d43a | ||
|
|
5f58e1da90 | ||
|
|
7f29baec77 | ||
|
|
7562946434 | ||
|
|
bc992f15fc | ||
|
|
904f94659f | ||
|
|
8892e4f30e | ||
|
|
41948cb337 | ||
|
|
4d19a5e855 | ||
|
|
1c85789a6e | ||
|
|
79d219bf50 | ||
|
|
9b2143a2a5 | ||
|
|
b174a50b2a | ||
|
|
26dac3a410 | ||
|
|
3ecb4ee4e2 | ||
|
|
3e0ee30428 | ||
|
|
0e7c50ef71 | ||
|
|
b8ee9fbe2c | ||
|
|
d49dea9790 | ||
|
|
8891890601 | ||
|
|
db85004b12 | ||
|
|
b7e4f78c56 | ||
|
|
159da8ba31 | ||
|
|
88ce622e4a | ||
|
|
c041e57b8f | ||
|
|
4a0f00f7db | ||
|
|
de21cab22a | ||
|
|
f8929e1f2e | ||
|
|
754ddbef3c | ||
|
|
ed93b77faa | ||
|
|
5a6cfdbe4c | ||
|
|
6b90d00cc2 | ||
|
|
3ba5e12f89 | ||
|
|
dc441c3973 | ||
|
|
9745df4d37 | ||
|
|
715adf62ca | ||
|
|
90ab70dfc6 | ||
|
|
b0c5185ebd | ||
|
|
d58873ef14 | ||
|
|
105c885c3f | ||
|
|
ad625029e8 | ||
|
|
aeaa184aec | ||
|
|
9dd707cd1b | ||
|
|
b41678d57a | ||
|
|
97be6e2cf4 | ||
|
|
1df76ed975 | ||
|
|
2278a423f5 | ||
|
|
282b3c74ec | ||
|
|
180276bd9d | ||
|
|
abab48c1b9 | ||
|
|
587f9965a2 | ||
|
|
bdad14e83a | ||
|
|
92e94e365f | ||
|
|
35918465f8 | ||
|
|
42e52fcaa6 | ||
|
|
ba29e66292 | ||
|
|
d8cc3202c6 | ||
|
|
02ce669993 | ||
|
|
a2a24ee367 | ||
|
|
5e0f163f1f | ||
|
|
c9a6a0f0aa | ||
|
|
f5681f1167 | ||
|
|
0e4d514007 | ||
|
|
c39d8ad591 | ||
|
|
f59796e290 | ||
|
|
cff11cae5a | ||
|
|
aa3047f338 | ||
|
|
e17b8cec38 | ||
|
|
fc2ee0c32a | ||
|
|
5690eb1dc5 | ||
|
|
fbba7166ae | ||
|
|
289c3e233e | ||
|
|
6345802b1a | ||
|
|
7fb654e3a4 | ||
|
|
8b9051751a | ||
|
|
335c3f363c | ||
|
|
6dac2ce253 | ||
|
|
f34e40e1e4 | ||
|
|
fe74760177 | ||
|
|
2d5e64a21e | ||
|
|
b503965e8c | ||
|
|
a9da80687f | ||
|
|
d4dd6cf634 | ||
|
|
85d2e08dc5 | ||
|
|
0eda9e6315 | ||
|
|
eb8abcfe63 | ||
|
|
80a5c24840 | ||
|
|
5144b1ce88 | ||
|
|
10e2edac90 | ||
|
|
340081a67d | ||
|
|
3d86f67acb | ||
|
|
58440e70ac | ||
|
|
dd16ead898 | ||
|
|
bdb8bc081f | ||
|
|
9f0ca2bc53 | ||
|
|
605cf0c985 | ||
|
|
7e1838dda6 | ||
|
|
2016e9bc2f | ||
|
|
b3d1410ca9 | ||
|
|
a2d56a47cd | ||
|
|
316bbfe43d | ||
|
|
3b5351afb6 | ||
|
|
ecc4052bdc | ||
|
|
fc84012977 | ||
|
|
c882afbd2c | ||
|
|
9c3b78e18f | ||
|
|
4365e1fee5 | ||
|
|
d19e7c169e | ||
|
|
226bb6b830 | ||
|
|
da5b68490c | ||
|
|
4dc5df3923 | ||
|
|
bc7bdf040f | ||
|
|
f8968f69f0 | ||
|
|
030e89ac7d | ||
|
|
37330522a4 | ||
|
|
807bb0039a | ||
|
|
c4c260f3cf | ||
|
|
87d6c73b7b | ||
|
|
a71875eedf | ||
|
|
51e03353dd | ||
|
|
175b0d92f2 | ||
|
|
c88a77a31c | ||
|
|
a3a5e05b5c | ||
|
|
bd2db5e249 | ||
|
|
6cbc79026f | ||
|
|
0ec755911e | ||
|
|
be626ad0fc | ||
|
|
305697a5d3 | ||
|
|
d954681132 | ||
|
|
872345366a | ||
|
|
5b1aec0f0b | ||
|
|
98d063132c | ||
|
|
fcb3699e99 | ||
|
|
39658a7ee1 | ||
|
|
cbf0cc842d | ||
|
|
914723c53a | ||
|
|
db965f8144 | ||
|
|
d99a52741d | ||
|
|
96f79fac0e | ||
|
|
856c1d685e | ||
|
|
5817aea2ee | ||
|
|
6213df9fb3 | ||
|
|
4bc624ea5c | ||
|
|
7444f5cf60 | ||
|
|
cbb96f8b82 | ||
|
|
63f6840d1b | ||
|
|
62173b8f61 | ||
|
|
7f3c0c9c03 | ||
|
|
f907f76eb5 | ||
|
|
30df7a43d5 | ||
|
|
8fc9fdb7dc | ||
|
|
77905e241f |
@@ -5,8 +5,6 @@ on:
|
||||
types: [opened, edited]
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
pull_request:
|
||||
types: [opened, edited]
|
||||
pull_request_review:
|
||||
types: [submitted, edited]
|
||||
pull_request_review_comment:
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
name: Benchmark
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.ref }}'
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
COMPOSE_FILE: docker-compose.yml
|
||||
IMAGE: appwrite-dev
|
||||
CACHE_KEY: 'appwrite-dev-${{ github.event.pull_request.head.sha }}'
|
||||
'on':
|
||||
- pull_request
|
||||
jobs:
|
||||
setup:
|
||||
name: Setup & Build Appwrite Image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build Appwrite
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: '${{ env.IMAGE }}'
|
||||
load: true
|
||||
cache-from: type=gha
|
||||
cache-to: 'type=gha,mode=max'
|
||||
outputs: 'type=docker,dest=/tmp/${{ env.IMAGE }}.tar'
|
||||
target: development
|
||||
build-args: |
|
||||
DEBUG=false
|
||||
TESTING=true
|
||||
VERSION=dev
|
||||
- name: Cache Docker Image
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
key: '${{ env.CACHE_KEY }}'
|
||||
path: '/tmp/${{ env.IMAGE }}.tar'
|
||||
benchmarking:
|
||||
name: Benchmark
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Load Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
key: '${{ env.CACHE_KEY }}'
|
||||
path: '/tmp/${{ env.IMAGE }}.tar'
|
||||
fail-on-cache-miss: true
|
||||
- name: Load and Start Appwrite
|
||||
run: |
|
||||
sed -i 's/traefik/localhost/g' .env
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
docker compose up -d
|
||||
sleep 10
|
||||
- name: Install Oha
|
||||
run: |
|
||||
echo "deb [signed-by=/usr/share/keyrings/azlux-archive-keyring.gpg] http://packages.azlux.fr/debian/ stable main" | sudo tee /etc/apt/sources.list.d/azlux.list
|
||||
sudo wget -O /usr/share/keyrings/azlux-archive-keyring.gpg https://azlux.fr/repo.gpg
|
||||
sudo apt update
|
||||
sudo apt install oha
|
||||
oha --version
|
||||
- name: Benchmark PR
|
||||
run: 'oha -z 180s http://localhost/v1/health/version --output-format json > benchmark.json'
|
||||
- name: Cleaning
|
||||
run: docker compose down -v
|
||||
- name: Installing latest version
|
||||
run: |
|
||||
rm docker-compose.yml
|
||||
rm .env
|
||||
curl https://appwrite.io/install/compose -o docker-compose.yml
|
||||
curl https://appwrite.io/install/env -o .env
|
||||
sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env
|
||||
docker compose up -d
|
||||
sleep 10
|
||||
- name: Benchmark Latest
|
||||
run: oha -z 180s http://localhost/v1/health/version --output-format json > benchmark-latest.json
|
||||
- name: Prepare comment
|
||||
run: |
|
||||
echo '## :sparkles: Benchmark results' > benchmark.txt
|
||||
echo ' ' >> benchmark.txt
|
||||
echo "- Requests per second: $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt
|
||||
echo "- Requests with 200 status code: $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt
|
||||
echo "- P99 latency: $(jq -r '.latencyPercentiles.p99' benchmark.json )" >> benchmark.txt
|
||||
echo " " >> benchmark.txt
|
||||
echo " " >> benchmark.txt
|
||||
echo "## :zap: Benchmark Comparison" >> benchmark.txt
|
||||
echo " " >> benchmark.txt
|
||||
echo "| Metric | This PR | Latest version | " >> benchmark.txt
|
||||
echo "| --- | --- | --- | " >> benchmark.txt
|
||||
echo "| RPS | $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.summary.requestsPerSec|tonumber|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt
|
||||
echo "| 200 | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt
|
||||
echo "| P99 | $(jq -r '.latencyPercentiles.p99' benchmark.json ) | $(jq -r '.latencyPercentiles.p99' benchmark-latest.json ) | " >> benchmark.txt
|
||||
- name: Save results
|
||||
uses: actions/upload-artifact@v6
|
||||
if: '${{ !cancelled() }}'
|
||||
with:
|
||||
name: benchmark.json
|
||||
path: benchmark.json
|
||||
retention-days: 7
|
||||
- name: Find Comment
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: peter-evans/find-comment@v3
|
||||
id: fc
|
||||
with:
|
||||
issue-number: '${{ github.event.pull_request.number }}'
|
||||
comment-author: 'github-actions[bot]'
|
||||
body-includes: Benchmark results
|
||||
- name: Comment on PR
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
with:
|
||||
comment-id: '${{ steps.fc.outputs.comment-id }}'
|
||||
issue-number: '${{ github.event.pull_request.number }}'
|
||||
body-path: benchmark.txt
|
||||
edit-mode: replace
|
||||
@@ -1,19 +0,0 @@
|
||||
name: Check dependencies
|
||||
|
||||
# Adapted from https://google.github.io/osv-scanner/github-action/#scan-on-pull-request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, 1.*.x]
|
||||
merge_group:
|
||||
branches: [main, 1.*.x]
|
||||
|
||||
permissions:
|
||||
# Require writing security events to upload SARIF file to security tab
|
||||
security-events: write
|
||||
# Only need to read contents
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
scan-pr:
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@v1.7.1"
|
||||
@@ -0,0 +1,707 @@
|
||||
name: CI
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
COMPOSE_FILE: docker-compose.yml
|
||||
IMAGE: appwrite-dev
|
||||
CACHE_KEY: appwrite-dev-${{ github.event.pull_request.head.sha }}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
response_format:
|
||||
description: 'Response format version to test (e.g., 1.5.0, 1.4.0)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
dependencies:
|
||||
name: Checks / Dependencies
|
||||
if: github.event_name == 'pull_request'
|
||||
permissions:
|
||||
actions: read
|
||||
security-events: write
|
||||
contents: read
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@v2.3.3"
|
||||
|
||||
security:
|
||||
name: Checks / Image
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Build the Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: pr_image:${{ github.sha }}
|
||||
target: production
|
||||
|
||||
- name: Run Trivy vulnerability scanner on image
|
||||
uses: aquasecurity/trivy-action@0.35.0
|
||||
with:
|
||||
image-ref: 'pr_image:${{ github.sha }}'
|
||||
format: 'sarif'
|
||||
output: 'trivy-image-results.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
|
||||
- name: Run Trivy vulnerability scanner on source code
|
||||
uses: aquasecurity/trivy-action@0.35.0
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: '.'
|
||||
format: 'sarif'
|
||||
output: 'trivy-fs-results.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
skip-setup-trivy: true
|
||||
|
||||
- name: Upload image scan results
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
if: always() && hashFiles('trivy-image-results.sarif') != ''
|
||||
with:
|
||||
sarif_file: 'trivy-image-results.sarif'
|
||||
category: 'trivy-image'
|
||||
|
||||
- name: Upload source code scan results
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
if: always() && hashFiles('trivy-fs-results.sarif') != ''
|
||||
with:
|
||||
sarif_file: 'trivy-fs-results.sarif'
|
||||
category: 'trivy-source'
|
||||
|
||||
composer:
|
||||
name: Checks / Composer
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
|
||||
- name: Validate
|
||||
run: composer validate
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress --ignore-platform-reqs
|
||||
|
||||
- name: Audit
|
||||
env:
|
||||
COMPOSER_NO_AUDIT: 0
|
||||
run: composer audit
|
||||
|
||||
format:
|
||||
name: Checks / Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- run: git checkout HEAD^2
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress --ignore-platform-reqs
|
||||
|
||||
- name: Run Linter
|
||||
run: composer lint
|
||||
|
||||
analyze:
|
||||
name: Checks / Analyze
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress --ignore-platform-reqs
|
||||
|
||||
- name: Run PHPStan
|
||||
run: composer analyze
|
||||
|
||||
locale:
|
||||
name: Checks / Locale
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Run Locale check
|
||||
run: node .github/workflows/static-analysis/locale/index.js
|
||||
|
||||
matrix:
|
||||
name: Tests / Matrix
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
databases: ${{ steps.generate.outputs.databases }}
|
||||
modes: ${{ steps.generate.outputs.modes }}
|
||||
steps:
|
||||
- name: Generate matrix
|
||||
id: generate
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const allDatabases = ['MariaDB', 'PostgreSQL', 'MongoDB'];
|
||||
const allModes = ['dedicated', 'shared_v1', 'shared_v2'];
|
||||
|
||||
const defaultDatabases = ['MongoDB'];
|
||||
const defaultModes = ['dedicated'];
|
||||
|
||||
const pr = context.payload.pull_request;
|
||||
if (!pr) {
|
||||
core.setOutput('databases', JSON.stringify(allDatabases));
|
||||
core.setOutput('modes', JSON.stringify(allModes));
|
||||
return;
|
||||
}
|
||||
|
||||
const getContent = (ref) => github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: 'composer.lock',
|
||||
ref,
|
||||
});
|
||||
|
||||
const getDbVersion = (lock) => lock.packages?.find(p => p.name === 'utopia-php/database')?.version;
|
||||
|
||||
const [{ data: base }, { data: head }] = await Promise.all([
|
||||
getContent(pr.base.sha),
|
||||
getContent(pr.head.sha),
|
||||
]);
|
||||
|
||||
const decode = (content) => JSON.parse(Buffer.from(content, 'base64').toString());
|
||||
const databaseChanged = getDbVersion(decode(base.content)) !== getDbVersion(decode(head.content));
|
||||
|
||||
core.setOutput('databases', JSON.stringify(databaseChanged ? allDatabases : defaultDatabases));
|
||||
core.setOutput('modes', JSON.stringify(databaseChanged ? allModes : defaultModes));
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build Appwrite
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: ${{ env.IMAGE }}
|
||||
load: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
outputs: type=docker,dest=/tmp/${{ env.IMAGE }}.tar
|
||||
target: development
|
||||
build-args: |
|
||||
DEBUG=false
|
||||
TESTING=true
|
||||
VERSION=dev
|
||||
|
||||
- name: Cache Docker Image
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
key: ${{ env.CACHE_KEY }}
|
||||
path: /tmp/${{ env.IMAGE }}.tar
|
||||
|
||||
unit:
|
||||
name: Tests / Unit
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Load Cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
key: ${{ env.CACHE_KEY }}
|
||||
path: /tmp/${{ env.IMAGE }}.tar
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Load and Start Appwrite
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
docker compose pull --quiet --ignore-buildable
|
||||
docker compose up -d --quiet-pull --wait
|
||||
|
||||
- name: Environment Variables
|
||||
run: docker compose exec -T appwrite vars
|
||||
|
||||
- name: Run Unit Tests
|
||||
uses: itznotabug/php-retry@v3
|
||||
with:
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 60
|
||||
timeout_minutes: 15
|
||||
job_id: ${{ job.check_run_id }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
test_dir: tests/unit
|
||||
command: >-
|
||||
docker compose exec
|
||||
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}"
|
||||
appwrite test /usr/src/code/tests/unit
|
||||
|
||||
e2e_general:
|
||||
name: Tests / E2E / General
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Load Cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
key: ${{ env.CACHE_KEY }}
|
||||
path: /tmp/${{ env.IMAGE }}.tar
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Load and Start Appwrite
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
docker compose pull --quiet --ignore-buildable
|
||||
docker compose up -d --quiet-pull --wait
|
||||
|
||||
- name: Wait for Open Runtimes
|
||||
timeout-minutes: 3
|
||||
run: |
|
||||
while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do
|
||||
echo "Waiting for Executor to come online"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run General Tests
|
||||
uses: itznotabug/php-retry@v3
|
||||
with:
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 60
|
||||
timeout_minutes: 15
|
||||
job_id: ${{ job.check_run_id }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
test_dir: tests/e2e/General
|
||||
command: >-
|
||||
docker compose exec -T
|
||||
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}"
|
||||
appwrite test /usr/src/code/tests/e2e/General
|
||||
|
||||
- name: Failure Logs
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== Appwrite Logs ==="
|
||||
docker compose logs
|
||||
|
||||
e2e_service:
|
||||
name: Tests / E2E / ${{ matrix.database }} (${{ matrix.mode }}) / ${{ matrix.service }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, matrix]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
database: ${{ fromJSON(needs.matrix.outputs.databases) }}
|
||||
mode: ${{ fromJSON(needs.matrix.outputs.modes) }}
|
||||
service: [
|
||||
Account,
|
||||
Avatars,
|
||||
Console,
|
||||
Databases,
|
||||
TablesDB,
|
||||
Functions,
|
||||
FunctionsSchedule,
|
||||
GraphQL,
|
||||
Health,
|
||||
Locale,
|
||||
Projects,
|
||||
Realtime,
|
||||
Sites,
|
||||
Proxy,
|
||||
Storage,
|
||||
Tokens,
|
||||
Teams,
|
||||
Users,
|
||||
ProjectWebhooks,
|
||||
Webhooks,
|
||||
VCS,
|
||||
Messaging,
|
||||
Migrations
|
||||
]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Load Cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
key: ${{ env.CACHE_KEY }}
|
||||
path: /tmp/${{ env.IMAGE }}.tar
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Set database environment
|
||||
run: |
|
||||
if [ "${{ matrix.database }}" = "MariaDB" ]; then
|
||||
echo "COMPOSE_PROFILES=mariadb" >> $GITHUB_ENV
|
||||
echo "_APP_DB_ADAPTER=mariadb" >> $GITHUB_ENV
|
||||
echo "_APP_DB_HOST=mariadb" >> $GITHUB_ENV
|
||||
echo "_APP_DB_PORT=3306" >> $GITHUB_ENV
|
||||
elif [ "${{ matrix.database }}" = "MongoDB" ]; then
|
||||
echo "COMPOSE_PROFILES=mongodb" >> $GITHUB_ENV
|
||||
echo "_APP_DB_ADAPTER=mongodb" >> $GITHUB_ENV
|
||||
echo "_APP_DB_HOST=mongodb" >> $GITHUB_ENV
|
||||
echo "_APP_DB_PORT=27017" >> $GITHUB_ENV
|
||||
elif [ "${{ matrix.database }}" = "PostgreSQL" ]; then
|
||||
echo "COMPOSE_PROFILES=postgresql" >> $GITHUB_ENV
|
||||
echo "_APP_DB_ADAPTER=postgresql" >> $GITHUB_ENV
|
||||
echo "_APP_DB_HOST=postgresql" >> $GITHUB_ENV
|
||||
echo "_APP_DB_PORT=5432" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Load and Start Appwrite
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
_APP_BROWSER_HOST: http://invalid-browser/v1
|
||||
_APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
|
||||
_APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }}
|
||||
run: |
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
docker compose pull --quiet --ignore-buildable
|
||||
docker compose up -d --quiet-pull --wait
|
||||
|
||||
- name: Wait for Open Runtimes
|
||||
timeout-minutes: 3
|
||||
run: |
|
||||
while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do
|
||||
echo "Waiting for Executor to come online"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run tests
|
||||
uses: itznotabug/php-retry@v3
|
||||
with:
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 60
|
||||
timeout_minutes: 20
|
||||
job_id: ${{ job.check_run_id }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
test_dir: tests/e2e/Services/${{ matrix.service }}
|
||||
command: |
|
||||
SERVICE_PATH="/usr/src/code/tests/e2e/Services/${{ matrix.service }}"
|
||||
|
||||
# Services that rely on sequential test method execution (shared static state)
|
||||
FUNCTIONAL_FLAG="--functional"
|
||||
case "${{ matrix.service }}" in
|
||||
Databases|TablesDB|Functions|Realtime) FUNCTIONAL_FLAG="" ;;
|
||||
esac
|
||||
|
||||
docker compose exec -T \
|
||||
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
|
||||
appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml
|
||||
|
||||
- name: Failure Logs
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== Appwrite Logs ==="
|
||||
docker compose logs
|
||||
|
||||
e2e_abuse:
|
||||
name: Tests / E2E / Abuse (${{ matrix.mode }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, matrix]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mode: ${{ fromJSON(needs.matrix.outputs.modes) }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Load Cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
key: ${{ env.CACHE_KEY }}
|
||||
path: /tmp/${{ env.IMAGE }}.tar
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Load and Start Appwrite
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
_APP_OPTIONS_ABUSE: enabled
|
||||
_APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
|
||||
_APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }}
|
||||
run: |
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
docker compose pull --quiet --ignore-buildable
|
||||
docker compose up -d --quiet-pull --wait
|
||||
|
||||
- name: Run tests
|
||||
uses: itznotabug/php-retry@v3
|
||||
with:
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 60
|
||||
timeout_minutes: 15
|
||||
job_id: ${{ job.check_run_id }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
test_dir: tests/e2e
|
||||
command: >-
|
||||
docker compose exec -T
|
||||
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}"
|
||||
appwrite test /usr/src/code/tests/e2e --group=abuseEnabled
|
||||
|
||||
- name: Failure Logs
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== Appwrite Logs ==="
|
||||
docker compose logs
|
||||
|
||||
e2e_screenshots:
|
||||
name: Tests / E2E / Screenshots (${{ matrix.mode }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, matrix]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mode: ${{ fromJSON(needs.matrix.outputs.modes) }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Load Cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
key: ${{ env.CACHE_KEY }}
|
||||
path: /tmp/${{ env.IMAGE }}.tar
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Load and Start Appwrite
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
_APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }}
|
||||
_APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }}
|
||||
run: |
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
docker compose pull --quiet --ignore-buildable
|
||||
docker compose up -d --quiet-pull --wait
|
||||
|
||||
- name: Wait for Open Runtimes
|
||||
timeout-minutes: 3
|
||||
run: |
|
||||
while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do
|
||||
echo "Waiting for Executor to come online"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run tests
|
||||
uses: itznotabug/php-retry@v3
|
||||
with:
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 60
|
||||
timeout_minutes: 15
|
||||
job_id: ${{ job.check_run_id }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
test_dir: tests/e2e/Services/Sites
|
||||
command: >-
|
||||
docker compose exec -T
|
||||
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}"
|
||||
appwrite test /usr/src/code/tests/e2e/Services/Sites --group=screenshots
|
||||
|
||||
- name: Failure Logs
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== Appwrite Logs ==="
|
||||
docker compose logs
|
||||
|
||||
benchmark:
|
||||
name: Benchmark
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Load Cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
key: ${{ env.CACHE_KEY }}
|
||||
path: /tmp/${{ env.IMAGE }}.tar
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Load and Start Appwrite
|
||||
run: |
|
||||
sed -i 's/traefik/localhost/g' .env
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
docker compose up -d
|
||||
sleep 10
|
||||
|
||||
- name: Install Oha
|
||||
run: |
|
||||
echo "deb [signed-by=/usr/share/keyrings/azlux-archive-keyring.gpg] http://packages.azlux.fr/debian/ stable main" | sudo tee /etc/apt/sources.list.d/azlux.list
|
||||
sudo wget -O /usr/share/keyrings/azlux-archive-keyring.gpg https://azlux.fr/repo.gpg
|
||||
sudo apt update
|
||||
sudo apt install oha
|
||||
oha --version
|
||||
|
||||
- name: Benchmark PR
|
||||
run: 'oha -z 180s http://localhost/v1/health/version --output-format json > benchmark.json'
|
||||
|
||||
- name: Cleaning
|
||||
run: docker compose down -v
|
||||
|
||||
- name: Installing latest version
|
||||
run: |
|
||||
rm docker-compose.yml
|
||||
rm .env
|
||||
curl https://appwrite.io/install/compose -o docker-compose.yml
|
||||
curl https://appwrite.io/install/env -o .env
|
||||
sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env
|
||||
docker compose up -d
|
||||
sleep 10
|
||||
|
||||
- name: Benchmark Latest
|
||||
run: oha -z 180s http://localhost/v1/health/version --output-format json > benchmark-latest.json
|
||||
|
||||
- name: Prepare comment
|
||||
run: |
|
||||
echo '## :sparkles: Benchmark results' > benchmark.txt
|
||||
echo ' ' >> benchmark.txt
|
||||
echo "- Requests per second: $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt
|
||||
echo "- Requests with 200 status code: $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt
|
||||
echo "- P99 latency: $(jq -r '.latencyPercentiles.p99' benchmark.json )" >> benchmark.txt
|
||||
echo " " >> benchmark.txt
|
||||
echo " " >> benchmark.txt
|
||||
echo "## :zap: Benchmark Comparison" >> benchmark.txt
|
||||
echo " " >> benchmark.txt
|
||||
echo "| Metric | This PR | Latest version | " >> benchmark.txt
|
||||
echo "| --- | --- | --- | " >> benchmark.txt
|
||||
echo "| RPS | $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.summary.requestsPerSec|tonumber|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt
|
||||
echo "| 200 | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt
|
||||
echo "| P99 | $(jq -r '.latencyPercentiles.p99' benchmark.json ) | $(jq -r '.latencyPercentiles.p99' benchmark-latest.json ) | " >> benchmark.txt
|
||||
|
||||
- name: Save results
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: benchmark.json
|
||||
path: benchmark.json
|
||||
retention-days: 7
|
||||
|
||||
- name: Find Comment
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: peter-evans/find-comment@v3
|
||||
id: fc
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-author: 'github-actions[bot]'
|
||||
body-includes: Benchmark results
|
||||
|
||||
- name: Comment on PR
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
with:
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body-path: benchmark.txt
|
||||
edit-mode: replace
|
||||
@@ -1,28 +0,0 @@
|
||||
name: "Linter"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on: [pull_request]
|
||||
jobs:
|
||||
lint:
|
||||
name: Linter
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- run: git checkout HEAD^2
|
||||
|
||||
- name: Validate composer.json and composer.lock
|
||||
run: |
|
||||
docker run --rm -v $PWD:/app composer:2.8 sh -c \
|
||||
"composer validate"
|
||||
- name: Run Linter
|
||||
run: |
|
||||
docker run --rm -v $PWD:/app composer:2.8 sh -c \
|
||||
"composer install --profile --ignore-platform-reqs && composer lint"
|
||||
@@ -1,106 +0,0 @@
|
||||
name: PR Security Scan
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Build the Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: pr_image:${{ github.sha }}
|
||||
target: production
|
||||
|
||||
- name: Run Trivy vulnerability scanner on image
|
||||
uses: aquasecurity/trivy-action@0.20.0
|
||||
with:
|
||||
image-ref: 'pr_image:${{ github.sha }}'
|
||||
format: 'json'
|
||||
output: 'trivy-image-results.json'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
|
||||
- name: Run Trivy vulnerability scanner on source code
|
||||
uses: aquasecurity/trivy-action@0.20.0
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: '.'
|
||||
format: 'json'
|
||||
output: 'trivy-fs-results.json'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
|
||||
- name: Process Trivy scan results
|
||||
id: process-results
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
let commentBody = '## Security Scan Results for PR\n\n';
|
||||
|
||||
function processResults(results, title) {
|
||||
let sectionBody = `### ${title}\n\n`;
|
||||
if (results.Results && results.Results.some(result => result.Vulnerabilities && result.Vulnerabilities.length > 0)) {
|
||||
sectionBody += '| Package | Version | Vulnerability | Severity |\n';
|
||||
sectionBody += '|---------|---------|----------------|----------|\n';
|
||||
|
||||
const uniqueVulns = new Set();
|
||||
results.Results.forEach(result => {
|
||||
if (result.Vulnerabilities) {
|
||||
result.Vulnerabilities.forEach(vuln => {
|
||||
const vulnKey = `${vuln.PkgName}-${vuln.InstalledVersion}-${vuln.VulnerabilityID}`;
|
||||
if (!uniqueVulns.has(vulnKey)) {
|
||||
uniqueVulns.add(vulnKey);
|
||||
sectionBody += `| ${vuln.PkgName} | ${vuln.InstalledVersion} | [${vuln.VulnerabilityID}](https://nvd.nist.gov/vuln/detail/${vuln.VulnerabilityID}) | ${vuln.Severity} |\n`;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
sectionBody += '🎉 No vulnerabilities found!\n';
|
||||
}
|
||||
return sectionBody;
|
||||
}
|
||||
|
||||
try {
|
||||
const imageResults = JSON.parse(fs.readFileSync('trivy-image-results.json', 'utf8'));
|
||||
const fsResults = JSON.parse(fs.readFileSync('trivy-fs-results.json', 'utf8'));
|
||||
|
||||
commentBody += processResults(imageResults, "Docker Image Scan Results");
|
||||
commentBody += '\n';
|
||||
commentBody += processResults(fsResults, "Source Code Scan Results");
|
||||
|
||||
} catch (error) {
|
||||
commentBody += `There was an error while running the security scan: ${error.message}\n`;
|
||||
commentBody += 'Please contact the core team for assistance.';
|
||||
}
|
||||
|
||||
core.setOutput('comment-body', commentBody);
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@v3
|
||||
id: fc
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-author: 'github-actions[bot]'
|
||||
body-includes: Security Scan Results for PR
|
||||
|
||||
- name: Create or update comment
|
||||
uses: peter-evans/create-or-update-comment@v3
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
body: ${{ steps.process-results.outputs.comment-body }}
|
||||
edit-mode: replace
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: "This issue has been labeled as a 'question', indicating that it requires additional information from the requestor. It has been inactive for 7 days. If no further activity occurs, this issue will be closed in 14 days."
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
name: "Static code analysis"
|
||||
|
||||
on: [pull_request]
|
||||
jobs:
|
||||
lint:
|
||||
name: CodeQL
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Run CodeQL
|
||||
run: |
|
||||
docker run --rm -v $PWD:/app composer:2.8 sh -c \
|
||||
"composer install --profile --ignore-platform-reqs && composer check"
|
||||
|
||||
- name: Run Locale check
|
||||
run: |
|
||||
docker run --rm -v $PWD:/app node:24-alpine sh -c \
|
||||
"cd /app/.github/workflows/static-analysis/locale && node index.js"
|
||||
@@ -1,691 +0,0 @@
|
||||
name: "Tests"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
COMPOSE_FILE: docker-compose.yml
|
||||
IMAGE: appwrite-dev
|
||||
CACHE_KEY: appwrite-dev-${{ github.event.pull_request.head.sha }}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
response_format:
|
||||
description: 'Response format version to test (e.g., 1.5.0, 1.4.0)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
check_database_changes:
|
||||
name: Check if utopia-php/database changed
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
database_changed: ${{ steps.check.outputs.database_changed }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Fetch base branch
|
||||
run: git fetch origin ${{ github.event.pull_request.base.ref }}
|
||||
|
||||
- name: Check for utopia-php/database changes
|
||||
id: check
|
||||
run: |
|
||||
if git diff origin/${{ github.event.pull_request.base.ref }} HEAD -- composer.lock | grep -q '"name": "utopia-php/database"'; then
|
||||
echo "Database version changed, going to run all mode tests."
|
||||
echo "database_changed=true" >> "$GITHUB_ENV"
|
||||
echo "database_changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "database_changed=false" >> "$GITHUB_ENV"
|
||||
echo "database_changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
setup:
|
||||
name: Setup & Build Appwrite Image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Appwrite
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: ${{ env.IMAGE }}
|
||||
load: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
outputs: type=docker,dest=/tmp/${{ env.IMAGE }}.tar
|
||||
target: development
|
||||
build-args: |
|
||||
DEBUG=false
|
||||
TESTING=true
|
||||
VERSION=dev
|
||||
|
||||
- name: Cache Docker Image
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
key: ${{ env.CACHE_KEY }}
|
||||
path: /tmp/${{ env.IMAGE }}.tar
|
||||
|
||||
unit_test:
|
||||
name: Unit Test
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Load Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
key: ${{ env.CACHE_KEY }}
|
||||
path: /tmp/${{ env.IMAGE }}.tar
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Load and Start Appwrite
|
||||
timeout-minutes: 3
|
||||
run: |
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
docker compose up -d
|
||||
until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
|
||||
echo "Waiting for Appwrite to be ready..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Environment Variables
|
||||
run: docker compose exec -T appwrite vars
|
||||
|
||||
- name: Run Unit Tests
|
||||
uses: itznotabug/php-retry@v3
|
||||
with:
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 300
|
||||
timeout_minutes: 15
|
||||
job_id: ${{ job.check_run_id }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
test_dir: tests/unit
|
||||
command: >-
|
||||
docker compose exec
|
||||
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}"
|
||||
appwrite test /usr/src/code/tests/unit
|
||||
|
||||
e2e_general_test:
|
||||
name: E2E General Test
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Load Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
key: ${{ env.CACHE_KEY }}
|
||||
path: /tmp/${{ env.IMAGE }}.tar
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Load and Start Appwrite
|
||||
timeout-minutes: 3
|
||||
run: |
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
docker compose up -d
|
||||
until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
|
||||
echo "Waiting for Appwrite to be ready..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Wait for Open Runtimes
|
||||
timeout-minutes: 3
|
||||
run: |
|
||||
while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do
|
||||
echo "Waiting for Executor to come online"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run General Tests
|
||||
uses: itznotabug/php-retry@v3
|
||||
with:
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 300
|
||||
timeout_minutes: 15
|
||||
job_id: ${{ job.check_run_id }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
test_dir: tests/e2e/General
|
||||
command: >-
|
||||
docker compose exec -T
|
||||
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}"
|
||||
appwrite test /usr/src/code/tests/e2e/General --debug
|
||||
|
||||
- name: Failure Logs
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== Appwrite Logs ==="
|
||||
docker compose logs
|
||||
|
||||
e2e_service_test:
|
||||
name: E2E Service Test
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
db_adapter: [
|
||||
MARIADB,
|
||||
POSTGRESQL,
|
||||
MONGODB
|
||||
]
|
||||
service: [
|
||||
Account,
|
||||
Avatars,
|
||||
Console,
|
||||
Databases,
|
||||
Functions,
|
||||
FunctionsSchedule,
|
||||
GraphQL,
|
||||
Health,
|
||||
Locale,
|
||||
Projects,
|
||||
Realtime,
|
||||
Sites,
|
||||
Proxy,
|
||||
Storage,
|
||||
Tokens,
|
||||
Teams,
|
||||
Users,
|
||||
Webhooks,
|
||||
VCS,
|
||||
Messaging,
|
||||
Migrations
|
||||
]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Load Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
key: ${{ env.CACHE_KEY }}
|
||||
path: /tmp/${{ env.IMAGE }}.tar
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Set DB Adapter environment
|
||||
id: set-db-env
|
||||
run: |
|
||||
DB_ADAPTER_LOWER=$(echo "${{ matrix.db_adapter }}" | tr 'A-Z' 'a-z')
|
||||
echo "COMPOSE_PROFILES=${DB_ADAPTER_LOWER}" >> $GITHUB_ENV
|
||||
|
||||
if [ "${{ matrix.db_adapter }}" = "MARIADB" ]; then
|
||||
echo "_APP_DB_ADAPTER=mariadb" >> $GITHUB_ENV
|
||||
echo "_APP_DB_HOST=mariadb" >> $GITHUB_ENV
|
||||
echo "_APP_DB_PORT=3306" >> $GITHUB_ENV
|
||||
elif [ "${{ matrix.db_adapter }}" = "MONGODB" ]; then
|
||||
echo "_APP_DB_ADAPTER=mongodb" >> $GITHUB_ENV
|
||||
echo "_APP_DB_HOST=mongodb" >> $GITHUB_ENV
|
||||
echo "_APP_DB_PORT=27017" >> $GITHUB_ENV
|
||||
elif [ "${{ matrix.db_adapter }}" = "POSTGRESQL" ]; then
|
||||
echo "_APP_DB_ADAPTER=postgresql" >> $GITHUB_ENV
|
||||
echo "_APP_DB_HOST=postgresql" >> $GITHUB_ENV
|
||||
echo "_APP_DB_PORT=5432" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Load and Start Appwrite
|
||||
timeout-minutes: 3
|
||||
env:
|
||||
_APP_BROWSER_HOST: http://invalid-browser/v1
|
||||
run: |
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
docker compose up -d
|
||||
until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
|
||||
echo "Waiting for Appwrite to be ready..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Wait for Open Runtimes
|
||||
timeout-minutes: 3
|
||||
run: |
|
||||
while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do
|
||||
echo "Waiting for Executor to come online"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run ${{ matrix.service }} tests with Project table mode
|
||||
uses: itznotabug/php-retry@v3
|
||||
with:
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 300
|
||||
timeout_minutes: 20
|
||||
job_id: ${{ job.check_run_id }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
test_dir: tests/e2e/Services/${{ matrix.service }}
|
||||
command: |
|
||||
echo "Using project tables"
|
||||
SERVICE_PATH="/usr/src/code/tests/e2e/Services/${{ matrix.service }}"
|
||||
|
||||
# Services that rely on sequential test method execution (shared static state)
|
||||
FUNCTIONAL_FLAG="--functional"
|
||||
case "${{ matrix.service }}" in
|
||||
Databases|Functions|Realtime) FUNCTIONAL_FLAG="" ;;
|
||||
esac
|
||||
|
||||
echo "Running with paratest (parallel) for: ${{ matrix.service }} ${FUNCTIONAL_FLAG:+(functional)}"
|
||||
docker compose exec -T \
|
||||
-e _APP_DATABASE_SHARED_TABLES="" \
|
||||
-e _APP_DATABASE_SHARED_TABLES_V1="" \
|
||||
-e _APP_DB_ADAPTER="${{ env._APP_DB_ADAPTER }}" \
|
||||
-e _APP_DB_HOST="${{ env._APP_DB_HOST }}" \
|
||||
-e _APP_DB_PORT="${{ env._APP_DB_PORT }}" \
|
||||
-e _APP_DB_SCHEMA=appwrite \
|
||||
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
|
||||
appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --exclude-group ciIgnore --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml
|
||||
|
||||
- name: Failure Logs
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== Appwrite Logs ==="
|
||||
docker compose logs
|
||||
|
||||
e2e_shared_mode_test:
|
||||
name: E2E Shared Mode Service Test
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ setup, check_database_changes ]
|
||||
if: needs.check_database_changes.outputs.database_changed == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
service:
|
||||
[
|
||||
Account,
|
||||
Avatars,
|
||||
Console,
|
||||
Databases,
|
||||
Functions,
|
||||
FunctionsSchedule,
|
||||
GraphQL,
|
||||
Health,
|
||||
Locale,
|
||||
Projects,
|
||||
Realtime,
|
||||
Sites,
|
||||
Proxy,
|
||||
Storage,
|
||||
Teams,
|
||||
Users,
|
||||
Webhooks,
|
||||
VCS,
|
||||
Messaging,
|
||||
Migrations,
|
||||
Tokens
|
||||
]
|
||||
tables-mode: [
|
||||
'Shared V1',
|
||||
'Shared V2',
|
||||
]
|
||||
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Load Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
key: ${{ env.CACHE_KEY }}
|
||||
path: /tmp/${{ env.IMAGE }}.tar
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Load and Start Appwrite
|
||||
timeout-minutes: 3
|
||||
run: |
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
docker compose up -d
|
||||
until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
|
||||
echo "Waiting for Appwrite to be ready..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Wait for Open Runtimes
|
||||
timeout-minutes: 3
|
||||
run: |
|
||||
while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do
|
||||
echo "Waiting for Executor to come online"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run ${{ matrix.service }} tests with ${{ matrix.tables-mode }} table mode
|
||||
uses: itznotabug/php-retry@v3
|
||||
with:
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 300
|
||||
timeout_minutes: 20
|
||||
job_id: ${{ job.check_run_id }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
test_dir: tests/e2e/Services/${{ matrix.service }}
|
||||
command: |
|
||||
if [ "${{ matrix.tables-mode }}" == "Shared V1" ]; then
|
||||
echo "Using shared tables V1"
|
||||
export _APP_DATABASE_SHARED_TABLES=database_db_main
|
||||
export _APP_DATABASE_SHARED_TABLES_V1=database_db_main
|
||||
elif [ "${{ matrix.tables-mode }}" == "Shared V2" ]; then
|
||||
echo "Using shared tables V2"
|
||||
export _APP_DATABASE_SHARED_TABLES=database_db_main
|
||||
export _APP_DATABASE_SHARED_TABLES_V1=
|
||||
fi
|
||||
|
||||
SERVICE_PATH="/usr/src/code/tests/e2e/Services/${{ matrix.service }}"
|
||||
|
||||
# Services that rely on sequential test method execution (shared static state)
|
||||
FUNCTIONAL_FLAG="--functional"
|
||||
case "${{ matrix.service }}" in
|
||||
Databases|Functions|Realtime) FUNCTIONAL_FLAG="" ;;
|
||||
esac
|
||||
|
||||
docker compose exec -T \
|
||||
-e _APP_DATABASE_SHARED_TABLES \
|
||||
-e _APP_DATABASE_SHARED_TABLES_V1 \
|
||||
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
|
||||
appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --exclude-group ciIgnore --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml
|
||||
|
||||
- name: Failure Logs
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== Appwrite Worker Builds Logs ==="
|
||||
docker compose logs appwrite-worker-builds
|
||||
echo "=== OpenRuntimes Executor Logs ==="
|
||||
docker compose logs openruntimes-executor
|
||||
|
||||
e2e_abuse_enabled:
|
||||
name: E2E Service Test (Abuse enabled)
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Load Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
key: ${{ env.CACHE_KEY }}
|
||||
path: /tmp/${{ env.IMAGE }}.tar
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Load and Start Appwrite
|
||||
timeout-minutes: 3
|
||||
run: |
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
sed -i 's/_APP_OPTIONS_ABUSE=disabled/_APP_OPTIONS_ABUSE=enabled/' .env
|
||||
docker compose up -d
|
||||
until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
|
||||
echo "Waiting for Appwrite to be ready..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Run Projects tests in dedicated table mode
|
||||
uses: itznotabug/php-retry@v3
|
||||
with:
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 300
|
||||
timeout_minutes: 15
|
||||
job_id: ${{ job.check_run_id }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
test_dir: tests/e2e/Services/Projects
|
||||
command: |
|
||||
echo "Using project tables"
|
||||
export _APP_DATABASE_SHARED_TABLES=
|
||||
export _APP_DATABASE_SHARED_TABLES_V1=
|
||||
|
||||
docker compose exec -T \
|
||||
-e _APP_DATABASE_SHARED_TABLES \
|
||||
-e _APP_DATABASE_SHARED_TABLES_V1 \
|
||||
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
|
||||
appwrite test /usr/src/code/tests/e2e/Services/Projects --debug --group=abuseEnabled
|
||||
|
||||
- name: Failure Logs
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== Appwrite Worker Builds Logs ==="
|
||||
docker compose logs appwrite-worker-builds
|
||||
echo "=== OpenRuntimes Executor Logs ==="
|
||||
docker compose logs openruntimes-executor
|
||||
|
||||
e2e_abuse_enabled_shared_mode:
|
||||
name: E2E Shared Mode Service Test (Abuse enabled)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ setup, check_database_changes ]
|
||||
if: needs.check_database_changes.outputs.database_changed == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
tables-mode: [
|
||||
'Shared V1',
|
||||
'Shared V2',
|
||||
]
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Load Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
key: ${{ env.CACHE_KEY }}
|
||||
path: /tmp/${{ env.IMAGE }}.tar
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Load and Start Appwrite
|
||||
timeout-minutes: 3
|
||||
run: |
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
sed -i 's/_APP_OPTIONS_ABUSE=disabled/_APP_OPTIONS_ABUSE=enabled/' .env
|
||||
docker compose up -d
|
||||
until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
|
||||
echo "Waiting for Appwrite to be ready..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Run Projects tests in ${{ matrix.tables-mode }} table mode
|
||||
uses: itznotabug/php-retry@v3
|
||||
with:
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 300
|
||||
timeout_minutes: 15
|
||||
job_id: ${{ job.check_run_id }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
test_dir: tests/e2e/Services/Projects
|
||||
command: |
|
||||
if [ "${{ matrix.tables-mode }}" == "Shared V1" ]; then
|
||||
echo "Using shared tables V1"
|
||||
export _APP_DATABASE_SHARED_TABLES=database_db_main
|
||||
export _APP_DATABASE_SHARED_TABLES_V1=database_db_main
|
||||
elif [ "${{ matrix.tables-mode }}" == "Shared V2" ]; then
|
||||
echo "Using shared tables V2"
|
||||
export _APP_DATABASE_SHARED_TABLES=database_db_main
|
||||
export _APP_DATABASE_SHARED_TABLES_V1=
|
||||
fi
|
||||
|
||||
docker compose exec -T \
|
||||
-e _APP_DATABASE_SHARED_TABLES \
|
||||
-e _APP_DATABASE_SHARED_TABLES_V1 \
|
||||
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
|
||||
appwrite test /usr/src/code/tests/e2e/Services/Projects --debug --group=abuseEnabled
|
||||
|
||||
- name: Failure Logs
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== Appwrite Worker Builds Logs ==="
|
||||
docker compose logs appwrite-worker-builds
|
||||
echo "=== OpenRuntimes Executor Logs ==="
|
||||
docker compose logs openruntimes-executor
|
||||
|
||||
e2e_screenshots:
|
||||
name: E2E Service Test (Site Screenshots)
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Load Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
key: ${{ env.CACHE_KEY }}
|
||||
path: /tmp/${{ env.IMAGE }}.tar
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Load and Start Appwrite
|
||||
timeout-minutes: 3
|
||||
run: |
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
sed -i 's/_APP_OPTIONS_ABUSE=disabled/_APP_OPTIONS_ABUSE=enabled/' .env
|
||||
docker compose up -d
|
||||
until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
|
||||
echo "Waiting for Appwrite to be ready..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Wait for Open Runtimes
|
||||
timeout-minutes: 3
|
||||
run: |
|
||||
while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do
|
||||
echo "Waiting for Executor to come online"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run Site tests with browser connected in dedicated table mode
|
||||
uses: itznotabug/php-retry@v3
|
||||
with:
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 300
|
||||
timeout_minutes: 15
|
||||
job_id: ${{ job.check_run_id }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
test_dir: tests/e2e/Services/Sites
|
||||
command: |
|
||||
echo "Keeping original value of _APP_BROWSER_HOST"
|
||||
echo "Using project tables"
|
||||
export _APP_DATABASE_SHARED_TABLES=
|
||||
export _APP_DATABASE_SHARED_TABLES_V1=
|
||||
|
||||
docker compose exec -T \
|
||||
-e _APP_DATABASE_SHARED_TABLES \
|
||||
-e _APP_DATABASE_SHARED_TABLES_V1 \
|
||||
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
|
||||
appwrite test /usr/src/code/tests/e2e/Services/Sites --debug --group=screenshots
|
||||
|
||||
- name: Failure Logs
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== Appwrite Worker Builds Logs ==="
|
||||
docker compose logs appwrite-worker-builds
|
||||
echo "=== OpenRuntimes Executor Logs ==="
|
||||
docker compose logs openruntimes-executor
|
||||
|
||||
e2e_screenshots_shared_mode:
|
||||
name: E2E Shared Mode Service Test (Site Screenshots)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ setup, check_database_changes ]
|
||||
if: needs.check_database_changes.outputs.database_changed == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
tables-mode: [
|
||||
'Shared V1',
|
||||
'Shared V2',
|
||||
]
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Load Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
key: ${{ env.CACHE_KEY }}
|
||||
path: /tmp/${{ env.IMAGE }}.tar
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Load and Start Appwrite
|
||||
timeout-minutes: 3
|
||||
run: |
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
sed -i 's/_APP_OPTIONS_ABUSE=disabled/_APP_OPTIONS_ABUSE=enabled/' .env
|
||||
docker compose up -d
|
||||
until docker compose exec -T appwrite doctor > /dev/null 2>&1; do
|
||||
echo "Waiting for Appwrite to be ready..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Wait for Open Runtimes
|
||||
timeout-minutes: 3
|
||||
run: |
|
||||
while ! docker compose logs openruntimes-executor | grep -q "Executor is ready."; do
|
||||
echo "Waiting for Executor to come online"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run Site tests with browser connected in ${{ matrix.tables-mode }} table mode
|
||||
uses: itznotabug/php-retry@v3
|
||||
with:
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 300
|
||||
timeout_minutes: 15
|
||||
job_id: ${{ job.check_run_id }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
test_dir: tests/e2e/Services/Sites
|
||||
command: |
|
||||
echo "Keeping original value of _APP_BROWSER_HOST"
|
||||
if [ "${{ matrix.tables-mode }}" == "Shared V1" ]; then
|
||||
echo "Using shared tables V1"
|
||||
export _APP_DATABASE_SHARED_TABLES=database_db_main
|
||||
export _APP_DATABASE_SHARED_TABLES_V1=database_db_main
|
||||
elif [ "${{ matrix.tables-mode }}" == "Shared V2" ]; then
|
||||
echo "Using shared tables V2"
|
||||
export _APP_DATABASE_SHARED_TABLES=database_db_main
|
||||
export _APP_DATABASE_SHARED_TABLES_V1=
|
||||
fi
|
||||
|
||||
docker compose exec -T \
|
||||
-e _APP_DATABASE_SHARED_TABLES \
|
||||
-e _APP_DATABASE_SHARED_TABLES_V1 \
|
||||
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
|
||||
appwrite test /usr/src/code/tests/e2e/Services/Sites --debug --group=screenshots
|
||||
|
||||
- name: Failure Logs
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== Appwrite Worker Builds Logs ==="
|
||||
docker compose logs appwrite-worker-builds
|
||||
echo "=== OpenRuntimes Executor Logs ==="
|
||||
docker compose logs openruntimes-executor
|
||||
@@ -21,3 +21,9 @@ appwrite.config.json
|
||||
/app/config/specs/
|
||||
/docs/examples/
|
||||
.phpunit.cache
|
||||
playwright-report
|
||||
test-results
|
||||
docker-compose.web-installer.yml
|
||||
.env.web-installer
|
||||
docker-compose.web-installer.yml.**.backup
|
||||
tests/playwright/screenshots
|
||||
|
||||
@@ -47,6 +47,36 @@ Examples:
|
||||
'resourceType' => 'deployments'
|
||||
```
|
||||
|
||||
## Performance Patterns
|
||||
|
||||
### Document Update Optimization
|
||||
|
||||
When updating documents, always pass only the changed attributes as a sparse `Document` rather than the full document. This is more efficient because `updateDocument()` internally performs `array_merge($old, $new)`.
|
||||
|
||||
**Correct Pattern:**
|
||||
```php
|
||||
// Good: Pass only changed attributes directly
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), new Document([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
]));
|
||||
```
|
||||
|
||||
**Incorrect Pattern:**
|
||||
```php
|
||||
$user->setAttribute('name', $name);
|
||||
$user->setAttribute('email', $email);
|
||||
|
||||
// Bad: Passing full document is inefficient
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
```
|
||||
|
||||
**Exceptions:**
|
||||
- Migration files (need full document updates by design)
|
||||
- Cases already using `array_merge()` with `getArrayCopy()`
|
||||
- Updates where almost all attributes of the document change at once (sparse update provides little benefit compared to passing the full document)
|
||||
- Complex nested relationship logic where full document state is required
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Critical Security Practices
|
||||
|
||||
@@ -111,7 +111,7 @@ $ git push origin [name_of_your_new_branch]
|
||||
|
||||
## Setup From Source
|
||||
|
||||
To set up a working **development environment**, just fork the project git repository and install the backend and frontend dependencies using the proper package manager and create run the docker-compose stack.
|
||||
To set up a working **development environment**, just fork the project git repository and install the backend and frontend dependencies using the proper package manager and run the docker-compose stack.
|
||||
|
||||
> If you just want to install Appwrite for day-to-day use and not as a contributor, you can reference the [installation guide](https://github.com/appwrite/appwrite#installation), the [getting started guide](https://appwrite.io/docs/quick-starts), or the main [README](README.md) file.
|
||||
|
||||
@@ -173,12 +173,12 @@ Learn more at our [Technology Stack](#technology-stack) section.
|
||||
- [MVVM](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel) - Appwrite console architecture
|
||||
|
||||
##### Container Namespace Conventions
|
||||
To keep our services easy to understand within Docker we follow a naming convention for all our containers depending on it's intended use.
|
||||
To keep our services easy to understand within Docker we follow a naming convention for all our containers depending on its intended use.
|
||||
|
||||
`appwrite-worker-X` - Workers (`src/Appwrite/Platform/Workers/*`)
|
||||
`appwrite-task-X` - Tasks (`src/Appwrite/Platform/Tasks/*`)
|
||||
|
||||
Other containes should be named the same as their service, for example `redis` should just be called `redis`.
|
||||
Other containers should be named the same as their service, for example `redis` should just be called `redis`.
|
||||
|
||||
##### Security
|
||||
|
||||
@@ -189,7 +189,7 @@ Other containes should be named the same as their service, for example `redis` s
|
||||
|
||||
## Modules
|
||||
|
||||
As Appwrite grows, we noticed approach of having all service endpoints in `app/controllers/api/[service].php` is not maintainable. Not only it creates massive files, it also doesnt contain all product's features such as workers or tasks. While there might still be some occurances of those controller files, we avoid it in all new development, and gradually migrate existing controllers to **HTTP modules**.
|
||||
As Appwrite grows, we noticed approach of having all service endpoints in `app/controllers/api/[service].php` is not maintainable. Not only it creates massive files, it also doesn't contain all product's features such as workers or tasks. While there might still be some occurrences of those controller files, we avoid it in all new development, and gradually migrate existing controllers to **HTTP modules**.
|
||||
|
||||
### HTTP Endpoints
|
||||
|
||||
@@ -204,7 +204,7 @@ Tips and tricks:
|
||||
1. If endpoint doesn't have resource, use service name as resource name too
|
||||
> Example: `Modules/Sites/Http/Sites/Get.php`
|
||||
|
||||
2. If there are multiple resources, use then all in folder structure
|
||||
2. If there are multiple resources, use them all in folder structure
|
||||
> Example: `Modules/Sites/Http/Deployments/Builds/Create.php`
|
||||
|
||||
3. Action can only be `Get`, `Create`, `Update`, `Delete` or `XList`
|
||||
@@ -395,7 +395,7 @@ These are the current metrics we collect usage stats for:
|
||||
|
||||
> Note: The curly brackets in the metric name represents a template and is replaced with a value when the metric is processed.
|
||||
|
||||
Metrics are collected within 3 scopes Daily, monthly, an infinity. Adding new usage metric in order to aggregate usage stats is very simple, but very much dependent on where do you want to collect
|
||||
Metrics are collected within 3 scopes Daily, monthly, and infinity. Adding new usage metric in order to aggregate usage stats is very simple, but very much dependent on where do you want to collect
|
||||
statistics ,via API or via background worker. For both cases you will need to add a `const` variable in `app/init.php` under the usage metrics list using the naming convention `METRIC_<RESOURCE_NAME>` as shown below.
|
||||
|
||||
```php
|
||||
@@ -409,14 +409,16 @@ Next follow the appropriate steps below depending on whether you're adding the m
|
||||
|
||||
**API**
|
||||
|
||||
In file `app/controllers/shared/api.php` On the database listener, add to an existing or create a new switch case. Add a call to the usage worker with your new metric const like so:
|
||||
In file `app/controllers/shared/api.php` On the database listener, add to an existing or create a new switch case. Accumulate metrics in the usage context like so:
|
||||
|
||||
```php
|
||||
case $document->getCollection() === 'teams':
|
||||
$queueForStatsUsage
|
||||
->addMetric(METRIC_TEAMS, $value); // per project
|
||||
$usage->addMetric(METRIC_TEAMS, $value); // per project
|
||||
break;
|
||||
```
|
||||
|
||||
The metrics will be automatically published by the shutdown hook at the end of the request. There is no need to manually trigger or publish.
|
||||
|
||||
There are cases when you need to handle metric that has a parent entity, like buckets.
|
||||
Files are linked to a parent bucket, you should verify you remove the files stats when you delete a bucket.
|
||||
|
||||
@@ -425,14 +427,13 @@ In that case you need also to handle children removal using addReduce() method c
|
||||
```php
|
||||
|
||||
case $document->getCollection() === 'buckets': //buckets
|
||||
$queueForStatsUsage
|
||||
->addMetric(METRIC_BUCKETS, $value); // per project
|
||||
$usage->addMetric(METRIC_BUCKETS, $value); // per project
|
||||
if ($event === Database::EVENT_DOCUMENT_DELETE) {
|
||||
$queueForStatsUsage
|
||||
$usage
|
||||
->addReduce($document);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
```
|
||||
|
||||
In addition, you will also need to add some logic to the `reduce()` method of the Usage worker located in `/src/Appwrite/Platform/Workers/Usage.php`, like so:
|
||||
@@ -460,8 +461,12 @@ case $document->getCollection() === 'buckets':
|
||||
|
||||
**Background worker**
|
||||
|
||||
You need to inject the usage queue in the desired worker on the constructor method
|
||||
You need to inject the usage context and publisher in the desired worker on the constructor method
|
||||
```php
|
||||
use Appwrite\Usage\Context;
|
||||
use Appwrite\Event\Publisher\Usage as UsagePublisher;
|
||||
use Appwrite\Event\Message\Usage as UsageMessage;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -474,24 +479,32 @@ public function __construct()
|
||||
->inject('dbForProject')
|
||||
->inject('queueForFunctions')
|
||||
->inject('queueForEvents')
|
||||
->inject('queueForStatsUsage')
|
||||
->inject('usage')
|
||||
->inject('publisherForUsage')
|
||||
->inject('log')
|
||||
->callback(fn (Message $message, Database $dbForProject, Func $queueForFunctions, Event $queueForEvents, StatsUsage $queueForStatsUsage, Log $log) => $this->action($message, $dbForProject, $queueForFunctions, $queueForEvents, $queueForStatsUsage, $log));
|
||||
->callback(fn (Message $message, Database $dbForProject, Func $queueForFunctions, Event $queueForEvents, Context $usage, UsagePublisher $publisherForUsage, Log $log) => $this->action($message, $dbForProject, $queueForFunctions, $queueForEvents, $usage, $publisherForUsage, $log));
|
||||
}
|
||||
```
|
||||
|
||||
and then trigger the queue with the new metric like so:
|
||||
and then accumulate metrics, create a message, and publish like so:
|
||||
|
||||
```php
|
||||
$queueForStatsUsage
|
||||
$usage
|
||||
->addMetric(METRIC_BUILDS, 1)
|
||||
->addMetric(METRIC_BUILDS_STORAGE, $build->getAttribute('size', 0))
|
||||
->addMetric(METRIC_BUILDS_COMPUTE, (int)$build->getAttribute('duration', 0) * 1000)
|
||||
->addMetric(str_replace('{functionInternalId}', $function->getSequence(), METRIC_FUNCTION_ID_BUILDS), 1)
|
||||
->addMetric(str_replace('{functionInternalId}', $function->getSequence(), METRIC_FUNCTION_ID_BUILDS), 1)
|
||||
->addMetric(str_replace('{functionInternalId}', $function->getSequence(), METRIC_FUNCTION_ID_BUILDS_STORAGE), $build->getAttribute('size', 0))
|
||||
->addMetric(str_replace('{functionInternalId}', $function->getSequence(), METRIC_FUNCTION_ID_BUILDS_COMPUTE), (int)$build->getAttribute('duration', 0) * 1000)
|
||||
->setProject($project)
|
||||
->trigger();
|
||||
->addMetric(str_replace('{functionInternalId}', $function->getSequence(), METRIC_FUNCTION_ID_BUILDS_COMPUTE), (int)$build->getAttribute('duration', 0) * 1000);
|
||||
|
||||
// Publish the accumulated metrics (workers don't have shutdown hooks)
|
||||
$message = new UsageMessage(
|
||||
project: $project,
|
||||
metrics: $usage->getMetrics(),
|
||||
reduce: $usage->getReduce()
|
||||
);
|
||||
$publisherForUsage->enqueue($message);
|
||||
$usage->reset();
|
||||
```
|
||||
|
||||
|
||||
@@ -661,7 +674,7 @@ docker compose exec redis redis-cli FLUSHALL
|
||||
|
||||
## Using preview domains locally
|
||||
|
||||
Appwrite Functions are automatically given a domain you can visit to execute the function. This domain has format `[SOMETHING].functions.localhost` unless you changed `_APP_DOMAIN_FUNCTIONS` environment variable. This default value works great when running Appwrite locally, but it can be impossible to use preview domains with Cloud woekspaces such as Gitpod or GitHub Codespaces.
|
||||
Appwrite Functions are automatically given a domain you can visit to execute the function. This domain has format `[SOMETHING].functions.localhost` unless you changed `_APP_DOMAIN_FUNCTIONS` environment variable. This default value works great when running Appwrite locally, but it can be impossible to use preview domains with Cloud workspaces such as Gitpod or GitHub Codespaces.
|
||||
|
||||
To use preview domains on Cloud workspaces, you can visit hostname provided by them, and supply function's preview domain as URL parameter:
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \
|
||||
--no-plugins --no-scripts --prefer-dist \
|
||||
`if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi`
|
||||
|
||||
FROM appwrite/base:1.0.0 AS base
|
||||
FROM appwrite/base:1.0.1 AS base
|
||||
|
||||
LABEL maintainer="team@appwrite.io"
|
||||
|
||||
@@ -70,6 +70,7 @@ RUN chmod +x /usr/local/bin/doctor && \
|
||||
chmod +x /usr/local/bin/sdks && \
|
||||
chmod +x /usr/local/bin/specs && \
|
||||
chmod +x /usr/local/bin/ssl && \
|
||||
chmod +x /usr/local/bin/task-time-travel && \
|
||||
chmod +x /usr/local/bin/screenshot && \
|
||||
chmod +x /usr/local/bin/test && \
|
||||
chmod +x /usr/local/bin/upgrade && \
|
||||
@@ -120,5 +121,6 @@ RUN if [ "$DEBUG" = "true" ]; then \
|
||||
fi
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 8080
|
||||
|
||||
CMD [ "php", "app/http.php" ]
|
||||
|
||||
@@ -4,11 +4,13 @@ require_once __DIR__ . '/init.php';
|
||||
|
||||
use Appwrite\Event\Certificate;
|
||||
use Appwrite\Event\Delete;
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Event\Func;
|
||||
use Appwrite\Event\Publisher\Usage as UsagePublisher;
|
||||
use Appwrite\Event\StatsResources;
|
||||
use Appwrite\Event\StatsUsage;
|
||||
use Appwrite\Platform\Appwrite;
|
||||
use Appwrite\Runtimes\Runtimes;
|
||||
use Appwrite\Usage\Context as UsageContext;
|
||||
use Appwrite\Utopia\Database\Documents\User;
|
||||
use Executor\Executor;
|
||||
use Swoole\Runtime;
|
||||
@@ -29,6 +31,7 @@ use Utopia\Platform\Service;
|
||||
use Utopia\Pools\Group;
|
||||
use Utopia\Queue\Broker\Pool as BrokerPool;
|
||||
use Utopia\Queue\Publisher;
|
||||
use Utopia\Queue\Queue;
|
||||
use Utopia\Registry\Registry;
|
||||
use Utopia\System\System;
|
||||
use Utopia\Telemetry\Adapter\None as NoTelemetry;
|
||||
@@ -47,7 +50,7 @@ $platform = new Appwrite();
|
||||
$args = $platform->getEnv('argv');
|
||||
|
||||
\array_shift($args);
|
||||
if (!isset($args[0])) {
|
||||
if (! isset($args[0])) {
|
||||
Console::error('Missing task name');
|
||||
Console::exit(1);
|
||||
}
|
||||
@@ -85,6 +88,7 @@ $setResource('pools', function (Registry $register) {
|
||||
$setResource('authorization', function () {
|
||||
$authorization = new Authorization();
|
||||
$authorization->disable();
|
||||
|
||||
return $authorization;
|
||||
}, []);
|
||||
|
||||
@@ -113,7 +117,7 @@ $setResource('dbForPlatform', function ($pools, $cache, $authorization) {
|
||||
$collections = Config::getParam('collections', [])['console'];
|
||||
$last = \array_key_last($collections);
|
||||
|
||||
if (!($dbForPlatform->exists($dbForPlatform->getDatabase(), $last))) { /** TODO cache ready variable using registry */
|
||||
if (! ($dbForPlatform->exists($dbForPlatform->getDatabase(), $last))) { /** TODO cache ready variable using registry */
|
||||
throw new Exception('Tables not ready yet.');
|
||||
}
|
||||
|
||||
@@ -122,10 +126,10 @@ $setResource('dbForPlatform', function ($pools, $cache, $authorization) {
|
||||
Console::warning($err->getMessage());
|
||||
sleep($sleep);
|
||||
}
|
||||
} while ($attempts < $maxAttempts && !$ready);
|
||||
} while ($attempts < $maxAttempts && ! $ready);
|
||||
|
||||
if (!$ready) {
|
||||
throw new Exception("Console is not ready yet. Please try again later.");
|
||||
if (! $ready) {
|
||||
throw new Exception('Console is not ready yet. Please try again later.');
|
||||
}
|
||||
|
||||
return $dbForPlatform;
|
||||
@@ -163,7 +167,7 @@ $setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $c
|
||||
if (\in_array($dsn->getHost(), $sharedTables)) {
|
||||
$database
|
||||
->setSharedTables(true)
|
||||
->setTenant((int)$project->getSequence())
|
||||
->setTenant($project->getSequence())
|
||||
->setNamespace($dsn->getParam('namespace'));
|
||||
} else {
|
||||
$database
|
||||
@@ -184,7 +188,7 @@ $setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $c
|
||||
if (\in_array($dsn->getHost(), $sharedTables)) {
|
||||
$database
|
||||
->setSharedTables(true)
|
||||
->setTenant((int)$project->getSequence())
|
||||
->setTenant($project->getSequence())
|
||||
->setNamespace($dsn->getParam('namespace'));
|
||||
} else {
|
||||
$database
|
||||
@@ -208,7 +212,7 @@ $setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $a
|
||||
|
||||
return function (?Document $project = null) use ($pools, $cache, $database, $authorization) {
|
||||
if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
|
||||
$database->setTenant((int)$project->getSequence());
|
||||
$database->setTenant($project->getSequence());
|
||||
return $database;
|
||||
}
|
||||
|
||||
@@ -225,7 +229,7 @@ $setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $a
|
||||
|
||||
// set tenant
|
||||
if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
|
||||
$database->setTenant((int)$project->getSequence());
|
||||
$database->setTenant($project->getSequence());
|
||||
}
|
||||
|
||||
return $database;
|
||||
@@ -243,15 +247,16 @@ $setResource('publisherFunctions', function (BrokerPool $publisher) {
|
||||
$setResource('publisherMigrations', function (BrokerPool $publisher) {
|
||||
return $publisher;
|
||||
}, ['publisher']);
|
||||
$setResource('publisherStatsUsage', function (BrokerPool $publisher) {
|
||||
return $publisher;
|
||||
}, ['publisher']);
|
||||
$setResource('publisherMessaging', function (BrokerPool $publisher) {
|
||||
return $publisher;
|
||||
}, ['publisher']);
|
||||
$setResource('queueForStatsUsage', function (Publisher $publisher) {
|
||||
return new StatsUsage($publisher);
|
||||
}, ['publisher']);
|
||||
$setResource('usage', function () {
|
||||
return new UsageContext();
|
||||
}, []);
|
||||
$setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
$setResource('queueForStatsResources', function (Publisher $publisher) {
|
||||
return new StatsResources($publisher);
|
||||
}, ['publisher']);
|
||||
@@ -318,6 +323,10 @@ $setResource('logError', function (Registry $register) {
|
||||
|
||||
$setResource('executor', fn () => new Executor(), []);
|
||||
|
||||
$setResource('bus', function (Registry $register) use ($cli) {
|
||||
return $register->get('bus')->setResolver(fn (string $name) => $cli->getResource($name));
|
||||
}, ['register']);
|
||||
|
||||
$setResource('telemetry', fn () => new NoTelemetry(), []);
|
||||
|
||||
$cli
|
||||
|
||||
@@ -777,6 +777,7 @@ return [
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
// At the moment, always empty (no runtime supports it yet)
|
||||
'array' => false,
|
||||
'$id' => ID::custom('startCommand'),
|
||||
'type' => Database::VAR_STRING,
|
||||
@@ -2190,13 +2191,6 @@ return [
|
||||
'lengths' => [],
|
||||
'orders' => [Database::ORDER_ASC],
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('_key_function_internal_id'),
|
||||
'type' => Database::INDEX_KEY,
|
||||
'attributes' => ['resourceInternalId'],
|
||||
'lengths' => [],
|
||||
'orders' => [],
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('_key_resourceType'),
|
||||
'type' => Database::INDEX_KEY,
|
||||
|
||||
@@ -26,7 +26,7 @@ $console = [
|
||||
'hostname' => 'localhost',
|
||||
], // Current host is added on app init
|
||||
],
|
||||
'region' => 'fra',
|
||||
'region' => System::getEnv('_APP_REGION', 'default'),
|
||||
'legalName' => '',
|
||||
'legalCountry' => '',
|
||||
'legalState' => '',
|
||||
|
||||
@@ -27,7 +27,7 @@ return [
|
||||
Exception::GENERAL_RESOURCE_BLOCKED => [
|
||||
'name' => Exception::GENERAL_RESOURCE_BLOCKED,
|
||||
'description' => 'Access to this resource is blocked.',
|
||||
'code' => 401,
|
||||
'code' => 403,
|
||||
],
|
||||
Exception::GENERAL_UNKNOWN_ORIGIN => [
|
||||
'name' => Exception::GENERAL_UNKNOWN_ORIGIN,
|
||||
@@ -168,8 +168,8 @@ return [
|
||||
],
|
||||
Exception::USER_BLOCKED => [
|
||||
'name' => Exception::USER_BLOCKED,
|
||||
'description' => 'The current user has been blocked. You can unblock the user by making a request to the User API\'s "Update User Status" endpoint or in the Appwrite Console\'s Auth section.',
|
||||
'code' => 401,
|
||||
'description' => 'The current user has been blocked.',
|
||||
'code' => 403,
|
||||
],
|
||||
Exception::USER_INVALID_TOKEN => [
|
||||
'name' => Exception::USER_INVALID_TOKEN,
|
||||
@@ -630,6 +630,11 @@ return [
|
||||
'description' => 'Site with the requested ID could not be found.',
|
||||
'code' => 404,
|
||||
],
|
||||
Exception::SITE_ALREADY_EXISTS => [
|
||||
'name' => Exception::SITE_ALREADY_EXISTS,
|
||||
'description' => 'Site with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
|
||||
'code' => 409,
|
||||
],
|
||||
Exception::SITE_TEMPLATE_NOT_FOUND => [
|
||||
'name' => Exception::SITE_TEMPLATE_NOT_FOUND,
|
||||
'description' => 'Site Template with the requested ID could not be found.',
|
||||
@@ -1139,6 +1144,11 @@ return [
|
||||
'description' => 'Webhook with the requested ID could not be found.',
|
||||
'code' => 404,
|
||||
],
|
||||
Exception::WEBHOOK_ALREADY_EXISTS => [
|
||||
'name' => Exception::WEBHOOK_ALREADY_EXISTS,
|
||||
'description' => 'Webhook with the same ID already exists. Try again with a different ID.',
|
||||
'code' => 409,
|
||||
],
|
||||
Exception::KEY_NOT_FOUND => [
|
||||
'name' => Exception::KEY_NOT_FOUND,
|
||||
'description' => 'Key with the requested ID could not be found.',
|
||||
@@ -1291,6 +1301,11 @@ return [
|
||||
'description' => 'Message with the requested ID could not be found.',
|
||||
'code' => 404,
|
||||
],
|
||||
Exception::MESSAGE_ALREADY_EXISTS => [
|
||||
'name' => Exception::MESSAGE_ALREADY_EXISTS,
|
||||
'description' => 'Message with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
|
||||
'code' => 409,
|
||||
],
|
||||
Exception::MESSAGE_MISSING_TARGET => [
|
||||
'name' => Exception::MESSAGE_MISSING_TARGET,
|
||||
'description' => 'Message with the requested ID has no recipients (topics or users or targets).',
|
||||
|
||||
@@ -172,4 +172,12 @@ return [ // List of publicly visible scopes
|
||||
'tokens.write' => [
|
||||
'description' => 'Access to create, update, and delete your project\'s tokens',
|
||||
],
|
||||
"webhooks.read" => [
|
||||
"description" =>
|
||||
"Access to read project\'s webhooks",
|
||||
],
|
||||
"webhooks.write" => [
|
||||
"description" =>
|
||||
"Access to create, update, and delete project\'s webhooks",
|
||||
],
|
||||
];
|
||||
|
||||
@@ -231,7 +231,7 @@ return [
|
||||
'url' => 'https://github.com/appwrite/sdk-for-cli',
|
||||
'package' => 'https://www.npmjs.com/package/appwrite-cli',
|
||||
'enabled' => true,
|
||||
'beta' => true,
|
||||
'beta' => false,
|
||||
'dev' => false,
|
||||
'hidden' => false,
|
||||
'family' => APP_SDK_PLATFORM_CONSOLE,
|
||||
|
||||
@@ -188,7 +188,7 @@ return [
|
||||
'name' => 'VCS',
|
||||
'subtitle' => 'The VCS service allows you to interact with providers like GitHub, GitLab etc.',
|
||||
'description' => '',
|
||||
'controller' => 'api/vcs.php',
|
||||
'controller' => '', // Uses modules
|
||||
'sdk' => false,
|
||||
'docs' => false,
|
||||
'docsUrl' => '',
|
||||
|
||||
@@ -14,7 +14,6 @@ use Appwrite\Event\Delete;
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Event\Mail;
|
||||
use Appwrite\Event\Messaging;
|
||||
use Appwrite\Event\StatsUsage;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Hooks\Hooks;
|
||||
use Appwrite\Network\Validator\Email as EmailValidator;
|
||||
@@ -28,6 +27,7 @@ use Appwrite\SDK\MethodType;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Template\Template;
|
||||
use Appwrite\URL\URL as URLParser;
|
||||
use Appwrite\Usage\Context;
|
||||
use Appwrite\Utopia\Database\Documents\User;
|
||||
use Appwrite\Utopia\Database\Validator\CustomId;
|
||||
use Appwrite\Utopia\Database\Validator\Queries\Identities;
|
||||
@@ -288,7 +288,10 @@ $createSession = function (string $userId, string $secret, Request $request, Res
|
||||
}
|
||||
|
||||
try {
|
||||
$dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
$dbForProject->updateDocument('users', $user->getId(), new Document([
|
||||
'emailVerification' => $user->getAttribute('emailVerification'),
|
||||
'phoneVerification' => $user->getAttribute('phoneVerification'),
|
||||
]));
|
||||
} catch (\Throwable $th) {
|
||||
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed saving user to DB');
|
||||
}
|
||||
@@ -1032,7 +1035,11 @@ Http::post('/v1/account/sessions/email')
|
||||
->setAttribute('password', $proofForPasswordUpdated->hash($password))
|
||||
->setAttribute('hash', $proofForPasswordUpdated->getHash()->getName())
|
||||
->setAttribute('hashOptions', $proofForPasswordUpdated->getHash()->getOptions());
|
||||
$dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
$dbForProject->updateDocument('users', $user->getId(), new Document([
|
||||
'password' => $user->getAttribute('password'),
|
||||
'hash' => $user->getAttribute('hash'),
|
||||
'hashOptions' => $user->getAttribute('hashOptions'),
|
||||
]));
|
||||
}
|
||||
|
||||
$dbForProject->purgeCachedDocument('users', $user->getId());
|
||||
@@ -1822,7 +1829,11 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
|
||||
->setAttribute('providerAccessToken', $accessToken)
|
||||
->setAttribute('providerRefreshToken', $refreshToken)
|
||||
->setAttribute('providerAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int) $accessTokenExpiry));
|
||||
$dbForProject->updateDocument('identities', $identity->getId(), $identity);
|
||||
$dbForProject->updateDocument('identities', $identity->getId(), new Document([
|
||||
'providerAccessToken' => $identity->getAttribute('providerAccessToken'),
|
||||
'providerRefreshToken' => $identity->getAttribute('providerRefreshToken'),
|
||||
'providerAccessTokenExpiry' => $identity->getAttribute('providerAccessTokenExpiry'),
|
||||
]));
|
||||
}
|
||||
|
||||
if (empty($user->getAttribute('email'))) {
|
||||
@@ -1960,7 +1971,10 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
|
||||
->setAttribute('sessionId', $session->getId())
|
||||
->setAttribute('sessionInternalId', $session->getSequence());
|
||||
|
||||
$dbForProject->updateDocument('targets', $target->getId(), $target);
|
||||
$dbForProject->updateDocument('targets', $target->getId(), new Document([
|
||||
'sessionId' => $target->getAttribute('sessionId'),
|
||||
'sessionInternalId' => $target->getAttribute('sessionInternalId'),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2787,12 +2801,12 @@ Http::post('/v1/account/tokens/phone')
|
||||
->inject('queueForMessaging')
|
||||
->inject('locale')
|
||||
->inject('timelimit')
|
||||
->inject('queueForStatsUsage')
|
||||
->inject('usage')
|
||||
->inject('plan')
|
||||
->inject('store')
|
||||
->inject('proofForCode')
|
||||
->inject('authorization')
|
||||
->action(function (string $userId, string $phone, Request $request, Response $response, User $user, Document $project, array $platform, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Locale $locale, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, Store $store, ProofsCode $proofForCode, Authorization $authorization) {
|
||||
->action(function (string $userId, string $phone, Request $request, Response $response, User $user, Document $project, array $platform, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Locale $locale, callable $timelimit, Context $usage, array $plan, Store $store, ProofsCode $proofForCode, Authorization $authorization) {
|
||||
if (empty(System::getEnv('_APP_SMS_PROVIDER'))) {
|
||||
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
|
||||
}
|
||||
@@ -2941,16 +2955,12 @@ Http::post('/v1/account/tokens/phone')
|
||||
$countryCode = $helper->parse($phone)->getCountryCode();
|
||||
|
||||
if (!empty($countryCode)) {
|
||||
$queueForStatsUsage
|
||||
->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1);
|
||||
$usage->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1);
|
||||
}
|
||||
} catch (NumberParseException $e) {
|
||||
// Ignore invalid phone number for country code stats
|
||||
}
|
||||
$queueForStatsUsage
|
||||
->addMetric(METRIC_AUTH_METHOD_PHONE, 1)
|
||||
->setProject($project)
|
||||
->trigger();
|
||||
$usage->addMetric(METRIC_AUTH_METHOD_PHONE, 1);
|
||||
}
|
||||
|
||||
$token->setAttribute('secret', $secret);
|
||||
@@ -3145,7 +3155,9 @@ Http::patch('/v1/account/name')
|
||||
|
||||
$user->setAttribute('name', $name);
|
||||
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), new Document([
|
||||
'name' => $user->getAttribute('name'),
|
||||
]));
|
||||
|
||||
$queueForEvents->setParam('userId', $user->getId());
|
||||
|
||||
@@ -3798,13 +3810,15 @@ Http::put('/v1/account/recovery')
|
||||
|
||||
$hooks->trigger('passwordValidator', [$dbForProject, $project, $password, &$user, true]);
|
||||
|
||||
$profile = $dbForProject->updateDocument('users', $profile->getId(), $profile
|
||||
->setAttribute('password', $newPassword)
|
||||
->setAttribute('passwordHistory', $history)
|
||||
->setAttribute('passwordUpdate', DateTime::now())
|
||||
->setAttribute('hash', $proofForPassword->getHash()->getName())
|
||||
->setAttribute('hashOptions', $proofForPassword->getHash()->getOptions())
|
||||
->setAttribute('emailVerification', true));
|
||||
$profile = $dbForProject->updateDocument('users', $profile->getId(), new Document(
|
||||
[
|
||||
'password' => $newPassword,
|
||||
'passwordHistory' => $history,
|
||||
'passwordUpdate' => DateTime::now(),
|
||||
'hash' => $proofForPassword->getHash()->getName(),
|
||||
'hashOptions' => $proofForPassword->getHash()->getOptions(),
|
||||
'emailVerification' => true]
|
||||
));
|
||||
|
||||
$user->setAttributes($profile->getArrayCopy());
|
||||
|
||||
@@ -4126,7 +4140,7 @@ Http::put('/v1/account/verifications/email')
|
||||
|
||||
$authorization->addRole(Role::user($profile->getId())->toString());
|
||||
|
||||
$profile = $dbForProject->updateDocument('users', $profile->getId(), $profile->setAttribute('emailVerification', true));
|
||||
$profile = $dbForProject->updateDocument('users', $profile->getId(), new Document(['emailVerification' => true]));
|
||||
|
||||
$user->setAttributes($profile->getArrayCopy());
|
||||
|
||||
@@ -4181,11 +4195,11 @@ Http::post('/v1/account/verifications/phone')
|
||||
->inject('project')
|
||||
->inject('locale')
|
||||
->inject('timelimit')
|
||||
->inject('queueForStatsUsage')
|
||||
->inject('usage')
|
||||
->inject('plan')
|
||||
->inject('proofForCode')
|
||||
->inject('authorization')
|
||||
->action(function (Request $request, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Document $project, Locale $locale, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, ProofsCode $proofForCode, Authorization $authorization) {
|
||||
->action(function (Request $request, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Document $project, Locale $locale, callable $timelimit, Context $usage, array $plan, ProofsCode $proofForCode, Authorization $authorization) {
|
||||
if (empty(System::getEnv('_APP_SMS_PROVIDER'))) {
|
||||
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
|
||||
}
|
||||
@@ -4270,16 +4284,12 @@ Http::post('/v1/account/verifications/phone')
|
||||
$countryCode = $helper->parse($phone)->getCountryCode();
|
||||
|
||||
if (!empty($countryCode)) {
|
||||
$queueForStatsUsage
|
||||
->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1);
|
||||
$usage->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1);
|
||||
}
|
||||
} catch (NumberParseException $e) {
|
||||
// Ignore invalid phone number for country code stats
|
||||
}
|
||||
$queueForStatsUsage
|
||||
->addMetric(METRIC_AUTH_METHOD_PHONE, 1)
|
||||
->setProject($project)
|
||||
->trigger();
|
||||
$usage->addMetric(METRIC_AUTH_METHOD_PHONE, 1);
|
||||
}
|
||||
|
||||
$verification->setAttribute('secret', $secret);
|
||||
@@ -4342,7 +4352,7 @@ Http::put('/v1/account/verifications/phone')
|
||||
|
||||
$authorization->addRole(Role::user($profile->getId())->toString());
|
||||
|
||||
$profile = $dbForProject->updateDocument('users', $profile->getId(), $profile->setAttribute('phoneVerification', true));
|
||||
$profile = $dbForProject->updateDocument('users', $profile->getId(), new Document(['phoneVerification' => true]));
|
||||
|
||||
$user->setAttributes($profile->getArrayCopy());
|
||||
|
||||
@@ -4500,7 +4510,11 @@ Http::put('/v1/account/targets/:targetId/push')
|
||||
|
||||
$target->setAttribute('name', "{$device['deviceBrand']} {$device['deviceModel']}");
|
||||
|
||||
$target = $dbForProject->updateDocument('targets', $target->getId(), $target);
|
||||
$target = $dbForProject->updateDocument('targets', $target->getId(), new Document([
|
||||
'identifier' => $target->getAttribute('identifier'),
|
||||
'expired' => $target->getAttribute('expired'),
|
||||
'name' => $target->getAttribute('name'),
|
||||
]));
|
||||
|
||||
$dbForProject->purgeCachedDocument('users', $user->getId());
|
||||
|
||||
|
||||
@@ -3251,7 +3251,7 @@ Http::post('/v1/messaging/messages/email')
|
||||
}
|
||||
}
|
||||
|
||||
$message = $dbForProject->createDocument('messages', new Document([
|
||||
$message = new Document([
|
||||
'$id' => $messageId,
|
||||
'providerType' => MESSAGE_TYPE_EMAIL,
|
||||
'topics' => $topics,
|
||||
@@ -3267,7 +3267,12 @@ Http::post('/v1/messaging/messages/email')
|
||||
'attachments' => $attachments,
|
||||
],
|
||||
'status' => $status,
|
||||
]));
|
||||
]);
|
||||
try {
|
||||
$message = $dbForProject->createDocument('messages', $message);
|
||||
} catch (DuplicateException) {
|
||||
throw new Exception(Exception::MESSAGE_ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
switch ($status) {
|
||||
case MessageStatus::PROCESSING:
|
||||
@@ -3400,7 +3405,7 @@ Http::post('/v1/messaging/messages/sms')
|
||||
}
|
||||
}
|
||||
|
||||
$message = $dbForProject->createDocument('messages', new Document([
|
||||
$message = new Document([
|
||||
'$id' => $messageId,
|
||||
'providerType' => MESSAGE_TYPE_SMS,
|
||||
'topics' => $topics,
|
||||
@@ -3410,7 +3415,12 @@ Http::post('/v1/messaging/messages/sms')
|
||||
'content' => $content,
|
||||
],
|
||||
'status' => $status,
|
||||
]));
|
||||
]);
|
||||
try {
|
||||
$message = $dbForProject->createDocument('messages', $message);
|
||||
} catch (DuplicateException) {
|
||||
throw new Exception(Exception::MESSAGE_ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
switch ($status) {
|
||||
case MessageStatus::PROCESSING:
|
||||
@@ -3620,7 +3630,7 @@ Http::post('/v1/messaging/messages/push')
|
||||
$pushData['priority'] = $priority;
|
||||
}
|
||||
|
||||
$message = $dbForProject->createDocument('messages', new Document([
|
||||
$message = new Document([
|
||||
'$id' => $messageId,
|
||||
'providerType' => MESSAGE_TYPE_PUSH,
|
||||
'topics' => $topics,
|
||||
@@ -3629,7 +3639,12 @@ Http::post('/v1/messaging/messages/push')
|
||||
'scheduledAt' => $scheduledAt,
|
||||
'data' => $pushData,
|
||||
'status' => $status,
|
||||
]));
|
||||
]);
|
||||
try {
|
||||
$message = $dbForProject->createDocument('messages', $message);
|
||||
} catch (DuplicateException) {
|
||||
throw new Exception(Exception::MESSAGE_ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
switch ($status) {
|
||||
case MessageStatus::PROCESSING:
|
||||
|
||||
@@ -717,22 +717,14 @@ Http::get('/v1/migrations/appwrite/report')
|
||||
->inject('project')
|
||||
->inject('user')
|
||||
->action(function (array $resources, string $endpoint, string $projectID, string $key, Response $response) {
|
||||
|
||||
$appwrite = new Appwrite($projectID, $endpoint, $key);
|
||||
|
||||
try {
|
||||
$appwrite = new Appwrite($projectID, $endpoint, $key);
|
||||
$report = $appwrite->report($resources);
|
||||
} catch (\Throwable $e) {
|
||||
switch ($e->getCode()) {
|
||||
case 401:
|
||||
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, 'Source Error: ' . $e->getMessage());
|
||||
case 429:
|
||||
throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Source Error: Rate Limit Exceeded, Is your Cloud Provider blocking Appwrite\'s IP?');
|
||||
case 500:
|
||||
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage());
|
||||
throw new Exception(
|
||||
Exception::MIGRATION_PROVIDER_ERROR,
|
||||
'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
|
||||
);
|
||||
}
|
||||
|
||||
$response
|
||||
@@ -771,21 +763,14 @@ Http::get('/v1/migrations/firebase/report')
|
||||
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON');
|
||||
}
|
||||
|
||||
$firebase = new Firebase($serviceAccount);
|
||||
|
||||
try {
|
||||
$firebase = new Firebase($serviceAccount);
|
||||
$report = $firebase->report($resources);
|
||||
} catch (\Throwable $e) {
|
||||
switch ($e->getCode()) {
|
||||
case 401:
|
||||
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, 'Source Error: ' . $e->getMessage());
|
||||
case 429:
|
||||
throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Source Error: Rate Limit Exceeded, Is your Cloud Provider blocking Appwrite\'s IP?');
|
||||
case 500:
|
||||
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage());
|
||||
throw new Exception(
|
||||
Exception::MIGRATION_PROVIDER_ERROR,
|
||||
'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
|
||||
);
|
||||
}
|
||||
|
||||
$response
|
||||
@@ -820,21 +805,14 @@ Http::get('/v1/migrations/supabase/report')
|
||||
->inject('response')
|
||||
->inject('dbForProject')
|
||||
->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response) {
|
||||
$supabase = new Supabase($endpoint, $apiKey, $databaseHost, 'postgres', $username, $password, $port);
|
||||
|
||||
try {
|
||||
$supabase = new Supabase($endpoint, $apiKey, $databaseHost, 'postgres', $username, $password, $port);
|
||||
$report = $supabase->report($resources);
|
||||
} catch (\Throwable $e) {
|
||||
switch ($e->getCode()) {
|
||||
case 401:
|
||||
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, 'Source Error: ' . $e->getMessage());
|
||||
case 429:
|
||||
throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Source Error: Rate Limit Exceeded, Is your Cloud Provider blocking Appwrite\'s IP?');
|
||||
case 500:
|
||||
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage());
|
||||
throw new Exception(
|
||||
Exception::MIGRATION_PROVIDER_ERROR,
|
||||
'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
|
||||
);
|
||||
}
|
||||
|
||||
$response
|
||||
@@ -869,21 +847,14 @@ Http::get('/v1/migrations/nhost/report')
|
||||
->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true)
|
||||
->inject('response')
|
||||
->action(function (array $resources, string $subdomain, string $region, string $adminSecret, string $database, string $username, string $password, int $port, Response $response) {
|
||||
$nhost = new NHost($subdomain, $region, $adminSecret, $database, $username, $password, $port);
|
||||
|
||||
try {
|
||||
$nhost = new NHost($subdomain, $region, $adminSecret, $database, $username, $password, $port);
|
||||
$report = $nhost->report($resources);
|
||||
} catch (\Throwable $e) {
|
||||
switch ($e->getCode()) {
|
||||
case 401:
|
||||
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, 'Source Error: ' . $e->getMessage());
|
||||
case 429:
|
||||
throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Source Error: Rate Limit Exceeded, Is your Cloud Provider blocking Appwrite\'s IP?');
|
||||
case 500:
|
||||
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Source Error: ' . $e->getMessage());
|
||||
throw new Exception(
|
||||
Exception::MIGRATION_PROVIDER_ERROR,
|
||||
'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.'
|
||||
);
|
||||
}
|
||||
|
||||
$response
|
||||
|
||||
@@ -73,8 +73,9 @@ use Utopia\Validator\Text;
|
||||
use Utopia\Validator\WhiteList;
|
||||
|
||||
/** TODO: Remove function when we move to using utopia/platform */
|
||||
function createUser(Hash $hash, string $userId, ?string $email, ?string $password, ?string $phone, string $name, Document $project, Database $dbForProject, Hooks $hooks): Document
|
||||
function createUser(Hash $hash, string $userId, ?string $email, ?string $password, ?string $phone, ?string $name, Document $project, Database $dbForProject, Hooks $hooks): Document
|
||||
{
|
||||
$name = $name ?? '';
|
||||
$plaintextPassword = $password;
|
||||
$passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
|
||||
|
||||
@@ -255,7 +256,7 @@ Http::post('/v1/users')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('hooks')
|
||||
->action(function (string $userId, ?string $email, ?string $phone, ?string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
->action(function (string $userId, ?string $email, ?string $phone, ?string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
$plaintext = new Plaintext();
|
||||
|
||||
$user = createUser($plaintext, $userId, $email, $password, $phone, $name, $project, $dbForProject, $hooks);
|
||||
@@ -291,7 +292,7 @@ Http::post('/v1/users/bcrypt')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('hooks')
|
||||
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
$bcrypt = new Bcrypt();
|
||||
$bcrypt->setCost(8); // Default cost
|
||||
|
||||
@@ -329,7 +330,7 @@ Http::post('/v1/users/md5')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('hooks')
|
||||
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
$md5 = new MD5();
|
||||
|
||||
$user = createUser($md5, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
|
||||
@@ -366,7 +367,7 @@ Http::post('/v1/users/argon2')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('hooks')
|
||||
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
$argon2 = new Argon2();
|
||||
|
||||
$user = createUser($argon2, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
|
||||
@@ -404,7 +405,7 @@ Http::post('/v1/users/sha')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('hooks')
|
||||
->action(function (string $userId, string $email, string $password, string $passwordVersion, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
->action(function (string $userId, string $email, string $password, string $passwordVersion, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
$sha = new Sha();
|
||||
if (!empty($passwordVersion)) {
|
||||
$sha->setVersion($passwordVersion);
|
||||
@@ -444,7 +445,7 @@ Http::post('/v1/users/phpass')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('hooks')
|
||||
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
$phpass = new PHPass();
|
||||
|
||||
$user = createUser($phpass, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
|
||||
@@ -486,7 +487,7 @@ Http::post('/v1/users/scrypt')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('hooks')
|
||||
->action(function (string $userId, string $email, string $password, string $passwordSalt, int $passwordCpu, int $passwordMemory, int $passwordParallel, int $passwordLength, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
->action(function (string $userId, string $email, string $password, string $passwordSalt, int $passwordCpu, int $passwordMemory, int $passwordParallel, int $passwordLength, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
$scrypt = new Scrypt();
|
||||
$scrypt
|
||||
->setSalt($passwordSalt)
|
||||
@@ -532,7 +533,7 @@ Http::post('/v1/users/scrypt-modified')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('hooks')
|
||||
->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
$scryptModified = new ScryptModified();
|
||||
$scryptModified
|
||||
->setSalt($passwordSalt)
|
||||
@@ -1160,7 +1161,7 @@ Http::patch('/v1/users/:userId/status')
|
||||
throw new Exception(Exception::USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('status', (bool) $status));
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), new Document(['status' => (bool) $status]));
|
||||
|
||||
$queueForEvents
|
||||
->setParam('userId', $user->getId());
|
||||
@@ -1203,7 +1204,7 @@ Http::put('/v1/users/:userId/labels')
|
||||
|
||||
$user->setAttribute('labels', (array) \array_values(\array_unique($labels)));
|
||||
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), new Document(['labels' => $user->getAttribute('labels')]));
|
||||
|
||||
$queueForEvents
|
||||
->setParam('userId', $user->getId());
|
||||
@@ -1244,7 +1245,7 @@ Http::patch('/v1/users/:userId/verification/phone')
|
||||
throw new Exception(Exception::USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('phoneVerification', $phoneVerification));
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), new Document(['phoneVerification' => $phoneVerification]));
|
||||
|
||||
$queueForEvents
|
||||
->setParam('userId', $user->getId());
|
||||
@@ -1288,7 +1289,7 @@ Http::patch('/v1/users/:userId/name')
|
||||
|
||||
$user->setAttribute('name', $name);
|
||||
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), new Document(['name' => $user->getAttribute('name')]));
|
||||
|
||||
$queueForEvents->setParam('userId', $user->getId());
|
||||
|
||||
@@ -1343,7 +1344,10 @@ Http::patch('/v1/users/:userId/password')
|
||||
->setAttribute('password', '')
|
||||
->setAttribute('passwordUpdate', DateTime::now());
|
||||
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), new Document([
|
||||
'password' => $user->getAttribute('password'),
|
||||
'passwordUpdate' => $user->getAttribute('passwordUpdate'),
|
||||
]));
|
||||
$queueForEvents->setParam('userId', $user->getId());
|
||||
$response->dynamic($user, Response::MODEL_USER);
|
||||
}
|
||||
@@ -1376,7 +1380,13 @@ Http::patch('/v1/users/:userId/password')
|
||||
->setAttribute('hash', $hasher->getName())
|
||||
->setAttribute('hashOptions', $hasher->getOptions());
|
||||
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), new Document([
|
||||
'password' => $user->getAttribute('password'),
|
||||
'passwordHistory' => $user->getAttribute('passwordHistory'),
|
||||
'passwordUpdate' => $user->getAttribute('passwordUpdate'),
|
||||
'hash' => $user->getAttribute('hash'),
|
||||
'hashOptions' => $user->getAttribute('hashOptions'),
|
||||
]));
|
||||
|
||||
$sessions = $user->getAttribute('sessions', []);
|
||||
$invalidate = $project->getAttribute('auths', default: [])['invalidateSessions'] ?? false;
|
||||
@@ -1468,7 +1478,15 @@ Http::patch('/v1/users/:userId/email')
|
||||
;
|
||||
|
||||
try {
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), new Document([
|
||||
'email' => $user->getAttribute('email'),
|
||||
'emailVerification' => $user->getAttribute('emailVerification'),
|
||||
'emailCanonical' => $user->getAttribute('emailCanonical'),
|
||||
'emailIsCanonical' => $user->getAttribute('emailIsCanonical'),
|
||||
'emailIsCorporate' => $user->getAttribute('emailIsCorporate'),
|
||||
'emailIsDisposable' => $user->getAttribute('emailIsDisposable'),
|
||||
'emailIsFree' => $user->getAttribute('emailIsFree'),
|
||||
]));
|
||||
/**
|
||||
* @var Document $oldTarget
|
||||
*/
|
||||
@@ -1476,7 +1494,8 @@ Http::patch('/v1/users/:userId/email')
|
||||
|
||||
if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) {
|
||||
if (\strlen($email) !== 0) {
|
||||
$dbForProject->updateDocument('targets', $oldTarget->getId(), $oldTarget->setAttribute('identifier', $email));
|
||||
$dbForProject->updateDocument('targets', $oldTarget->getId(), new Document(['identifier' => $email]));
|
||||
$oldTarget->setAttribute('identifier', $email);
|
||||
} else {
|
||||
$dbForProject->deleteDocument('targets', $oldTarget->getId());
|
||||
}
|
||||
@@ -1541,12 +1560,15 @@ Http::patch('/v1/users/:userId/phone')
|
||||
|
||||
$oldPhone = $user->getAttribute('phone');
|
||||
|
||||
// Store null instead of empty string so unique constraint allows multiple users without phone
|
||||
$phoneValue = $number !== '' ? $number : null;
|
||||
|
||||
$user
|
||||
->setAttribute('phone', $number)
|
||||
->setAttribute('phone', $phoneValue)
|
||||
->setAttribute('phoneVerification', false)
|
||||
;
|
||||
|
||||
if (\strlen($number) !== 0) {
|
||||
if ($number !== '') {
|
||||
$target = $dbForProject->findOne('targets', [
|
||||
Query::equal('identifier', [$number]),
|
||||
]);
|
||||
@@ -1557,20 +1579,24 @@ Http::patch('/v1/users/:userId/phone')
|
||||
}
|
||||
|
||||
try {
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), new Document([
|
||||
'phone' => $phoneValue,
|
||||
'phoneVerification' => $user->getAttribute('phoneVerification'),
|
||||
]));
|
||||
/**
|
||||
* @var Document $oldTarget
|
||||
*/
|
||||
$oldTarget = $user->find('identifier', $oldPhone, 'targets');
|
||||
|
||||
if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) {
|
||||
if (\strlen($number) !== 0) {
|
||||
$dbForProject->updateDocument('targets', $oldTarget->getId(), $oldTarget->setAttribute('identifier', $number));
|
||||
if ($number !== '') {
|
||||
$dbForProject->updateDocument('targets', $oldTarget->getId(), new Document(['identifier' => $number]));
|
||||
$oldTarget->setAttribute('identifier', $number);
|
||||
} else {
|
||||
$dbForProject->deleteDocument('targets', $oldTarget->getId());
|
||||
}
|
||||
} else {
|
||||
if (\strlen($number) !== 0) {
|
||||
if ($number !== '') {
|
||||
$target = $dbForProject->createDocument('targets', new Document([
|
||||
'$permissions' => [
|
||||
Permission::read(Role::user($user->getId())),
|
||||
@@ -1629,7 +1655,7 @@ Http::patch('/v1/users/:userId/verification')
|
||||
throw new Exception(Exception::USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', $emailVerification));
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), new Document(['emailVerification' => $emailVerification]));
|
||||
|
||||
$queueForEvents->setParam('userId', $user->getId());
|
||||
|
||||
@@ -1667,7 +1693,7 @@ Http::patch('/v1/users/:userId/prefs')
|
||||
throw new Exception(Exception::USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('prefs', $prefs));
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), new Document(['prefs' => $prefs]));
|
||||
|
||||
$queueForEvents
|
||||
->setParam('userId', $user->getId());
|
||||
@@ -1767,7 +1793,13 @@ Http::patch('/v1/users/:userId/targets/:targetId')
|
||||
$target->setAttribute('name', $name);
|
||||
}
|
||||
|
||||
$target = $dbForProject->updateDocument('targets', $target->getId(), $target);
|
||||
$target = $dbForProject->updateDocument('targets', $target->getId(), new Document([
|
||||
'identifier' => $target->getAttribute('identifier'),
|
||||
'expired' => $target->getAttribute('expired'),
|
||||
'providerId' => $target->getAttribute('providerId'),
|
||||
'providerInternalId' => $target->getAttribute('providerInternalId'),
|
||||
'name' => $target->getAttribute('name'),
|
||||
]));
|
||||
$dbForProject->purgeCachedDocument('users', $user->getId());
|
||||
|
||||
$queueForEvents
|
||||
@@ -1835,7 +1867,7 @@ Http::patch('/v1/users/:userId/mfa')
|
||||
|
||||
$user->setAttribute('mfa', $mfa);
|
||||
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), new Document(['mfa' => $user->getAttribute('mfa')]));
|
||||
|
||||
$queueForEvents->setParam('userId', $user->getId());
|
||||
|
||||
@@ -2023,7 +2055,7 @@ Http::patch('/v1/users/:userId/mfa/recovery-codes')
|
||||
|
||||
$mfaRecoveryCodes = Type::generateBackupCodes();
|
||||
$user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes);
|
||||
$dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
$dbForProject->updateDocument('users', $user->getId(), new Document(['mfaRecoveryCodes' => $mfaRecoveryCodes]));
|
||||
|
||||
$queueForEvents->setParam('userId', $user->getId());
|
||||
|
||||
@@ -2095,7 +2127,7 @@ Http::put('/v1/users/:userId/mfa/recovery-codes')
|
||||
|
||||
$mfaRecoveryCodes = Type::generateBackupCodes();
|
||||
$user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes);
|
||||
$dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
$dbForProject->updateDocument('users', $user->getId(), new Document(['mfaRecoveryCodes' => $mfaRecoveryCodes]));
|
||||
|
||||
$queueForEvents->setParam('userId', $user->getId());
|
||||
|
||||
|
||||
@@ -1,705 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Appwrite\Event\Build;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Request;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Appwrite\Vcs\Comment;
|
||||
use Utopia\Console;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Duplicate;
|
||||
use Utopia\Database\Helpers\ID;
|
||||
use Utopia\Database\Helpers\Permission;
|
||||
use Utopia\Database\Helpers\Role;
|
||||
use Utopia\Database\Query;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
use Utopia\Http\Http;
|
||||
use Utopia\System\System;
|
||||
use Utopia\Validator\Text;
|
||||
use Utopia\VCS\Adapter\Git\GitHub;
|
||||
use Utopia\VCS\Exception\RepositoryNotFound;
|
||||
|
||||
$createGitDeployments = function (GitHub $github, string $providerInstallationId, array $repositories, string $providerBranch, string $providerBranchUrl, string $providerRepositoryName, string $providerRepositoryUrl, string $providerRepositoryOwner, string $providerCommitHash, string $providerCommitAuthor, string $providerCommitAuthorUrl, string $providerCommitMessage, string $providerCommitUrl, string $providerPullRequestId, bool $external, Database $dbForPlatform, Authorization $authorization, Build $queueForBuilds, callable $getProjectDB, Request $request, array $platform) {
|
||||
$errors = [];
|
||||
foreach ($repositories as $repository) {
|
||||
try {
|
||||
$resourceType = $repository->getAttribute('resourceType');
|
||||
|
||||
if ($resourceType !== "function" && $resourceType !== "site") {
|
||||
continue;
|
||||
}
|
||||
|
||||
$projectId = $repository->getAttribute('projectId');
|
||||
$project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
|
||||
if ($project->isEmpty()) {
|
||||
throw new Exception(Exception::PROJECT_NOT_FOUND, 'Repository references non-existent project');
|
||||
}
|
||||
$dbForProject = $getProjectDB($project);
|
||||
|
||||
$resourceCollection = $resourceType === "function" ? 'functions' : 'sites';
|
||||
$resourceId = $repository->getAttribute('resourceId');
|
||||
$resource = $authorization->skip(fn () => $dbForProject->getDocument($resourceCollection, $resourceId));
|
||||
$resourceInternalId = $resource->getSequence();
|
||||
|
||||
$deploymentId = ID::unique();
|
||||
$repositoryId = $repository->getId();
|
||||
$repositoryInternalId = $repository->getSequence();
|
||||
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
|
||||
$installationId = $repository->getAttribute('installationId');
|
||||
$installationInternalId = $repository->getAttribute('installationInternalId');
|
||||
$productionBranch = $resource->getAttribute('providerBranch');
|
||||
$activate = false;
|
||||
|
||||
if ($providerBranch == $productionBranch && $external === false) {
|
||||
$activate = true;
|
||||
}
|
||||
|
||||
$owner = $github->getOwnerName($providerInstallationId) ?? '';
|
||||
try {
|
||||
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
|
||||
if (empty($repositoryName)) {
|
||||
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
|
||||
}
|
||||
} catch (RepositoryNotFound $e) {
|
||||
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (empty($repositoryName)) {
|
||||
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
|
||||
}
|
||||
|
||||
$isAuthorized = !$external;
|
||||
|
||||
if (!$isAuthorized && !empty($providerPullRequestId)) {
|
||||
if (\in_array($providerPullRequestId, $repository->getAttribute('providerPullRequestIds', []))) {
|
||||
$isAuthorized = true;
|
||||
}
|
||||
}
|
||||
|
||||
$commentStatus = $isAuthorized ? 'waiting' : 'failed';
|
||||
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
|
||||
$hostname = $platform['consoleHostname'] ?? '';
|
||||
|
||||
$authorizeUrl = $protocol . '://' . $hostname . "/console/git/authorize-contributor?projectId={$projectId}&installationId={$installationId}&repositoryId={$repositoryId}&providerPullRequestId={$providerPullRequestId}";
|
||||
|
||||
$action = $isAuthorized ? ['type' => 'logs'] : ['type' => 'authorize', 'url' => $authorizeUrl];
|
||||
|
||||
$latestCommentId = '';
|
||||
|
||||
if (!empty($providerPullRequestId) && $resource->getAttribute('providerSilentMode', false) === false) {
|
||||
$latestComment = $authorization->skip(fn () => $dbForPlatform->findOne('vcsComments', [
|
||||
Query::equal('providerRepositoryId', [$providerRepositoryId]),
|
||||
Query::equal('providerPullRequestId', [$providerPullRequestId]),
|
||||
Query::orderDesc('$createdAt'),
|
||||
]));
|
||||
|
||||
if (!$latestComment->isEmpty()) {
|
||||
$latestCommentId = $latestComment->getAttribute('providerCommentId', '');
|
||||
|
||||
$retries = 0;
|
||||
$lockAcquired = false;
|
||||
|
||||
while ($retries < 9) {
|
||||
$retries++;
|
||||
|
||||
try {
|
||||
$dbForPlatform->createDocument('vcsCommentLocks', new Document([
|
||||
'$id' => $latestCommentId
|
||||
]));
|
||||
$lockAcquired = true;
|
||||
break;
|
||||
} catch (\Throwable $err) {
|
||||
if ($retries >= 9) {
|
||||
Console::warning("Error creating vcs comment lock for " . $latestCommentId . ": " . $err->getMessage());
|
||||
}
|
||||
|
||||
\sleep(1);
|
||||
}
|
||||
}
|
||||
|
||||
if ($lockAcquired) {
|
||||
// Wrap in try/finally to ensure lock file gets deleted
|
||||
try {
|
||||
$comment = new Comment($platform);
|
||||
$comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId));
|
||||
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, '');
|
||||
|
||||
$latestCommentId = \strval($github->updateComment($owner, $repositoryName, $latestCommentId, $comment->generateComment()));
|
||||
} finally {
|
||||
$authorization->skip(fn () => $dbForPlatform->deleteDocument('vcsCommentLocks', $latestCommentId));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$comment = new Comment($platform);
|
||||
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, '');
|
||||
$latestCommentId = \strval($github->createComment($owner, $repositoryName, $providerPullRequestId, $comment->generateComment()));
|
||||
|
||||
if (!empty($latestCommentId)) {
|
||||
$teamId = $project->getAttribute('teamId', '');
|
||||
|
||||
$latestComment = $authorization->skip(fn () => $dbForPlatform->createDocument('vcsComments', new Document([
|
||||
'$id' => ID::unique(),
|
||||
'$permissions' => [
|
||||
Permission::read(Role::team(ID::custom($teamId))),
|
||||
Permission::update(Role::team(ID::custom($teamId), 'owner')),
|
||||
Permission::update(Role::team(ID::custom($teamId), 'developer')),
|
||||
Permission::delete(Role::team(ID::custom($teamId), 'owner')),
|
||||
Permission::delete(Role::team(ID::custom($teamId), 'developer')),
|
||||
],
|
||||
'installationInternalId' => $installationInternalId,
|
||||
'installationId' => $installationId,
|
||||
'projectInternalId' => $project->getSequence(),
|
||||
'projectId' => $project->getId(),
|
||||
'providerRepositoryId' => $providerRepositoryId,
|
||||
'providerBranch' => $providerBranch,
|
||||
'providerPullRequestId' => $providerPullRequestId,
|
||||
'providerCommentId' => $latestCommentId
|
||||
])));
|
||||
}
|
||||
}
|
||||
} elseif (!empty($providerBranch)) {
|
||||
$latestComments = $authorization->skip(fn () => $dbForPlatform->find('vcsComments', [
|
||||
Query::equal('providerRepositoryId', [$providerRepositoryId]),
|
||||
Query::equal('providerBranch', [$providerBranch]),
|
||||
Query::orderDesc('$createdAt'),
|
||||
]));
|
||||
|
||||
foreach ($latestComments as $comment) {
|
||||
$latestCommentId = $comment->getAttribute('providerCommentId', '');
|
||||
|
||||
$retries = 0;
|
||||
$lockAcquired = false;
|
||||
|
||||
while ($retries < 9) {
|
||||
$retries++;
|
||||
|
||||
try {
|
||||
$dbForPlatform->createDocument('vcsCommentLocks', new Document([
|
||||
'$id' => $latestCommentId
|
||||
]));
|
||||
$lockAcquired = true;
|
||||
break;
|
||||
} catch (\Throwable $err) {
|
||||
if ($retries >= 9) {
|
||||
Console::warning("Error creating vcs comment lock for " . $latestCommentId . ": " . $err->getMessage());
|
||||
}
|
||||
|
||||
\sleep(1);
|
||||
}
|
||||
}
|
||||
|
||||
if ($lockAcquired) {
|
||||
// Wrap in try/finally to ensure lock file gets deleted
|
||||
try {
|
||||
$comment = new Comment($platform);
|
||||
$comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId));
|
||||
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, '');
|
||||
|
||||
$latestCommentId = \strval($github->updateComment($owner, $repositoryName, $latestCommentId, $comment->generateComment()));
|
||||
} finally {
|
||||
$authorization->skip(fn () => $dbForPlatform->deleteDocument('vcsCommentLocks', $latestCommentId));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$isAuthorized) {
|
||||
$resourceName = $resource->getAttribute('name');
|
||||
$projectName = $project->getAttribute('name');
|
||||
$name = "{$resourceName} ({$projectName})";
|
||||
$message = 'Authorization required for external contributor.';
|
||||
|
||||
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
|
||||
try {
|
||||
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
|
||||
if (empty($repositoryName)) {
|
||||
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
|
||||
}
|
||||
} catch (RepositoryNotFound $e) {
|
||||
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
|
||||
}
|
||||
$owner = $github->getOwnerName($providerInstallationId);
|
||||
$github->updateCommitStatus($repositoryName, $providerCommitHash, $owner, 'failure', $message, $authorizeUrl, $name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($external) {
|
||||
$pullRequestResponse = $github->getPullRequest($owner, $repositoryName, $providerPullRequestId);
|
||||
$providerRepositoryName = $pullRequestResponse['head']['repo']['owner']['login'];
|
||||
$providerRepositoryOwner = $pullRequestResponse['head']['repo']['name'];
|
||||
}
|
||||
|
||||
$commands = [];
|
||||
if (!empty($resource->getAttribute('installCommand', ''))) {
|
||||
$commands[] = $resource->getAttribute('installCommand', '');
|
||||
}
|
||||
if (!empty($resource->getAttribute('buildCommand', ''))) {
|
||||
$commands[] = $resource->getAttribute('buildCommand', '');
|
||||
}
|
||||
if (!empty($resource->getAttribute('commands', ''))) {
|
||||
$commands[] = $resource->getAttribute('commands', '');
|
||||
}
|
||||
|
||||
$deployment = $authorization->skip(fn () => $dbForProject->createDocument('deployments', new Document([
|
||||
'$id' => $deploymentId,
|
||||
'$permissions' => [
|
||||
Permission::read(Role::any()),
|
||||
Permission::update(Role::any()),
|
||||
Permission::delete(Role::any()),
|
||||
],
|
||||
'resourceId' => $resourceId,
|
||||
'resourceInternalId' => $resourceInternalId,
|
||||
'resourceType' => $resourceCollection,
|
||||
'entrypoint' => $resource->getAttribute('entrypoint', ''),
|
||||
'buildCommands' => \implode(' && ', $commands),
|
||||
'startCommand' => $resource->getAttribute('startCommand', ''),
|
||||
'buildOutput' => $resource->getAttribute('outputDirectory', ''),
|
||||
'adapter' => $resource->getAttribute('adapter', ''),
|
||||
'fallbackFile' => $resource->getAttribute('fallbackFile', ''),
|
||||
'type' => 'vcs',
|
||||
'installationId' => $installationId,
|
||||
'installationInternalId' => $installationInternalId,
|
||||
'providerRepositoryId' => $providerRepositoryId,
|
||||
'repositoryId' => $repositoryId,
|
||||
'repositoryInternalId' => $repositoryInternalId,
|
||||
'providerBranchUrl' => $providerBranchUrl,
|
||||
'providerRepositoryName' => $providerRepositoryName,
|
||||
'providerRepositoryOwner' => $providerRepositoryOwner,
|
||||
'providerRepositoryUrl' => $providerRepositoryUrl,
|
||||
'providerCommitHash' => $providerCommitHash,
|
||||
'providerCommitAuthorUrl' => $providerCommitAuthorUrl,
|
||||
'providerCommitAuthor' => $providerCommitAuthor,
|
||||
'providerCommitMessage' => mb_strimwidth($providerCommitMessage, 0, 255, '...'),
|
||||
'providerCommitUrl' => $providerCommitUrl,
|
||||
'providerCommentId' => \strval($latestCommentId),
|
||||
'providerBranch' => $providerBranch,
|
||||
'activate' => $activate,
|
||||
])));
|
||||
|
||||
$resource = $resource
|
||||
->setAttribute('latestDeploymentId', $deployment->getId())
|
||||
->setAttribute('latestDeploymentInternalId', $deployment->getSequence())
|
||||
->setAttribute('latestDeploymentCreatedAt', $deployment->getCreatedAt())
|
||||
->setAttribute('latestDeploymentStatus', $deployment->getAttribute('status', ''));
|
||||
$authorization->skip(fn () => $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), $resource));
|
||||
|
||||
if ($resource->getCollection() === 'sites') {
|
||||
$projectId = $project->getId();
|
||||
|
||||
// Deployment preview
|
||||
$sitesDomain = $platform['sitesDomain'];
|
||||
$domain = ID::unique() . "." . $sitesDomain;
|
||||
$ruleId = md5($domain);
|
||||
$previewRuleId = $ruleId;
|
||||
$authorization->skip(
|
||||
fn () => $dbForPlatform->createDocument('rules', new Document([
|
||||
'$id' => $ruleId,
|
||||
'projectId' => $project->getId(),
|
||||
'projectInternalId' => $project->getSequence(),
|
||||
'domain' => $domain,
|
||||
'type' => 'deployment',
|
||||
'trigger' => 'deployment',
|
||||
'deploymentId' => $deployment->getId(),
|
||||
'deploymentInternalId' => $deployment->getSequence(),
|
||||
'deploymentResourceType' => 'site',
|
||||
'deploymentResourceId' => $resourceId,
|
||||
'deploymentResourceInternalId' => $resourceInternalId,
|
||||
'deploymentVcsProviderBranch' => $providerBranch,
|
||||
'status' => 'verified',
|
||||
'certificateId' => '',
|
||||
'search' => implode(' ', [$ruleId, $domain]),
|
||||
'owner' => 'Appwrite',
|
||||
'region' => $project->getAttribute('region')
|
||||
]))
|
||||
);
|
||||
|
||||
// VCS branch preview
|
||||
if (!empty($providerBranch)) {
|
||||
$domain = (new BranchDomainFilter())->apply([
|
||||
'branch' => $providerBranch,
|
||||
'resourceId' => $resource->getId(),
|
||||
'projectId' => $project->getId(),
|
||||
'sitesDomain' => $sitesDomain,
|
||||
]);
|
||||
$ruleId = md5($domain);
|
||||
try {
|
||||
$authorization->skip(
|
||||
fn () => $dbForPlatform->createDocument('rules', new Document([
|
||||
'$id' => $ruleId,
|
||||
'projectId' => $project->getId(),
|
||||
'projectInternalId' => $project->getSequence(),
|
||||
'domain' => $domain,
|
||||
'type' => 'deployment',
|
||||
'trigger' => 'deployment',
|
||||
'deploymentId' => $deployment->getId(),
|
||||
'deploymentInternalId' => $deployment->getSequence(),
|
||||
'deploymentResourceType' => 'site',
|
||||
'deploymentResourceId' => $resourceId,
|
||||
'deploymentResourceInternalId' => $resourceInternalId,
|
||||
'deploymentVcsProviderBranch' => $providerBranch,
|
||||
'status' => 'verified',
|
||||
'certificateId' => '',
|
||||
'search' => implode(' ', [$ruleId, $domain]),
|
||||
'owner' => 'Appwrite',
|
||||
'region' => $project->getAttribute('region')
|
||||
]))
|
||||
);
|
||||
} catch (Duplicate $err) {
|
||||
// Ignore, rule already exists; will be updated by builds worker
|
||||
}
|
||||
}
|
||||
|
||||
// VCS commit preview
|
||||
if (!empty($providerCommitHash)) {
|
||||
$domain = "commit-" . substr($providerCommitHash, 0, 16) . ".{$sitesDomain}";
|
||||
$ruleId = md5($domain);
|
||||
try {
|
||||
$authorization->skip(
|
||||
fn () => $dbForPlatform->createDocument('rules', new Document([
|
||||
'$id' => $ruleId,
|
||||
'projectId' => $project->getId(),
|
||||
'projectInternalId' => $project->getSequence(),
|
||||
'domain' => $domain,
|
||||
'type' => 'deployment',
|
||||
'trigger' => 'deployment',
|
||||
'deploymentId' => $deployment->getId(),
|
||||
'deploymentInternalId' => $deployment->getSequence(),
|
||||
'deploymentResourceType' => 'site',
|
||||
'deploymentResourceId' => $resourceId,
|
||||
'deploymentResourceInternalId' => $resourceInternalId,
|
||||
'deploymentVcsProviderBranch' => $providerBranch,
|
||||
'status' => 'verified',
|
||||
'certificateId' => '',
|
||||
'search' => implode(' ', [$ruleId, $domain]),
|
||||
'owner' => 'Appwrite',
|
||||
'region' => $project->getAttribute('region')
|
||||
]))
|
||||
);
|
||||
} catch (Duplicate $err) {
|
||||
// Ignore, rule already exists; will be updated by builds worker
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($resource->getCollection() === 'sites' && !empty($latestCommentId) && !empty($previewRuleId)) {
|
||||
$retries = 0;
|
||||
$lockAcquired = false;
|
||||
|
||||
while ($retries < 9) {
|
||||
$retries++;
|
||||
|
||||
try {
|
||||
$dbForPlatform->createDocument('vcsCommentLocks', new Document([
|
||||
'$id' => $latestCommentId
|
||||
]));
|
||||
$lockAcquired = true;
|
||||
break;
|
||||
} catch (\Throwable $err) {
|
||||
if ($retries >= 9) {
|
||||
Console::warning("Error creating vcs comment lock for " . $latestCommentId . ": " . $err->getMessage());
|
||||
}
|
||||
|
||||
\sleep(1);
|
||||
}
|
||||
}
|
||||
|
||||
if ($lockAcquired) {
|
||||
// Wrap in try/finally to ensure lock file gets deleted
|
||||
try {
|
||||
$rule = $authorization->skip(fn () => $dbForPlatform->getDocument('rules', $previewRuleId));
|
||||
|
||||
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
|
||||
$previewUrl = !empty($rule) ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '';
|
||||
|
||||
if (!empty($previewUrl)) {
|
||||
$comment = new Comment($platform);
|
||||
$comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId));
|
||||
$comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, $previewUrl);
|
||||
$github->updateComment($owner, $repositoryName, $latestCommentId, $comment->generateComment());
|
||||
}
|
||||
} finally {
|
||||
$authorization->skip(fn () => $dbForPlatform->deleteDocument('vcsCommentLocks', $latestCommentId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($providerCommitHash) && $resource->getAttribute('providerSilentMode', false) === false) {
|
||||
$resourceName = $resource->getAttribute('name');
|
||||
$projectName = $project->getAttribute('name');
|
||||
$region = $project->getAttribute('region', 'default');
|
||||
$name = "{$resourceName} ({$projectName})";
|
||||
$message = 'Starting...';
|
||||
|
||||
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
|
||||
try {
|
||||
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
|
||||
if (empty($repositoryName)) {
|
||||
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
|
||||
}
|
||||
} catch (RepositoryNotFound $e) {
|
||||
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
|
||||
}
|
||||
$owner = $github->getOwnerName($providerInstallationId);
|
||||
|
||||
$providerTargetUrl = $protocol . '://' . $hostname . "/console/project-$region-$projectId/$resourceCollection/$resourceType-$resourceId";
|
||||
$github->updateCommitStatus($repositoryName, $providerCommitHash, $owner, 'pending', $message, $providerTargetUrl, $name);
|
||||
}
|
||||
|
||||
$queueForBuilds
|
||||
->setType(BUILD_TYPE_DEPLOYMENT)
|
||||
->setResource($resource)
|
||||
->setDeployment($deployment)
|
||||
->setProject($project); // set the project because it won't be set for git deployments
|
||||
|
||||
$queueForBuilds->trigger(); // must trigger here so that we create a build for each function/site
|
||||
|
||||
//TODO: Add event?
|
||||
} catch (Throwable $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$queueForBuilds->reset(); // prevent shutdown hook from triggering again
|
||||
|
||||
if (!empty($errors)) {
|
||||
throw new Exception(Exception::GENERAL_UNKNOWN, \implode("\n", $errors));
|
||||
}
|
||||
};
|
||||
|
||||
Http::post('/v1/vcs/github/events')
|
||||
->desc('Create event')
|
||||
->groups(['api', 'vcs'])
|
||||
->label('scope', 'public')
|
||||
->inject('gitHub')
|
||||
->inject('request')
|
||||
->inject('response')
|
||||
->inject('dbForPlatform')
|
||||
->inject('authorization')
|
||||
->inject('getProjectDB')
|
||||
->inject('queueForBuilds')
|
||||
->inject('platform')
|
||||
->action(
|
||||
function (GitHub $github, Request $request, Response $response, Database $dbForPlatform, Authorization $authorization, callable $getProjectDB, Build $queueForBuilds, array $platform) use ($createGitDeployments) {
|
||||
$payload = $request->getRawPayload();
|
||||
$signatureRemote = $request->getHeader('x-hub-signature-256', '');
|
||||
$signatureLocal = System::getEnv('_APP_VCS_GITHUB_WEBHOOK_SECRET', '');
|
||||
|
||||
$valid = empty($signatureRemote) ? true : $github->validateWebhookEvent($payload, $signatureRemote, $signatureLocal);
|
||||
|
||||
if (!$valid) {
|
||||
throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN, "Invalid webhook payload signature. Please make sure the webhook secret has same value in your GitHub app and in the _APP_VCS_GITHUB_WEBHOOK_SECRET environment variable");
|
||||
}
|
||||
|
||||
$event = $request->getHeader('x-github-event', '');
|
||||
$privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
|
||||
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
|
||||
$parsedPayload = $github->getEvent($event, $payload);
|
||||
|
||||
if ($event == $github::EVENT_PUSH) {
|
||||
$providerBranchCreated = $parsedPayload["branchCreated"] ?? false;
|
||||
$providerBranchDeleted = $parsedPayload["branchDeleted"] ?? false;
|
||||
$providerBranch = $parsedPayload["branch"] ?? '';
|
||||
$providerBranchUrl = $parsedPayload["branchUrl"] ?? '';
|
||||
$providerRepositoryId = $parsedPayload["repositoryId"] ?? '';
|
||||
$providerRepositoryName = $parsedPayload["repositoryName"] ?? '';
|
||||
$providerInstallationId = $parsedPayload["installationId"] ?? '';
|
||||
$providerRepositoryUrl = $parsedPayload["repositoryUrl"] ?? '';
|
||||
$providerCommitHash = $parsedPayload["commitHash"] ?? '';
|
||||
$providerRepositoryOwner = $parsedPayload["owner"] ?? '';
|
||||
$providerCommitAuthorName = $parsedPayload["headCommitAuthorName"] ?? '';
|
||||
$providerCommitAuthorEmail = $parsedPayload["headCommitAuthorEmail"] ?? '';
|
||||
$providerCommitAuthorUrl = $parsedPayload["authorUrl"] ?? '';
|
||||
$providerCommitMessage = $parsedPayload["headCommitMessage"] ?? '';
|
||||
$providerCommitUrl = $parsedPayload["headCommitUrl"] ?? '';
|
||||
|
||||
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
|
||||
|
||||
//find resourceId from relevant resources table
|
||||
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
|
||||
Query::equal('providerRepositoryId', [$providerRepositoryId]),
|
||||
Query::limit(100),
|
||||
]));
|
||||
|
||||
// create new deployment only on push (not committed by us) and not when branch is created or deleted
|
||||
if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchCreated && !$providerBranchDeleted) {
|
||||
$createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $request, $platform);
|
||||
}
|
||||
} elseif ($event == $github::EVENT_INSTALLATION) {
|
||||
if ($parsedPayload["action"] == "deleted") {
|
||||
// TODO: Use worker for this job instead (update function/site as well)
|
||||
$providerInstallationId = $parsedPayload["installationId"];
|
||||
|
||||
$installations = $dbForPlatform->find('installations', [
|
||||
Query::equal('providerInstallationId', [$providerInstallationId]),
|
||||
Query::limit(1000)
|
||||
]);
|
||||
|
||||
foreach ($installations as $installation) {
|
||||
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
|
||||
Query::equal('installationInternalId', [$installation->getSequence()]),
|
||||
Query::limit(1000)
|
||||
]));
|
||||
|
||||
foreach ($repositories as $repository) {
|
||||
$authorization->skip(fn () => $dbForPlatform->deleteDocument('repositories', $repository->getId()));
|
||||
}
|
||||
|
||||
$authorization->skip(fn () => $dbForPlatform->deleteDocument('installations', $installation->getId()));
|
||||
}
|
||||
}
|
||||
} elseif ($event == $github::EVENT_PULL_REQUEST) {
|
||||
if ($parsedPayload["action"] == "opened" || $parsedPayload["action"] == "reopened" || $parsedPayload["action"] == "synchronize") {
|
||||
$providerBranch = $parsedPayload["branch"] ?? '';
|
||||
$providerBranchUrl = $parsedPayload["branchUrl"] ?? '';
|
||||
$providerRepositoryId = $parsedPayload["repositoryId"] ?? '';
|
||||
$providerRepositoryName = $parsedPayload["repositoryName"] ?? '';
|
||||
$providerInstallationId = $parsedPayload["installationId"] ?? '';
|
||||
$providerRepositoryUrl = $parsedPayload["repositoryUrl"] ?? '';
|
||||
$providerPullRequestId = $parsedPayload["pullRequestNumber"] ?? '';
|
||||
$providerCommitHash = $parsedPayload["commitHash"] ?? '';
|
||||
$providerRepositoryOwner = $parsedPayload["owner"] ?? '';
|
||||
$external = $parsedPayload["external"] ?? true;
|
||||
$providerCommitUrl = $parsedPayload["headCommitUrl"] ?? '';
|
||||
$providerCommitAuthorUrl = $parsedPayload["authorUrl"] ?? '';
|
||||
|
||||
// Ignore sync for non-external. We handle it in push webhook
|
||||
if (!$external && $parsedPayload["action"] == "synchronize") {
|
||||
return $response->json($parsedPayload);
|
||||
}
|
||||
|
||||
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
|
||||
|
||||
$commitDetails = $github->getCommit($providerRepositoryOwner, $providerRepositoryName, $providerCommitHash);
|
||||
$providerCommitAuthor = $commitDetails["commitAuthor"] ?? '';
|
||||
$providerCommitMessage = $commitDetails["commitMessage"] ?? '';
|
||||
|
||||
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
|
||||
Query::equal('providerRepositoryId', [$providerRepositoryId]),
|
||||
Query::orderDesc('$createdAt')
|
||||
]));
|
||||
|
||||
$createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $request, $platform);
|
||||
} elseif ($parsedPayload["action"] == "closed") {
|
||||
// Allowed external contributions cleanup
|
||||
|
||||
$providerRepositoryId = $parsedPayload["repositoryId"] ?? '';
|
||||
$providerPullRequestId = $parsedPayload["pullRequestNumber"] ?? '';
|
||||
$external = $parsedPayload["external"] ?? true;
|
||||
|
||||
if ($external) {
|
||||
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
|
||||
Query::equal('providerRepositoryId', [$providerRepositoryId]),
|
||||
Query::orderDesc('$createdAt')
|
||||
]));
|
||||
|
||||
foreach ($repositories as $repository) {
|
||||
$providerPullRequestIds = $repository->getAttribute('providerPullRequestIds', []);
|
||||
|
||||
if (\in_array($providerPullRequestId, $providerPullRequestIds)) {
|
||||
$providerPullRequestIds = \array_diff($providerPullRequestIds, [$providerPullRequestId]);
|
||||
$repository = $repository->setAttribute('providerPullRequestIds', $providerPullRequestIds);
|
||||
$repository = $authorization->skip(fn () => $dbForPlatform->updateDocument('repositories', $repository->getId(), $repository));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$response->json($parsedPayload);
|
||||
}
|
||||
);
|
||||
|
||||
Http::patch('/v1/vcs/github/installations/:installationId/repositories/:repositoryId')
|
||||
->desc('Update external deployment (authorize)')
|
||||
->groups(['api', 'vcs'])
|
||||
->label('scope', 'vcs.write')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'vcs',
|
||||
group: 'repositories',
|
||||
name: 'updateExternalDeployments',
|
||||
description: '/docs/references/vcs/update-external-deployments.md',
|
||||
auth: [AuthType::ADMIN],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_NOCONTENT,
|
||||
model: Response::MODEL_NONE,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('installationId', '', new Text(256), 'Installation Id')
|
||||
->param('repositoryId', '', new Text(256), 'VCS Repository Id')
|
||||
->param('providerPullRequestId', '', new Text(256), 'GitHub Pull Request Id')
|
||||
->inject('gitHub')
|
||||
->inject('response')
|
||||
->inject('project')
|
||||
->inject('dbForPlatform')
|
||||
->inject('authorization')
|
||||
->inject('getProjectDB')
|
||||
->inject('queueForBuilds')
|
||||
->inject('platform')
|
||||
->action(function (string $installationId, string $repositoryId, string $providerPullRequestId, GitHub $github, Request $request, Response $response, Document $project, Database $dbForPlatform, Authorization $authorization, callable $getProjectDB, Build $queueForBuilds, array $platform) use ($createGitDeployments) {
|
||||
$installation = $dbForPlatform->getDocument('installations', $installationId);
|
||||
|
||||
if ($installation->isEmpty()) {
|
||||
throw new Exception(Exception::INSTALLATION_NOT_FOUND);
|
||||
}
|
||||
|
||||
$repository = $authorization->skip(fn () => $dbForPlatform->findOne('repositories', [
|
||||
Query::equal('$id', [$repositoryId]),
|
||||
Query::equal('projectInternalId', [$project->getSequence()])
|
||||
]));
|
||||
|
||||
if ($repository->isEmpty()) {
|
||||
throw new Exception(Exception::REPOSITORY_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (\in_array($providerPullRequestId, $repository->getAttribute('providerPullRequestIds', []))) {
|
||||
throw new Exception(Exception::PROVIDER_CONTRIBUTION_CONFLICT);
|
||||
}
|
||||
|
||||
$providerPullRequestIds = \array_unique(\array_merge($repository->getAttribute('providerPullRequestIds', []), [$providerPullRequestId]));
|
||||
$repository = $repository->setAttribute('providerPullRequestIds', $providerPullRequestIds);
|
||||
|
||||
// TODO: Delete from array when PR is closed
|
||||
|
||||
$repository = $authorization->skip(fn () => $dbForPlatform->updateDocument('repositories', $repository->getId(), $repository));
|
||||
|
||||
$privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
|
||||
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
|
||||
$providerInstallationId = $installation->getAttribute('providerInstallationId');
|
||||
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
|
||||
|
||||
$repositories = [$repository];
|
||||
$providerRepositoryId = $repository->getAttribute('providerRepositoryId');
|
||||
|
||||
$owner = $github->getOwnerName($providerInstallationId);
|
||||
try {
|
||||
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
|
||||
if (empty($repositoryName)) {
|
||||
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
|
||||
}
|
||||
} catch (RepositoryNotFound $e) {
|
||||
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
|
||||
}
|
||||
$pullRequestResponse = $github->getPullRequest($owner, $repositoryName, $providerPullRequestId);
|
||||
|
||||
$providerBranch = \explode(':', $pullRequestResponse['head']['label'])[1] ?? '';
|
||||
$providerCommitHash = $pullRequestResponse['head']['sha'] ?? '';
|
||||
$providerBranchUrl = $pullRequestResponse['head']['repo']['html_url'] ?? '';
|
||||
$providerRepositoryName = $pullRequestResponse['head']['repo']['name'] ?? '';
|
||||
$providerRepositoryUrl = $pullRequestResponse['head']['repo']['html_url'] ?? '';
|
||||
$providerRepositoryOwner = $pullRequestResponse['head']['repo']['owner']['login'] ?? '';
|
||||
$providerCommitAuthor = $pullRequestResponse['head']['user']['login'] ?? '';
|
||||
$providerCommitAuthorUrl = $pullRequestResponse['head']['user']['html_url'] ?? '';
|
||||
$providerCommitMessage = $pullRequestResponse['title'] ?? '';
|
||||
$providerCommitUrl = $pullRequestResponse['html_url'] ?? '';
|
||||
|
||||
$createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, '', '', '', '', $providerCommitHash, '', '', '', '', $providerPullRequestId, true, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $request, $platform);
|
||||
|
||||
$response->noContent();
|
||||
});
|
||||
@@ -5,11 +5,11 @@ require_once __DIR__ . '/../init.php';
|
||||
use Ahc\Jwt\JWT;
|
||||
use Ahc\Jwt\JWTException;
|
||||
use Appwrite\Auth\Key;
|
||||
use Appwrite\Bus\Events\ExecutionCompleted;
|
||||
use Appwrite\Bus\Events\RequestCompleted;
|
||||
use Appwrite\Event\Certificate;
|
||||
use Appwrite\Event\Delete as DeleteEvent;
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Event\Execution;
|
||||
use Appwrite\Event\StatsUsage;
|
||||
use Appwrite\Extend\Exception as AppwriteException;
|
||||
use Appwrite\Network\Cors;
|
||||
use Appwrite\Platform\Appwrite;
|
||||
@@ -30,11 +30,14 @@ use Appwrite\Utopia\Response\Filters\V16 as ResponseV16;
|
||||
use Appwrite\Utopia\Response\Filters\V17 as ResponseV17;
|
||||
use Appwrite\Utopia\Response\Filters\V18 as ResponseV18;
|
||||
use Appwrite\Utopia\Response\Filters\V19 as ResponseV19;
|
||||
use Appwrite\Utopia\Response\Filters\V20 as ResponseV20;
|
||||
use Appwrite\Utopia\Response\Filters\V21 as ResponseV21;
|
||||
use Appwrite\Utopia\View;
|
||||
use Executor\Executor;
|
||||
use MaxMind\Db\Reader;
|
||||
use Swoole\Http\Request as SwooleRequest;
|
||||
use Swoole\Table;
|
||||
use Utopia\Bus\Bus;
|
||||
use Utopia\Config\Config;
|
||||
use Utopia\Console;
|
||||
use Utopia\Database\Database;
|
||||
@@ -62,7 +65,7 @@ Config::setParam('domainVerification', false);
|
||||
Config::setParam('cookieDomain', 'localhost');
|
||||
Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE);
|
||||
|
||||
function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount)
|
||||
function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount)
|
||||
{
|
||||
$host = $request->getHostname() ?? '';
|
||||
if (!empty($previewHostname)) {
|
||||
@@ -131,8 +134,9 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
|
||||
if (!$project->isEmpty() && $project->getId() !== 'console') {
|
||||
$accessedAt = $project->getAttribute('accessedAt', 0);
|
||||
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) {
|
||||
$project->setAttribute('accessedAt', DateTime::now());
|
||||
$authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project));
|
||||
$authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
|
||||
'accessedAt' => DateTime::now()
|
||||
])));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,7 +324,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
|
||||
};
|
||||
|
||||
$runtimes = Config::getParam($version === 'v2' ? 'runtimes-v2' : 'runtimes', []);
|
||||
$spec = Config::getParam('specifications')[$resource->getAttribute('specification', APP_COMPUTE_SPECIFICATION_DEFAULT)];
|
||||
$spec = Config::getParam('specifications')[$resource->getAttribute('runtimeSpecification', APP_COMPUTE_SPECIFICATION_DEFAULT)];
|
||||
|
||||
$runtime = match ($type) {
|
||||
'function' => $runtimes[$resource->getAttribute('runtime')] ?? null,
|
||||
@@ -550,6 +554,10 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($deployment->getAttribute('startCommand', ''))) {
|
||||
$startCommand = 'cd /usr/local/server/src/function/ && ' . str_replace(['"', '`', '$'], ['\\"', '\\`', '\\$'], $deployment->getAttribute('startCommand', ''));
|
||||
}
|
||||
|
||||
$runtimeEntrypoint = match ($version) {
|
||||
'v2' => '',
|
||||
default => "cp /tmp/code.$extension /mnt/code/code.$extension && nohup helpers/start.sh \"$startCommand\"",
|
||||
@@ -706,10 +714,12 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
|
||||
}
|
||||
} finally {
|
||||
if ($type === 'function' || $type === 'site') {
|
||||
$queueForExecutions
|
||||
->setExecution($execution)
|
||||
->setProject($project)
|
||||
->trigger();
|
||||
$bus->dispatch(new ExecutionCompleted(
|
||||
execution: $execution->getArrayCopy(),
|
||||
project: $project->getArrayCopy(),
|
||||
spec: $spec,
|
||||
resource: $resource->getArrayCopy(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,70 +764,12 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
|
||||
->setStatusCode($execution['responseStatusCode'] ?? 200)
|
||||
->send($body);
|
||||
|
||||
$fileSize = 0;
|
||||
$file = $request->getFiles('file');
|
||||
if (!empty($file)) {
|
||||
$fileSize = (\is_array($file['size']) && isset($file['size'][0])) ? $file['size'][0] : $file['size'];
|
||||
}
|
||||
|
||||
if (!empty($apiKey) && !empty($apiKey->getDisabledMetrics())) {
|
||||
foreach ($apiKey->getDisabledMetrics() as $key) {
|
||||
$queueForStatsUsage->disableMetric($key);
|
||||
}
|
||||
}
|
||||
|
||||
$metricTypeExecutions = str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_EXECUTIONS);
|
||||
$metricTypeIdExecutions = str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS);
|
||||
$metricTypeExecutionsCompute = str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_EXECUTIONS_COMPUTE);
|
||||
$metricTypeIdExecutionsCompute = str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_COMPUTE);
|
||||
$metricTypeExecutionsMbSeconds = str_replace(['{resourceType}'], [$deployment->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_EXECUTIONS_MB_SECONDS);
|
||||
$metricTypeIdExecutionsMBSeconds = str_replace(['{resourceType}', '{resourceInternalId}'], [$deployment->getAttribute('resourceType'), $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS);
|
||||
if ($deployment->getAttribute('resourceType') === 'sites') {
|
||||
$queueForStatsUsage
|
||||
->disableMetric(METRIC_NETWORK_REQUESTS)
|
||||
->disableMetric(METRIC_NETWORK_INBOUND)
|
||||
->disableMetric(METRIC_NETWORK_OUTBOUND);
|
||||
if ($resource->getAttribute('adapter') !== 'ssr') {
|
||||
$queueForStatsUsage
|
||||
->disableMetric(METRIC_EXECUTIONS)
|
||||
->disableMetric(METRIC_EXECUTIONS_COMPUTE)
|
||||
->disableMetric(METRIC_EXECUTIONS_MB_SECONDS)
|
||||
->disableMetric($metricTypeExecutions)
|
||||
->disableMetric($metricTypeIdExecutions)
|
||||
->disableMetric($metricTypeExecutionsCompute)
|
||||
->disableMetric($metricTypeIdExecutionsCompute)
|
||||
->disableMetric($metricTypeExecutionsMbSeconds)
|
||||
->disableMetric($metricTypeIdExecutionsMBSeconds);
|
||||
}
|
||||
|
||||
$queueForStatsUsage
|
||||
->addMetric(METRIC_SITES_REQUESTS, 1)
|
||||
->addMetric(METRIC_SITES_INBOUND, $request->getSize() + $fileSize)
|
||||
->addMetric(METRIC_SITES_OUTBOUND, $response->getSize())
|
||||
->addMetric(str_replace('{siteInternalId}', $resource->getSequence(), METRIC_SITES_ID_REQUESTS), 1)
|
||||
->addMetric(str_replace('{siteInternalId}', $resource->getSequence(), METRIC_SITES_ID_INBOUND), $request->getSize() + $fileSize)
|
||||
->addMetric(str_replace('{siteInternalId}', $resource->getSequence(), METRIC_SITES_ID_OUTBOUND), $response->getSize())
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
$compute = (int)($execution->getAttribute('duration') * 1000);
|
||||
$mbSeconds = (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT));
|
||||
$queueForStatsUsage
|
||||
->addMetric(METRIC_NETWORK_REQUESTS, 1)
|
||||
->addMetric(METRIC_NETWORK_INBOUND, $request->getSize() + $fileSize)
|
||||
->addMetric(METRIC_NETWORK_OUTBOUND, $response->getSize())
|
||||
->addMetric(METRIC_EXECUTIONS, 1)
|
||||
->addMetric($metricTypeExecutions, 1)
|
||||
->addMetric($metricTypeIdExecutions, 1)
|
||||
->addMetric(METRIC_EXECUTIONS_COMPUTE, $compute) // per project
|
||||
->addMetric($metricTypeExecutionsCompute, $compute) // per function
|
||||
->addMetric($metricTypeIdExecutionsCompute, $compute) // per function
|
||||
->addMetric(METRIC_EXECUTIONS_MB_SECONDS, $mbSeconds)
|
||||
->addMetric($metricTypeExecutionsMbSeconds, $mbSeconds)
|
||||
->addMetric($metricTypeIdExecutionsMBSeconds, $mbSeconds)
|
||||
->setProject($project)
|
||||
->trigger();
|
||||
$bus->dispatch(new RequestCompleted(
|
||||
project: $project->getArrayCopy(),
|
||||
request: $request,
|
||||
response: $response,
|
||||
deployment: $deployment->getArrayCopy(),
|
||||
));
|
||||
|
||||
/* cleanup */
|
||||
if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) {
|
||||
@@ -881,9 +833,8 @@ Http::init()
|
||||
->inject('locale')
|
||||
->inject('localeCodes')
|
||||
->inject('geodb')
|
||||
->inject('queueForStatsUsage')
|
||||
->inject('queueForEvents')
|
||||
->inject('queueForExecutions')
|
||||
->inject('bus')
|
||||
->inject('executor')
|
||||
->inject('platform')
|
||||
->inject('isResourceBlocked')
|
||||
@@ -894,7 +845,7 @@ Http::init()
|
||||
->inject('authorization')
|
||||
->inject('queueForDeletes')
|
||||
->inject('executionsRetentionCount')
|
||||
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, StatsUsage $queueForStatsUsage, Event $queueForEvents, Execution $queueForExecutions, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
|
||||
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, Event $queueForEvents, Bus $bus, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
|
||||
/*
|
||||
* Appwrite Router
|
||||
*/
|
||||
@@ -902,7 +853,7 @@ Http::init()
|
||||
$platformHostnames = $platform['hostnames'] ?? [];
|
||||
// Only run Router when external domain
|
||||
if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) {
|
||||
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
|
||||
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
|
||||
$utopia->getRoute()?->label('router', true);
|
||||
}
|
||||
}
|
||||
@@ -988,17 +939,23 @@ Http::init()
|
||||
*/
|
||||
$responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', ''));
|
||||
if ($responseFormat) {
|
||||
if (version_compare($responseFormat, '1.4.0', '<')) {
|
||||
$response->addFilter(new ResponseV16());
|
||||
if (version_compare($responseFormat, '1.9.0', '<')) {
|
||||
$response->addFilter(new ResponseV21());
|
||||
}
|
||||
if (version_compare($responseFormat, '1.5.0', '<')) {
|
||||
$response->addFilter(new ResponseV17());
|
||||
if (version_compare($responseFormat, '1.8.0', '<')) {
|
||||
$response->addFilter(new ResponseV20());
|
||||
}
|
||||
if (version_compare($responseFormat, '1.7.0', '<')) {
|
||||
$response->addFilter(new ResponseV19());
|
||||
}
|
||||
if (version_compare($responseFormat, '1.6.0', '<')) {
|
||||
$response->addFilter(new ResponseV18());
|
||||
}
|
||||
if (version_compare($responseFormat, '1.7.0', '<')) {
|
||||
$response->addFilter(new ResponseV19());
|
||||
if (version_compare($responseFormat, '1.5.0', '<')) {
|
||||
$response->addFilter(new ResponseV17());
|
||||
}
|
||||
if (version_compare($responseFormat, '1.4.0', '<')) {
|
||||
$response->addFilter(new ResponseV16());
|
||||
}
|
||||
if (version_compare($responseFormat, APP_VERSION_STABLE, '>')) {
|
||||
$warnings[] = "The current SDK is built for Appwrite " . $responseFormat . ". However, the current Appwrite server version is " . APP_VERSION_STABLE . ". Please downgrade your SDK to match the Appwrite version: https://appwrite.io/docs/sdks";
|
||||
@@ -1178,8 +1135,7 @@ Http::options()
|
||||
->inject('dbForPlatform')
|
||||
->inject('getProjectDB')
|
||||
->inject('queueForEvents')
|
||||
->inject('queueForStatsUsage')
|
||||
->inject('queueForExecutions')
|
||||
->inject('bus')
|
||||
->inject('executor')
|
||||
->inject('geodb')
|
||||
->inject('isResourceBlocked')
|
||||
@@ -1192,14 +1148,14 @@ Http::options()
|
||||
->inject('authorization')
|
||||
->inject('queueForDeletes')
|
||||
->inject('executionsRetentionCount')
|
||||
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
|
||||
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
|
||||
/*
|
||||
* Appwrite Router
|
||||
*/
|
||||
$platformHostnames = $platform['hostnames'] ?? [];
|
||||
// Only run Router when external domain
|
||||
if (!in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
|
||||
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
|
||||
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
|
||||
$utopia->getRoute()?->label('router', true);
|
||||
}
|
||||
}
|
||||
@@ -1215,12 +1171,11 @@ Http::options()
|
||||
/** OPTIONS requests in utopia do not execute shutdown handlers, as a result we need to track the OPTIONS requests explicitly
|
||||
* @see https://github.com/utopia-php/http/blob/0.33.16/src/App.php#L825-L855
|
||||
*/
|
||||
$queueForStatsUsage
|
||||
->addMetric(METRIC_NETWORK_REQUESTS, 1)
|
||||
->addMetric(METRIC_NETWORK_INBOUND, $request->getSize())
|
||||
->addMetric(METRIC_NETWORK_OUTBOUND, $response->getSize())
|
||||
->setProject($project)
|
||||
->trigger();
|
||||
$bus->dispatch(new RequestCompleted(
|
||||
project: $project->getArrayCopy(),
|
||||
request: $request,
|
||||
response: $response,
|
||||
));
|
||||
});
|
||||
|
||||
Http::error()
|
||||
@@ -1231,10 +1186,10 @@ Http::error()
|
||||
->inject('project')
|
||||
->inject('logger')
|
||||
->inject('log')
|
||||
->inject('queueForStatsUsage')
|
||||
->inject('bus')
|
||||
->inject('devKey')
|
||||
->inject('authorization')
|
||||
->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, StatsUsage $queueForStatsUsage, Document $devKey, Authorization $authorization) {
|
||||
->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization) {
|
||||
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
|
||||
$route = $utopia->getRoute();
|
||||
$class = \get_class($error);
|
||||
@@ -1307,21 +1262,12 @@ Http::error()
|
||||
*/
|
||||
if (!$publish && $project->getId() !== 'console') {
|
||||
if (!DBUser::isPrivileged($authorization->getRoles())) {
|
||||
$fileSize = 0;
|
||||
$file = $request->getFiles('file');
|
||||
if (!empty($file)) {
|
||||
$fileSize = (\is_array($file['size']) && isset($file['size'][0])) ? $file['size'][0] : $file['size'];
|
||||
}
|
||||
|
||||
$queueForStatsUsage
|
||||
->addMetric(METRIC_NETWORK_REQUESTS, 1)
|
||||
->addMetric(METRIC_NETWORK_INBOUND, $request->getSize() + $fileSize)
|
||||
->addMetric(METRIC_NETWORK_OUTBOUND, $response->getSize());
|
||||
$bus->dispatch(new RequestCompleted(
|
||||
project: $project->getArrayCopy(),
|
||||
request: $request,
|
||||
response: $response,
|
||||
));
|
||||
}
|
||||
|
||||
$queueForStatsUsage
|
||||
->setProject($project)
|
||||
->trigger();
|
||||
}
|
||||
|
||||
if ($logger && $publish) {
|
||||
@@ -1453,6 +1399,9 @@ Http::error()
|
||||
$sdk = $route?->getLabel("sdk", false);
|
||||
$action = 'UNKNOWN_NAMESPACE.UNKNOWN.METHOD';
|
||||
if (!empty($sdk)) {
|
||||
if (\is_array($sdk)) {
|
||||
$sdk = $sdk[0];
|
||||
}
|
||||
/** @var \Appwrite\SDK\Method $sdk */
|
||||
$action = $sdk->getNamespace() . '.' . $sdk->getMethodName();
|
||||
} elseif ($route === null) {
|
||||
@@ -1568,8 +1517,7 @@ Http::get('/robots.txt')
|
||||
->inject('dbForPlatform')
|
||||
->inject('getProjectDB')
|
||||
->inject('queueForEvents')
|
||||
->inject('queueForStatsUsage')
|
||||
->inject('queueForExecutions')
|
||||
->inject('bus')
|
||||
->inject('executor')
|
||||
->inject('geodb')
|
||||
->inject('isResourceBlocked')
|
||||
@@ -1579,13 +1527,13 @@ Http::get('/robots.txt')
|
||||
->inject('authorization')
|
||||
->inject('queueForDeletes')
|
||||
->inject('executionsRetentionCount')
|
||||
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
|
||||
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
|
||||
$platformHostnames = $platform['hostnames'] ?? [];
|
||||
if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
|
||||
$template = new View(__DIR__ . '/../views/general/robots.phtml');
|
||||
$response->text($template->render(false));
|
||||
} else {
|
||||
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
|
||||
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
|
||||
$utopia->getRoute()?->label('router', true);
|
||||
}
|
||||
}
|
||||
@@ -1603,8 +1551,7 @@ Http::get('/humans.txt')
|
||||
->inject('dbForPlatform')
|
||||
->inject('getProjectDB')
|
||||
->inject('queueForEvents')
|
||||
->inject('queueForStatsUsage')
|
||||
->inject('queueForExecutions')
|
||||
->inject('bus')
|
||||
->inject('executor')
|
||||
->inject('geodb')
|
||||
->inject('isResourceBlocked')
|
||||
@@ -1614,13 +1561,13 @@ Http::get('/humans.txt')
|
||||
->inject('authorization')
|
||||
->inject('queueForDeletes')
|
||||
->inject('executionsRetentionCount')
|
||||
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
|
||||
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
|
||||
$platformHostnames = $platform['hostnames'] ?? [];
|
||||
if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
|
||||
$template = new View(__DIR__ . '/../views/general/humans.phtml');
|
||||
$response->text($template->render(false));
|
||||
} else {
|
||||
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
|
||||
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
|
||||
$utopia->getRoute()?->label('router', true);
|
||||
}
|
||||
}
|
||||
@@ -1718,7 +1665,10 @@ Http::get('/v1/ping')
|
||||
->setAttribute('pingedAt', $pingedAt);
|
||||
|
||||
$authorization->skip(function () use ($dbForPlatform, $project) {
|
||||
$dbForPlatform->updateDocument('projects', $project->getId(), $project);
|
||||
$dbForPlatform->updateDocument('projects', $project->getId(), new Document([
|
||||
'pingCount' => $project->getAttribute('pingCount'),
|
||||
'pingedAt' => $project->getAttribute('pingedAt')
|
||||
]));
|
||||
});
|
||||
|
||||
$queueForEvents
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use Appwrite\Auth\Key;
|
||||
use Appwrite\Auth\MFA\Type\TOTP;
|
||||
use Appwrite\Bus\Events\RequestCompleted;
|
||||
use Appwrite\Event\Audit;
|
||||
use Appwrite\Event\Build;
|
||||
use Appwrite\Event\Database as EventDatabase;
|
||||
@@ -9,24 +10,28 @@ use Appwrite\Event\Delete;
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Event\Func;
|
||||
use Appwrite\Event\Mail;
|
||||
use Appwrite\Event\Message\Usage as UsageMessage;
|
||||
use Appwrite\Event\Messaging;
|
||||
use Appwrite\Event\Publisher\Usage as UsagePublisher;
|
||||
use Appwrite\Event\Realtime;
|
||||
use Appwrite\Event\StatsUsage;
|
||||
use Appwrite\Event\Webhook;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Extend\Exception as AppwriteException;
|
||||
use Appwrite\Functions\EventProcessor;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\Usage\Context;
|
||||
use Appwrite\Utopia\Database\Documents\User;
|
||||
use Appwrite\Utopia\Request;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Abuse\Abuse;
|
||||
use Utopia\Bus\Bus;
|
||||
use Utopia\Cache\Adapter\Filesystem;
|
||||
use Utopia\Cache\Cache;
|
||||
use Utopia\Config\Config;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\DateTime;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Duplicate as DuplicateException;
|
||||
use Utopia\Database\Helpers\Role;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
use Utopia\Database\Validator\Authorization\Input;
|
||||
@@ -50,7 +55,7 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar
|
||||
$replace = $parts[1] ?? '';
|
||||
|
||||
$params = match ($namespace) {
|
||||
'user' => (array)$user,
|
||||
'user' => (array) $user,
|
||||
'request' => $requestParams,
|
||||
default => $responsePayload,
|
||||
};
|
||||
@@ -58,13 +63,13 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar
|
||||
if (array_key_exists($replace, $params)) {
|
||||
$replacement = $params[$replace];
|
||||
// Convert to string if it's not already a string
|
||||
if (!is_string($replacement)) {
|
||||
if (! is_string($replacement)) {
|
||||
if (is_array($replacement)) {
|
||||
$replacement = json_encode($replacement);
|
||||
} elseif (is_object($replacement) && method_exists($replacement, '__toString')) {
|
||||
$replacement = (string)$replacement;
|
||||
$replacement = (string) $replacement;
|
||||
} elseif (is_scalar($replacement)) {
|
||||
$replacement = (string)$replacement;
|
||||
$replacement = (string) $replacement;
|
||||
} else {
|
||||
throw new Exception(Exception::GENERAL_SERVER_ERROR, "The server encountered an error while parsing the label: $label. Please create an issue on GitHub to allow us to investigate further https://github.com/appwrite/appwrite/issues/new/choose");
|
||||
}
|
||||
@@ -72,6 +77,7 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar
|
||||
$label = \str_replace($find, $replacement, $label);
|
||||
}
|
||||
}
|
||||
|
||||
return $label;
|
||||
};
|
||||
|
||||
@@ -157,7 +163,7 @@ Http::init()
|
||||
$scopes = $roles[$role]['scopes'];
|
||||
|
||||
// Step 5: API Key Authentication
|
||||
if (!empty($apiKey)) {
|
||||
if (! empty($apiKey)) {
|
||||
// Check if key is expired
|
||||
if ($apiKey->isExpired()) {
|
||||
throw new Exception(Exception::PROJECT_KEY_EXPIRED);
|
||||
@@ -167,7 +173,6 @@ Http::init()
|
||||
$role = $apiKey->getRole();
|
||||
$scopes = $apiKey->getScopes();
|
||||
|
||||
|
||||
// Handle special app role case
|
||||
if ($apiKey->getRole() === User::ROLE_APPS) {
|
||||
// Disable authorization checks for project API keys
|
||||
@@ -190,19 +195,19 @@ Http::init()
|
||||
// For standard keys, update last accessed time
|
||||
if (\in_array($apiKey->getType(), [API_KEY_STANDARD, API_KEY_ORGANIZATION, API_KEY_ACCOUNT])) {
|
||||
$dbKey = null;
|
||||
if (!empty($apiKey->getProjectId())) {
|
||||
if (! empty($apiKey->getProjectId())) {
|
||||
$dbKey = $project->find(
|
||||
key: 'secret',
|
||||
find: $request->getHeader('x-appwrite-key', ''),
|
||||
subject: 'keys'
|
||||
);
|
||||
} elseif (!empty($apiKey->getUserId())) {
|
||||
} elseif (! empty($apiKey->getUserId())) {
|
||||
$dbKey = $user->find(
|
||||
key: 'secret',
|
||||
find: $request->getHeader('x-appwrite-key', ''),
|
||||
subject: 'keys'
|
||||
);
|
||||
} elseif (!empty($apiKey->getTeamId())) {
|
||||
} elseif (! empty($apiKey->getTeamId())) {
|
||||
$dbKey = $team->find(
|
||||
key: 'secret',
|
||||
find: $request->getHeader('x-appwrite-key', ''),
|
||||
@@ -228,7 +233,7 @@ Http::init()
|
||||
if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) {
|
||||
$sdks = $dbKey->getAttribute('sdks', []);
|
||||
|
||||
if (!in_array($sdk, $sdks)) {
|
||||
if (! in_array($sdk, $sdks)) {
|
||||
$sdks[] = $sdk;
|
||||
|
||||
$updates->setAttribute('sdks', $sdks);
|
||||
@@ -236,14 +241,14 @@ Http::init()
|
||||
}
|
||||
}
|
||||
|
||||
if (!$updates->isEmpty()) {
|
||||
if (! $updates->isEmpty()) {
|
||||
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->updateDocument('keys', $dbKey->getId(), $updates));
|
||||
|
||||
if (!empty($apiKey->getProjectId())) {
|
||||
if (! empty($apiKey->getProjectId())) {
|
||||
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
|
||||
} elseif (!empty($apiKey->getUserId())) {
|
||||
} elseif (! empty($apiKey->getUserId())) {
|
||||
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('users', $user->getId()));
|
||||
} elseif (!empty($apiKey->getTeamId())) {
|
||||
} elseif (! empty($apiKey->getTeamId())) {
|
||||
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('teams', $team->getId()));
|
||||
}
|
||||
}
|
||||
@@ -280,7 +285,7 @@ Http::init()
|
||||
}
|
||||
}
|
||||
} // Admin User Authentication
|
||||
elseif (($project->getId() === 'console' && !$team->isEmpty() && !$user->isEmpty()) || ($project->getId() !== 'console' && !$user->isEmpty() && $mode === APP_MODE_ADMIN)) {
|
||||
elseif (($project->getId() === 'console' && ! $team->isEmpty() && ! $user->isEmpty()) || ($project->getId() !== 'console' && ! $user->isEmpty() && $mode === APP_MODE_ADMIN)) {
|
||||
$teamId = $team->getId();
|
||||
$adminRoles = [];
|
||||
$memberships = $user->getAttribute('memberships', []);
|
||||
@@ -305,7 +310,7 @@ Http::init()
|
||||
// Useful for those who have project-specific roles but don't have team-wide role.
|
||||
$scopes = ['teams.read', 'projects.read'];
|
||||
foreach ($adminRoles as $adminRole) {
|
||||
$isTeamWideRole = !str_starts_with($adminRole, 'project-');
|
||||
$isTeamWideRole = ! str_starts_with($adminRole, 'project-');
|
||||
$isProjectSpecificRole = $projectId !== 'console' && str_starts_with($adminRole, 'project-' . $projectId);
|
||||
|
||||
if ($isTeamWideRole || $isProjectSpecificRole) {
|
||||
@@ -343,34 +348,39 @@ Http::init()
|
||||
* But, for actions on resources (sites, functions, etc.) in a non-console project, we explicitly check
|
||||
* whether the admin user has necessary permission on the project (sites, functions, etc. don't have permissions associated to them).
|
||||
*/
|
||||
if (empty($apiKey) && !$user->isEmpty() && $project->getId() !== 'console' && $mode === APP_MODE_ADMIN) {
|
||||
if (empty($apiKey) && ! $user->isEmpty() && $project->getId() !== 'console' && $mode === APP_MODE_ADMIN) {
|
||||
$input = new Input(Database::PERMISSION_READ, $project->getPermissionsByType(Database::PERMISSION_READ));
|
||||
$initialStatus = $authorization->getStatus();
|
||||
$authorization->enable();
|
||||
if (!$authorization->isValid($input)) {
|
||||
if (! $authorization->isValid($input)) {
|
||||
throw new Exception(Exception::PROJECT_NOT_FOUND);
|
||||
}
|
||||
$authorization->setStatus($initialStatus);
|
||||
}
|
||||
|
||||
// Step 6: Update project and user last activity
|
||||
if (!$project->isEmpty() && $project->getId() !== 'console') {
|
||||
if (! $project->isEmpty() && $project->getId() !== 'console') {
|
||||
$accessedAt = $project->getAttribute('accessedAt', 0);
|
||||
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) {
|
||||
$project->setAttribute('accessedAt', DateTime::now());
|
||||
$authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project));
|
||||
$authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
|
||||
'accessedAt' => DateTime::now()
|
||||
])));
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($user->getId())) {
|
||||
if (! empty($user->getId())) {
|
||||
$accessedAt = $user->getAttribute('accessedAt', 0);
|
||||
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_USER_ACCESS)) > $accessedAt) {
|
||||
$user->setAttribute('accessedAt', DateTime::now());
|
||||
|
||||
if ($project->getId() !== 'console' && APP_MODE_ADMIN !== $mode) {
|
||||
$dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
if ($project->getId() !== 'console' && $mode !== APP_MODE_ADMIN) {
|
||||
$dbForProject->updateDocument('users', $user->getId(), new Document([
|
||||
'accessedAt' => $user->getAttribute('accessedAt')
|
||||
]));
|
||||
} else {
|
||||
$authorization->skip(fn () => $dbForPlatform->updateDocument('users', $user->getId(), $user));
|
||||
$authorization->skip(fn () => $dbForPlatform->updateDocument('users', $user->getId(), new Document([
|
||||
'accessedAt' => $user->getAttribute('accessedAt')
|
||||
])));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,26 +397,26 @@ Http::init()
|
||||
$method = $method[0];
|
||||
}
|
||||
|
||||
if (!empty($method)) {
|
||||
if (! empty($method)) {
|
||||
$namespace = $method->getNamespace();
|
||||
|
||||
if (
|
||||
array_key_exists($namespace, $project->getAttribute('services', []))
|
||||
&& !$project->getAttribute('services', [])[$namespace]
|
||||
&& !(User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
|
||||
&& ! $project->getAttribute('services', [])[$namespace]
|
||||
&& ! (User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
|
||||
) {
|
||||
throw new Exception(Exception::GENERAL_SERVICE_DISABLED);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 9: Validate scope permissions
|
||||
$allowed = (array)$route->getLabel('scope', 'none');
|
||||
$allowed = (array) $route->getLabel('scope', 'none');
|
||||
if (empty(\array_intersect($allowed, $scopes))) {
|
||||
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, $user->getAttribute('email', 'User') . ' (role: ' . \strtolower($roles[$role]['label']) . ') missing scopes (' . \json_encode($allowed) . ')');
|
||||
}
|
||||
|
||||
// Step 10: Check if user is blocked
|
||||
if (false === $user->getAttribute('status')) { // Account is blocked
|
||||
if ($user->getAttribute('status') === false) { // Account is blocked
|
||||
throw new Exception(Exception::USER_BLOCKED);
|
||||
}
|
||||
|
||||
@@ -424,7 +434,7 @@ Http::init()
|
||||
$minimumFactors = ($mfaEnabled && $hasMoreFactors) ? 2 : 1;
|
||||
|
||||
// Step 13: Handle Multi-Factor Authentication
|
||||
if (!in_array('mfa', $route->getGroups())) {
|
||||
if (! in_array('mfa', $route->getGroups())) {
|
||||
if ($session && \count($session->getAttribute('factors', [])) < $minimumFactors) {
|
||||
throw new Exception(Exception::USER_MORE_FACTORS_REQUIRED);
|
||||
}
|
||||
@@ -444,7 +454,7 @@ Http::init()
|
||||
->inject('queueForDeletes')
|
||||
->inject('queueForDatabase')
|
||||
->inject('queueForBuilds')
|
||||
->inject('queueForStatsUsage')
|
||||
->inject('usage')
|
||||
->inject('queueForFunctions')
|
||||
->inject('queueForMails')
|
||||
->inject('dbForProject')
|
||||
@@ -457,14 +467,14 @@ Http::init()
|
||||
->inject('telemetry')
|
||||
->inject('platform')
|
||||
->inject('authorization')
|
||||
->action(function (Http $utopia, Request $request, Response $response, Document $project, Document $user, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) {
|
||||
->action(function (Http $utopia, Request $request, Response $response, Document $project, Document $user, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) {
|
||||
|
||||
$route = $utopia->getRoute();
|
||||
|
||||
if (
|
||||
array_key_exists('rest', $project->getAttribute('apis', []))
|
||||
&& !$project->getAttribute('apis', [])['rest']
|
||||
&& !(User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
|
||||
&& ! $project->getAttribute('apis', [])['rest']
|
||||
&& ! (User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles()))
|
||||
) {
|
||||
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
|
||||
}
|
||||
@@ -476,7 +486,7 @@ Http::init()
|
||||
$abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}');
|
||||
$timeLimitArray = [];
|
||||
|
||||
$abuseKeyLabel = (!is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel;
|
||||
$abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel;
|
||||
|
||||
foreach ($abuseKeyLabel as $abuseKey) {
|
||||
$start = $request->getContentRangeStart();
|
||||
@@ -489,7 +499,7 @@ Http::init()
|
||||
->setParam('{ip}', $request->getIP())
|
||||
->setParam('{url}', $request->getHostname() . $route->getPath())
|
||||
->setParam('{method}', $request->getMethod())
|
||||
->setParam('{chunkId}', (int)($start / ($end + 1 - $start)));
|
||||
->setParam('{chunkId}', (int) ($start / ($end + 1 - $start)));
|
||||
$timeLimitArray[] = $timeLimit;
|
||||
}
|
||||
|
||||
@@ -501,7 +511,7 @@ Http::init()
|
||||
|
||||
foreach ($timeLimitArray as $timeLimit) {
|
||||
foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys
|
||||
if (!empty($value)) {
|
||||
if (! empty($value)) {
|
||||
$timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value);
|
||||
}
|
||||
}
|
||||
@@ -524,8 +534,8 @@ Http::init()
|
||||
|
||||
if (
|
||||
$enabled // Abuse is enabled
|
||||
&& !$isAppUser // User is not API key
|
||||
&& !$isPrivilegedUser // User is not an admin
|
||||
&& ! $isAppUser // User is not API key
|
||||
&& ! $isPrivilegedUser // User is not an admin
|
||||
&& $devKey->isEmpty() // request doesn't not contain development key
|
||||
&& $abuse->check() // Route is rate-limited
|
||||
) {
|
||||
@@ -554,19 +564,13 @@ Http::init()
|
||||
->setProject($project);
|
||||
|
||||
/* If a session exists, use the user associated with the session */
|
||||
if (!$user->isEmpty()) {
|
||||
if (! $user->isEmpty()) {
|
||||
$userClone = clone $user;
|
||||
// $user doesn't support `type` and can cause unintended effects.
|
||||
$userClone->setAttribute('type', ACTIVITY_TYPE_USER);
|
||||
$queueForAudits->setUser($userClone);
|
||||
}
|
||||
|
||||
if (!empty($apiKey) && !empty($apiKey->getDisabledMetrics())) {
|
||||
foreach ($apiKey->getDisabledMetrics() as $key) {
|
||||
$queueForStatsUsage->disableMetric($key);
|
||||
}
|
||||
}
|
||||
|
||||
/* Auto-set projects */
|
||||
$queueForDeletes->setProject($project);
|
||||
$queueForDatabase->setProject($project);
|
||||
@@ -580,73 +584,70 @@ Http::init()
|
||||
$queueForBuilds->setPlatform($platform);
|
||||
$queueForMails->setPlatform($platform);
|
||||
|
||||
|
||||
$useCache = $route->getLabel('cache', false);
|
||||
$storageCacheOperationsCounter = $telemetry->createCounter('storage.cache.operations.load');
|
||||
if ($useCache) {
|
||||
$route = $utopia->match($request);
|
||||
$isImageTransformation = $route->getPath() === '/v1/storage/buckets/:bucketId/files/:fileId/preview';
|
||||
$isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && !User::isPrivileged($authorization->getRoles());
|
||||
$isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && ! User::isPrivileged($authorization->getRoles());
|
||||
|
||||
$key = $request->cacheIdentifier();
|
||||
$cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key));
|
||||
$cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key));
|
||||
$cache = new Cache(
|
||||
new Filesystem(APP_STORAGE_CACHE . DIRECTORY_SEPARATOR . 'app-' . $project->getId())
|
||||
);
|
||||
$timestamp = 60 * 60 * 24 * 180; // Temporarily increase the TTL to 180 day to ensure files in the cache are still fetched.
|
||||
$data = $cache->load($key, $timestamp);
|
||||
|
||||
if (!empty($data) && !$cacheLog->isEmpty()) {
|
||||
$usageMetric = $route->getLabel('usage.metric', null);
|
||||
if ($usageMetric === METRIC_AVATARS_SCREENSHOTS_GENERATED) {
|
||||
$queueForStatsUsage->disableMetric(METRIC_AVATARS_SCREENSHOTS_GENERATED);
|
||||
}
|
||||
if (! empty($data) && ! $cacheLog->isEmpty()) {
|
||||
$parts = explode('/', $cacheLog->getAttribute('resourceType', ''));
|
||||
$type = $parts[0] ?? null;
|
||||
|
||||
if ($type === 'bucket' && (!$isImageTransformation || !$isDisabled)) {
|
||||
if ($type === 'bucket' && (! $isImageTransformation || ! $isDisabled)) {
|
||||
$bucketId = $parts[1] ?? null;
|
||||
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
|
||||
|
||||
$isToken = !$resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence();
|
||||
$isToken = ! $resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence();
|
||||
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
|
||||
|
||||
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAppUser && !$isPrivilegedUser)) {
|
||||
if ($bucket->isEmpty() || (! $bucket->getAttribute('enabled') && ! $isAppUser && ! $isPrivilegedUser)) {
|
||||
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (!$bucket->getAttribute('transformations', true) && !$isAppUser && !$isPrivilegedUser) {
|
||||
if (! $bucket->getAttribute('transformations', true) && ! $isAppUser && ! $isPrivilegedUser) {
|
||||
throw new Exception(Exception::STORAGE_BUCKET_TRANSFORMATIONS_DISABLED);
|
||||
}
|
||||
|
||||
$fileSecurity = $bucket->getAttribute('fileSecurity', false);
|
||||
$valid = $authorization->isValid(new Input(Database::PERMISSION_READ, $bucket->getRead()));
|
||||
if (!$fileSecurity && !$valid && !$isToken) {
|
||||
if (! $fileSecurity && ! $valid && ! $isToken) {
|
||||
throw new Exception(Exception::USER_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$parts = explode('/', $cacheLog->getAttribute('resource'));
|
||||
$fileId = $parts[1] ?? null;
|
||||
|
||||
if ($fileSecurity && !$valid && !$isToken) {
|
||||
if ($fileSecurity && ! $valid && ! $isToken) {
|
||||
$file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId);
|
||||
} else {
|
||||
$file = $authorization->skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId));
|
||||
}
|
||||
|
||||
if (!$resourceToken->isEmpty() && $resourceToken->getAttribute('fileInternalId') !== $file->getSequence()) {
|
||||
if (! $resourceToken->isEmpty() && $resourceToken->getAttribute('fileInternalId') !== $file->getSequence()) {
|
||||
throw new Exception(Exception::USER_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
if ($file->isEmpty()) {
|
||||
throw new Exception(Exception::STORAGE_FILE_NOT_FOUND);
|
||||
}
|
||||
//Do not update transformedAt if it's a console user
|
||||
if (!User::isPrivileged($authorization->getRoles())) {
|
||||
// Do not update transformedAt if it's a console user
|
||||
if (! User::isPrivileged($authorization->getRoles())) {
|
||||
$transformedAt = $file->getAttribute('transformedAt', '');
|
||||
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) {
|
||||
$file->setAttribute('transformedAt', DateTime::now());
|
||||
$authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $file->getAttribute('bucketInternalId'), $file->getId(), $file));
|
||||
$authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $file->getAttribute('bucketInternalId'), $file->getId(), new Document([
|
||||
'transformedAt' => $file->getAttribute('transformedAt')
|
||||
])));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -656,7 +657,7 @@ Http::init()
|
||||
->addHeader('X-Appwrite-Cache', 'hit')
|
||||
->setContentType($cacheLog->getAttribute('mimeType'));
|
||||
$storageCacheOperationsCounter->add(1, ['result' => 'hit']);
|
||||
if (!$isImageTransformation || !$isDisabled) {
|
||||
if (! $isImageTransformation || ! $isDisabled) {
|
||||
$response->send($data);
|
||||
}
|
||||
} else {
|
||||
@@ -679,7 +680,7 @@ Http::init()
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$user->isEmpty()) {
|
||||
if (! $user->isEmpty()) {
|
||||
throw new Exception(Exception::USER_SESSION_ALREADY_EXISTS);
|
||||
}
|
||||
});
|
||||
@@ -733,7 +734,8 @@ Http::shutdown()
|
||||
->inject('user')
|
||||
->inject('queueForEvents')
|
||||
->inject('queueForAudits')
|
||||
->inject('queueForStatsUsage')
|
||||
->inject('usage')
|
||||
->inject('publisherForUsage')
|
||||
->inject('queueForDeletes')
|
||||
->inject('queueForDatabase')
|
||||
->inject('queueForBuilds')
|
||||
@@ -745,11 +747,13 @@ Http::shutdown()
|
||||
->inject('authorization')
|
||||
->inject('timelimit')
|
||||
->inject('eventProcessor')
|
||||
->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, StatsUsage $queueForStatsUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor) use ($parseLabel) {
|
||||
->inject('bus')
|
||||
->inject('apiKey')
|
||||
->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey) use ($parseLabel) {
|
||||
|
||||
$responsePayload = $response->getPayload();
|
||||
|
||||
if (!empty($queueForEvents->getEvent())) {
|
||||
if (! empty($queueForEvents->getEvent())) {
|
||||
if (empty($queueForEvents->getPayload())) {
|
||||
$queueForEvents->setPayload($responsePayload);
|
||||
}
|
||||
@@ -771,7 +775,7 @@ Http::shutdown()
|
||||
}
|
||||
|
||||
// Only trigger functions if there are matching function events
|
||||
if (!empty($functionsEvents)) {
|
||||
if (! empty($functionsEvents)) {
|
||||
foreach ($generatedEvents as $event) {
|
||||
if (isset($functionsEvents[$event])) {
|
||||
$queueForFunctions
|
||||
@@ -783,7 +787,7 @@ Http::shutdown()
|
||||
}
|
||||
|
||||
// Only trigger webhooks if there are matching webhook events
|
||||
if (!empty($webhooksEvents)) {
|
||||
if (! empty($webhooksEvents)) {
|
||||
foreach ($generatedEvents as $event) {
|
||||
if (isset($webhooksEvents[$event])) {
|
||||
$queueForWebhooks
|
||||
@@ -807,7 +811,7 @@ Http::shutdown()
|
||||
|
||||
if ($abuseEnabled && \count($abuseResetCode) > 0 && \in_array($response->getStatusCode(), $abuseResetCode)) {
|
||||
$abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}');
|
||||
$abuseKeyLabel = (!is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel;
|
||||
$abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel;
|
||||
|
||||
foreach ($abuseKeyLabel as $abuseKey) {
|
||||
$start = $request->getContentRangeStart();
|
||||
@@ -820,10 +824,10 @@ Http::shutdown()
|
||||
->setParam('{ip}', $request->getIP())
|
||||
->setParam('{url}', $request->getHostname() . $route->getPath())
|
||||
->setParam('{method}', $request->getMethod())
|
||||
->setParam('{chunkId}', (int)($start / ($end + 1 - $start)));
|
||||
->setParam('{chunkId}', (int) ($start / ($end + 1 - $start)));
|
||||
|
||||
foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys
|
||||
if (!empty($value)) {
|
||||
if (! empty($value)) {
|
||||
$timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value);
|
||||
}
|
||||
}
|
||||
@@ -837,14 +841,14 @@ Http::shutdown()
|
||||
* Audit labels
|
||||
*/
|
||||
$pattern = $route->getLabel('audits.resource', null);
|
||||
if (!empty($pattern)) {
|
||||
if (! empty($pattern)) {
|
||||
$resource = $parseLabel($pattern, $responsePayload, $requestParams, $user);
|
||||
if (!empty($resource) && $resource !== $pattern) {
|
||||
if (! empty($resource) && $resource !== $pattern) {
|
||||
$queueForAudits->setResource($resource);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$user->isEmpty()) {
|
||||
if (! $user->isEmpty()) {
|
||||
$userClone = clone $user;
|
||||
// $user doesn't support `type` and can cause unintended effects.
|
||||
$userClone->setAttribute('type', ACTIVITY_TYPE_USER);
|
||||
@@ -870,13 +874,13 @@ Http::shutdown()
|
||||
$queueForAudits->setUser($user);
|
||||
}
|
||||
|
||||
if (!empty($queueForAudits->getResource()) && !$queueForAudits->getUser()->isEmpty()) {
|
||||
if (! empty($queueForAudits->getResource()) && ! $queueForAudits->getUser()->isEmpty()) {
|
||||
/**
|
||||
* audits.payload is switched to default true
|
||||
* in order to auto audit payload for all endpoints
|
||||
*/
|
||||
$pattern = $route->getLabel('audits.payload', true);
|
||||
if (!empty($pattern)) {
|
||||
if (! empty($pattern)) {
|
||||
$queueForAudits->setPayload($responsePayload);
|
||||
}
|
||||
|
||||
@@ -887,19 +891,19 @@ Http::shutdown()
|
||||
$queueForAudits->trigger();
|
||||
}
|
||||
|
||||
if (!empty($queueForDeletes->getType())) {
|
||||
if (! empty($queueForDeletes->getType())) {
|
||||
$queueForDeletes->trigger();
|
||||
}
|
||||
|
||||
if (!empty($queueForDatabase->getType())) {
|
||||
if (! empty($queueForDatabase->getType())) {
|
||||
$queueForDatabase->trigger();
|
||||
}
|
||||
|
||||
if (!empty($queueForBuilds->getType())) {
|
||||
if (! empty($queueForBuilds->getType())) {
|
||||
$queueForBuilds->trigger();
|
||||
}
|
||||
|
||||
if (!empty($queueForMessaging->getType())) {
|
||||
if (! empty($queueForMessaging->getType())) {
|
||||
$queueForMessaging->trigger();
|
||||
}
|
||||
|
||||
@@ -908,14 +912,14 @@ Http::shutdown()
|
||||
if ($useCache) {
|
||||
$resource = $resourceType = null;
|
||||
$data = $response->getPayload();
|
||||
if (!empty($data['payload'])) {
|
||||
if (! empty($data['payload'])) {
|
||||
$pattern = $route->getLabel('cache.resource', null);
|
||||
if (!empty($pattern)) {
|
||||
if (! empty($pattern)) {
|
||||
$resource = $parseLabel($pattern, $responsePayload, $requestParams, $user);
|
||||
}
|
||||
|
||||
$pattern = $route->getLabel('cache.resourceType', null);
|
||||
if (!empty($pattern)) {
|
||||
if (! empty($pattern)) {
|
||||
$resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user);
|
||||
}
|
||||
|
||||
@@ -925,21 +929,28 @@ Http::shutdown()
|
||||
|
||||
$key = $request->cacheIdentifier();
|
||||
$signature = md5($data['payload']);
|
||||
$cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key));
|
||||
$cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key));
|
||||
$accessedAt = $cacheLog->getAttribute('accessedAt', 0);
|
||||
$now = DateTime::now();
|
||||
if ($cacheLog->isEmpty()) {
|
||||
$authorization->skip(fn () => $dbForProject->createDocument('cache', new Document([
|
||||
'$id' => $key,
|
||||
'resource' => $resource,
|
||||
'resourceType' => $resourceType,
|
||||
'mimeType' => $response->getContentType(),
|
||||
'accessedAt' => $now,
|
||||
'signature' => $signature,
|
||||
])));
|
||||
try {
|
||||
$authorization->skip(fn () => $dbForProject->createDocument('cache', new Document([
|
||||
'$id' => $key,
|
||||
'resource' => $resource,
|
||||
'resourceType' => $resourceType,
|
||||
'mimeType' => $response->getContentType(),
|
||||
'accessedAt' => $now,
|
||||
'signature' => $signature,
|
||||
])));
|
||||
} catch (DuplicateException) {
|
||||
// Race condition: another concurrent request already created the cache document
|
||||
$cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key));
|
||||
}
|
||||
} elseif (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) {
|
||||
$cacheLog->setAttribute('accessedAt', $now);
|
||||
$authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), $cacheLog));
|
||||
$authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), new Document([
|
||||
'accessedAt' => $cacheLog->getAttribute('accessedAt')
|
||||
])));
|
||||
// Overwrite the file every APP_CACHE_UPDATE seconds to update the file modified time that is used in the TTL checks in cache->load()
|
||||
$cache->save($key, $data['payload']);
|
||||
}
|
||||
@@ -951,22 +962,40 @@ Http::shutdown()
|
||||
}
|
||||
|
||||
if ($project->getId() !== 'console') {
|
||||
if (!User::isPrivileged($authorization->getRoles())) {
|
||||
$fileSize = 0;
|
||||
$file = $request->getFiles('file');
|
||||
if (!empty($file)) {
|
||||
$fileSize = (\is_array($file['size']) && isset($file['size'][0])) ? $file['size'][0] : $file['size'];
|
||||
}
|
||||
|
||||
$queueForStatsUsage
|
||||
->addMetric(METRIC_NETWORK_REQUESTS, 1)
|
||||
->addMetric(METRIC_NETWORK_INBOUND, $request->getSize() + $fileSize)
|
||||
->addMetric(METRIC_NETWORK_OUTBOUND, $response->getSize());
|
||||
if (! User::isPrivileged($authorization->getRoles())) {
|
||||
$bus->dispatch(new RequestCompleted(
|
||||
project: $project->getArrayCopy(),
|
||||
request: $request,
|
||||
response: $response,
|
||||
));
|
||||
}
|
||||
|
||||
$queueForStatsUsage
|
||||
->setProject($project)
|
||||
->trigger();
|
||||
// Publish usage metrics if context has data
|
||||
if (! $usage->isEmpty()) {
|
||||
$metrics = $usage->getMetrics();
|
||||
|
||||
// Filter out API key disabled metrics using suffix pattern matching
|
||||
$disabledMetrics = $apiKey?->getDisabledMetrics() ?? [];
|
||||
if (! empty($disabledMetrics)) {
|
||||
$metrics = array_values(array_filter($metrics, function ($metric) use ($disabledMetrics) {
|
||||
foreach ($disabledMetrics as $pattern) {
|
||||
if (str_ends_with($metric['key'], $pattern)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
$message = new UsageMessage(
|
||||
project: $project,
|
||||
metrics: $metrics,
|
||||
reduce: $usage->getReduce()
|
||||
);
|
||||
|
||||
$publisherForUsage->enqueue($message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -188,6 +188,10 @@ $http->on(Constant::EVENT_AFTER_RELOAD, function ($server) {
|
||||
Console::success('Reload completed...');
|
||||
});
|
||||
|
||||
Http::setResource('bus', function ($register, $utopia) {
|
||||
return $register->get('bus')->setResolver(fn (string $name) => $utopia->getResource($name));
|
||||
}, ['register', 'utopia']);
|
||||
|
||||
include __DIR__ . '/controllers/general.php';
|
||||
|
||||
function createDatabase(Http $app, string $resourceKey, string $dbName, array $collections, mixed $pools, ?callable $extraSetup = null): void
|
||||
@@ -562,11 +566,6 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool
|
||||
|
||||
$log->addTag('method', $route?->getMethod() ?? $request->getMethod());
|
||||
$log->addTag('url', $route?->getPath() ?? $request->getURI());
|
||||
|
||||
if (str_contains($th->getMessage(), 'FTS_TERM or FTS_NUMB')) {
|
||||
$log->addTag('paramQueries', json_encode($request->getParam('queries')));
|
||||
}
|
||||
|
||||
$log->addTag('verboseType', get_class($th));
|
||||
$log->addTag('code', $th->getCode());
|
||||
// $log->addTag('projectId', $project->getId()); // TODO: Figure out how to get ProjectID, if it becomes relevant
|
||||
@@ -582,6 +581,9 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool
|
||||
|
||||
$action = 'UNKNOWN_NAMESPACE.UNKNOWN.METHOD';
|
||||
if (!empty($sdk)) {
|
||||
if (\is_array($sdk)) {
|
||||
$sdk = $sdk[0];
|
||||
}
|
||||
/** @var Appwrite\SDK\Method $sdk */
|
||||
$action = $sdk->getNamespace() . '.' . $sdk->getMethodName();
|
||||
} elseif ($route === null) {
|
||||
|
||||
@@ -93,6 +93,7 @@ const APP_SOCIAL_YOUTUBE = 'https://www.youtube.com/c/appwrite?sub_confirmation=
|
||||
const APP_COMPUTE_CPUS_DEFAULT = 0.5;
|
||||
const APP_COMPUTE_MEMORY_DEFAULT = 512;
|
||||
const APP_COMPUTE_SPECIFICATION_DEFAULT = Specification::S_1VCPU_512MB;
|
||||
const APP_COMPUTE_DEPLOYMENT_MAX_RETENTION = 100 * 365; // 100 years
|
||||
const APP_SDK_PLATFORM_SERVER = 'server';
|
||||
const APP_SDK_PLATFORM_CLIENT = 'client';
|
||||
const APP_SDK_PLATFORM_CONSOLE = 'console';
|
||||
@@ -361,6 +362,12 @@ const METRIC_AVATARS_SCREENSHOTS_GENERATED = 'avatars.screenshotsGenerated';
|
||||
const METRIC_FUNCTIONS_RUNTIME = 'functions.runtimes.{runtime}';
|
||||
const METRIC_SITES_FRAMEWORK = 'sites.frameworks.{framework}';
|
||||
|
||||
// Realtime metrics
|
||||
const METRIC_REALTIME_CONNECTIONS = 'realtime.connections';
|
||||
const METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT = 'realtime.messages.sent';
|
||||
const METRIC_REALTIME_INBOUND = 'realtime.inbound';
|
||||
const METRIC_REALTIME_OUTBOUND = 'realtime.outbound';
|
||||
|
||||
// Resource types
|
||||
const RESOURCE_TYPE_PROJECTS = 'projects';
|
||||
const RESOURCE_TYPE_FUNCTIONS = 'functions';
|
||||
|
||||
@@ -420,6 +420,7 @@ $register->set('smtp', function () {
|
||||
$mail->Password = $password;
|
||||
$mail->SMTPSecure = System::getEnv('_APP_SMTP_SECURE', '');
|
||||
$mail->SMTPAutoTLS = false;
|
||||
$mail->SMTPKeepAlive = true;
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->Timeout = 10; /* Connection timeout */
|
||||
$mail->getSMTPInstance()->Timelimit = 30; /* Timeout for each individual SMTP command (e.g. HELO, EHLO, etc.) */
|
||||
@@ -449,3 +450,11 @@ $register->set('promiseAdapter', function () {
|
||||
$register->set('hooks', function () {
|
||||
return new Hooks();
|
||||
});
|
||||
$listeners = require __DIR__ . '/../listeners.php';
|
||||
$register->set('bus', function () use ($listeners) {
|
||||
$bus = new \Utopia\Bus\Bus();
|
||||
foreach ($listeners as $listener) {
|
||||
$bus->subscribe($listener);
|
||||
}
|
||||
return $bus;
|
||||
});
|
||||
|
||||
@@ -10,15 +10,14 @@ use Appwrite\Event\Certificate;
|
||||
use Appwrite\Event\Database as EventDatabase;
|
||||
use Appwrite\Event\Delete;
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Event\Execution;
|
||||
use Appwrite\Event\Func;
|
||||
use Appwrite\Event\Mail;
|
||||
use Appwrite\Event\Messaging;
|
||||
use Appwrite\Event\Migration;
|
||||
use Appwrite\Event\Publisher\Usage as UsagePublisher;
|
||||
use Appwrite\Event\Realtime;
|
||||
use Appwrite\Event\Screenshot;
|
||||
use Appwrite\Event\StatsResources;
|
||||
use Appwrite\Event\StatsUsage;
|
||||
use Appwrite\Event\Webhook;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Functions\EventProcessor;
|
||||
@@ -27,6 +26,7 @@ use Appwrite\Network\Cors;
|
||||
use Appwrite\Network\Platform;
|
||||
use Appwrite\Network\Validator\Origin;
|
||||
use Appwrite\Network\Validator\Redirect;
|
||||
use Appwrite\Usage\Context as UsageContext;
|
||||
use Appwrite\Utopia\Database\Documents\User;
|
||||
use Appwrite\Utopia\Request;
|
||||
use Appwrite\Utopia\Response;
|
||||
@@ -58,6 +58,7 @@ use Utopia\Logger\Log;
|
||||
use Utopia\Pools\Group;
|
||||
use Utopia\Queue\Broker\Pool as BrokerPool;
|
||||
use Utopia\Queue\Publisher;
|
||||
use Utopia\Queue\Queue;
|
||||
use Utopia\Storage\Device;
|
||||
use Utopia\Storage\Device\AWS;
|
||||
use Utopia\Storage\Device\Backblaze;
|
||||
@@ -89,6 +90,7 @@ Http::setResource('register', fn () => $register);
|
||||
Http::setResource('locale', function () {
|
||||
$locale = new Locale(System::getEnv('_APP_LOCALE', 'en'));
|
||||
$locale->setFallback(System::getEnv('_APP_LOCALE', 'en'));
|
||||
|
||||
return $locale;
|
||||
});
|
||||
|
||||
@@ -109,9 +111,6 @@ Http::setResource('publisherFunctions', function (Publisher $publisher) {
|
||||
Http::setResource('publisherMigrations', function (Publisher $publisher) {
|
||||
return $publisher;
|
||||
}, ['publisher']);
|
||||
Http::setResource('publisherStatsUsage', function (Publisher $publisher) {
|
||||
return $publisher;
|
||||
}, ['publisher']);
|
||||
Http::setResource('publisherMails', function (Publisher $publisher) {
|
||||
return $publisher;
|
||||
}, ['publisher']);
|
||||
@@ -151,18 +150,19 @@ Http::setResource('queueForWebhooks', function (Publisher $publisher) {
|
||||
Http::setResource('queueForRealtime', function () {
|
||||
return new Realtime();
|
||||
}, []);
|
||||
Http::setResource('queueForStatsUsage', function (Publisher $publisher) {
|
||||
return new StatsUsage($publisher);
|
||||
}, ['publisher']);
|
||||
Http::setResource('usage', function () {
|
||||
return new UsageContext();
|
||||
}, []);
|
||||
Http::setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
Http::setResource('queueForAudits', function (Publisher $publisher) {
|
||||
return new AuditEvent($publisher);
|
||||
}, ['publisher']);
|
||||
Http::setResource('queueForFunctions', function (Publisher $publisher) {
|
||||
return new Func($publisher);
|
||||
}, ['publisher']);
|
||||
Http::setResource('queueForExecutions', function (Publisher $publisher) {
|
||||
return new Execution($publisher);
|
||||
}, ['publisher']);
|
||||
Http::setResource('eventProcessor', function () {
|
||||
return new EventProcessor();
|
||||
}, []);
|
||||
@@ -190,14 +190,14 @@ Http::setResource('allowedHostnames', function (array $platform, Document $proje
|
||||
$allowed = [...($platform['hostnames'] ?? [])];
|
||||
|
||||
/* Add platform configured hostnames */
|
||||
if (!$project->isEmpty() && $project->getId() !== 'console') {
|
||||
if (! $project->isEmpty() && $project->getId() !== 'console') {
|
||||
$platforms = $project->getAttribute('platforms', []);
|
||||
$hostnames = Platform::getHostnames($platforms);
|
||||
$allowed = [...$allowed, ...$hostnames];
|
||||
}
|
||||
|
||||
/* Add the request hostname if a dev key is found */
|
||||
if (!$devKey->isEmpty()) {
|
||||
if (! $devKey->isEmpty()) {
|
||||
$allowed[] = $request->getHostname();
|
||||
}
|
||||
|
||||
@@ -215,12 +215,12 @@ Http::setResource('allowedHostnames', function (array $platform, Document $proje
|
||||
}
|
||||
|
||||
/* Allow the request origin of rule */
|
||||
if (!$rule->isEmpty() && !empty($rule->getAttribute('domain', ''))) {
|
||||
if (! $rule->isEmpty() && ! empty($rule->getAttribute('domain', ''))) {
|
||||
$allowed[] = $rule->getAttribute('domain', '');
|
||||
}
|
||||
|
||||
/* Allow the request origin if a dev key is found */
|
||||
if (!$devKey->isEmpty() && !empty($hostname)) {
|
||||
if (! $devKey->isEmpty() && ! empty($hostname)) {
|
||||
$allowed[] = $hostname;
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ Http::setResource('allowedHostnames', function (array $platform, Document $proje
|
||||
Http::setResource('allowedSchemes', function (array $platform, Document $project) {
|
||||
$allowed = [...($platform['schemas'] ?? [])];
|
||||
|
||||
if (!$project->isEmpty() && $project->getId() !== 'console') {
|
||||
if (! $project->isEmpty() && $project->getId() !== 'console') {
|
||||
/* Add hardcoded schemes */
|
||||
$allowed[] = 'exp';
|
||||
$allowed[] = 'appwrite-callback-' . $project->getId();
|
||||
@@ -277,7 +277,7 @@ Http::setResource('rule', function (Request $request, Database $dbForPlatform, D
|
||||
|
||||
// Temporary implementation until custom wildcard domains are an official feature
|
||||
// Allow trusted projects; Used for Console (website) previews
|
||||
if (!$permitsCurrentProject && !$rule->isEmpty() && !empty($rule->getAttribute('projectId', ''))) {
|
||||
if (! $permitsCurrentProject && ! $rule->isEmpty() && ! empty($rule->getAttribute('projectId', ''))) {
|
||||
$trustedProjects = [];
|
||||
foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) {
|
||||
if (empty($trustedProject)) {
|
||||
@@ -290,7 +290,7 @@ Http::setResource('rule', function (Request $request, Database $dbForPlatform, D
|
||||
}
|
||||
}
|
||||
|
||||
if (!$permitsCurrentProject) {
|
||||
if (! $permitsCurrentProject) {
|
||||
return new Document();
|
||||
}
|
||||
|
||||
@@ -313,16 +313,18 @@ Http::setResource('cors', function (array $allowedHostnames) {
|
||||
}, ['allowedHostnames']);
|
||||
|
||||
Http::setResource('originValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) {
|
||||
if (!$devKey->isEmpty()) {
|
||||
if (! $devKey->isEmpty()) {
|
||||
return new URL();
|
||||
}
|
||||
|
||||
return new Origin($allowedHostnames, $allowedSchemes);
|
||||
}, ['devKey', 'allowedHostnames', 'allowedSchemes']);
|
||||
|
||||
Http::setResource('redirectValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) {
|
||||
if (!$devKey->isEmpty()) {
|
||||
if (! $devKey->isEmpty()) {
|
||||
return new URL();
|
||||
}
|
||||
|
||||
return new Redirect($allowedHostnames, $allowedSchemes);
|
||||
}, ['devKey', 'allowedHostnames', 'allowedSchemes']);
|
||||
|
||||
@@ -346,12 +348,11 @@ Http::setResource('user', function (string $mode, Document $project, Document $c
|
||||
* overwriting the previous value.
|
||||
* 7. If account API key is passed, use user of the account API key as long as user ID header matches too
|
||||
*/
|
||||
|
||||
$authorization->setDefaultStatus(true);
|
||||
|
||||
$store->setKey('a_session_' . $project->getId());
|
||||
|
||||
if (APP_MODE_ADMIN === $mode) {
|
||||
if ($mode === APP_MODE_ADMIN) {
|
||||
$store->setKey('a_session_' . $console->getId());
|
||||
}
|
||||
|
||||
@@ -366,7 +367,7 @@ Http::setResource('user', function (string $mode, Document $project, Document $c
|
||||
if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
|
||||
$sessionHeader = $request->getHeader('x-appwrite-session', '');
|
||||
|
||||
if (!empty($sessionHeader)) {
|
||||
if (! empty($sessionHeader)) {
|
||||
$store->decode($sessionHeader);
|
||||
}
|
||||
}
|
||||
@@ -386,14 +387,14 @@ Http::setResource('user', function (string $mode, Document $project, Document $c
|
||||
}
|
||||
|
||||
$user = null;
|
||||
if (APP_MODE_ADMIN === $mode) {
|
||||
if ($mode === APP_MODE_ADMIN) {
|
||||
/** @var User $user */
|
||||
$user = $dbForPlatform->getDocument('users', $store->getProperty('id', ''));
|
||||
} else {
|
||||
if ($project->isEmpty()) {
|
||||
$user = new User([]);
|
||||
} else {
|
||||
if (!empty($store->getProperty('id', ''))) {
|
||||
if (! empty($store->getProperty('id', ''))) {
|
||||
if ($project->getId() === 'console') {
|
||||
/** @var User $user */
|
||||
$user = $dbForPlatform->getDocument('users', $store->getProperty('id', ''));
|
||||
@@ -406,16 +407,16 @@ Http::setResource('user', function (string $mode, Document $project, Document $c
|
||||
}
|
||||
|
||||
if (
|
||||
!$user ||
|
||||
! $user ||
|
||||
$user->isEmpty() // Check a document has been found in the DB
|
||||
|| !$user->sessionVerify($store->getProperty('secret', ''), $proofForToken)
|
||||
|| ! $user->sessionVerify($store->getProperty('secret', ''), $proofForToken)
|
||||
) { // Validate user has valid login token
|
||||
$user = new User([]);
|
||||
}
|
||||
|
||||
$authJWT = $request->getHeader('x-appwrite-jwt', '');
|
||||
if (!empty($authJWT) && !$project->isEmpty()) { // JWT authentication
|
||||
if (!$user->isEmpty()) {
|
||||
if (! empty($authJWT) && ! $project->isEmpty()) { // JWT authentication
|
||||
if (! $user->isEmpty()) {
|
||||
throw new Exception(Exception::USER_JWT_AND_COOKIE_SET);
|
||||
}
|
||||
|
||||
@@ -427,7 +428,7 @@ Http::setResource('user', function (string $mode, Document $project, Document $c
|
||||
}
|
||||
|
||||
$jwtUserId = $payload['userId'] ?? '';
|
||||
if (!empty($jwtUserId)) {
|
||||
if (! empty($jwtUserId)) {
|
||||
if ($mode === APP_MODE_ADMIN) {
|
||||
$user = $dbForPlatform->getDocument('users', $jwtUserId);
|
||||
} else {
|
||||
@@ -435,7 +436,7 @@ Http::setResource('user', function (string $mode, Document $project, Document $c
|
||||
}
|
||||
}
|
||||
$jwtSessionId = $payload['sessionId'] ?? '';
|
||||
if (!empty($jwtSessionId)) {
|
||||
if (! empty($jwtSessionId)) {
|
||||
if (empty($user->find('$id', $jwtSessionId, 'sessions'))) { // Match JWT to active token
|
||||
$user = new User([]);
|
||||
}
|
||||
@@ -445,22 +446,22 @@ Http::setResource('user', function (string $mode, Document $project, Document $c
|
||||
// Account based on account API key
|
||||
$accountKey = $request->getHeader('x-appwrite-key', '');
|
||||
$accountKeyUserId = $request->getHeader('x-appwrite-user', '');
|
||||
if (!empty($accountKeyUserId) && !empty($accountKey)) {
|
||||
if (!$user->isEmpty()) {
|
||||
if (! empty($accountKeyUserId) && ! empty($accountKey)) {
|
||||
if (! $user->isEmpty()) {
|
||||
throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET);
|
||||
}
|
||||
|
||||
$accountKeyUser = $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId));
|
||||
if (!$accountKeyUser->isEmpty()) {
|
||||
if (! $accountKeyUser->isEmpty()) {
|
||||
$key = $accountKeyUser->find(
|
||||
key: 'secret',
|
||||
find: $accountKey,
|
||||
subject: 'keys'
|
||||
);
|
||||
|
||||
if (!empty($key)) {
|
||||
if (! empty($key)) {
|
||||
$expire = $key->getAttribute('expire');
|
||||
if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
|
||||
if (! empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
|
||||
throw new Exception(Exception::ACCOUNT_KEY_EXPIRED);
|
||||
}
|
||||
|
||||
@@ -479,10 +480,9 @@ Http::setResource('project', function ($dbForPlatform, $request, $console, $auth
|
||||
/** @var Appwrite\Utopia\Request $request */
|
||||
/** @var Utopia\Database\Database $dbForPlatform */
|
||||
/** @var Utopia\Database\Document $console */
|
||||
|
||||
$projectId = $request->getParam('project', $request->getHeader('x-appwrite-project', ''));
|
||||
// Realtime channel "project" can send project=Query array
|
||||
if (!\is_string($projectId)) {
|
||||
if (! \is_string($projectId)) {
|
||||
$projectId = $request->getHeader('x-appwrite-project', '');
|
||||
}
|
||||
|
||||
@@ -503,7 +503,7 @@ Http::setResource('session', function (User $user, Store $store, Token $proofFor
|
||||
$sessions = $user->getAttribute('sessions', []);
|
||||
$sessionId = $user->sessionVerify($store->getProperty('secret', ''), $proofForToken);
|
||||
|
||||
if (!$sessionId) {
|
||||
if (! $sessionId) {
|
||||
return;
|
||||
}
|
||||
foreach ($sessions as $session) {
|
||||
@@ -513,7 +513,6 @@ Http::setResource('session', function (User $user, Store $store, Token $proofFor
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}, ['user', 'store', 'proofForToken']);
|
||||
|
||||
Http::setResource('store', function (): Store {
|
||||
@@ -537,12 +536,14 @@ Http::setResource('proofForPassword', function (): Password {
|
||||
Http::setResource('proofForToken', function (): Token {
|
||||
$token = new Token();
|
||||
$token->setHash(new Sha());
|
||||
|
||||
return $token;
|
||||
});
|
||||
|
||||
Http::setResource('proofForCode', function (): Code {
|
||||
$code = new Code();
|
||||
$code->setHash(new Sha());
|
||||
|
||||
return $code;
|
||||
});
|
||||
|
||||
@@ -554,7 +555,7 @@ Http::setResource('authorization', function () {
|
||||
return new Authorization();
|
||||
}, []);
|
||||
|
||||
Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, StatsUsage $queueForStatsUsage, Authorization $authorization) {
|
||||
Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, UsageContext $usage, Authorization $authorization) {
|
||||
if ($project->isEmpty() || $project->getId() === 'console') {
|
||||
return $dbForPlatform;
|
||||
}
|
||||
@@ -588,7 +589,7 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor
|
||||
if (\in_array($dsn->getHost(), $sharedTables)) {
|
||||
$database
|
||||
->setSharedTables(true)
|
||||
->setTenant((int) $project->getSequence())
|
||||
->setTenant($project->getSequence())
|
||||
->setNamespace($dsn->getParam('namespace'));
|
||||
} else {
|
||||
$database
|
||||
@@ -619,9 +620,8 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor
|
||||
->from($queueForEvents)
|
||||
->trigger();
|
||||
|
||||
|
||||
/** Trigger webhooks events only if a project has them enabled */
|
||||
if (!empty($project->getAttribute('webhooks'))) {
|
||||
if (! empty($project->getAttribute('webhooks'))) {
|
||||
$queueForWebhooks
|
||||
->from($queueForEvents)
|
||||
->trigger();
|
||||
@@ -640,7 +640,6 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor
|
||||
*/
|
||||
$functionsEventsCacheListener = function (string $event, Document $document, Document $project, Database $dbForProject) {
|
||||
|
||||
|
||||
if ($document->getCollection() !== 'functions') {
|
||||
return;
|
||||
}
|
||||
@@ -662,7 +661,7 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor
|
||||
$dbForProject->getCache()->purge($cacheKey);
|
||||
};
|
||||
|
||||
$usageDatabaseListener = function (string $event, Document $document, StatsUsage $queueForStatsUsage) {
|
||||
$usageDatabaseListener = function (string $event, Document $document, UsageContext $usage) {
|
||||
$value = 1;
|
||||
|
||||
switch ($event) {
|
||||
@@ -682,81 +681,78 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor
|
||||
|
||||
switch (true) {
|
||||
case $document->getCollection() === 'teams':
|
||||
$queueForStatsUsage->addMetric(METRIC_TEAMS, $value); // per project
|
||||
$usage->addMetric(METRIC_TEAMS, $value); // per project
|
||||
break;
|
||||
case $document->getCollection() === 'users':
|
||||
$queueForStatsUsage->addMetric(METRIC_USERS, $value); // per project
|
||||
$usage->addMetric(METRIC_USERS, $value); // per project
|
||||
if ($event === Database::EVENT_DOCUMENT_DELETE) {
|
||||
$queueForStatsUsage->addReduce($document);
|
||||
$usage->addReduce($document);
|
||||
}
|
||||
break;
|
||||
case $document->getCollection() === 'sessions': // sessions
|
||||
$queueForStatsUsage->addMetric(METRIC_SESSIONS, $value); //per project
|
||||
$usage->addMetric(METRIC_SESSIONS, $value); // per project
|
||||
break;
|
||||
case $document->getCollection() === 'databases': // databases
|
||||
$queueForStatsUsage->addMetric(METRIC_DATABASES, $value); // per project
|
||||
$usage->addMetric(METRIC_DATABASES, $value); // per project
|
||||
|
||||
if ($event === Database::EVENT_DOCUMENT_DELETE) {
|
||||
$queueForStatsUsage->addReduce($document);
|
||||
$usage->addReduce($document);
|
||||
}
|
||||
break;
|
||||
case str_starts_with($document->getCollection(), 'database_') && !str_contains($document->getCollection(), 'collection'): //collections
|
||||
case str_starts_with($document->getCollection(), 'database_') && ! str_contains($document->getCollection(), 'collection'): // collections
|
||||
$parts = explode('_', $document->getCollection());
|
||||
$databaseInternalId = $parts[1] ?? 0;
|
||||
$queueForStatsUsage
|
||||
$usage
|
||||
->addMetric(METRIC_COLLECTIONS, $value) // per project
|
||||
->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_COLLECTIONS), $value);
|
||||
|
||||
if ($event === Database::EVENT_DOCUMENT_DELETE) {
|
||||
$queueForStatsUsage->addReduce($document);
|
||||
$usage->addReduce($document);
|
||||
}
|
||||
break;
|
||||
case str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_'): //documents
|
||||
case str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_'): // documents
|
||||
$parts = explode('_', $document->getCollection());
|
||||
$databaseInternalId = $parts[1] ?? 0;
|
||||
$databaseInternalId = $parts[1] ?? 0;
|
||||
$collectionInternalId = $parts[3] ?? 0;
|
||||
$queueForStatsUsage
|
||||
$usage
|
||||
->addMetric(METRIC_DOCUMENTS, $value) // per project
|
||||
->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_DOCUMENTS), $value) // per database
|
||||
->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS), $value); // per collection
|
||||
break;
|
||||
case $document->getCollection() === 'buckets': //buckets
|
||||
$queueForStatsUsage
|
||||
->addMetric(METRIC_BUCKETS, $value); // per project
|
||||
case $document->getCollection() === 'buckets': // buckets
|
||||
$usage->addMetric(METRIC_BUCKETS, $value); // per project
|
||||
if ($event === Database::EVENT_DOCUMENT_DELETE) {
|
||||
$queueForStatsUsage
|
||||
$usage
|
||||
->addReduce($document);
|
||||
}
|
||||
break;
|
||||
case str_starts_with($document->getCollection(), 'bucket_'): // files
|
||||
$parts = explode('_', $document->getCollection());
|
||||
$bucketInternalId = $parts[1];
|
||||
$queueForStatsUsage
|
||||
$bucketInternalId = $parts[1];
|
||||
$usage
|
||||
->addMetric(METRIC_FILES, $value) // per project
|
||||
->addMetric(METRIC_FILES_STORAGE, $document->getAttribute('sizeOriginal') * $value) // per project
|
||||
->addMetric(str_replace('{bucketInternalId}', $bucketInternalId, METRIC_BUCKET_ID_FILES), $value) // per bucket
|
||||
->addMetric(str_replace('{bucketInternalId}', $bucketInternalId, METRIC_BUCKET_ID_FILES_STORAGE), $document->getAttribute('sizeOriginal') * $value); // per bucket
|
||||
break;
|
||||
case $document->getCollection() === 'functions':
|
||||
$queueForStatsUsage
|
||||
->addMetric(METRIC_FUNCTIONS, $value); // per project
|
||||
$usage->addMetric(METRIC_FUNCTIONS, $value); // per project
|
||||
|
||||
if ($event === Database::EVENT_DOCUMENT_DELETE) {
|
||||
$queueForStatsUsage
|
||||
$usage
|
||||
->addReduce($document);
|
||||
}
|
||||
break;
|
||||
case $document->getCollection() === 'sites':
|
||||
$queueForStatsUsage
|
||||
->addMetric(METRIC_SITES, $value); // per project
|
||||
$usage->addMetric(METRIC_SITES, $value); // per project
|
||||
|
||||
if ($event === Database::EVENT_DOCUMENT_DELETE) {
|
||||
$queueForStatsUsage
|
||||
$usage
|
||||
->addReduce($document);
|
||||
}
|
||||
break;
|
||||
case $document->getCollection() === 'deployments':
|
||||
$queueForStatsUsage
|
||||
$usage
|
||||
->addMetric(METRIC_DEPLOYMENTS, $value) // per project
|
||||
->addMetric(METRIC_DEPLOYMENTS_STORAGE, $document->getAttribute('size') * $value) // per project
|
||||
->addMetric(str_replace(['{resourceType}'], [$document->getAttribute('resourceType')], METRIC_RESOURCE_TYPE_DEPLOYMENTS), $value) // per function
|
||||
@@ -776,30 +772,27 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor
|
||||
$queueForWebhooks = new Webhook($publisherWebhooks);
|
||||
$queueForRealtime = new Realtime();
|
||||
|
||||
|
||||
$database
|
||||
->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForStatsUsage))
|
||||
->on(Database::EVENT_DOCUMENT_DELETE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForStatsUsage))
|
||||
->on(Database::EVENT_DOCUMENTS_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForStatsUsage))
|
||||
->on(Database::EVENT_DOCUMENTS_DELETE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForStatsUsage))
|
||||
->on(Database::EVENT_DOCUMENTS_UPSERT, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForStatsUsage))
|
||||
->on(Database::EVENT_DOCUMENT_CREATE, 'create-trigger-events', fn ($event, $document) => $eventDatabaseListener(
|
||||
$project,
|
||||
$document,
|
||||
$response,
|
||||
$queueForEventsClone->from($queueForEvents),
|
||||
$queueForFunctions->from($queueForEvents),
|
||||
$queueForWebhooks->from($queueForEvents),
|
||||
$queueForRealtime->from($queueForEvents)
|
||||
))
|
||||
->on(Database::EVENT_DOCUMENT_CREATE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database))
|
||||
->on(Database::EVENT_DOCUMENT_UPDATE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database))
|
||||
->on(Database::EVENT_DOCUMENT_DELETE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database))
|
||||
;
|
||||
|
||||
->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage))
|
||||
->on(Database::EVENT_DOCUMENT_DELETE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage))
|
||||
->on(Database::EVENT_DOCUMENTS_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage))
|
||||
->on(Database::EVENT_DOCUMENTS_DELETE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage))
|
||||
->on(Database::EVENT_DOCUMENTS_UPSERT, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $usage))
|
||||
->on(Database::EVENT_DOCUMENT_CREATE, 'create-trigger-events', fn ($event, $document) => $eventDatabaseListener(
|
||||
$project,
|
||||
$document,
|
||||
$response,
|
||||
$queueForEventsClone->from($queueForEvents),
|
||||
$queueForFunctions->from($queueForEvents),
|
||||
$queueForWebhooks->from($queueForEvents),
|
||||
$queueForRealtime->from($queueForEvents)
|
||||
))
|
||||
->on(Database::EVENT_DOCUMENT_CREATE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database))
|
||||
->on(Database::EVENT_DOCUMENT_UPDATE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database))
|
||||
->on(Database::EVENT_DOCUMENT_DELETE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database));
|
||||
|
||||
return $database;
|
||||
}, ['pools', 'dbForPlatform', 'cache', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'queueForFunctions', 'queueForWebhooks', 'queueForRealtime', 'queueForStatsUsage', 'authorization']);
|
||||
}, ['pools', 'dbForPlatform', 'cache', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'queueForFunctions', 'queueForWebhooks', 'queueForRealtime', 'usage', 'authorization']);
|
||||
|
||||
Http::setResource('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) {
|
||||
|
||||
@@ -848,15 +841,14 @@ Http::setResource('getProjectDB', function (Group $pools, Database $dbForPlatfor
|
||||
->setMetadata('project', $project->getId())
|
||||
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
|
||||
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES)
|
||||
->setDocumentType('users', User::class)
|
||||
;
|
||||
->setDocumentType('users', User::class);
|
||||
|
||||
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
|
||||
|
||||
if (\in_array($dsn->getHost(), $sharedTables)) {
|
||||
$database
|
||||
->setSharedTables(true)
|
||||
->setTenant((int) $project->getSequence())
|
||||
->setTenant($project->getSequence())
|
||||
->setNamespace($dsn->getParam('namespace'));
|
||||
} else {
|
||||
$database
|
||||
@@ -869,6 +861,7 @@ Http::setResource('getProjectDB', function (Group $pools, Database $dbForPlatfor
|
||||
if (isset($databases[$dsn->getHost()])) {
|
||||
$database = $databases[$dsn->getHost()];
|
||||
$configure($database);
|
||||
|
||||
return $database;
|
||||
}
|
||||
|
||||
@@ -886,7 +879,7 @@ Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorizati
|
||||
|
||||
return function (?Document $project = null) use ($pools, $cache, $authorization, &$database) {
|
||||
if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
|
||||
$database->setTenant((int) $project->getSequence());
|
||||
$database->setTenant($project->getSequence());
|
||||
return $database;
|
||||
}
|
||||
|
||||
@@ -903,7 +896,7 @@ Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorizati
|
||||
|
||||
// set tenant
|
||||
if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
|
||||
$database->setTenant((int) $project->getSequence());
|
||||
$database->setTenant($project->getSequence());
|
||||
}
|
||||
|
||||
return $database;
|
||||
@@ -912,6 +905,7 @@ Http::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorizati
|
||||
|
||||
Http::setResource('audit', function ($dbForProject) {
|
||||
$adapter = new AdapterDatabase($dbForProject);
|
||||
|
||||
return new Audit($adapter);
|
||||
}, ['dbForProject']);
|
||||
|
||||
@@ -927,6 +921,7 @@ Http::setResource('cache', function (Group $pools, Telemetry $telemetry) {
|
||||
|
||||
$cache = new Cache(new Sharding($adapters));
|
||||
$cache->setTelemetry($telemetry);
|
||||
|
||||
return $cache;
|
||||
}, ['pools', 'telemetry']);
|
||||
|
||||
@@ -972,9 +967,9 @@ Http::setResource('deviceForBuilds', function ($project, Telemetry $telemetry) {
|
||||
|
||||
function getDevice(string $root, string $connection = ''): Device
|
||||
{
|
||||
$connection = !empty($connection) ? $connection : System::getEnv('_APP_CONNECTIONS_STORAGE', '');
|
||||
$connection = ! empty($connection) ? $connection : System::getEnv('_APP_CONNECTIONS_STORAGE', '');
|
||||
|
||||
if (!empty($connection)) {
|
||||
if (! empty($connection)) {
|
||||
$acl = 'private';
|
||||
$device = Storage::DEVICE_LOCAL;
|
||||
$accessKey = '';
|
||||
@@ -996,8 +991,9 @@ function getDevice(string $root, string $connection = ''): Device
|
||||
|
||||
switch ($device) {
|
||||
case Storage::DEVICE_S3:
|
||||
if (!empty($url)) {
|
||||
$bucketRoot = (!empty($bucket) ? $bucket . '/' : '') . \ltrim($root, '/');
|
||||
if (! empty($url)) {
|
||||
$bucketRoot = (! empty($bucket) ? $bucket . '/' : '') . \ltrim($root, '/');
|
||||
|
||||
return new S3($bucketRoot, $accessKey, $accessSecret, $url, $region, $acl);
|
||||
} else {
|
||||
return new AWS($root, $accessKey, $accessSecret, $bucket, $region, $acl);
|
||||
@@ -1006,6 +1002,7 @@ function getDevice(string $root, string $connection = ''): Device
|
||||
case STORAGE::DEVICE_DO_SPACES:
|
||||
$device = new DOSpaces($root, $accessKey, $accessSecret, $bucket, $region, $acl);
|
||||
$device->setHttpVersion(S3::HTTP_VERSION_1_1);
|
||||
|
||||
return $device;
|
||||
case Storage::DEVICE_BACKBLAZE:
|
||||
return new Backblaze($root, $accessKey, $accessSecret, $bucket, $region, $acl);
|
||||
@@ -1029,8 +1026,9 @@ function getDevice(string $root, string $connection = ''): Device
|
||||
$s3Bucket = System::getEnv('_APP_STORAGE_S3_BUCKET', '');
|
||||
$s3Acl = 'private';
|
||||
$s3EndpointUrl = System::getEnv('_APP_STORAGE_S3_ENDPOINT', '');
|
||||
if (!empty($s3EndpointUrl)) {
|
||||
$bucketRoot = (!empty($s3Bucket) ? $s3Bucket . '/' : '') . \ltrim($root, '/');
|
||||
if (! empty($s3EndpointUrl)) {
|
||||
$bucketRoot = (! empty($s3Bucket) ? $s3Bucket . '/' : '') . \ltrim($root, '/');
|
||||
|
||||
return new S3($bucketRoot, $s3AccessKey, $s3SecretKey, $s3EndpointUrl, $s3Region, $s3Acl);
|
||||
} else {
|
||||
return new AWS($root, $s3AccessKey, $s3SecretKey, $s3Bucket, $s3Region, $s3Acl);
|
||||
@@ -1044,6 +1042,7 @@ function getDevice(string $root, string $connection = ''): Device
|
||||
$doSpacesAcl = 'private';
|
||||
$device = new DOSpaces($root, $doSpacesAccessKey, $doSpacesSecretKey, $doSpacesBucket, $doSpacesRegion, $doSpacesAcl);
|
||||
$device->setHttpVersion(S3::HTTP_VERSION_1_1);
|
||||
|
||||
return $device;
|
||||
case Storage::DEVICE_BACKBLAZE:
|
||||
$backblazeAccessKey = System::getEnv('_APP_STORAGE_BACKBLAZE_ACCESS_KEY', '');
|
||||
@@ -1051,6 +1050,7 @@ function getDevice(string $root, string $connection = ''): Device
|
||||
$backblazeRegion = System::getEnv('_APP_STORAGE_BACKBLAZE_REGION', '');
|
||||
$backblazeBucket = System::getEnv('_APP_STORAGE_BACKBLAZE_BUCKET', '');
|
||||
$backblazeAcl = 'private';
|
||||
|
||||
return new Backblaze($root, $backblazeAccessKey, $backblazeSecretKey, $backblazeBucket, $backblazeRegion, $backblazeAcl);
|
||||
case Storage::DEVICE_LINODE:
|
||||
$linodeAccessKey = System::getEnv('_APP_STORAGE_LINODE_ACCESS_KEY', '');
|
||||
@@ -1058,6 +1058,7 @@ function getDevice(string $root, string $connection = ''): Device
|
||||
$linodeRegion = System::getEnv('_APP_STORAGE_LINODE_REGION', '');
|
||||
$linodeBucket = System::getEnv('_APP_STORAGE_LINODE_BUCKET', '');
|
||||
$linodeAcl = 'private';
|
||||
|
||||
return new Linode($root, $linodeAccessKey, $linodeSecretKey, $linodeBucket, $linodeRegion, $linodeAcl);
|
||||
case Storage::DEVICE_WASABI:
|
||||
$wasabiAccessKey = System::getEnv('_APP_STORAGE_WASABI_ACCESS_KEY', '');
|
||||
@@ -1065,6 +1066,7 @@ function getDevice(string $root, string $connection = ''): Device
|
||||
$wasabiRegion = System::getEnv('_APP_STORAGE_WASABI_REGION', '');
|
||||
$wasabiBucket = System::getEnv('_APP_STORAGE_WASABI_BUCKET', '');
|
||||
$wasabiAcl = 'private';
|
||||
|
||||
return new Wasabi($root, $wasabiAccessKey, $wasabiSecretKey, $wasabiBucket, $wasabiRegion, $wasabiAcl);
|
||||
}
|
||||
}
|
||||
@@ -1091,7 +1093,6 @@ Http::setResource('passwordsDictionary', function ($register) {
|
||||
return $register->get('passwordsDictionary');
|
||||
}, ['register']);
|
||||
|
||||
|
||||
Http::setResource('servers', function () {
|
||||
$platforms = Config::getParam('sdks');
|
||||
$server = $platforms[APP_SDK_PLATFORM_SERVER];
|
||||
@@ -1199,16 +1200,17 @@ Http::setResource('gitHub', function (Cache $cache) {
|
||||
}, ['cache']);
|
||||
|
||||
Http::setResource('requestTimestamp', function ($request) {
|
||||
//TODO: Move this to the Request class itself
|
||||
// TODO: Move this to the Request class itself
|
||||
$timestampHeader = $request->getHeader('x-appwrite-timestamp');
|
||||
$requestTimestamp = null;
|
||||
if (!empty($timestampHeader)) {
|
||||
if (! empty($timestampHeader)) {
|
||||
try {
|
||||
$requestTimestamp = new \DateTime($timestampHeader);
|
||||
} catch (\Throwable $e) {
|
||||
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid X-Appwrite-Timestamp header value');
|
||||
}
|
||||
}
|
||||
|
||||
return $requestTimestamp;
|
||||
}, ['request']);
|
||||
|
||||
@@ -1225,13 +1227,13 @@ Http::setResource('devKey', function (Request $request, Document $project, array
|
||||
|
||||
// Check if given key match project's development keys
|
||||
$key = $project->find('secret', $devKey, 'devKeys');
|
||||
if (!$key) {
|
||||
if (! $key) {
|
||||
return new Document([]);
|
||||
}
|
||||
|
||||
// check expiration
|
||||
$expire = $key->getAttribute('expire');
|
||||
if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
|
||||
if (! empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) {
|
||||
return new Document([]);
|
||||
}
|
||||
|
||||
@@ -1239,7 +1241,9 @@ Http::setResource('devKey', function (Request $request, Document $project, array
|
||||
$accessedAt = $key->getAttribute('accessedAt', 0);
|
||||
if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_KEY_ACCESS)) > $accessedAt) {
|
||||
$key->setAttribute('accessedAt', DatabaseDateTime::now());
|
||||
$authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), $key));
|
||||
$authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([
|
||||
'accessedAt' => $key->getAttribute('accessedAt')
|
||||
])));
|
||||
$dbForPlatform->purgeCachedDocument('projects', $project->getId());
|
||||
}
|
||||
|
||||
@@ -1250,13 +1254,16 @@ Http::setResource('devKey', function (Request $request, Document $project, array
|
||||
if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) {
|
||||
$sdks = $key->getAttribute('sdks', []);
|
||||
|
||||
if (!in_array($sdk, $sdks)) {
|
||||
if (! in_array($sdk, $sdks)) {
|
||||
$sdks[] = $sdk;
|
||||
$key->setAttribute('sdks', $sdks);
|
||||
|
||||
/** Update access time as well */
|
||||
$key->setAttribute('accessedAt', DatabaseDateTime::now());
|
||||
$key = $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), $key));
|
||||
$key = $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([
|
||||
'sdks' => $key->getAttribute('sdks'),
|
||||
'accessedAt' => $key->getAttribute('accessedAt')
|
||||
])));
|
||||
$dbForPlatform->purgeCachedDocument('projects', $project->getId());
|
||||
}
|
||||
}
|
||||
@@ -1270,7 +1277,7 @@ Http::setResource('team', function (Document $project, Database $dbForPlatform,
|
||||
$teamInternalId = $project->getAttribute('teamInternalId', '');
|
||||
} else {
|
||||
$route = $utopia->match($request);
|
||||
$path = !empty($route) ? $route->getPath() : $request->getURI();
|
||||
$path = ! empty($route) ? $route->getPath() : $request->getURI();
|
||||
$orgHeader = $request->getHeader('x-appwrite-organization', '');
|
||||
if (str_starts_with($path, '/v1/projects/:projectId')) {
|
||||
$uri = $request->getURI();
|
||||
@@ -1285,8 +1292,9 @@ Http::setResource('team', function (Document $project, Database $dbForPlatform,
|
||||
}
|
||||
|
||||
$team = $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $teamId));
|
||||
|
||||
return $team;
|
||||
} elseif (!empty($orgHeader)) {
|
||||
} elseif (! empty($orgHeader)) {
|
||||
return $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $orgHeader));
|
||||
}
|
||||
}
|
||||
@@ -1316,13 +1324,13 @@ Http::setResource('previewHostname', function (Request $request, ?Key $apiKey) {
|
||||
|
||||
if (Http::isDevelopment()) {
|
||||
$allowed = true;
|
||||
} elseif (!\is_null($apiKey) && $apiKey->getHostnameOverride() === true) {
|
||||
} elseif (! \is_null($apiKey) && $apiKey->getHostnameOverride() === true) {
|
||||
$allowed = true;
|
||||
}
|
||||
|
||||
if ($allowed) {
|
||||
$host = $request->getQuery('appwrite-hostname', $request->getHeader('x-appwrite-hostname', '')) ?? '';
|
||||
if (!empty($host)) {
|
||||
if (! empty($host)) {
|
||||
return $host;
|
||||
}
|
||||
}
|
||||
@@ -1343,19 +1351,19 @@ Http::setResource('apiKey', function (Request $request, Document $project, Docum
|
||||
$organizationHeader = $request->getHeader('x-appwrite-organization');
|
||||
$projectHeader = $request->getHeader('x-appwrite-project');
|
||||
|
||||
if (!empty($key->getProjectId())) {
|
||||
if (! empty($key->getProjectId())) {
|
||||
if (empty($projectHeader) || $projectHeader !== $key->getProjectId()) {
|
||||
throw new Exception(Exception::PROJECT_ID_MISSING);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($key->getUserId())) {
|
||||
if (! empty($key->getUserId())) {
|
||||
if (empty($userHeader) || $userHeader !== $key->getUserId()) {
|
||||
throw new Exception(Exception::USER_ID_MISSING);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($key->getTeamId())) {
|
||||
if (! empty($key->getTeamId())) {
|
||||
if (empty($organizationHeader) || $organizationHeader !== $key->getTeamId()) {
|
||||
throw new Exception(Exception::ORGANIZATION_ID_MISSING);
|
||||
}
|
||||
@@ -1369,7 +1377,7 @@ Http::setResource('executor', fn () => new Executor());
|
||||
Http::setResource('resourceToken', function ($project, $dbForProject, $request, Authorization $authorization) {
|
||||
$tokenJWT = $request->getParam('token');
|
||||
|
||||
if (!empty($tokenJWT) && !$project->isEmpty()) { // JWT authentication
|
||||
if (! empty($tokenJWT) && ! $project->isEmpty()) { // JWT authentication
|
||||
// Use a large but reasonable maxAge to avoid auto-exp when token has no expiry
|
||||
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), RESOURCE_TOKEN_ALGORITHM, RESOURCE_TOKEN_MAX_AGE, RESOURCE_TOKEN_LEEWAY); // Instantiate with key, algo, maxAge and leeway.
|
||||
|
||||
@@ -1413,7 +1421,9 @@ Http::setResource('resourceToken', function ($project, $dbForProject, $request,
|
||||
$accessedAt = $token->getAttribute('accessedAt', 0);
|
||||
if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_RESOURCE_TOKEN_ACCESS)) > $accessedAt) {
|
||||
$token->setAttribute('accessedAt', DatabaseDateTime::now());
|
||||
$authorization->skip(fn () => $dbForProject->updateDocument('resourceTokens', $token->getId(), $token));
|
||||
$authorization->skip(fn () => $dbForProject->updateDocument('resourceTokens', $token->getId(), new Document([
|
||||
'accessedAt' => $token->getAttribute('accessedAt')
|
||||
])));
|
||||
}
|
||||
|
||||
return new Document([
|
||||
@@ -1427,6 +1437,7 @@ Http::setResource('resourceToken', function ($project, $dbForProject, $request,
|
||||
default => throw new Exception(Exception::TOKEN_RESOURCE_TYPE_INVALID),
|
||||
};
|
||||
}
|
||||
|
||||
return new Document([]);
|
||||
}, ['project', 'dbForProject', 'request', 'authorization']);
|
||||
|
||||
|
||||
@@ -5,4 +5,9 @@ use Utopia\Span\Span;
|
||||
use Utopia\Span\Storage;
|
||||
|
||||
Span::setStorage(new Storage\Coroutine());
|
||||
Span::addExporter(new Exporter\Pretty());
|
||||
Span::addExporter(new Exporter\Pretty(), function (Span $span): bool {
|
||||
if (\str_starts_with($span->getAction(), 'listener.')) {
|
||||
return $span->getError() !== null;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use Appwrite\Bus\Listeners\Log;
|
||||
use Appwrite\Bus\Listeners\Usage;
|
||||
|
||||
return [
|
||||
new Log(),
|
||||
new Usage(),
|
||||
];
|
||||
@@ -115,7 +115,7 @@ if (!function_exists('getProjectDB')) {
|
||||
if (\in_array($dsn->getHost(), $sharedTables)) {
|
||||
$database
|
||||
->setSharedTables(true)
|
||||
->setTenant((int)$project->getSequence())
|
||||
->setTenant($project->getSequence())
|
||||
->setNamespace($dsn->getParam('namespace'));
|
||||
} else {
|
||||
$database
|
||||
@@ -224,6 +224,13 @@ if (!function_exists('getTelemetry')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('triggerStats')) {
|
||||
function triggerStats(array $event, string $projectId): void
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$realtime = getRealtime();
|
||||
|
||||
/**
|
||||
@@ -248,48 +255,62 @@ $adapter
|
||||
|
||||
$server = new Server($adapter);
|
||||
|
||||
$logError = function (Throwable $error, string $action) use ($register) {
|
||||
$logger = $register->get('realtimeLogger');
|
||||
// Allows overriding
|
||||
if (!function_exists('logError')) {
|
||||
function logError(Throwable $error, string $action, array $tags = [], ?Document $project = null, ?Document $user = null, ?Authorization $authorization = null): void
|
||||
{
|
||||
global $register;
|
||||
|
||||
if ($logger && !$error instanceof Exception) {
|
||||
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
|
||||
$logger = $register->get('realtimeLogger');
|
||||
|
||||
$log = new Log();
|
||||
$log->setNamespace("realtime");
|
||||
$log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
|
||||
$log->setVersion($version);
|
||||
$log->setType(Log::TYPE_ERROR);
|
||||
$log->setMessage($error->getMessage());
|
||||
if ($logger && !$error instanceof Exception) {
|
||||
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
|
||||
|
||||
$log->addTag('code', $error->getCode());
|
||||
$log->addTag('verboseType', get_class($error));
|
||||
$log = new Log();
|
||||
$log->setNamespace("realtime");
|
||||
$log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
|
||||
$log->setVersion($version);
|
||||
$log->setType(Log::TYPE_ERROR);
|
||||
$log->setMessage($error->getMessage());
|
||||
|
||||
$log->addExtra('file', $error->getFile());
|
||||
$log->addExtra('line', $error->getLine());
|
||||
$log->addExtra('trace', $error->getTraceAsString());
|
||||
$log->addTag('code', $error->getCode());
|
||||
$log->addTag('verboseType', get_class($error));
|
||||
$log->addTag('projectId', $project?->getId() ?: 'n/a');
|
||||
$log->addTag('userId', $user?->getId() ?: 'n/a');
|
||||
|
||||
$log->setAction($action);
|
||||
foreach ($tags as $key => $value) {
|
||||
$log->addTag($key, $value ?: 'n/a');
|
||||
}
|
||||
|
||||
$isProduction = System::getEnv('_APP_ENV', 'development') === 'production';
|
||||
$log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING);
|
||||
$log->addExtra('file', $error->getFile());
|
||||
$log->addExtra('line', $error->getLine());
|
||||
$log->addExtra('trace', $error->getTraceAsString());
|
||||
$log->addExtra('detailedTrace', $error->getTrace());
|
||||
$log->addExtra('roles', $authorization?->getRoles() ?? []);
|
||||
|
||||
try {
|
||||
$responseCode = $logger->addLog($log);
|
||||
Console::info('Error log pushed with status code: ' . $responseCode);
|
||||
} catch (Throwable $th) {
|
||||
Console::error('Error pushing log: ' . $th->getMessage());
|
||||
$log->setAction($action);
|
||||
|
||||
$isProduction = System::getEnv('_APP_ENV', 'development') === 'production';
|
||||
$log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING);
|
||||
|
||||
try {
|
||||
$responseCode = $logger->addLog($log);
|
||||
Console::info('Error log pushed with status code: ' . $responseCode);
|
||||
} catch (Throwable $th) {
|
||||
Console::error('Error pushing log: ' . $th->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
Console::error('[Error] Type: ' . get_class($error));
|
||||
Console::error('[Error] Message: ' . $error->getMessage());
|
||||
Console::error('[Error] File: ' . $error->getFile());
|
||||
Console::error('[Error] Line: ' . $error->getLine());
|
||||
}
|
||||
}
|
||||
|
||||
Console::error('[Error] Type: ' . get_class($error));
|
||||
Console::error('[Error] Message: ' . $error->getMessage());
|
||||
Console::error('[Error] File: ' . $error->getFile());
|
||||
Console::error('[Error] Line: ' . $error->getLine());
|
||||
};
|
||||
$server->error(logError(...));
|
||||
|
||||
$server->error($logError);
|
||||
|
||||
$server->onStart(function () use ($stats, $register, $containerId, &$statsDocument, $logError) {
|
||||
$server->onStart(function () use ($stats, $register, $containerId, &$statsDocument) {
|
||||
sleep(5); // wait for the initial database schema to be ready
|
||||
Console::success('Server started successfully');
|
||||
|
||||
@@ -326,7 +347,7 @@ $server->onStart(function () use ($stats, $register, $containerId, &$statsDocume
|
||||
*/
|
||||
// TODO: Remove this if check once it doesn't cause issues for cloud
|
||||
if (System::getEnv('_APP_EDITION', 'self-hosted') === 'self-hosted') {
|
||||
Timer::tick(5000, function () use ($register, $stats, &$statsDocument, $logError) {
|
||||
Timer::tick(5000, function () use ($register, $stats, &$statsDocument) {
|
||||
$payload = [];
|
||||
foreach ($stats as $projectId => $value) {
|
||||
$payload[$projectId] = $stats->get($projectId, 'connectionsTotal');
|
||||
@@ -342,15 +363,18 @@ $server->onStart(function () use ($stats, $register, $containerId, &$statsDocume
|
||||
->setAttribute('timestamp', DateTime::now())
|
||||
->setAttribute('value', json_encode($payload));
|
||||
|
||||
$database->getAuthorization()->skip(fn () => $database->updateDocument('realtime', $statsDocument->getId(), $statsDocument));
|
||||
$database->getAuthorization()->skip(fn () => $database->updateDocument('realtime', $statsDocument->getId(), new Document([
|
||||
'timestamp' => $statsDocument->getAttribute('timestamp'),
|
||||
'value' => $statsDocument->getAttribute('value')
|
||||
])));
|
||||
} catch (Throwable $th) {
|
||||
$logError($th, "updateWorkerDocument");
|
||||
logError($th, "updateWorkerDocument");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $realtime, $logError) {
|
||||
$server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $realtime) {
|
||||
Console::success('Worker ' . $workerId . ' started successfully');
|
||||
|
||||
$telemetry = getTelemetry($workerId);
|
||||
@@ -362,7 +386,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
|
||||
$attempts = 0;
|
||||
$start = time();
|
||||
|
||||
Timer::tick(5000, function () use ($server, $register, $realtime, $stats, $logError) {
|
||||
Timer::tick(5000, function () use ($server, $register, $realtime, $stats) {
|
||||
/**
|
||||
* Sending current connections to project channels on the console project every 5 seconds.
|
||||
*/
|
||||
@@ -531,24 +555,45 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
$outboundBytes = 0;
|
||||
|
||||
foreach ($groups as $group) {
|
||||
$data = $event['data'];
|
||||
$data['subscriptions'] = $group['subscriptions'];
|
||||
|
||||
$server->send($group['ids'], json_encode([
|
||||
$payloadJson = json_encode([
|
||||
'type' => 'event',
|
||||
'data' => $data
|
||||
]));
|
||||
$total += count($group['ids']);
|
||||
]);
|
||||
|
||||
$server->send($group['ids'], $payloadJson);
|
||||
|
||||
$count = count($group['ids']);
|
||||
$total += $count;
|
||||
$outboundBytes += strlen($payloadJson) * $count;
|
||||
}
|
||||
|
||||
if ($total > 0) {
|
||||
$register->get('telemetry.messageSentCounter')->add($total);
|
||||
$stats->incr($event['project'], 'messages', $total);
|
||||
|
||||
$projectId = $event['project'] ?? null;
|
||||
|
||||
if (!empty($projectId)) {
|
||||
$metrics = [
|
||||
METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT => $total,
|
||||
];
|
||||
|
||||
if ($outboundBytes > 0) {
|
||||
$metrics[METRIC_REALTIME_OUTBOUND] = $outboundBytes;
|
||||
}
|
||||
|
||||
triggerStats($metrics, $projectId);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Throwable $th) {
|
||||
$logError($th, "pubSubConnection");
|
||||
logError($th, "pubSubConnection");
|
||||
|
||||
Console::error('Pub/sub error: ' . $th->getMessage());
|
||||
$attempts++;
|
||||
@@ -560,7 +605,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
|
||||
Console::error('Failed to restart pub/sub...');
|
||||
});
|
||||
|
||||
$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $logError) {
|
||||
$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime) {
|
||||
$app = new Http('UTC');
|
||||
$request = new Request($request);
|
||||
$response = new Response(new SwooleResponse());
|
||||
@@ -571,6 +616,10 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
Http::setResource('request', fn () => $request);
|
||||
Http::setResource('response', fn () => $response);
|
||||
|
||||
$project = null;
|
||||
$logUser = null;
|
||||
$authorization = null;
|
||||
|
||||
try {
|
||||
/** @var Document $project */
|
||||
$project = $app->getResource('project');
|
||||
@@ -591,8 +640,15 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
|
||||
}
|
||||
|
||||
$projectRegion = $project->getAttribute('region', '');
|
||||
$currentRegion = System::getEnv('_APP_REGION', 'default');
|
||||
if (!empty($projectRegion) && $projectRegion !== $currentRegion) {
|
||||
throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Project is not accessible in this region. Please make sure you are using the correct endpoint');
|
||||
}
|
||||
|
||||
$timelimit = $app->getResource('timelimit');
|
||||
$user = $app->getResource('user'); /** @var User $user */
|
||||
$logUser = $user;
|
||||
|
||||
/*
|
||||
* Abuse Check
|
||||
@@ -610,6 +666,12 @@ $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());
|
||||
|
||||
/*
|
||||
* Validate Client Domain - Check to avoid CSRF attack.
|
||||
* Adding Appwrite API domains to allow XDOMAIN communication.
|
||||
@@ -664,14 +726,16 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
|
||||
$user = empty($user->getId()) ? null : $response->output($user, Response::MODEL_ACCOUNT);
|
||||
|
||||
$server->send([$connection], json_encode([
|
||||
$connectedPayloadJson = json_encode([
|
||||
'type' => 'connected',
|
||||
'data' => [
|
||||
'channels' => $names,
|
||||
'subscriptions' => $mapping,
|
||||
'user' => $user
|
||||
]
|
||||
]));
|
||||
]);
|
||||
|
||||
$server->send([$connection], $connectedPayloadJson);
|
||||
|
||||
$register->get('telemetry.connectionCounter')->add(1);
|
||||
$register->get('telemetry.connectionCreatedCounter')->add(1);
|
||||
@@ -682,12 +746,18 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
]);
|
||||
$stats->incr($project->getId(), 'connections');
|
||||
$stats->incr($project->getId(), 'connectionsTotal');
|
||||
|
||||
$connectedOutboundBytes = \strlen($connectedPayloadJson);
|
||||
|
||||
triggerStats([METRIC_REALTIME_CONNECTIONS => 1, METRIC_REALTIME_OUTBOUND => $connectedOutboundBytes], $project->getId());
|
||||
|
||||
|
||||
} catch (Throwable $th) {
|
||||
$logError($th, "initServer");
|
||||
logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization);
|
||||
|
||||
// Handle SQL error code is 'HY000'
|
||||
$code = $th->getCode();
|
||||
if (!is_int($code)) {
|
||||
if (!\is_int($code)) {
|
||||
$code = 500;
|
||||
}
|
||||
|
||||
@@ -718,8 +788,12 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
}
|
||||
});
|
||||
|
||||
$server->onMessage(function (int $connection, string $message) use ($server, $register, $realtime, $containerId, $logError) {
|
||||
$server->onMessage(function (int $connection, string $message) use ($server, $register, $realtime, $containerId) {
|
||||
$project = null;
|
||||
$authorization = null;
|
||||
|
||||
try {
|
||||
$rawSize = \strlen($message);
|
||||
$response = new Response(new SwooleResponse());
|
||||
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
|
||||
|
||||
@@ -732,7 +806,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
$database = getConsoleDB();
|
||||
$database->setAuthorization($authorization);
|
||||
|
||||
if ($projectId !== 'console') {
|
||||
if (!empty($projectId) && $projectId !== 'console') {
|
||||
$project = $authorization->skip(fn () => $database->getDocument('projects', $projectId));
|
||||
|
||||
$database = getProjectDB($project);
|
||||
@@ -758,17 +832,41 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
throw new Exception(Exception::REALTIME_TOO_MANY_MESSAGES, 'Too many messages.');
|
||||
}
|
||||
|
||||
// Record realtime inbound bytes for this project
|
||||
if ($project !== null && !$project->isEmpty()) {
|
||||
triggerStats([
|
||||
METRIC_REALTIME_INBOUND => $rawSize,
|
||||
], $project->getId());
|
||||
}
|
||||
|
||||
$message = json_decode($message, true);
|
||||
|
||||
if (is_null($message) || (!array_key_exists('type', $message) && !array_key_exists('data', $message))) {
|
||||
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format 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.');
|
||||
}
|
||||
|
||||
switch ($message['type']) {
|
||||
case 'ping':
|
||||
$server->send([$connection], json_encode([
|
||||
$pongPayloadJson = json_encode([
|
||||
'type' => 'pong'
|
||||
]));
|
||||
]);
|
||||
|
||||
$server->send([$connection], $pongPayloadJson);
|
||||
|
||||
if ($project !== null && !$project->isEmpty()) {
|
||||
$pongOutboundBytes = \strlen($pongPayloadJson);
|
||||
|
||||
if ($pongOutboundBytes > 0) {
|
||||
triggerStats([
|
||||
METRIC_REALTIME_OUTBOUND => $pongOutboundBytes,
|
||||
], $project->getId());
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case 'authentication':
|
||||
@@ -829,14 +927,27 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
}
|
||||
|
||||
$user = $response->output($user, Response::MODEL_ACCOUNT);
|
||||
$server->send([$connection], json_encode([
|
||||
|
||||
$authResponsePayloadJson = json_encode([
|
||||
'type' => 'response',
|
||||
'data' => [
|
||||
'to' => 'authentication',
|
||||
'success' => true,
|
||||
'user' => $user
|
||||
]
|
||||
]));
|
||||
]);
|
||||
|
||||
$server->send([$connection], $authResponsePayloadJson);
|
||||
|
||||
if ($project !== null && !$project->isEmpty()) {
|
||||
$authOutboundBytes = \strlen($authResponsePayloadJson);
|
||||
|
||||
if ($authOutboundBytes > 0) {
|
||||
triggerStats([
|
||||
METRIC_REALTIME_OUTBOUND => $authOutboundBytes,
|
||||
], $project->getId());
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
@@ -844,7 +955,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.');
|
||||
}
|
||||
} catch (Throwable $th) {
|
||||
$logError($th, "realtimeMessage");
|
||||
logError($th, 'realtimeMessage', project: $project, authorization: $authorization);
|
||||
$code = $th->getCode();
|
||||
if (!is_int($code)) {
|
||||
$code = 500;
|
||||
@@ -877,6 +988,12 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) {
|
||||
if (array_key_exists($connection, $realtime->connections)) {
|
||||
$stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal');
|
||||
$register->get('telemetry.connectionCounter')->add(-1);
|
||||
|
||||
$projectId = $realtime->connections[$connection]['projectId'];
|
||||
|
||||
triggerStats([
|
||||
METRIC_REALTIME_CONNECTIONS => -1,
|
||||
], $projectId);
|
||||
}
|
||||
$realtime->unsubscribe($connection);
|
||||
|
||||
|
||||
@@ -12,8 +12,12 @@ $version = $this->getParam('version', '');
|
||||
$organization = $this->getParam('organization', '');
|
||||
$image = $this->getParam('image', '');
|
||||
$enableAssistant = $this->getParam('enableAssistant', false);
|
||||
$dbService = $this->getParam('database');
|
||||
|
||||
$dbService = $this->getParam('database', 'mongodb');
|
||||
$allowedDbServices = ['mariadb', 'mongodb', 'postgresql'];
|
||||
if (!\in_array($dbService, $allowedDbServices, true)) {
|
||||
$dbService = 'mongodb';
|
||||
}
|
||||
$hostPath = rtrim($this->getParam('hostPath', ''), '/');
|
||||
?>services:
|
||||
traefik:
|
||||
image: traefik:3.6
|
||||
@@ -63,6 +67,9 @@ $dbService = $this->getParam('database');
|
||||
- traefik.http.routers.appwrite_api_https.service=appwrite_api
|
||||
- traefik.http.routers.appwrite_api_https.tls=true
|
||||
volumes:
|
||||
<?php if ($version === 'local' && !empty($hostPath)): ?>
|
||||
- "<?php echo $hostPath; ?>:/usr/src/code:rw"
|
||||
<?php endif; ?>
|
||||
- appwrite-uploads:/storage/uploads:rw
|
||||
- appwrite-imports:/storage/imports:rw
|
||||
- appwrite-cache:/storage/cache:rw
|
||||
@@ -72,8 +79,10 @@ $dbService = $this->getParam('database');
|
||||
- appwrite-sites:/storage/sites:rw
|
||||
- appwrite-builds:/storage/builds:rw
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
# - clamav
|
||||
environment:
|
||||
- _APP_ENV
|
||||
@@ -227,8 +236,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -258,8 +269,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -286,8 +299,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -316,8 +331,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- appwrite-uploads:/storage/uploads:rw
|
||||
- appwrite-cache:/storage/cache:rw
|
||||
@@ -381,8 +398,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -409,8 +428,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- appwrite-functions:/storage/functions:rw
|
||||
- appwrite-sites:/storage/sites:rw
|
||||
@@ -479,8 +500,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- appwrite-config:/storage/config:rw
|
||||
- appwrite-certificates:/storage/certificates:rw
|
||||
@@ -518,9 +541,12 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
- openruntimes-executor
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
openruntimes-executor:
|
||||
condition: service_started
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -559,8 +585,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -598,8 +626,10 @@ $dbService = $this->getParam('database');
|
||||
volumes:
|
||||
- appwrite-uploads:/storage/uploads:rw
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -652,7 +682,8 @@ $dbService = $this->getParam('database');
|
||||
volumes:
|
||||
- appwrite-imports:/storage/imports:rw
|
||||
depends_on:
|
||||
- <?= $dbService . "\n" ?>
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -688,8 +719,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -730,8 +763,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -761,8 +796,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -791,8 +828,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- redis
|
||||
- <?= $dbService . "\n" ?>
|
||||
redis:
|
||||
condition: service_healthy
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -821,8 +860,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- <?= $dbService . "\n" ?>
|
||||
- redis
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -848,8 +889,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- <?= $dbService . "\n" ?>
|
||||
- redis
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -875,8 +918,10 @@ $dbService = $this->getParam('database');
|
||||
networks:
|
||||
- appwrite
|
||||
depends_on:
|
||||
- <?= $dbService . "\n" ?>
|
||||
- redis
|
||||
<?= $dbService ?>:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_WORKER_PER_CORE
|
||||
@@ -966,7 +1011,6 @@ $dbService = $this->getParam('database');
|
||||
- OPR_EXECUTOR_STORAGE_WASABI_BUCKET=$_APP_STORAGE_WASABI_BUCKET
|
||||
|
||||
<?php if ($dbService === 'mariadb'): ?>
|
||||
|
||||
mariadb:
|
||||
image: mariadb:10.11
|
||||
container_name: appwrite-mariadb
|
||||
@@ -982,6 +1026,12 @@ $dbService = $this->getParam('database');
|
||||
- MYSQL_PASSWORD=${_APP_DB_PASS}
|
||||
- MARIADB_AUTO_UPGRADE=1
|
||||
command: 'mysqld --innodb-flush-method=fsync'
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
<?php elseif ($dbService === 'mongodb'): ?>
|
||||
|
||||
@@ -1059,18 +1109,24 @@ $dbService = $this->getParam('database');
|
||||
<?php elseif ($dbService === 'postgresql'): ?>
|
||||
|
||||
postgresql:
|
||||
image: postgres:18
|
||||
image: appwrite/postgres:0.1.0
|
||||
container_name: appwrite-postgresql
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- appwrite
|
||||
volumes:
|
||||
- appwrite-postgresql:/var/lib/postgresql/data:rw
|
||||
- appwrite-postgresql:/var/lib/postgresql:rw
|
||||
environment:
|
||||
- POSTGRES_DB=${_APP_DB_SCHEMA}
|
||||
- POSTGRES_USER=${_APP_DB_USER}
|
||||
- POSTGRES_PASSWORD=${_APP_DB_PASS}
|
||||
command: "postgres"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${_APP_DB_USER} -d ${_APP_DB_SCHEMA}"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -1088,6 +1144,12 @@ $dbService = $this->getParam('database');
|
||||
- appwrite
|
||||
volumes:
|
||||
- appwrite-redis:/data:rw
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
# clamav:
|
||||
# image: appwrite/clamav:1.2.0
|
||||
@@ -1114,6 +1176,7 @@ volumes:
|
||||
<?php elseif ($dbService === 'mongodb'): ?>
|
||||
appwrite-mongodb:
|
||||
appwrite-mongodb-keyfile:
|
||||
appwrite-mongodb-config:
|
||||
<?php endif; ?>
|
||||
appwrite-redis:
|
||||
appwrite-cache:
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
$vars = $this->getParam('vars');
|
||||
|
||||
foreach ($vars as $key => $value) {
|
||||
echo $key.'='.$value."\n";
|
||||
if ($value === null || $value === '') {
|
||||
echo $key . "=\n";
|
||||
} else {
|
||||
echo $key . '="' . addcslashes((string) $value, '"\\$`') . '"' . "\n";
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
$isUpgrade = $isUpgrade ?? false;
|
||||
$lockedDatabase = $lockedDatabase ?? null;
|
||||
$vars = $vars ?? [];
|
||||
$defaultHttpPort = $defaultHttpPort ?? '80';
|
||||
$defaultHttpsPort = $defaultHttpsPort ?? '443';
|
||||
$defaultAppDomain = $vars['_APP_DOMAIN']['default'] ?? 'localhost';
|
||||
$defaultAppDomain = ($defaultAppDomain === 'traefik') ? 'localhost' : $defaultAppDomain;
|
||||
$defaultEmailCertificates = $defaultEmailCertificates ?? '';
|
||||
$defaultDatabase = $vars['_APP_DB_ADAPTER']['default'] ?? 'mongodb';
|
||||
$lockedDatabase = $isUpgrade && empty($lockedDatabase) ? $defaultDatabase : $lockedDatabase;
|
||||
$isLocalInstall = $isLocalInstall ?? false;
|
||||
|
||||
|
||||
$cardStep = min(4, $step);
|
||||
$stepFile = __DIR__ . "/installer/templates/steps/step-{$cardStep}.phtml";
|
||||
if (!is_file($stepFile)) {
|
||||
$stepFile = __DIR__ . "/installer/templates/steps/step-1.phtml";
|
||||
}
|
||||
$cssVersion = @filemtime(__DIR__ . '/installer/css/styles.css') ?: time();
|
||||
$tooltipsVersion = @filemtime(__DIR__ . '/installer/js/tooltips.js') ?: time();
|
||||
$constantsVersion = @filemtime(__DIR__ . '/installer/js/constants.js') ?: time();
|
||||
$stepsContextVersion = @filemtime(__DIR__ . '/installer/js/modules/context.js') ?: time();
|
||||
$stepsStateVersion = @filemtime(__DIR__ . '/installer/js/modules/state.js') ?: time();
|
||||
$stepsValidationVersion = @filemtime(__DIR__ . '/installer/js/modules/validation.js') ?: time();
|
||||
$stepsUiVersion = @filemtime(__DIR__ . '/installer/js/modules/ui.js') ?: time();
|
||||
$stepsToastVersion = @filemtime(__DIR__ . '/installer/js/modules/toast.js') ?: time();
|
||||
$stepsProgressVersion = @filemtime(__DIR__ . '/installer/js/modules/progress.js') ?: time();
|
||||
$stepsVersion = @filemtime(__DIR__ . '/installer/js/steps.js') ?: time();
|
||||
$installerVersion = @filemtime(__DIR__ . '/installer/js/installer.js') ?: time();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?php echo $isUpgrade ? 'Appwrite Update' : 'Appwrite Installation'; ?></title>
|
||||
<meta name="appwrite-installer-csrf" content="<?php echo htmlspecialchars($csrfToken ?? '', ENT_QUOTES); ?>">
|
||||
<link rel="icon" type="image/svg+xml" href="installer/icons/appwrite-mark.svg">
|
||||
<link rel="stylesheet" href="installer/css/styles.css?v=<?php echo $cssVersion; ?>">
|
||||
<script src="installer/js/tooltips.js?v=<?php echo $tooltipsVersion; ?>" defer></script>
|
||||
<script src="installer/js/constants.js?v=<?php echo $constantsVersion; ?>" defer></script>
|
||||
<!-- Load context first; other modules depend on InstallerStepsContext. -->
|
||||
<script src="installer/js/modules/context.js?v=<?php echo $stepsContextVersion; ?>" defer></script>
|
||||
<script src="installer/js/modules/state.js?v=<?php echo $stepsStateVersion; ?>" defer></script>
|
||||
<script src="installer/js/modules/validation.js?v=<?php echo $stepsValidationVersion; ?>" defer></script>
|
||||
<script src="installer/js/modules/ui.js?v=<?php echo $stepsUiVersion; ?>" defer></script>
|
||||
<script src="installer/js/modules/toast.js?v=<?php echo $stepsToastVersion; ?>" defer></script>
|
||||
<script src="installer/js/modules/progress.js?v=<?php echo $stepsProgressVersion; ?>" defer></script>
|
||||
<script src="installer/js/steps.js?v=<?php echo $stepsVersion; ?>" defer></script>
|
||||
<script src="installer/js/installer.js?v=<?php echo $installerVersion; ?>" defer></script>
|
||||
</head>
|
||||
<body
|
||||
class="installer-page"
|
||||
data-step="<?php echo $step; ?>"
|
||||
data-upgrade="<?php echo $isUpgrade ? 'true' : 'false'; ?>"
|
||||
<?php if (!empty($lockedDatabase)) { ?>
|
||||
data-locked-database="<?php echo htmlspecialchars((string) $lockedDatabase, ENT_QUOTES, 'UTF-8'); ?>"
|
||||
<?php } ?>
|
||||
data-default-http-port="<?php echo htmlspecialchars((string) $defaultHttpPort, ENT_QUOTES, 'UTF-8'); ?>"
|
||||
data-default-https-port="<?php echo htmlspecialchars((string) $defaultHttpsPort, ENT_QUOTES, 'UTF-8'); ?>"
|
||||
data-default-app-domain="<?php echo htmlspecialchars((string) $defaultAppDomain, ENT_QUOTES, 'UTF-8'); ?>"
|
||||
data-default-email-certificates="<?php echo htmlspecialchars((string) $defaultEmailCertificates, ENT_QUOTES, 'UTF-8'); ?>"
|
||||
data-default-secret-key=""
|
||||
data-default-assistant-openai-key=""
|
||||
data-default-database="<?php echo htmlspecialchars((string) $defaultDatabase, ENT_QUOTES, 'UTF-8'); ?>"
|
||||
<?php if ($isLocalInstall) { ?>
|
||||
data-dev-mode="true"
|
||||
<?php } ?>
|
||||
>
|
||||
<div class="installer-backdrop" aria-hidden="true">
|
||||
<span class="installer-gradients">
|
||||
<span class="installer-blob blob-one">
|
||||
<?php include __DIR__ . '/installer/icons/install-bg-1.svg'; ?>
|
||||
</span>
|
||||
<span class="installer-blob blob-two">
|
||||
<?php include __DIR__ . '/installer/icons/install-bg-2.svg'; ?>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<main class="installer-main">
|
||||
<div class="installer-card" data-step="<?php echo $step; ?>">
|
||||
<div class="installer-step">
|
||||
<div class="step-panel is-active">
|
||||
<?php include $stepFile; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-shell">
|
||||
<div class="divider"></div>
|
||||
<div class="action-bar">
|
||||
<button class="button secondary" type="button" data-action="back">
|
||||
<span class="button-text typography-text-m-500">Back</span>
|
||||
</button>
|
||||
|
||||
<div class="step-indicators" aria-hidden="true">
|
||||
<span class="step-indicator" data-step="1">
|
||||
<span class="indicator-active"><?php include __DIR__ . '/installer/icons/indicator-active.svg'; ?></span>
|
||||
<span class="indicator-inactive"><?php include __DIR__ . '/installer/icons/indicator-inactive.svg'; ?></span>
|
||||
</span>
|
||||
<span class="step-indicator" data-step="2">
|
||||
<span class="indicator-active"><?php include __DIR__ . '/installer/icons/indicator-active.svg'; ?></span>
|
||||
<span class="indicator-inactive"><?php include __DIR__ . '/installer/icons/indicator-inactive.svg'; ?></span>
|
||||
</span>
|
||||
<span class="step-indicator" data-step="3">
|
||||
<span class="indicator-active"><?php include __DIR__ . '/installer/icons/indicator-active.svg'; ?></span>
|
||||
<span class="indicator-inactive"><?php include __DIR__ . '/installer/icons/indicator-inactive.svg'; ?></span>
|
||||
</span>
|
||||
<span class="step-indicator" data-step="4">
|
||||
<span class="indicator-active"><?php include __DIR__ . '/installer/icons/indicator-active.svg'; ?></span>
|
||||
<span class="indicator-inactive"><?php include __DIR__ . '/installer/icons/indicator-inactive.svg'; ?></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button class="button primary" type="button" data-action="next">
|
||||
<span class="button-text typography-text-m-500">Next</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="install-screen" aria-live="polite">
|
||||
<div class="install-screen-content">
|
||||
<?php if ($step === 5) { include __DIR__ . '/installer/templates/steps/step-5.phtml'; } ?>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="installer-footer">
|
||||
<div class="appwrite-logo">
|
||||
<?php include __DIR__ . '/installer/icons/appwrite-logo.svg'; ?>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<template id="field-error-template">
|
||||
<div class="field-error typography-caption-400 text-error">
|
||||
<span class="field-error-icon">
|
||||
<?php include __DIR__ . '/installer/icons/exclamation-circle.svg'; ?>
|
||||
</span>
|
||||
<span class="field-error-text"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div id="installer-toast-stack" class="installer-toast-stack" aria-live="polite" aria-atomic="true"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg width="131" height="25" viewBox="0 0 131 25" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M38.2643 19.5087C40.4069 19.5087 41.49 18.3947 41.9609 17.6362H42.1728C42.267 18.4421 42.832 19.2006 43.9386 19.2006H46.0341V16.8304H45.4926C45.1159 16.8304 44.9275 16.617 44.9275 16.2852V6.78055H42.1492V8.29751H41.9373C41.3958 7.53903 40.2656 6.47242 38.1937 6.47242C34.8974 6.47242 32.4487 9.2219 32.4487 12.9906C32.4487 16.7593 34.9445 19.5087 38.2643 19.5087ZM38.7588 16.8067C36.8045 16.8067 35.2741 15.3608 35.2741 13.0143C35.2741 10.7151 36.7574 9.15079 38.7352 9.15079C40.6188 9.15079 42.1963 10.5492 42.1963 13.0143C42.1963 15.1238 40.8543 16.8067 38.7588 16.8067Z" fill="#19191D"/>
|
||||
<path d="M47.6745 24.0166H50.4528V17.6362H50.6647C51.1826 18.3947 52.2893 19.5087 54.4789 19.5087C57.7752 19.5087 60.1768 16.7118 60.1768 12.9906C60.1768 9.2456 57.6104 6.47242 54.2906 6.47242C52.1715 6.47242 51.1356 7.63384 50.6411 8.2738H50.4292V6.78055H47.6745V24.0166ZM53.8903 16.8778C51.9832 16.8778 50.4057 15.4556 50.4057 12.9906C50.4057 10.8811 51.7477 9.10339 53.8432 9.10339C55.7974 9.10339 57.3279 10.644 57.3279 12.9906C57.3279 15.2897 55.8445 16.8778 53.8903 16.8778Z" fill="#19191D"/>
|
||||
<path d="M61.6104 24.0166H64.3887V17.6362H64.6006C65.1186 18.3947 66.2252 19.5087 68.4149 19.5087C71.7112 19.5087 73.8839 16.7118 73.8839 12.9906C73.8839 9.2456 71.5464 6.47242 68.2265 6.47242C66.1075 6.47242 65.0715 7.63384 64.5771 8.2738H64.3652V6.78055H61.6104V24.0166ZM67.8263 16.8778C65.9191 16.8778 64.3416 15.4556 64.3416 12.9906C64.3416 10.8811 65.6837 9.10339 67.7792 9.10339C69.7334 9.10339 71.2638 10.644 71.2638 12.9906C71.2638 15.2897 69.7805 16.8778 67.8263 16.8778Z" fill="#19191D"/>
|
||||
<path d="M77.5565 19.489H81.4885L83.7252 9.74733H83.8665L86.1033 19.489H90.0117L93.1415 7.06896H90.3414L88.1046 16.8343H87.8927L85.6559 7.06896H81.9594L79.6991 16.8343H79.4872L77.2739 7.06896H74.3073L77.5565 19.489Z" fill="#19191D"/>
|
||||
<path d="M94.549 19.489H97.3273V13.3501C97.3273 11.0036 98.4104 9.55771 100.435 9.55771H101.66V6.76083H100.741C99.1638 6.76083 97.963 7.85114 97.4921 8.89405H97.3038V7.06896H94.549V19.489Z" fill="#19191D"/>
|
||||
<path d="M115.447 19.489H117.613V17.0003H115.47C114.623 17.0003 114.27 16.621 114.27 15.744V9.53401H117.754V7.06896H114.27V3.58472H111.633V7.06896H109.325V9.53401H111.468V15.7677C111.468 18.3987 113.045 19.489 115.447 19.489Z" fill="#19191D"/>
|
||||
<path d="M125.067 19.5087C127.633 19.5087 129.893 18.2288 130.694 15.6452L128.151 15.029C127.704 16.4037 126.409 17.1148 125.043 17.1148C123.018 17.1148 121.676 15.7875 121.653 13.7016H131V12.9195C131 9.2219 128.716 6.47242 124.949 6.47242C121.629 6.47242 118.78 9.10339 118.78 13.0143C118.78 16.8067 121.3 19.5087 125.067 19.5087ZM121.676 11.6632C121.841 10.17 123.183 8.91377 124.949 8.91377C126.644 8.91377 128.033 9.98037 128.175 11.6632H121.676Z" fill="#19191D"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M108.09 19.489H105.312V9.53401H103.145V7.06896H108.09V19.489Z" fill="#19191D"/>
|
||||
<path d="M106.494 5.34533C107.507 5.34533 108.26 4.58686 108.26 3.59136C108.26 2.61956 107.507 1.86108 106.494 1.86108C105.482 1.86108 104.729 2.61956 104.729 3.59136C104.729 4.58686 105.482 5.34533 106.494 5.34533Z" fill="#19191D"/>
|
||||
<path d="M24.2577 16.4436V21.9248H10.6705C6.71194 21.9248 3.25559 19.7204 1.40636 16.4436C1.13754 15.9672 0.90225 15.4674 0.704883 14.9487C0.31744 13.9322 0.0738912 12.8415 0 11.7034V10.2214C0.0160422 9.96781 0.0413207 9.71617 0.0743773 9.46752C0.141949 8.95727 0.244035 8.45799 0.378206 7.97265C1.64748 3.37143 5.77469 0 10.6705 0C15.5662 0 19.693 3.37143 20.9622 7.97265H15.1526C14.1988 6.47279 12.5479 5.4812 10.6705 5.4812C8.79305 5.4812 7.14216 6.47279 6.18839 7.97265C5.89768 8.42859 5.67212 8.93136 5.52434 9.46752C5.39308 9.94289 5.32308 10.4442 5.32308 10.9624C5.32308 12.5335 5.96768 13.9497 7.00119 14.9487C7.95886 15.876 9.25 16.4436 10.6705 16.4436H24.2577Z" fill="#FD366E"/>
|
||||
<path d="M24.2578 9.46753V14.9487H14.3398C15.3733 13.9497 16.018 12.5335 16.018 10.9624C16.018 10.4442 15.9479 9.9429 15.8167 9.46753H24.2578Z" fill="#FD366E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="25" height="22" viewBox="0 0 25 22" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M24.2577 16.4436V21.9248H10.6705C6.71194 21.9248 3.25559 19.7204 1.40636 16.4436C1.13754 15.9672 0.90225 15.4674 0.704883 14.9487C0.31744 13.9322 0.0738912 12.8415 0 11.7034V10.2214C0.0160422 9.96781 0.0413207 9.71617 0.0743773 9.46752C0.141949 8.95727 0.244035 8.45799 0.378206 7.97265C1.64748 3.37143 5.77469 0 10.6705 0C15.5662 0 19.693 3.37143 20.9622 7.97265H15.1526C14.1988 6.47279 12.5479 5.4812 10.6705 5.4812C8.79305 5.4812 7.14216 6.47279 6.18839 7.97265C5.89768 8.42859 5.67212 8.93136 5.52434 9.46752C5.39308 9.94289 5.32308 10.4442 5.32308 10.9624C5.32308 12.5335 5.96768 13.9497 7.00119 14.9487C7.95886 15.876 9.25 16.4436 10.6705 16.4436H24.2577Z" fill="#FD366E"/>
|
||||
<path d="M24.2578 9.46753V14.9487H14.3398C15.3733 13.9497 16.018 12.5335 16.018 10.9624C16.018 10.4442 15.9479 9.9429 15.8167 9.46753H24.2578Z" fill="#FD366E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 980 B |
@@ -0,0 +1,3 @@
|
||||
<svg class="accordion-chevron" data-open="false" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.29289 7.29289C5.68342 6.90237 6.31658 6.90237 6.70711 7.29289L10 10.5858L13.2929 7.29289C13.6834 6.90237 14.3166 6.90237 14.7071 7.29289C15.0976 7.68342 15.0976 8.31658 14.7071 8.70711L10.7071 12.7071C10.3166 13.0976 9.68342 13.0976 9.29289 12.7071L5.29289 8.70711C4.90237 8.31658 4.90237 7.68342 5.29289 7.29289Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 536 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill="currentColor" d="M7 9a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2z"/>
|
||||
<path fill="currentColor" d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2V5h8a2 2 0 0 0-2-2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 293 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0m-7 4a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-1-9a1 1 0 0 0-1 1v4a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 296 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M3.707 2.293a1 1 0 1 0-1.414 1.414l14 14a1 1 0 1 0 1.414-1.414l-1.473-1.473A10 10 0 0 0 19.543 10C18.268 5.943 14.478 3 10 3a9.96 9.96 0 0 0-4.512 1.074zm4.261 4.26 1.514 1.515a2.003 2.003 0 0 1 2.45 2.45l1.515 1.514a4 4 0 0 0-5.478-5.478" clip-rule="evenodd"/>
|
||||
<path fill="currentColor" d="M12.454 16.697 9.75 13.992a4 4 0 0 1-3.742-3.742L2.335 6.578A10 10 0 0 0 .458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.67-.105 2.454-.303"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 568 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path fill="currentColor" d="M10 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4"/>
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M.457 10C1.732 5.943 5.522 3 10 3s8.267 2.943 9.542 7c-1.275 4.057-5.065 7-9.542 7-4.478 0-8.268-2.943-9.543-7M14 10a4 4 0 1 1-8 0 4 4 0 0 1 8 0" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 372 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M5 8C5 6.34315 6.34315 5 8 5C9.65685 5 11 6.34315 11 8C11 9.65685 9.65685 11 8 11C6.34315 11 5 9.65685 5 8Z" fill="#FD366E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M5 8C5 6.34315 6.34315 5 8 5C9.65685 5 11 6.34315 11 8C11 9.65685 9.65685 11 8 11C6.34315 11 5 9.65685 5 8Z" fill="#D8D8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0m-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0M9 9a1 1 0 1 0 0 2v3a1 1 0 0 0 1 1h1a1 1 0 1 0 0-2v-3a1 1 0 0 0-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 313 B |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="408" height="353" viewBox="0 0 408 353" fill="none" aria-hidden="true">
|
||||
<g filter="url(#filter0_f_3091_1065)">
|
||||
<path d="M216.4 88.698C162.554 64.5014 104.764 118.126 82.6001 147.963C90.6227 156.819 114.663 172.323 146.645 163.495C186.622 152.459 191.109 162.269 229.454 181.887C267.799 201.506 268.207 261.18 291.051 268.537C313.895 275.894 322.054 244.422 324.501 193.74C326.949 143.058 283.709 118.944 216.4 88.698Z" fill="url(#paint0_radial_3091_1065)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_3091_1065" x="9.91821e-05" y="-2.28882e-05" width="407.2" height="352.2" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="41.3" result="effect1_foregroundBlur_3091_1065"/>
|
||||
</filter>
|
||||
<radialGradient id="paint0_radial_3091_1065" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(203.6 176.1) rotate(-90) scale(93.5 121)">
|
||||
<stop stop-color="#FE9567"/>
|
||||
<stop offset="1" stop-color="#FE9567" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg width="569" height="367" viewBox="0 0 569 367" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<g filter="url(#install-bg-2-filter)">
|
||||
<path d="M136.265 227.973C89.0703 207.713 99.6672 144.647 110.865 115.646C130.119 111.289 176.904 102.33 210.005 101.35C251.382 100.125 288.253 121.773 333.317 135.252C378.381 148.731 466.051 125.449 467.28 160.577C468.509 195.704 412.793 254.522 402.142 242.269C391.49 230.015 319.798 227.973 246.057 254.522C172.316 281.072 195.257 253.297 136.265 227.973Z" fill="url(#install-bg-2-paint)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="install-bg-2-filter" x="-0.000198364" y="0.0000457764" width="568.6" height="366.6" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="50.65" result="effect1_foregroundBlur_install_bg_2"/>
|
||||
</filter>
|
||||
<radialGradient id="install-bg-2-paint" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(284.3 183.3) rotate(-90) scale(82 183)">
|
||||
<stop stop-color="#FD366E"/>
|
||||
<stop offset="1" stop-color="#FD366E" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<circle class="check-bg" cx="8" cy="8" r="7" fill="var(--fgcolor-neutral-primary)"/>
|
||||
<path class="check-mark" d="M5 8.2L7.2 10.2L11.2 6.2" fill="none" stroke="var(--fgcolor-on-invert)" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 383 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path class="spinner-ring" opacity="0.2" d="M14.4001 7.99998C14.4001 11.5346 11.5347 14.4 8.0001 14.4C4.46548 14.4 1.6001 11.5346 1.6001 7.99998C1.6001 4.46535 4.46548 1.59998 8.0001 1.59998C11.5347 1.59998 14.4001 4.46535 14.4001 7.99998ZM2.8801 7.99998C2.8801 10.8277 5.1724 13.12 8.0001 13.12C10.8278 13.12 13.1201 10.8277 13.1201 7.99998C13.1201 5.17228 10.8278 2.87998 8.0001 2.87998C5.1724 2.87998 2.8801 5.17228 2.8801 7.99998Z" fill="var(--fgcolor-neutral-secondary)"/>
|
||||
<path class="spinner-arc" d="M4.32921 2.7574C5.15654 2.1781 6.10923 1.80282 7.10939 1.66226C8.10955 1.5217 9.12877 1.61984 10.0837 1.94866C11.0387 2.27748 11.9023 2.82764 12.6039 3.55416C13.3055 4.28069 13.8252 5.16294 14.1204 6.1288C14.4157 7.09465 14.4782 8.11668 14.3029 9.11133C14.1275 10.106 13.7192 11.045 13.1114 11.8516C12.5035 12.6582 11.7134 13.3095 10.8057 13.7523C9.8979 14.195 8.89823 14.4166 7.8884 14.399L7.91074 13.1192C8.71861 13.1333 9.51834 12.956 10.2446 12.6018C10.9708 12.2476 11.6029 11.7266 12.0891 11.0813C12.5754 10.436 12.902 9.68477 13.0423 8.88906C13.1826 8.09334 13.1326 7.27572 12.8964 6.50303C12.6601 5.73035 12.2444 5.02454 11.6831 4.44333C11.1218 3.86211 10.431 3.42198 9.66701 3.15892C8.90304 2.89586 8.08766 2.81735 7.28753 2.9298C6.48741 3.04225 5.72525 3.34247 5.06339 3.80592L4.32921 2.7574Z" fill="var(--fgcolor-neutral-secondary)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M5 9V7a5 5 0 1 1 10 0v2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2m8-2v2H7V7a3 3 0 1 1 6 0" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
@@ -0,0 +1,10 @@
|
||||
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="33" height="33" rx="8.5" fill="white" stroke="#EDEDF0"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.3208 9.43304C27.9548 9.4451 28.0607 9.61 27.2684 9.80574C26.468 10.0028 25.5 9.88216 24.6393 10.2441C22.387 11.188 22.0224 14.8372 19.3397 16.249C17.5807 17.2424 15.7869 17.4703 14.1835 17.973C12.8965 18.4892 12.0961 18.8311 11.1523 19.6448C10.4203 20.2763 10.242 20.889 9.47647 21.6813C8.70022 22.7364 5.75747 21.7993 5 23.1225C5.39952 23.3813 5.6301 23.4523 6.33127 23.3598C6.18647 23.6346 5.26411 23.9966 5.43169 24.4015C5.43169 24.4015 7.66121 24.8077 9.54081 23.6735C10.4176 23.3169 11.2394 22.5621 12.6015 22.3811C14.3658 22.1465 16.354 22.7512 18.487 22.9214C18.046 23.7928 17.5915 24.3077 17.1048 25.0263C16.9547 25.1885 17.2335 25.3319 17.751 25.2341C18.6814 25.0035 19.3558 24.7541 20.0248 24.2916C20.8975 23.6896 21.2729 23.1386 22.009 22.2658C22.6484 23.2914 24.9034 23.5167 25.366 22.6305C24.5053 22.2658 24.3216 20.3688 24.6165 19.5497C24.9651 18.7694 25.2158 17.666 25.4974 16.6404C25.7507 15.7167 25.9076 14.3077 26.2106 13.5864C26.5726 12.6895 27.2764 12.4093 27.8046 11.9334C28.3328 11.4574 28.8584 11.0606 28.8423 9.97198C28.8369 9.61805 28.6546 9.42231 28.3208 9.43304Z" fill="#1F305F"/>
|
||||
<path d="M5.673 24.2137C7.05522 24.2553 7.43061 24.2191 8.52324 23.7445C9.45232 23.341 10.6951 22.2537 11.7931 21.9024C13.4046 21.3849 15.1327 21.4627 16.8474 21.6651C17.4212 21.7335 17.9977 21.8287 18.4093 21.7844C19.0514 21.3903 19.0823 20.3204 19.4805 20.2346C19.3719 22.3046 18.4843 23.6292 17.5968 24.8304C19.467 24.5006 20.7165 23.3329 21.4727 21.8877C21.7019 21.4493 22.0827 20.732 22.257 20.2387C22.395 20.5658 22.0773 20.7736 22.2261 21.1302C23.43 20.1368 23.9985 19.0053 24.4865 17.3482C25.0522 15.4311 25.6327 13.7834 25.9974 13.2163C26.3527 12.6626 26.9064 12.3207 27.4118 11.9668C27.9856 11.5633 28.4991 11.1436 28.5875 10.3754C27.9816 10.3191 27.8421 10.1797 27.7523 9.87402C27.4493 10.0443 27.1705 10.0818 26.8554 10.0912C26.5819 10.0993 26.2816 10.0872 25.9143 10.1247C22.8804 10.4358 22.7396 13.4013 20.5342 15.5785C20.3921 15.7166 20.1494 15.9204 19.9886 16.0411C19.3075 16.5492 18.5782 16.828 17.81 17.1216C16.5658 17.5962 15.3861 17.7343 14.2197 18.1446C13.363 18.4449 12.5667 18.7894 11.8628 19.2707C11.6872 19.3914 11.4486 19.6059 11.2957 19.7306C10.8828 20.0684 10.612 20.4424 10.3492 20.8286C10.0784 21.2254 9.81831 21.6343 9.42014 22.0244C8.77528 22.6572 6.36746 22.2094 5.51882 22.7966C5.42497 22.8623 5.34855 22.9401 5.29761 23.0339C5.76014 23.2444 6.06982 23.1157 6.60207 23.1734C6.66776 23.6815 5.50005 23.9818 5.673 24.2137Z" fill="#C0765A"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.9033 11.3797C25.3323 11.7524 26.2333 11.4534 26.0724 10.712C25.4047 10.6557 25.0186 10.8823 24.9033 11.3797Z" fill="#1F305F"/>
|
||||
<path d="M27.8971 10.5123C27.7831 10.7523 27.5646 11.0606 27.5646 11.6706C27.5632 11.7752 27.4855 11.8476 27.4841 11.6854C27.4895 11.0888 27.6477 10.8314 27.8153 10.4935C27.8917 10.3554 27.9386 10.4117 27.8971 10.5123Z" fill="#1F305F"/>
|
||||
<path d="M27.7818 10.4225C27.6464 10.6504 27.322 11.0674 27.2697 11.676C27.2603 11.7806 27.1745 11.845 27.1879 11.6841C27.2469 11.0915 27.5057 10.7202 27.7028 10.3971C27.7912 10.2657 27.8328 10.326 27.7818 10.4225Z" fill="#1F305F"/>
|
||||
<path d="M27.6772 10.303C27.523 10.5189 27.0243 11.0203 26.9197 11.6209C26.901 11.7241 26.8112 11.7804 26.838 11.6222C26.9466 11.0364 27.3769 10.5752 27.5995 10.2708C27.6987 10.1462 27.7362 10.2105 27.6772 10.303Z" fill="#1F305F"/>
|
||||
<path d="M27.5835 10.1688C27.4012 10.3619 26.8059 11 26.6182 11.5805C26.5847 11.6797 26.4882 11.724 26.5365 11.5698C26.7255 11.0041 27.247 10.3954 27.5111 10.1259C27.6278 10.0173 27.6546 10.087 27.5835 10.1688Z" fill="#1F305F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="8" fill="#081E2B"/>
|
||||
<g transform="translate(11.4, 6.1)">
|
||||
<path d="M6.03701 2.19958C5.24603 1.21687 4.56489 0.218806 4.42574 0.0115161C4.41109 -0.00383871 4.38911 -0.00383871 4.37447 0.0115161C4.23531 0.218806 3.55418 1.21687 2.76319 2.19958C-4.02614 11.2666 3.8325 17.3856 3.8325 17.3856L3.89841 17.4316C3.95699 18.3759 4.10348 19.7348 4.10348 19.7348H4.39644H4.6894C4.6894 19.7348 4.83588 18.3836 4.89447 17.4316L4.96038 17.3779C4.9677 17.3779 12.8264 11.2666 6.03701 2.19958ZM4.39644 17.2473C4.39644 17.2473 4.04489 16.9325 3.94968 16.7713V16.756L4.37447 6.88281C4.37447 6.8521 4.41842 6.8521 4.41842 6.88281L4.8432 16.756V16.7713C4.74799 16.9325 4.39644 17.2473 4.39644 17.2473Z" fill="#00ED64"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 861 B |
@@ -0,0 +1,27 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="31" height="31" rx="7.5" fill="white" stroke="#EDEDF0"/>
|
||||
<g transform="translate(3.2, 3.2) scale(1)">
|
||||
<svg viewBox="0 0 25.6 25.6" width="25.6" height="25.6">
|
||||
<style><![CDATA[.B{stroke-linecap:round}.C{stroke-linejoin:round}.D{stroke-linejoin:miter}.E{stroke-width:.716}]]></style>
|
||||
<g fill="none" stroke="#fff">
|
||||
<path d="M18.983 18.636c.163-1.357.114-1.555 1.124-1.336l.257.023c.777.035 1.793-.125 2.4-.402 1.285-.596 2.047-1.592.78-1.33-2.89.596-3.1-.383-3.1-.383 3.053-4.53 4.33-10.28 3.227-11.687-3.004-3.84-8.205-2.024-8.292-1.976l-.028.005c-.57-.12-1.2-.19-1.93-.2-1.308-.02-2.3.343-3.054.914 0 0-9.277-3.822-8.846 4.807.092 1.836 2.63 13.9 5.66 10.25C8.29 15.987 9.36 14.86 9.36 14.86c.53.353 1.167.533 1.834.468l.052-.044a2.01 2.01 0 0 0 .021.518c-.78.872-.55 1.025-2.11 1.346-1.578.325-.65.904-.046 1.056.734.184 2.432.444 3.58-1.162l-.046.183c.306.245.285 1.76.33 2.842s.116 2.093.337 2.688.48 2.13 2.53 1.7c1.713-.367 3.023-.896 3.143-5.81" fill="#000" stroke="#000" stroke-linecap="butt" stroke-width="2.149" class="D"/>
|
||||
<path d="M23.535 15.6c-2.89.596-3.1-.383-3.1-.383 3.053-4.53 4.33-10.28 3.228-11.687-3.004-3.84-8.205-2.023-8.292-1.976l-.028.005a10.31 10.31 0 0 0-1.929-.201c-1.308-.02-2.3.343-3.054.914 0 0-9.278-3.822-8.846 4.807.092 1.836 2.63 13.9 5.66 10.25C8.29 15.987 9.36 14.86 9.36 14.86c.53.353 1.167.533 1.834.468l.052-.044a2.02 2.02 0 0 0 .021.518c-.78.872-.55 1.025-2.11 1.346-1.578.325-.65.904-.046 1.056.734.184 2.432.444 3.58-1.162l-.046.183c.306.245.52 1.593.484 2.815s-.06 2.06.18 2.716.48 2.13 2.53 1.7c1.713-.367 2.6-1.32 2.725-2.906.088-1.128.286-.962.3-1.97l.16-.478c.183-1.53.03-2.023 1.085-1.793l.257.023c.777.035 1.794-.125 2.39-.402 1.285-.596 2.047-1.592.78-1.33z" fill="#336791" stroke="none"/>
|
||||
<g class="E">
|
||||
<g class="B">
|
||||
<path d="M12.814 16.467c-.08 2.846.02 5.712.298 6.4s.875 2.05 2.926 1.612c1.713-.367 2.337-1.078 2.607-2.647l.633-5.017M10.356 2.2S1.072-1.596 1.504 7.033c.092 1.836 2.63 13.9 5.66 10.25C8.27 15.95 9.27 14.907 9.27 14.907m6.1-13.4c-.32.1 5.164-2.005 8.282 1.978 1.1 1.407-.175 7.157-3.228 11.687" class="C"/>
|
||||
<path d="M20.425 15.17s.2.98 3.1.382c1.267-.262.504.734-.78 1.33-1.054.49-3.418.615-3.457-.06-.1-1.745 1.244-1.215 1.147-1.652-.088-.394-.69-.78-1.086-1.744-.347-.84-4.76-7.29 1.224-6.333.22-.045-1.56-5.7-7.16-5.782S7.99 8.196 7.99 8.196" stroke-linejoin="bevel"/>
|
||||
</g>
|
||||
<g class="C">
|
||||
<path d="M11.247 15.768c-.78.872-.55 1.025-2.11 1.346-1.578.325-.65.904-.046 1.056.734.184 2.432.444 3.58-1.163.35-.49-.002-1.27-.482-1.468-.232-.096-.542-.216-.94.23z"/>
|
||||
<path d="M11.196 15.753c-.08-.513.168-1.122.433-1.836.398-1.07 1.316-2.14.582-5.537-.547-2.53-4.22-.527-4.22-.184s.166 1.74-.06 3.365c-.297 2.122 1.35 3.916 3.246 3.733" class="B"/>
|
||||
</g>
|
||||
</g>
|
||||
<g fill="#fff" class="D">
|
||||
<path d="M10.322 8.145c-.017.117.215.43.516.472s.558-.202.575-.32-.215-.246-.516-.288-.56.02-.575.136z" stroke-width=".239"/>
|
||||
<path d="M19.486 7.906c.016.117-.215.43-.516.472s-.56-.202-.575-.32.215-.246.516-.288.56.02.575.136z" stroke-width=".119"/>
|
||||
</g>
|
||||
<path d="M20.562 7.095c.05.92-.198 1.545-.23 2.524-.046 1.422.678 3.05-.413 4.68" class="B C E"/>
|
||||
</g>
|
||||
</svg>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.99976 2C4.55204 2 4.99976 2.44772 4.99976 3V5.10125C6.26985 3.80489 8.04028 3 9.99976 3C13.0492 3 15.6407 4.94932 16.6012 7.66675C16.7852 8.18747 16.5123 8.75879 15.9916 8.94284C15.4709 9.12689 14.8996 8.85396 14.7155 8.33325C14.0286 6.38991 12.1752 5 9.99976 5C8.36482 5 6.91179 5.78502 5.99911 7H8.99976C9.55204 7 9.99976 7.44771 9.99976 8C9.99976 8.55228 9.55204 9 8.99976 9H3.99976C3.44747 9 2.99976 8.55228 2.99976 8V3C2.99976 2.44772 3.44747 2 3.99976 2ZM4.00792 11.0572C4.52864 10.8731 5.09996 11.146 5.28401 11.6668C5.97088 13.6101 7.82429 15 9.99976 15C11.6347 15 13.0877 14.215 14.0004 13L10.9998 13C10.4475 13 9.99976 12.5523 9.99976 12C9.99976 11.4477 10.4475 11 10.9998 11H15.9998C16.265 11 16.5193 11.1054 16.7069 11.2929C16.8944 11.4804 16.9998 11.7348 16.9998 12V17C16.9998 17.5523 16.552 18 15.9998 18C15.4475 18 14.9998 17.5523 14.9998 17V14.8987C13.7297 16.1951 11.9592 17 9.99976 17C6.95035 17 4.3588 15.0507 3.39833 12.3332C3.21428 11.8125 3.48721 11.2412 4.00792 11.0572Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="3" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 157 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.25706 3.09882C9.02167 1.73952 10.9788 1.73952 11.7434 3.09882L17.3237 13.0194C18.0736 14.3526 17.1102 15.9999 15.5805 15.9999H4.4199C2.89025 15.9999 1.92682 14.3526 2.67675 13.0194L8.25706 3.09882ZM11.0001 13C11.0001 13.5523 10.5524 14 10.0001 14C9.44784 14 9.00012 13.5523 9.00012 13C9.00012 12.4477 9.44784 12 10.0001 12C10.5524 12 11.0001 12.4477 11.0001 13ZM10.0001 5C9.44784 5 9.00012 5.44772 9.00012 6V9C9.00012 9.55228 9.44784 10 10.0001 10C10.5524 10 11.0001 9.55228 11.0001 9V6C11.0001 5.44772 10.5524 5 10.0001 5Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 725 B |
@@ -0,0 +1,11 @@
|
||||
(() => {
|
||||
window.InstallerConstants = Object.freeze({
|
||||
stepTransitionMs: 260,
|
||||
errorClearMs: 180,
|
||||
installPollIntervalMs: 4000,
|
||||
installFallbackDelayMs: 12000,
|
||||
redirectDelayMs: 2500,
|
||||
progressTransitionDelayMs: 320,
|
||||
progressCompleteDelayMs: 140,
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,450 @@
|
||||
(() => {
|
||||
const stepContainer = document.querySelector('.installer-step');
|
||||
const installerCard = document.querySelector('.installer-card');
|
||||
const backButton = document.querySelector('[data-action="back"]');
|
||||
const nextButton = document.querySelector('[data-action="next"]');
|
||||
const installScreen = document.querySelector('.install-screen-content');
|
||||
const indicatorNodes = Array.from(document.querySelectorAll('.step-indicator'));
|
||||
const STEP_TRANSITION_TIMEOUT = window.InstallerConstants?.stepTransitionMs ?? 260;
|
||||
|
||||
if (!stepContainer || !installerCard) return;
|
||||
|
||||
const { validateInstallRequest } = window.InstallerStepsProgress || {};
|
||||
|
||||
const isUpgrade = document.body?.dataset.upgrade === 'true';
|
||||
const stepFlow = isUpgrade ? [1, 4, 5] : [1, 2, 3, 4, 5];
|
||||
const cardSteps = stepFlow.filter((step) => step !== 5);
|
||||
|
||||
const normalizeStep = (step) => {
|
||||
const numeric = clampStep(step);
|
||||
if (stepFlow.includes(numeric)) return numeric;
|
||||
if (numeric <= stepFlow[0]) return stepFlow[0];
|
||||
for (let i = 0; i < stepFlow.length; i += 1) {
|
||||
if (numeric < stepFlow[i]) {
|
||||
return stepFlow[i];
|
||||
}
|
||||
}
|
||||
return stepFlow[stepFlow.length - 1];
|
||||
};
|
||||
|
||||
const buildStepConfig = () => {
|
||||
const config = {};
|
||||
stepFlow.forEach((step, index) => {
|
||||
if (step === 5) {
|
||||
config[step] = { back: { target: null }, next: { target: null } };
|
||||
return;
|
||||
}
|
||||
const prev = stepFlow[index - 1] ?? null;
|
||||
const next = stepFlow[index + 1] ?? null;
|
||||
const label = next === 5 ? (isUpgrade ? 'Update' : 'Install') : 'Next';
|
||||
config[step] = {
|
||||
back: { target: prev },
|
||||
next: { label, target: next }
|
||||
};
|
||||
});
|
||||
return config;
|
||||
};
|
||||
|
||||
const STEP_CONFIG = buildStepConfig();
|
||||
|
||||
const stepCache = new Map();
|
||||
let maxStepHeight = 0;
|
||||
let isTransitioning = false;
|
||||
let pendingStep = null;
|
||||
let pendingPushState = false;
|
||||
|
||||
const clampStep = (step) => Math.max(1, Math.min(5, step));
|
||||
const isInstallLocked = () => Boolean(window.InstallerSteps?.isInstallLocked?.());
|
||||
|
||||
const scrollToFirstError = (panel) => {
|
||||
if (!panel) return;
|
||||
const getErrorNode = () => panel.querySelector('.field-error.is-visible')
|
||||
|| panel.querySelector('.field-error')
|
||||
|| panel.querySelector('.input-field.is-error, .input-action.is-error');
|
||||
const container = panel.closest('.step-panel') || panel;
|
||||
const attemptScroll = () => {
|
||||
const target = getErrorNode();
|
||||
if (!target || typeof target.getBoundingClientRect !== 'function') return false;
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const targetTop = targetRect.top - containerRect.top + container.scrollTop;
|
||||
const targetBottom = targetTop + targetRect.height;
|
||||
const viewTop = container.scrollTop;
|
||||
const viewBottom = viewTop + containerRect.height;
|
||||
const padding = 12;
|
||||
|
||||
let nextScrollTop = viewTop;
|
||||
if (targetTop < viewTop + padding) {
|
||||
nextScrollTop = Math.max(0, targetTop - padding);
|
||||
} else if (targetBottom > viewBottom - padding) {
|
||||
nextScrollTop = Math.max(0, targetBottom - containerRect.height + padding);
|
||||
}
|
||||
|
||||
if (Math.abs(nextScrollTop - viewTop) < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
container.scrollTo({ top: nextScrollTop, behavior: 'smooth' });
|
||||
return true;
|
||||
};
|
||||
|
||||
let remaining = 20;
|
||||
let lastScrollTop = -1;
|
||||
const settle = () => {
|
||||
if (remaining <= 0) return;
|
||||
const moved = attemptScroll();
|
||||
remaining -= 1;
|
||||
const currentTop = container.scrollTop;
|
||||
const delta = Math.abs(currentTop - lastScrollTop);
|
||||
lastScrollTop = currentTop;
|
||||
if (!moved && delta < 0.5) {
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(settle);
|
||||
};
|
||||
requestAnimationFrame(settle);
|
||||
};
|
||||
|
||||
const getStepFromUrl = () => {
|
||||
const url = new URL(window.location.href);
|
||||
const step = Number(url.searchParams.get('step') || 1);
|
||||
return normalizeStep(Number.isNaN(step) ? 1 : step);
|
||||
};
|
||||
|
||||
const buildStepUrl = (step) => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('step', step);
|
||||
return url;
|
||||
};
|
||||
|
||||
const setStepInUrl = (step, pushState) => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('step', step);
|
||||
|
||||
if (pushState) {
|
||||
window.history.pushState({ step }, '', url.toString());
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
const updateActionBar = (step) => {
|
||||
const config = STEP_CONFIG[step] || STEP_CONFIG[1];
|
||||
if (!backButton || !nextButton) return;
|
||||
const locked = isInstallLocked();
|
||||
|
||||
const setButtonLabel = (button, label) => {
|
||||
if (!button) return;
|
||||
let text = button.querySelector('.button-text');
|
||||
if (!text) {
|
||||
text = document.createElement('span');
|
||||
text.className = 'button-text typography-text-m-500';
|
||||
button.textContent = '';
|
||||
button.appendChild(text);
|
||||
}
|
||||
text.textContent = label;
|
||||
};
|
||||
|
||||
if (!locked && config.back?.target) {
|
||||
backButton.disabled = false;
|
||||
backButton.setAttribute('data-step-target', String(config.back.target));
|
||||
} else {
|
||||
backButton.disabled = true;
|
||||
backButton.removeAttribute('data-step-target');
|
||||
}
|
||||
setButtonLabel(backButton, 'Back');
|
||||
|
||||
if (!locked && config.next?.target) {
|
||||
setButtonLabel(nextButton, config.next?.label || 'Next');
|
||||
nextButton.setAttribute('data-step-target', String(config.next?.target || 1));
|
||||
nextButton.disabled = false;
|
||||
} else {
|
||||
setButtonLabel(nextButton, config.next?.label || 'Next');
|
||||
nextButton.removeAttribute('data-step-target');
|
||||
nextButton.disabled = true;
|
||||
}
|
||||
|
||||
indicatorNodes.forEach((node, index) => {
|
||||
const isVisible = index < cardSteps.length;
|
||||
node.classList.toggle('is-hidden', !isVisible);
|
||||
if (!isVisible) {
|
||||
node.classList.remove('is-active');
|
||||
return;
|
||||
}
|
||||
node.classList.toggle('is-active', cardSteps[index] === step);
|
||||
});
|
||||
|
||||
installerCard.setAttribute('data-step', String(step));
|
||||
document.body.dataset.step = String(step);
|
||||
if (locked) {
|
||||
document.body.dataset.installLocked = 'true';
|
||||
} else {
|
||||
delete document.body.dataset.installLocked;
|
||||
}
|
||||
};
|
||||
|
||||
const measureStepHeight = (panel) => {
|
||||
if (!panel) return;
|
||||
const height = panel.getBoundingClientRect().height;
|
||||
if (!height) return;
|
||||
maxStepHeight = Math.max(maxStepHeight, height);
|
||||
stepContainer.style.setProperty('--step-min-height', `${maxStepHeight}px`);
|
||||
};
|
||||
|
||||
const runStepInit = (step, rootElement) => {
|
||||
if (!window.InstallerSteps || typeof window.InstallerSteps.initStep !== 'function') return;
|
||||
const root = rootElement || stepContainer;
|
||||
window.InstallerSteps.initStep(step, root);
|
||||
updateActionBar(step);
|
||||
};
|
||||
|
||||
const fetchStepHtml = (step, url) => {
|
||||
if (stepCache.has(step)) {
|
||||
return Promise.resolve(stepCache.get(step));
|
||||
}
|
||||
|
||||
const fetchUrl = new URL(url);
|
||||
fetchUrl.searchParams.set('partial', '1');
|
||||
|
||||
return fetch(fetchUrl.toString(), {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load step');
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then((html) => {
|
||||
stepCache.set(step, html);
|
||||
return html;
|
||||
});
|
||||
};
|
||||
|
||||
const preloadSteps = (steps) => {
|
||||
const current = getStepFromUrl();
|
||||
const targets = steps.filter((step) => step !== current);
|
||||
|
||||
return Promise.all(
|
||||
targets.map((step) => {
|
||||
const url = buildStepUrl(step);
|
||||
return fetchStepHtml(step, url)
|
||||
.then((html) => {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'step-panel is-measure';
|
||||
panel.innerHTML = html;
|
||||
stepContainer.appendChild(panel);
|
||||
panel.getBoundingClientRect();
|
||||
measureStepHeight(panel);
|
||||
panel.remove();
|
||||
})
|
||||
.catch(() => null);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const swapPanels = (step, html, onDone) => {
|
||||
const activePanel = stepContainer.querySelector('.step-panel');
|
||||
|
||||
const measurePanel = document.createElement('div');
|
||||
measurePanel.className = 'step-panel is-measure';
|
||||
measurePanel.innerHTML = html;
|
||||
stepContainer.appendChild(measurePanel);
|
||||
measurePanel.getBoundingClientRect();
|
||||
measureStepHeight(measurePanel);
|
||||
measurePanel.remove();
|
||||
|
||||
const newPanel = document.createElement('div');
|
||||
newPanel.className = 'step-panel is-entering';
|
||||
newPanel.innerHTML = html;
|
||||
stepContainer.appendChild(newPanel);
|
||||
runStepInit(step, newPanel);
|
||||
|
||||
newPanel.getBoundingClientRect();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
newPanel.classList.remove('is-entering');
|
||||
newPanel.classList.add('is-active');
|
||||
if (activePanel) {
|
||||
activePanel.classList.add('is-exiting');
|
||||
}
|
||||
});
|
||||
|
||||
const finalize = () => {
|
||||
if (activePanel && activePanel.parentNode) {
|
||||
activePanel.parentNode.removeChild(activePanel);
|
||||
}
|
||||
newPanel.classList.remove('is-entering');
|
||||
if (typeof onDone === 'function') {
|
||||
onDone();
|
||||
}
|
||||
};
|
||||
|
||||
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (prefersReduced) {
|
||||
finalize();
|
||||
return;
|
||||
}
|
||||
|
||||
let finished = false;
|
||||
const finishOnce = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
finalize();
|
||||
};
|
||||
|
||||
newPanel.addEventListener(
|
||||
'transitionend',
|
||||
(event) => {
|
||||
if (event.propertyName === 'opacity') {
|
||||
finishOnce();
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
setTimeout(finishOnce, STEP_TRANSITION_TIMEOUT);
|
||||
};
|
||||
|
||||
const showInstallScreen = (step, html) => {
|
||||
if (!installScreen) return;
|
||||
installScreen.innerHTML = html;
|
||||
runStepInit(step, installScreen);
|
||||
};
|
||||
|
||||
const hideInstallScreen = () => {
|
||||
if (!installScreen) return;
|
||||
installScreen.innerHTML = '';
|
||||
};
|
||||
|
||||
const loadStep = (step, pushState) => {
|
||||
const targetStep = normalizeStep(Number(step));
|
||||
const currentStep = getStepFromUrl();
|
||||
if (targetStep === currentStep && pushState) return;
|
||||
|
||||
isTransitioning = true;
|
||||
const url = setStepInUrl(targetStep, pushState);
|
||||
|
||||
fetchStepHtml(targetStep, url)
|
||||
.then((html) => {
|
||||
if (targetStep === 5) {
|
||||
showInstallScreen(targetStep, html);
|
||||
isTransitioning = false;
|
||||
if (pendingStep !== null && pendingStep !== targetStep) {
|
||||
const nextStep = pendingStep;
|
||||
const nextPushState = pendingPushState;
|
||||
pendingStep = null;
|
||||
pendingPushState = false;
|
||||
loadStep(nextStep, nextPushState);
|
||||
return;
|
||||
}
|
||||
pendingStep = null;
|
||||
pendingPushState = false;
|
||||
return;
|
||||
}
|
||||
|
||||
hideInstallScreen();
|
||||
swapPanels(targetStep, html, () => {
|
||||
isTransitioning = false;
|
||||
if (pendingStep !== null && pendingStep !== targetStep) {
|
||||
const nextStep = pendingStep;
|
||||
const nextPushState = pendingPushState;
|
||||
pendingStep = null;
|
||||
pendingPushState = false;
|
||||
loadStep(nextStep, nextPushState);
|
||||
return;
|
||||
}
|
||||
pendingStep = null;
|
||||
pendingPushState = false;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
isTransitioning = false;
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
};
|
||||
|
||||
const requestStep = (step, pushState) => {
|
||||
const targetStep = normalizeStep(Number(step));
|
||||
if (isInstallLocked() && targetStep !== 5) {
|
||||
loadStep(5, true);
|
||||
return;
|
||||
}
|
||||
if (isTransitioning) {
|
||||
pendingStep = targetStep;
|
||||
pendingPushState = pendingPushState || pushState;
|
||||
return;
|
||||
}
|
||||
loadStep(targetStep, pushState);
|
||||
};
|
||||
|
||||
document.addEventListener('click', async (event) => {
|
||||
const button = event.target.closest('[data-step-target]');
|
||||
if (!button || button.disabled) return;
|
||||
event.preventDefault();
|
||||
const target = button.getAttribute('data-step-target');
|
||||
if (!target) return;
|
||||
const action = button.getAttribute('data-action');
|
||||
if (action === 'next') {
|
||||
const currentStep = getStepFromUrl();
|
||||
const panel = stepContainer.querySelector('.step-panel') || stepContainer;
|
||||
const validator = window.InstallerSteps?.validateStep;
|
||||
if (typeof validator === 'function') {
|
||||
const valid = validator(currentStep, panel);
|
||||
if (!valid) {
|
||||
scrollToFirstError(panel);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (action === 'next' && String(target) === '5' && typeof validateInstallRequest === 'function') {
|
||||
const isValid = await validateInstallRequest();
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isInstallLocked() && Number(target) !== 5) {
|
||||
requestStep(5, true);
|
||||
return;
|
||||
}
|
||||
requestStep(target, true);
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', (event) => {
|
||||
const step = event.state?.step || getStepFromUrl();
|
||||
if (isInstallLocked() && Number(step) !== 5) {
|
||||
requestStep(5, false);
|
||||
return;
|
||||
}
|
||||
requestStep(step, false);
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
let step = getStepFromUrl();
|
||||
if (isInstallLocked() && step !== 5) {
|
||||
const url = buildStepUrl(5);
|
||||
window.history.replaceState({ step: 5 }, '', url.toString());
|
||||
step = 5;
|
||||
} else {
|
||||
const url = buildStepUrl(step);
|
||||
window.history.replaceState({ step }, '', url.toString());
|
||||
}
|
||||
const activePanel = stepContainer.querySelector('.step-panel') || stepContainer;
|
||||
runStepInit(step, activePanel);
|
||||
measureStepHeight(activePanel);
|
||||
if (step === 5 && installScreen) {
|
||||
runStepInit(step, installScreen);
|
||||
}
|
||||
const preload = () => {
|
||||
measureStepHeight(activePanel);
|
||||
preloadSteps(cardSteps);
|
||||
};
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
document.fonts.ready.then(preload).catch(preload);
|
||||
} else {
|
||||
preload();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,105 @@
|
||||
(() => {
|
||||
const getBodyDataset = () => document.body?.dataset ?? {};
|
||||
const isUpgradeMode = () => getBodyDataset().upgrade === 'true';
|
||||
const getLockedDatabase = () => getBodyDataset().lockedDatabase || '';
|
||||
|
||||
const STEP_IDS = Object.freeze({
|
||||
CONFIG_FILES: 'config-files',
|
||||
DOCKER_COMPOSE: 'docker-compose',
|
||||
ENV_VARS: 'env-vars',
|
||||
DOCKER_CONTAINERS: 'docker-containers',
|
||||
ACCOUNT_SETUP: 'account-setup'
|
||||
});
|
||||
|
||||
const STATUS = Object.freeze({
|
||||
IN_PROGRESS: 'in-progress',
|
||||
COMPLETED: 'completed',
|
||||
ERROR: 'error'
|
||||
});
|
||||
|
||||
const SSE_EVENTS = Object.freeze({
|
||||
PING: 'ping',
|
||||
INSTALL_ID: 'install-id',
|
||||
PROGRESS: 'progress',
|
||||
DONE: 'done',
|
||||
ERROR: 'error'
|
||||
});
|
||||
|
||||
const buildInstallationSteps = (upgrade) => (upgrade ? [
|
||||
{
|
||||
id: STEP_IDS.CONFIG_FILES,
|
||||
inProgress: 'Updating configuration files...',
|
||||
done: 'Configuration files updated'
|
||||
},
|
||||
{
|
||||
id: STEP_IDS.DOCKER_COMPOSE,
|
||||
inProgress: 'Updating Docker Compose file...',
|
||||
done: 'Docker Compose file updated'
|
||||
},
|
||||
{
|
||||
id: STEP_IDS.ENV_VARS,
|
||||
inProgress: 'Updating environment variables...',
|
||||
done: 'Environment variables updated'
|
||||
},
|
||||
{
|
||||
id: STEP_IDS.DOCKER_CONTAINERS,
|
||||
inProgress: 'Restarting Docker containers...',
|
||||
done: 'Docker containers restarted'
|
||||
}
|
||||
] : [
|
||||
{
|
||||
id: STEP_IDS.CONFIG_FILES,
|
||||
inProgress: 'Creating configuration files...',
|
||||
done: 'Configuration files created'
|
||||
},
|
||||
{
|
||||
id: STEP_IDS.DOCKER_COMPOSE,
|
||||
inProgress: 'Generating Docker Compose file...',
|
||||
done: 'Docker Compose file generated'
|
||||
},
|
||||
{
|
||||
id: STEP_IDS.ENV_VARS,
|
||||
inProgress: 'Configuring environment variables...',
|
||||
done: 'Environment variables configured'
|
||||
},
|
||||
{
|
||||
id: STEP_IDS.DOCKER_CONTAINERS,
|
||||
inProgress: 'Starting Docker containers...',
|
||||
done: 'Docker containers started'
|
||||
},
|
||||
{
|
||||
id: STEP_IDS.ACCOUNT_SETUP,
|
||||
inProgress: 'Creating Appwrite account...',
|
||||
done: 'Appwrite account created (redirecting...)'
|
||||
}
|
||||
]);
|
||||
|
||||
const INSTALLATION_STEPS = buildInstallationSteps(isUpgradeMode());
|
||||
const CONSTANTS = window.InstallerConstants || {};
|
||||
const TIMINGS = {
|
||||
errorClear: CONSTANTS.errorClearMs ?? 180,
|
||||
installPollInterval: CONSTANTS.installPollIntervalMs ?? 4000,
|
||||
installFallbackDelay: CONSTANTS.installFallbackDelayMs ?? 12000,
|
||||
redirectDelay: CONSTANTS.redirectDelayMs ?? 500,
|
||||
progressTransitionDelay: CONSTANTS.progressTransitionDelayMs ?? 140,
|
||||
progressCompleteDelay: CONSTANTS.progressCompleteDelayMs ?? 120
|
||||
};
|
||||
|
||||
const clampStep = (step) => {
|
||||
const numeric = Number(step);
|
||||
if (Number.isNaN(numeric)) return 1;
|
||||
return Math.max(1, Math.min(5, numeric));
|
||||
};
|
||||
|
||||
window.InstallerStepsContext = Object.freeze({
|
||||
getBodyDataset,
|
||||
isUpgradeMode,
|
||||
getLockedDatabase,
|
||||
STEP_IDS,
|
||||
STATUS,
|
||||
SSE_EVENTS,
|
||||
INSTALLATION_STEPS,
|
||||
TIMINGS,
|
||||
clampStep
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,899 @@
|
||||
(() => {
|
||||
const {
|
||||
INSTALLATION_STEPS,
|
||||
TIMINGS,
|
||||
getBodyDataset,
|
||||
isUpgradeMode,
|
||||
STEP_IDS,
|
||||
STATUS,
|
||||
SSE_EVENTS
|
||||
} = window.InstallerStepsContext;
|
||||
const {
|
||||
formState,
|
||||
applyLockPayload,
|
||||
applyBodyDefaults,
|
||||
setInstallLock,
|
||||
getInstallLock,
|
||||
clearInstallLock,
|
||||
isInstallLocked,
|
||||
syncInstallLockFlag,
|
||||
getStoredInstallId,
|
||||
storeInstallId,
|
||||
clearInstallId
|
||||
} = window.InstallerStepsState || {};
|
||||
const { extractHostname, isLocalHost } = window.InstallerStepsValidation || {};
|
||||
const { generateSecretKey } = window.InstallerStepsUI || {};
|
||||
const { showToast } = window.InstallerToast || {};
|
||||
|
||||
let activeInstall = null;
|
||||
let unloadGuard = null;
|
||||
const csrfToken = document.querySelector('meta[name="appwrite-installer-csrf"]')?.getAttribute('content') || '';
|
||||
|
||||
const withCsrfHeader = (headers = {}) => {
|
||||
if (!csrfToken) {
|
||||
return headers;
|
||||
}
|
||||
return { ...headers, 'X-Appwrite-Installer-CSRF': csrfToken };
|
||||
};
|
||||
|
||||
const showCsrfToast = () => {
|
||||
showToast?.({
|
||||
status: 'error',
|
||||
title: 'Session expired',
|
||||
description: 'Refresh the page and try again.',
|
||||
dismissible: true
|
||||
});
|
||||
};
|
||||
|
||||
const validateInstallRequest = async () => {
|
||||
try {
|
||||
const response = await fetch('/install/validate', {
|
||||
method: 'POST',
|
||||
headers: withCsrfHeader({
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
showCsrfToast();
|
||||
return false;
|
||||
}
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!data?.success) {
|
||||
showCsrfToast();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
showCsrfToast();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const setUnloadGuard = (enabled) => {
|
||||
if (!enabled && unloadGuard) {
|
||||
window.removeEventListener('beforeunload', unloadGuard);
|
||||
unloadGuard = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled && !unloadGuard) {
|
||||
unloadGuard = (event) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
return '';
|
||||
};
|
||||
window.addEventListener('beforeunload', unloadGuard);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupInstallFlow = () => {
|
||||
if (activeInstall?.controller) {
|
||||
activeInstall.controller.abort();
|
||||
if (activeInstall.pollTimer) {
|
||||
clearInterval(activeInstall.pollTimer);
|
||||
}
|
||||
if (activeInstall.fallbackTimer) {
|
||||
clearTimeout(activeInstall.fallbackTimer);
|
||||
}
|
||||
activeInstall = null;
|
||||
}
|
||||
stopSyncedSpinnerRotation();
|
||||
setUnloadGuard(false);
|
||||
};
|
||||
|
||||
const getStepDefinition = (id) => INSTALLATION_STEPS.find((step) => step.id === id);
|
||||
|
||||
const getProgressLabel = (step, status, message) => {
|
||||
if (!step) return message || '';
|
||||
if (status === STATUS.ERROR) {
|
||||
const normalized = normalizeInstallError(message || '');
|
||||
return normalized.summary || 'Installation failed.';
|
||||
}
|
||||
if (status === STATUS.COMPLETED) return step.done;
|
||||
return step.inProgress;
|
||||
};
|
||||
|
||||
const updateInstallRow = (row, step, status, message) => {
|
||||
if (!row || !step) return;
|
||||
row.dataset.status = status;
|
||||
row.dataset.step = step.id;
|
||||
if (status !== STATUS.ERROR) {
|
||||
row.classList.remove('is-open');
|
||||
const toggle = row.querySelector('[data-install-toggle]');
|
||||
if (toggle) {
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
const label = getProgressLabel(step, status, message);
|
||||
const text = row.querySelector('[data-install-text]');
|
||||
if (text) {
|
||||
if (text.textContent !== label) {
|
||||
text.classList.remove('is-enter');
|
||||
text.textContent = label;
|
||||
text.classList.add('is-enter');
|
||||
requestAnimationFrame(() => {
|
||||
text.classList.remove('is-enter');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide "Navigate to Console" button for account setup errors
|
||||
const consoleBtn = row.querySelector('[data-install-console]');
|
||||
if (consoleBtn) {
|
||||
const shouldShow = step.id === STEP_IDS.ACCOUNT_SETUP && status === STATUS.ERROR;
|
||||
consoleBtn.classList.toggle('is-hidden', !shouldShow);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeInstallError = (message) => {
|
||||
const text = String(message || '').trim();
|
||||
if (!text) {
|
||||
return { summary: '', details: '' };
|
||||
}
|
||||
const colonIndex = text.indexOf(':');
|
||||
if (colonIndex > 0 && colonIndex < 80) {
|
||||
const summary = text.slice(0, colonIndex).trim();
|
||||
const details = text.slice(colonIndex + 1).trim();
|
||||
return { summary, details };
|
||||
}
|
||||
if (text.length > 180) {
|
||||
return { summary: text.slice(0, 180).trim() + '…', details: text };
|
||||
}
|
||||
return { summary: text, details: '' };
|
||||
};
|
||||
|
||||
let spinnerAnimationFrame = null;
|
||||
const stopSyncedSpinnerRotation = () => {
|
||||
if (spinnerAnimationFrame) {
|
||||
cancelAnimationFrame(spinnerAnimationFrame);
|
||||
spinnerAnimationFrame = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startSyncedSpinnerRotation = (container) => {
|
||||
stopSyncedSpinnerRotation();
|
||||
if (!container) return;
|
||||
let startTime = null;
|
||||
const animate = (timestamp) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const elapsed = timestamp - startTime;
|
||||
const rotation = ((elapsed / 1000) * 360 * 1.5) % 360;
|
||||
container.style.setProperty('--spinner-rotation', `${rotation}deg`);
|
||||
spinnerAnimationFrame = requestAnimationFrame(animate);
|
||||
};
|
||||
spinnerAnimationFrame = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
const updateInstallErrorDetails = (row, error) => {
|
||||
if (!row) return;
|
||||
const traceNode = row.querySelector('[data-install-trace]');
|
||||
const normalized = normalizeInstallError(error?.message || '');
|
||||
const output = error?.output || '';
|
||||
const trace = error?.trace || '';
|
||||
const detailChunks = [];
|
||||
if (normalized.details) detailChunks.push(normalized.details);
|
||||
if (output) detailChunks.push(output);
|
||||
if (trace) detailChunks.push(trace);
|
||||
const detailText = detailChunks.join('\n\n');
|
||||
|
||||
if (traceNode) {
|
||||
traceNode.textContent = detailText;
|
||||
traceNode.style.display = detailText ? 'block' : 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const createInstallRow = (template, step) => {
|
||||
const fragment = template.content.cloneNode(true);
|
||||
const row = fragment.querySelector('.install-row');
|
||||
if (!row) return null;
|
||||
const toggle = row.querySelector('[data-install-toggle]');
|
||||
const setOpenState = (isOpen) => {
|
||||
row.classList.toggle('is-open', isOpen);
|
||||
if (toggle) {
|
||||
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
}
|
||||
};
|
||||
const toggleRow = () => {
|
||||
if (!row.dataset.status || row.dataset.status !== STATUS?.ERROR) {
|
||||
return;
|
||||
}
|
||||
setOpenState(!row.classList.contains('is-open'));
|
||||
};
|
||||
row.addEventListener('click', (event) => {
|
||||
if (event.target.closest('[data-install-retry]')) {
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-install-toggle]')) {
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('.install-row-details')) {
|
||||
return;
|
||||
}
|
||||
toggleRow();
|
||||
});
|
||||
if (toggle) {
|
||||
toggle.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
toggleRow();
|
||||
});
|
||||
}
|
||||
updateInstallRow(row, step, STATUS.IN_PROGRESS);
|
||||
return row;
|
||||
};
|
||||
|
||||
const generateInstallId = () => {
|
||||
if (window.crypto?.randomUUID) {
|
||||
return window.crypto.randomUUID();
|
||||
}
|
||||
const bytes = new Uint8Array(16);
|
||||
window.crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
|
||||
const buildRedirectUrl = () => {
|
||||
const dataset = getBodyDataset?.() ?? {};
|
||||
const rawDomain = (formState?.appDomain || dataset.defaultAppDomain || '').trim();
|
||||
if (!rawDomain) return '';
|
||||
const httpPort = (formState?.httpPort || dataset.defaultHttpPort || '').trim();
|
||||
const httpsPort = (formState?.httpsPort || dataset.defaultHttpsPort || '').trim();
|
||||
const hasPort = rawDomain.includes(':') || rawDomain.startsWith('[');
|
||||
let host = rawDomain;
|
||||
const hostForProtocol = extractHostname?.(rawDomain);
|
||||
const normalizedHost = hostForProtocol?.toLowerCase?.() ?? '';
|
||||
if (hostForProtocol === '0.0.0.0') {
|
||||
host = rawDomain.replace('0.0.0.0', 'localhost');
|
||||
} else if (normalizedHost === 'traefik') {
|
||||
host = rawDomain.replace(hostForProtocol, 'localhost');
|
||||
}
|
||||
let protocol = 'http';
|
||||
let port = httpPort;
|
||||
if (httpsPort && httpsPort !== '0' && !isLocalHost?.(normalizedHost)) {
|
||||
protocol = 'https';
|
||||
port = httpsPort;
|
||||
}
|
||||
if (!hasPort && port && ((protocol === 'http' && port !== '80') || (protocol === 'https' && port !== '443'))) {
|
||||
host = `${host}:${port}`;
|
||||
}
|
||||
return `${protocol}://${host}`;
|
||||
};
|
||||
|
||||
const redirectToApp = () => {
|
||||
const url = buildRedirectUrl();
|
||||
if (!url) return;
|
||||
// Fire-and-forget: tell the installer server it can shut down
|
||||
fetch('/install/shutdown', { method: 'POST', headers: withCsrfHeader() }).catch(() => {});
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
const notifyInstallComplete = (installId, session) => {
|
||||
if (!installId) return Promise.resolve();
|
||||
const payload = { installId };
|
||||
const sessionSecret = session?.sessionSecret || session?.secret;
|
||||
const sessionId = session?.sessionId || session?.id;
|
||||
const sessionExpire = session?.sessionExpire || session?.expire;
|
||||
if (sessionSecret) {
|
||||
payload.sessionSecret = sessionSecret;
|
||||
}
|
||||
if (sessionId) {
|
||||
payload.sessionId = sessionId;
|
||||
}
|
||||
if (sessionExpire) {
|
||||
payload.sessionExpire = sessionExpire;
|
||||
}
|
||||
return fetch('/install/complete', {
|
||||
method: 'POST',
|
||||
headers: withCsrfHeader({
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const buildInstallPayload = (installId) => {
|
||||
const normalizedSecret = (formState?.opensslKey || '').trim();
|
||||
if (!normalizedSecret && generateSecretKey && !isUpgradeMode?.()) {
|
||||
formState.opensslKey = generateSecretKey();
|
||||
}
|
||||
const normalizedDomain = (formState?.appDomain || '').trim() || 'localhost';
|
||||
const normalizedHttpPort = (formState?.httpPort || '').trim() || '80';
|
||||
const normalizedHttpsPort = (formState?.httpsPort || '').trim() || '443';
|
||||
const normalizedEmail = (formState?.emailCertificates || '').trim();
|
||||
const normalizedAssistantKey = (formState?.assistantOpenAIKey || '').trim();
|
||||
const normalizedAccountEmail = (formState?.accountEmail || '').trim();
|
||||
const normalizedAccountPassword = (formState?.accountPassword || '').trim();
|
||||
|
||||
return {
|
||||
installId,
|
||||
httpPort: normalizedHttpPort,
|
||||
httpsPort: normalizedHttpsPort,
|
||||
database: formState?.database || 'mongodb',
|
||||
appDomain: normalizedDomain,
|
||||
emailCertificates: normalizedEmail,
|
||||
opensslKey: (formState?.opensslKey || '').trim(),
|
||||
assistantOpenAIKey: normalizedAssistantKey,
|
||||
accountEmail: normalizedAccountEmail,
|
||||
accountPassword: normalizedAccountPassword
|
||||
};
|
||||
};
|
||||
|
||||
const fetchInstallStatus = async (installId) => {
|
||||
if (!installId) return null;
|
||||
const response = await fetch(`/install/status?installId=${encodeURIComponent(installId)}`, {
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const json = await response.json();
|
||||
return json.progress || null;
|
||||
};
|
||||
|
||||
const readEventStream = async (stream, onEvent) => {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
const processEvent = (rawEvent) => {
|
||||
if (!rawEvent) return;
|
||||
const lines = rawEvent.split('\n');
|
||||
let eventName = 'message';
|
||||
let data = '';
|
||||
|
||||
lines.forEach((line) => {
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.replace('event:', '').trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
data += line.replace('data:', '').trim();
|
||||
}
|
||||
});
|
||||
|
||||
if (data) {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
onEvent(eventName, parsed);
|
||||
} catch (error) {
|
||||
onEvent(eventName, { message: data });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
buffer = buffer.replace(/\r\n/g, '\n');
|
||||
if (buffer.trim()) {
|
||||
processEvent(buffer);
|
||||
}
|
||||
break;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
buffer = buffer.replace(/\r\n/g, '\n');
|
||||
let separatorIndex = buffer.indexOf('\n\n');
|
||||
|
||||
while (separatorIndex !== -1) {
|
||||
const rawEvent = buffer.slice(0, separatorIndex);
|
||||
buffer = buffer.slice(separatorIndex + 2);
|
||||
processEvent(rawEvent);
|
||||
separatorIndex = buffer.indexOf('\n\n');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (error) {}
|
||||
}
|
||||
};
|
||||
|
||||
const initStep5 = (root) => {
|
||||
if (!root) return;
|
||||
|
||||
if (activeInstall?.controller) {
|
||||
activeInstall.controller.abort();
|
||||
}
|
||||
if (activeInstall?.pollTimer) {
|
||||
clearInterval(activeInstall.pollTimer);
|
||||
}
|
||||
if (activeInstall?.fallbackTimer) {
|
||||
clearTimeout(activeInstall.fallbackTimer);
|
||||
}
|
||||
activeInstall = null;
|
||||
|
||||
const list = root.querySelector('[data-install-list]');
|
||||
const template = root.querySelector('#install-row-template');
|
||||
if (!list || !template) return;
|
||||
startSyncedSpinnerRotation(list);
|
||||
|
||||
list.innerHTML = '';
|
||||
const rowsById = new Map();
|
||||
const progressState = new Map();
|
||||
syncInstallLockFlag?.();
|
||||
applyLockPayload?.();
|
||||
applyBodyDefaults?.();
|
||||
|
||||
const ensureRow = (step) => {
|
||||
if (!step) return null;
|
||||
if (rowsById.has(step.id)) {
|
||||
return rowsById.get(step.id);
|
||||
}
|
||||
const row = createInstallRow(template, step);
|
||||
if (!row) return null;
|
||||
row.classList.add('is-entering');
|
||||
list.appendChild(row);
|
||||
row.getBoundingClientRect();
|
||||
requestAnimationFrame(() => {
|
||||
row.classList.remove('is-entering');
|
||||
});
|
||||
rowsById.set(step.id, row);
|
||||
return row;
|
||||
};
|
||||
|
||||
const installPanel = root.querySelector('.install-panel');
|
||||
let panelHeightCleanup = null;
|
||||
const animatePanelHeight = (mutate) => {
|
||||
if (!installPanel) {
|
||||
mutate();
|
||||
return;
|
||||
}
|
||||
if (panelHeightCleanup) {
|
||||
panelHeightCleanup();
|
||||
panelHeightCleanup = null;
|
||||
}
|
||||
const currentHeight = installPanel.getBoundingClientRect().height;
|
||||
installPanel.style.height = `${currentHeight}px`;
|
||||
installPanel.getBoundingClientRect();
|
||||
mutate();
|
||||
const nextHeight = installPanel.getBoundingClientRect().height;
|
||||
if (currentHeight === nextHeight) {
|
||||
installPanel.style.height = '';
|
||||
return;
|
||||
}
|
||||
installPanel.style.height = `${currentHeight}px`;
|
||||
installPanel.getBoundingClientRect();
|
||||
installPanel.style.height = `${nextHeight}px`;
|
||||
const cleanup = () => {
|
||||
installPanel.style.height = '';
|
||||
installPanel.removeEventListener('transitionend', onEnd);
|
||||
};
|
||||
const onEnd = (event) => {
|
||||
if (event.propertyName === 'height') {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
panelHeightCleanup = cleanup;
|
||||
installPanel.addEventListener('transitionend', onEnd);
|
||||
};
|
||||
|
||||
const renderProgress = () => {
|
||||
animatePanelHeight(() => {
|
||||
const visibleSteps = [];
|
||||
for (const step of INSTALLATION_STEPS) {
|
||||
const state = progressState.get(step.id);
|
||||
if (!state) break;
|
||||
visibleSteps.push(step);
|
||||
}
|
||||
|
||||
visibleSteps.forEach((step) => {
|
||||
const state = progressState.get(step.id);
|
||||
if (!state) return;
|
||||
const row = ensureRow(step);
|
||||
if (row) {
|
||||
updateInstallRow(row, step, state.status || STATUS.IN_PROGRESS, state.message);
|
||||
if (state.status === STATUS?.ERROR) {
|
||||
updateInstallErrorDetails(row, {
|
||||
message: state.message,
|
||||
trace: state.details?.trace,
|
||||
output: state.details?.output
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const firstStep = INSTALLATION_STEPS[0];
|
||||
if (firstStep) {
|
||||
progressState.set(firstStep.id, {
|
||||
status: STATUS.IN_PROGRESS,
|
||||
message: firstStep.inProgress
|
||||
});
|
||||
}
|
||||
renderProgress();
|
||||
|
||||
const applyProgress = (payload) => {
|
||||
const step = getStepDefinition(payload.step) || {
|
||||
id: payload.step,
|
||||
inProgress: payload.message || payload.step,
|
||||
done: payload.message || payload.step
|
||||
};
|
||||
progressState.set(step.id, {
|
||||
status: payload.status || STATUS.IN_PROGRESS,
|
||||
message: payload.message,
|
||||
details: payload.details
|
||||
});
|
||||
renderProgress();
|
||||
if (activeInstall) {
|
||||
activeInstall.lastEventAt = Date.now();
|
||||
if (payload.status === STATUS.ERROR) {
|
||||
if (activeInstall.pollTimer) {
|
||||
clearInterval(activeInstall.pollTimer);
|
||||
activeInstall.pollTimer = null;
|
||||
}
|
||||
if (activeInstall.fallbackTimer) {
|
||||
clearTimeout(activeInstall.fallbackTimer);
|
||||
activeInstall.fallbackTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
scheduleFallback();
|
||||
};
|
||||
|
||||
const handleProgress = (payload) => {
|
||||
if (!payload || !payload.step) return;
|
||||
|
||||
const existingState = progressState.get(payload.step);
|
||||
if (existingState && existingState.status === STATUS.COMPLETED && payload.status === STATUS.IN_PROGRESS) {
|
||||
return;
|
||||
}
|
||||
|
||||
const step = getStepDefinition(payload.step) || {
|
||||
id: payload.step,
|
||||
inProgress: payload.message || payload.step,
|
||||
done: payload.message || payload.step
|
||||
};
|
||||
if (payload.status === STATUS.IN_PROGRESS) {
|
||||
const currentIndex = INSTALLATION_STEPS.findIndex((candidate) => candidate.id === step.id);
|
||||
if (currentIndex > 0) {
|
||||
for (let i = 0; i < currentIndex; i += 1) {
|
||||
const previousStep = INSTALLATION_STEPS[i];
|
||||
const previousState = progressState.get(previousStep.id);
|
||||
if (previousState && previousState.status !== STATUS.COMPLETED) {
|
||||
progressState.set(previousStep.id, {
|
||||
status: STATUS.COMPLETED,
|
||||
message: previousStep.done,
|
||||
details: previousState.details
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
applyProgress(payload);
|
||||
};
|
||||
|
||||
const applySnapshot = (snapshot) => {
|
||||
if (!snapshot || !snapshot.steps) return;
|
||||
INSTALLATION_STEPS.forEach((step) => {
|
||||
const detail = snapshot.steps[step.id];
|
||||
if (!detail) return;
|
||||
progressState.set(step.id, {
|
||||
status: detail.status,
|
||||
message: detail.message,
|
||||
details: snapshot.details?.[step.id]
|
||||
});
|
||||
});
|
||||
renderProgress();
|
||||
};
|
||||
|
||||
const checkAllCompleted = () => {
|
||||
const allDone = INSTALLATION_STEPS.every((step) => {
|
||||
const state = progressState.get(step.id);
|
||||
return state && state.status === STATUS.COMPLETED;
|
||||
});
|
||||
if (!allDone) return;
|
||||
const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP);
|
||||
const sessionDetails = accountState?.details;
|
||||
finalizeInstall();
|
||||
notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => {
|
||||
setTimeout(() => redirectToApp(), TIMINGS?.redirectDelay ?? 0);
|
||||
});
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
if (!activeInstall || activeInstall.pollTimer) return;
|
||||
activeInstall.pollTimer = setInterval(async () => {
|
||||
if (!activeInstall || activeInstall.completed) return;
|
||||
const snapshot = await fetchInstallStatus(activeInstall.installId);
|
||||
if (snapshot) {
|
||||
applySnapshot(snapshot);
|
||||
checkAllCompleted();
|
||||
}
|
||||
}, TIMINGS?.installPollInterval ?? 0);
|
||||
};
|
||||
|
||||
const scheduleFallback = () => {
|
||||
if (!activeInstall) return;
|
||||
if (activeInstall.fallbackTimer) {
|
||||
clearTimeout(activeInstall.fallbackTimer);
|
||||
}
|
||||
activeInstall.fallbackTimer = setTimeout(() => {
|
||||
if (!activeInstall) return;
|
||||
startPolling();
|
||||
}, TIMINGS?.installFallbackDelay ?? 0);
|
||||
};
|
||||
|
||||
const finalizeInstall = () => {
|
||||
if (!activeInstall) return;
|
||||
activeInstall.completed = true;
|
||||
if (activeInstall.pollTimer) {
|
||||
clearInterval(activeInstall.pollTimer);
|
||||
}
|
||||
if (activeInstall.fallbackTimer) {
|
||||
clearTimeout(activeInstall.fallbackTimer);
|
||||
}
|
||||
stopSyncedSpinnerRotation();
|
||||
setUnloadGuard(false);
|
||||
};
|
||||
|
||||
const startInstallStream = async (installId, options = {}) => {
|
||||
const isValid = await validateInstallRequest();
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
activeInstall = {
|
||||
installId,
|
||||
controller: new AbortController(),
|
||||
lastEventAt: Date.now(),
|
||||
pollTimer: null,
|
||||
fallbackTimer: null,
|
||||
completed: false
|
||||
};
|
||||
|
||||
const payload = buildInstallPayload(installId);
|
||||
if (options.retryStep) {
|
||||
payload.retryStep = options.retryStep;
|
||||
}
|
||||
setInstallLock?.(installId, payload);
|
||||
setUnloadGuard(true);
|
||||
|
||||
try {
|
||||
scheduleFallback();
|
||||
const response = await fetch('/install', {
|
||||
method: 'POST',
|
||||
headers: withCsrfHeader({
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream'
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
signal: activeInstall.controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
let errorMessage = null;
|
||||
try {
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
errorMessage = data?.message || null;
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage = null;
|
||||
}
|
||||
if (errorMessage) {
|
||||
handleProgress({
|
||||
step: STEP_IDS.CONFIG_FILES,
|
||||
status: STATUS.ERROR,
|
||||
message: errorMessage
|
||||
});
|
||||
finalizeInstall();
|
||||
return;
|
||||
}
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
await readEventStream(response.body, (event, data) => {
|
||||
if (!activeInstall) return;
|
||||
if (event === SSE_EVENTS.INSTALL_ID && data?.installId) {
|
||||
activeInstall.installId = data.installId;
|
||||
storeInstallId?.(data.installId);
|
||||
return;
|
||||
}
|
||||
if (event === SSE_EVENTS.PROGRESS) {
|
||||
handleProgress(data);
|
||||
return;
|
||||
}
|
||||
if (event === SSE_EVENTS.DONE) {
|
||||
// Mark every step as completed (preserving details
|
||||
// from earlier progress events, e.g. session info).
|
||||
INSTALLATION_STEPS.forEach((step) => {
|
||||
const existing = progressState.get(step.id);
|
||||
if (!existing || (existing.status !== STATUS.COMPLETED && existing.status !== STATUS.ERROR)) {
|
||||
progressState.set(step.id, {
|
||||
status: STATUS.COMPLETED,
|
||||
message: step.done,
|
||||
details: existing?.details
|
||||
});
|
||||
}
|
||||
});
|
||||
renderProgress();
|
||||
|
||||
// If any step ended in error (e.g. account creation
|
||||
// failed), stay on the progress screen so the user can
|
||||
// see the error and choose to retry or navigate to the
|
||||
// console manually — don't auto-redirect.
|
||||
const hasErrors = INSTALLATION_STEPS.some((step) => {
|
||||
const state = progressState.get(step.id);
|
||||
return state && state.status === STATUS.ERROR;
|
||||
});
|
||||
|
||||
if (hasErrors) {
|
||||
finalizeInstall();
|
||||
return;
|
||||
}
|
||||
|
||||
const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP);
|
||||
const sessionDetails = accountState?.details;
|
||||
finalizeInstall();
|
||||
notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => {
|
||||
setTimeout(() => redirectToApp(), TIMINGS?.redirectDelay ?? 0);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event === SSE_EVENTS.ERROR) {
|
||||
if (data?.message) {
|
||||
const existingError = Array.from(progressState.values()).some((state) => state?.status === STATUS.ERROR);
|
||||
if (data.step || !existingError) {
|
||||
let targetStep = data.step;
|
||||
if (!targetStep) {
|
||||
for (const candidate of INSTALLATION_STEPS) {
|
||||
const state = progressState.get(candidate.id);
|
||||
if (!state || state.status !== STATUS.COMPLETED) {
|
||||
targetStep = candidate.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
handleProgress({
|
||||
step: targetStep || STEP_IDS.CONFIG_FILES,
|
||||
status: STATUS.ERROR,
|
||||
message: data.message,
|
||||
details: data.details
|
||||
});
|
||||
}
|
||||
}
|
||||
finalizeInstall();
|
||||
}
|
||||
});
|
||||
if (activeInstall && !activeInstall.completed) {
|
||||
// Stream ended without a "done" event (e.g. browser
|
||||
// throttled the background tab). Check if we're done.
|
||||
checkAllCompleted();
|
||||
if (!activeInstall?.completed) {
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!activeInstall || activeInstall.controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
startPolling();
|
||||
}
|
||||
};
|
||||
|
||||
const resumeInstall = async (installId) => {
|
||||
const snapshot = await fetchInstallStatus(installId);
|
||||
if (!snapshot) return false;
|
||||
activeInstall = {
|
||||
installId,
|
||||
controller: new AbortController(),
|
||||
lastEventAt: Date.now(),
|
||||
pollTimer: null,
|
||||
fallbackTimer: null,
|
||||
completed: false
|
||||
};
|
||||
applySnapshot(snapshot);
|
||||
startPolling();
|
||||
setUnloadGuard(true);
|
||||
return true;
|
||||
};
|
||||
|
||||
const resetProgressFrom = (stepId) => {
|
||||
const index = INSTALLATION_STEPS.findIndex((step) => step.id === stepId);
|
||||
if (index === -1) return;
|
||||
INSTALLATION_STEPS.slice(index).forEach((step) => {
|
||||
progressState.delete(step.id);
|
||||
const row = rowsById.get(step.id);
|
||||
if (row && row.parentNode) {
|
||||
row.parentNode.removeChild(row);
|
||||
}
|
||||
rowsById.delete(step.id);
|
||||
});
|
||||
};
|
||||
|
||||
const retryInstallStep = (stepId) => {
|
||||
if (!stepId) return;
|
||||
if (activeInstall?.controller) {
|
||||
activeInstall.controller.abort();
|
||||
}
|
||||
if (activeInstall?.pollTimer) {
|
||||
clearInterval(activeInstall.pollTimer);
|
||||
}
|
||||
if (activeInstall?.fallbackTimer) {
|
||||
clearTimeout(activeInstall.fallbackTimer);
|
||||
}
|
||||
|
||||
resetProgressFrom(stepId);
|
||||
|
||||
const step = getStepDefinition(stepId);
|
||||
progressState.set(stepId, {
|
||||
status: STATUS.IN_PROGRESS,
|
||||
message: step?.inProgress || 'Retrying...'
|
||||
});
|
||||
|
||||
const row = ensureRow(step);
|
||||
if (row) {
|
||||
updateInstallRow(row, step, STATUS.IN_PROGRESS, step.inProgress || 'Retrying...');
|
||||
}
|
||||
|
||||
const installId = activeInstall?.installId || getInstallLock?.()?.installId || generateInstallId();
|
||||
storeInstallId?.(installId);
|
||||
startInstallStream(installId, { retryStep: stepId });
|
||||
};
|
||||
|
||||
list.addEventListener('click', (event) => {
|
||||
const consoleButton = event.target.closest('[data-install-console]');
|
||||
const retryButton = event.target.closest('[data-install-retry]');
|
||||
|
||||
if (consoleButton) {
|
||||
redirectToApp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (retryButton) {
|
||||
const row = retryButton.closest('.install-row');
|
||||
const stepId = row?.dataset.step;
|
||||
retryInstallStep(stepId);
|
||||
}
|
||||
});
|
||||
|
||||
// When the user switches back to this tab, check if installation
|
||||
// finished while the tab was in the background.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && activeInstall && !activeInstall.completed) {
|
||||
checkAllCompleted();
|
||||
}
|
||||
});
|
||||
|
||||
const lock = getInstallLock?.();
|
||||
const existingInstallId = lock?.installId || getStoredInstallId?.();
|
||||
if (existingInstallId) {
|
||||
resumeInstall(existingInstallId).then((resumed) => {
|
||||
if (!resumed) {
|
||||
clearInstallId?.();
|
||||
clearInstallLock?.();
|
||||
const newInstallId = generateInstallId();
|
||||
storeInstallId?.(newInstallId);
|
||||
startInstallStream(newInstallId);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const newInstallId = generateInstallId();
|
||||
storeInstallId?.(newInstallId);
|
||||
startInstallStream(newInstallId);
|
||||
}
|
||||
};
|
||||
|
||||
window.InstallerStepsProgress = {
|
||||
initStep5,
|
||||
cleanupInstallFlow,
|
||||
validateInstallRequest
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,158 @@
|
||||
(() => {
|
||||
const {
|
||||
getBodyDataset,
|
||||
isUpgradeMode,
|
||||
getLockedDatabase
|
||||
} = window.InstallerStepsContext || {};
|
||||
|
||||
const INSTALL_LOCK_KEY = 'appwrite-install-lock';
|
||||
const INSTALL_ID_KEY = 'appwrite-install-id';
|
||||
|
||||
const formState = {
|
||||
appDomain: null,
|
||||
database: null,
|
||||
httpPort: null,
|
||||
httpsPort: null,
|
||||
emailCertificates: null,
|
||||
opensslKey: null,
|
||||
assistantOpenAIKey: null,
|
||||
accountEmail: null,
|
||||
accountPassword: null
|
||||
};
|
||||
|
||||
const dispatchStateChange = (key) => {
|
||||
if (!key || typeof document === 'undefined') return;
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('installer:state-change', {
|
||||
detail: { key, value: formState[key] }
|
||||
}));
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
const setStateIfEmpty = (key, value) => {
|
||||
if (value === null || value === undefined || value === '') return;
|
||||
if (formState[key] === null || formState[key] === undefined || formState[key] === '') {
|
||||
formState[key] = value;
|
||||
}
|
||||
};
|
||||
|
||||
const applyBodyDefaults = () => {
|
||||
const data = getBodyDataset?.() ?? {};
|
||||
setStateIfEmpty('appDomain', data.defaultAppDomain);
|
||||
setStateIfEmpty('httpPort', data.defaultHttpPort);
|
||||
setStateIfEmpty('httpsPort', data.defaultHttpsPort);
|
||||
setStateIfEmpty('emailCertificates', data.defaultEmailCertificates);
|
||||
setStateIfEmpty('opensslKey', data.defaultSecretKey);
|
||||
setStateIfEmpty('assistantOpenAIKey', data.defaultAssistantOpenaiKey);
|
||||
if (data.lockedDatabase) {
|
||||
formState.database = data.lockedDatabase;
|
||||
}
|
||||
if (!isUpgradeMode?.()) {
|
||||
setStateIfEmpty('database', data.defaultDatabase);
|
||||
}
|
||||
};
|
||||
|
||||
const getInstallLock = () => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(INSTALL_LOCK_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const setInstallLock = (installId, payload) => {
|
||||
const sanitizedPayload = payload ? { ...payload } : null;
|
||||
if (sanitizedPayload) {
|
||||
delete sanitizedPayload.opensslKey;
|
||||
delete sanitizedPayload.accountPassword;
|
||||
delete sanitizedPayload.assistantOpenAIKey;
|
||||
}
|
||||
const lock = {
|
||||
installId,
|
||||
payload: sanitizedPayload,
|
||||
startedAt: Date.now()
|
||||
};
|
||||
try {
|
||||
sessionStorage.setItem(INSTALL_LOCK_KEY, JSON.stringify(lock));
|
||||
} catch (error) {}
|
||||
if (document.body) {
|
||||
document.body.dataset.installLocked = 'true';
|
||||
}
|
||||
return lock;
|
||||
};
|
||||
|
||||
const clearInstallLock = () => {
|
||||
try {
|
||||
sessionStorage.removeItem(INSTALL_LOCK_KEY);
|
||||
} catch (error) {}
|
||||
if (document.body) {
|
||||
delete document.body.dataset.installLocked;
|
||||
}
|
||||
};
|
||||
|
||||
const isInstallLocked = () => {
|
||||
return Boolean(getInstallLock());
|
||||
};
|
||||
|
||||
const syncInstallLockFlag = () => {
|
||||
if (!document.body) return;
|
||||
if (isInstallLocked()) {
|
||||
document.body.dataset.installLocked = 'true';
|
||||
} else {
|
||||
delete document.body.dataset.installLocked;
|
||||
}
|
||||
};
|
||||
|
||||
const applyLockPayload = () => {
|
||||
const lock = getInstallLock();
|
||||
if (!lock || !lock.payload) return;
|
||||
const payload = lock.payload;
|
||||
setStateIfEmpty('appDomain', payload.appDomain);
|
||||
setStateIfEmpty('database', payload.database);
|
||||
setStateIfEmpty('httpPort', payload.httpPort);
|
||||
setStateIfEmpty('httpsPort', payload.httpsPort);
|
||||
setStateIfEmpty('emailCertificates', payload.emailCertificates);
|
||||
setStateIfEmpty('accountEmail', payload.accountEmail);
|
||||
};
|
||||
|
||||
const getStoredInstallId = () => {
|
||||
try {
|
||||
return sessionStorage.getItem(INSTALL_ID_KEY);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const storeInstallId = (installId) => {
|
||||
try {
|
||||
sessionStorage.setItem(INSTALL_ID_KEY, installId);
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
const clearInstallId = () => {
|
||||
try {
|
||||
sessionStorage.removeItem(INSTALL_ID_KEY);
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
window.InstallerStepsState = {
|
||||
formState,
|
||||
dispatchStateChange,
|
||||
setStateIfEmpty,
|
||||
applyBodyDefaults,
|
||||
applyLockPayload,
|
||||
getInstallLock,
|
||||
setInstallLock,
|
||||
clearInstallLock,
|
||||
isInstallLocked,
|
||||
syncInstallLockFlag,
|
||||
getStoredInstallId,
|
||||
storeInstallId,
|
||||
clearInstallId,
|
||||
getLockedDatabase: getLockedDatabase || (() => '')
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,95 @@
|
||||
(() => {
|
||||
const TOAST_STACK_ID = 'installer-toast-stack';
|
||||
const DEFAULT_TIMEOUT = 5000;
|
||||
const MAX_TOASTS = 3;
|
||||
const ICONS = {
|
||||
error: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0m-7 4a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-1-9a1 1 0 0 0-1 1v4a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1" clip-rule="evenodd"/></svg>',
|
||||
close: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M5.293 5.293a1 1 0 0 1 1.414 0L10 8.586l3.293-3.293a1 1 0 1 1 1.414 1.414L11.414 10l3.293 3.293a1 1 0 0 1-1.414 1.414L10 11.414l-3.293 3.293a1 1 0 0 1-1.414-1.414L8.586 10 5.293 6.707a1 1 0 0 1 0-1.414" clip-rule="evenodd"/></svg>'
|
||||
};
|
||||
|
||||
const getStack = () => document.getElementById(TOAST_STACK_ID);
|
||||
|
||||
const dismissToast = (toast) => {
|
||||
if (!toast) return;
|
||||
if (toast.classList.contains('is-leaving')) return;
|
||||
toast.classList.add('is-leaving');
|
||||
const remove = () => toast.remove();
|
||||
toast.addEventListener('transitionend', remove, { once: true });
|
||||
setTimeout(remove, 450);
|
||||
};
|
||||
|
||||
const showToast = ({
|
||||
title = '',
|
||||
description = '',
|
||||
status = 'error',
|
||||
dismissible = true,
|
||||
timeout = DEFAULT_TIMEOUT
|
||||
} = {}) => {
|
||||
const stack = getStack();
|
||||
if (!stack) return;
|
||||
const visibleToasts = Array.from(
|
||||
stack.querySelectorAll('.installer-toast:not(.is-leaving)')
|
||||
);
|
||||
if (visibleToasts.length >= MAX_TOASTS) {
|
||||
dismissToast(visibleToasts[0]);
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'installer-toast is-entering';
|
||||
toast.dataset.status = status;
|
||||
toast.setAttribute('role', status === 'error' ? 'alert' : 'status');
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'installer-toast-content';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'installer-toast-icon';
|
||||
icon.dataset.status = status;
|
||||
icon.innerHTML = ICONS.error;
|
||||
content.appendChild(icon);
|
||||
|
||||
const body = document.createElement('section');
|
||||
body.className = 'installer-toast-body';
|
||||
|
||||
if (title) {
|
||||
const titleNode = document.createElement('p');
|
||||
titleNode.className = 'installer-toast-title typography-text-m-500';
|
||||
titleNode.textContent = title;
|
||||
body.appendChild(titleNode);
|
||||
}
|
||||
|
||||
if (description) {
|
||||
const descNode = document.createElement('p');
|
||||
descNode.className = 'installer-toast-description typography-text-m-400';
|
||||
descNode.textContent = description;
|
||||
body.appendChild(descNode);
|
||||
}
|
||||
|
||||
content.appendChild(body);
|
||||
toast.appendChild(content);
|
||||
|
||||
if (dismissible) {
|
||||
const close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
close.className = 'installer-toast-close';
|
||||
close.setAttribute('aria-label', 'Dismiss notification');
|
||||
close.innerHTML = ICONS.close;
|
||||
close.addEventListener('click', () => dismissToast(toast));
|
||||
toast.appendChild(close);
|
||||
}
|
||||
|
||||
stack.appendChild(toast);
|
||||
toast.getBoundingClientRect();
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.remove('is-entering');
|
||||
});
|
||||
|
||||
if (timeout > 0) {
|
||||
setTimeout(() => dismissToast(toast), timeout);
|
||||
}
|
||||
};
|
||||
|
||||
window.InstallerToast = Object.freeze({
|
||||
showToast
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,281 @@
|
||||
(() => {
|
||||
const { TIMINGS } = window.InstallerStepsContext || {};
|
||||
const { formState } = window.InstallerStepsState || {};
|
||||
|
||||
const clearFieldErrors = (root) => {
|
||||
if (!root) return;
|
||||
root.querySelectorAll('.field-error').forEach((node) => {
|
||||
node.classList.remove('is-visible');
|
||||
});
|
||||
root.querySelectorAll('.input-field.is-error, .input-action.is-error').forEach((node) => {
|
||||
node.classList.remove('is-error');
|
||||
});
|
||||
root.querySelectorAll('.field-helper').forEach((helper) => {
|
||||
helper.style.display = '';
|
||||
});
|
||||
};
|
||||
|
||||
const setFieldError = (input, message) => {
|
||||
if (!input) return;
|
||||
const group = input.closest('.input-group');
|
||||
if (!group) return;
|
||||
let error = group.querySelector('.field-error');
|
||||
let errorText = error?.querySelector('.field-error-text');
|
||||
const hasSameMessage = Boolean(errorText && errorText.textContent === message);
|
||||
const alreadyVisible = Boolean(error && error.classList.contains('is-visible'));
|
||||
|
||||
if (hasSameMessage && alreadyVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
const template = document.getElementById('field-error-template');
|
||||
if (template && template.content) {
|
||||
const fragment = template.content.cloneNode(true);
|
||||
error = fragment.querySelector('.field-error');
|
||||
group.appendChild(fragment);
|
||||
}
|
||||
errorText = error?.querySelector('.field-error-text');
|
||||
}
|
||||
if (errorText) {
|
||||
errorText.textContent = message;
|
||||
}
|
||||
|
||||
if (!alreadyVisible) {
|
||||
requestAnimationFrame(() => {
|
||||
error.classList.add('is-visible');
|
||||
});
|
||||
}
|
||||
|
||||
input.classList.add('is-error');
|
||||
const actionWrapper = input.closest('.input-action');
|
||||
if (actionWrapper) {
|
||||
actionWrapper.classList.add('is-error');
|
||||
}
|
||||
const helper = group.querySelector('.field-helper');
|
||||
if (helper) {
|
||||
helper.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const bindErrorClear = (input) => {
|
||||
if (!input) return;
|
||||
const handler = () => {
|
||||
const group = input.closest('.input-group');
|
||||
const error = group?.querySelector('.field-error');
|
||||
if (error) {
|
||||
error.classList.remove('is-visible');
|
||||
}
|
||||
input.classList.remove('is-error');
|
||||
const actionWrapper = input.closest('.input-action');
|
||||
if (actionWrapper) {
|
||||
actionWrapper.classList.remove('is-error');
|
||||
}
|
||||
const helper = group?.querySelector('.field-helper');
|
||||
if (helper) {
|
||||
helper.style.display = '';
|
||||
}
|
||||
};
|
||||
input.addEventListener('input', handler);
|
||||
input.addEventListener('change', handler);
|
||||
};
|
||||
|
||||
const toDatabaseLabel = (value) => {
|
||||
if (!value) return '';
|
||||
const lower = value.toLowerCase();
|
||||
if (lower === 'mariadb') return 'MariaDB';
|
||||
if (lower === 'postgresql') return 'PostgreSQL';
|
||||
return 'MongoDB';
|
||||
};
|
||||
|
||||
const updateDatabaseSelection = (radio, root) => {
|
||||
if (!radio || !root) return;
|
||||
const allOptions = root.querySelectorAll('.selector-card');
|
||||
allOptions.forEach((option) => option.classList.remove('selected'));
|
||||
const selectedOption = radio.closest('.selector-card');
|
||||
if (selectedOption) {
|
||||
selectedOption.classList.add('selected');
|
||||
}
|
||||
};
|
||||
|
||||
const syncResetButton = (input, button) => {
|
||||
const defaultValue = input.dataset.default ?? '';
|
||||
button.disabled = input.value === defaultValue;
|
||||
};
|
||||
|
||||
const setupResetButtons = (root) => {
|
||||
const inputs = root.querySelectorAll('.input-field[data-default]');
|
||||
inputs.forEach((input) => {
|
||||
const button = root.querySelector(`[data-reset-target="${input.id}"]`);
|
||||
if (!button) return;
|
||||
|
||||
syncResetButton(input, button);
|
||||
|
||||
input.addEventListener('input', () => syncResetButton(input, button));
|
||||
button.addEventListener('click', () => {
|
||||
input.value = input.dataset.default ?? '';
|
||||
syncResetButton(input, button);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAccordion = (button) => {
|
||||
const content = button.nextElementSibling;
|
||||
const icon = button.querySelector('.accordion-chevron');
|
||||
const isOpen = button.classList.contains('is-open');
|
||||
|
||||
button.classList.toggle('is-open', !isOpen);
|
||||
button.setAttribute('aria-expanded', String(!isOpen));
|
||||
|
||||
if (content) {
|
||||
if (!isOpen) {
|
||||
content.classList.add('open');
|
||||
content.style.maxHeight = `${content.scrollHeight}px`;
|
||||
} else {
|
||||
content.style.maxHeight = '0px';
|
||||
content.classList.remove('open');
|
||||
}
|
||||
}
|
||||
|
||||
if (icon) {
|
||||
icon.setAttribute('data-open', String(!isOpen));
|
||||
}
|
||||
};
|
||||
|
||||
const setupAccordion = (root) => {
|
||||
const buttons = root.querySelectorAll('.accordion-toggle');
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener('click', () => toggleAccordion(button));
|
||||
});
|
||||
};
|
||||
|
||||
const openAccordion = (root) => {
|
||||
const toggle = root.querySelector('.accordion-toggle');
|
||||
const content = root.querySelector('.accordion-content');
|
||||
if (!toggle || !content) return;
|
||||
if (!toggle.classList.contains('is-open')) {
|
||||
toggle.classList.add('is-open');
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
content.classList.add('open');
|
||||
content.style.maxHeight = `${content.scrollHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const disableControls = (root) => {
|
||||
const inputs = root.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach((input) => {
|
||||
if (input.type === 'radio' || input.type === 'checkbox') {
|
||||
input.disabled = true;
|
||||
} else {
|
||||
input.readOnly = true;
|
||||
input.setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
const buttons = root.querySelectorAll('button');
|
||||
buttons.forEach((button) => {
|
||||
if (button.matches('[data-copy-target]')) return;
|
||||
button.disabled = true;
|
||||
button.setAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
root.classList.add('is-locked');
|
||||
};
|
||||
|
||||
const generateSecretKey = () => {
|
||||
const array = new Uint8Array(32);
|
||||
window.crypto.getRandomValues(array);
|
||||
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
|
||||
const copyToClipboard = (value, input) => {
|
||||
if (!value) return;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(value);
|
||||
return;
|
||||
}
|
||||
if (input) {
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
input.setSelectionRange(0, 0);
|
||||
return;
|
||||
}
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = value;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.top = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch (error) {} finally {
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
};
|
||||
|
||||
const setTooltipText = (wrapper, message) => {
|
||||
if (!wrapper) return;
|
||||
const tooltip = wrapper.querySelector('.tooltip');
|
||||
if (tooltip && message) {
|
||||
tooltip.textContent = message;
|
||||
}
|
||||
};
|
||||
|
||||
const resetTooltipText = (wrapper) => {
|
||||
if (!wrapper) return;
|
||||
const defaultText = wrapper.dataset.tooltipDefault;
|
||||
if (!defaultText) return;
|
||||
setTooltipText(wrapper, defaultText);
|
||||
};
|
||||
|
||||
const updateReviewSummary = (root) => {
|
||||
if (!root) return;
|
||||
const valueNodes = root.querySelectorAll('[data-review-value]');
|
||||
valueNodes.forEach((node) => {
|
||||
const key = node.dataset.reviewValue;
|
||||
if (!key) return;
|
||||
let value = formState?.[key];
|
||||
if (key === 'database') {
|
||||
value = toDatabaseLabel(formState?.database);
|
||||
}
|
||||
if (value) {
|
||||
node.textContent = value;
|
||||
}
|
||||
});
|
||||
|
||||
const badge = root.querySelector('[data-review-badge]');
|
||||
if (badge) {
|
||||
const hasKey = Boolean((formState?.opensslKey || '').trim());
|
||||
badge.textContent = hasKey ? 'Generated' : 'Missing';
|
||||
badge.classList.remove('badge-success', 'badge-warning');
|
||||
badge.classList.add(hasKey ? 'badge-success' : 'badge-warning');
|
||||
}
|
||||
|
||||
const assistantBadge = root.querySelector('[data-review-assistant-badge]');
|
||||
if (assistantBadge) {
|
||||
const hasAssistantKey = Boolean((formState?.assistantOpenAIKey || '').trim());
|
||||
assistantBadge.textContent = hasAssistantKey ? 'Enabled' : 'Disabled';
|
||||
assistantBadge.classList.remove('badge-success', 'badge-neutral');
|
||||
assistantBadge.classList.add(hasAssistantKey ? 'badge-success' : 'badge-neutral');
|
||||
}
|
||||
};
|
||||
|
||||
window.InstallerStepsUI = {
|
||||
clearFieldErrors,
|
||||
setFieldError,
|
||||
bindErrorClear,
|
||||
toDatabaseLabel,
|
||||
updateDatabaseSelection,
|
||||
setupResetButtons,
|
||||
setupAccordion,
|
||||
openAccordion,
|
||||
disableControls,
|
||||
generateSecretKey,
|
||||
copyToClipboard,
|
||||
setTooltipText,
|
||||
resetTooltipText,
|
||||
updateReviewSummary
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,117 @@
|
||||
(() => {
|
||||
const isValidEmail = (email) => {
|
||||
if (!email) return false;
|
||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return re.test(email);
|
||||
};
|
||||
|
||||
const isValidPort = (value) => {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isInteger(numeric)) return false;
|
||||
return numeric >= 1 && numeric <= 65535;
|
||||
};
|
||||
|
||||
const isValidPassword = (value) => {
|
||||
if (!value) return false;
|
||||
return value.length >= 8 && /\S/.test(value);
|
||||
};
|
||||
|
||||
const isValidIPv4 = (host) => {
|
||||
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return false;
|
||||
return host.split('.').every((part) => {
|
||||
const num = Number(part);
|
||||
return num >= 0 && num <= 255;
|
||||
});
|
||||
};
|
||||
|
||||
const isValidIPv6 = (host) => {
|
||||
try {
|
||||
const url = new URL(`http://[${host}]`);
|
||||
return url.hostname.toLowerCase() === host.toLowerCase();
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isValidHostnameLabel = (label) => {
|
||||
if (!label || label.length > 63) return false;
|
||||
if (label.startsWith('-') || label.endsWith('-')) return false;
|
||||
return /^[a-zA-Z0-9-]+$/.test(label);
|
||||
};
|
||||
|
||||
const isValidDomain = (host) => {
|
||||
if (host.length > 253) return false;
|
||||
const labels = host.split('.');
|
||||
return labels.every((label) => isValidHostnameLabel(label));
|
||||
};
|
||||
|
||||
const isValidHost = (host) => {
|
||||
if (host === 'localhost') return true;
|
||||
if (isValidIPv4(host)) return true;
|
||||
if (isValidIPv6(host)) return true;
|
||||
return isValidDomain(host);
|
||||
};
|
||||
|
||||
const isValidHostnameInput = (value) => {
|
||||
if (!value) return false;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
let host = trimmed;
|
||||
let port = null;
|
||||
|
||||
if (trimmed.startsWith('[')) {
|
||||
const match = trimmed.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
||||
if (!match) return false;
|
||||
host = match[1] || '';
|
||||
port = match[2] || null;
|
||||
} else {
|
||||
const parts = trimmed.split(':');
|
||||
if (parts.length > 2) return false;
|
||||
if (parts.length === 2) {
|
||||
host = parts[0];
|
||||
port = parts[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (port !== null && port !== '' && !isValidPort(port)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValidHost(host);
|
||||
};
|
||||
|
||||
const extractHostname = (value) => {
|
||||
if (!value) return '';
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith('[')) {
|
||||
const end = trimmed.indexOf(']');
|
||||
if (end !== -1) {
|
||||
return trimmed.slice(1, end);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
const colonCount = (trimmed.match(/:/g) || []).length;
|
||||
if (colonCount === 1) {
|
||||
return trimmed.split(':')[0];
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']);
|
||||
|
||||
const isLocalHost = (host) => {
|
||||
if (!host) return false;
|
||||
const normalized = host.toLowerCase();
|
||||
return LOCAL_HOSTS.has(normalized);
|
||||
};
|
||||
|
||||
window.InstallerStepsValidation = {
|
||||
isValidEmail,
|
||||
isValidPort,
|
||||
isValidPassword,
|
||||
isValidHostnameInput,
|
||||
extractHostname,
|
||||
isLocalHost
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,435 @@
|
||||
(() => {
|
||||
const Context = window.InstallerStepsContext || {};
|
||||
const State = window.InstallerStepsState || {};
|
||||
const Validation = window.InstallerStepsValidation || {};
|
||||
const UI = window.InstallerStepsUI || {};
|
||||
const Progress = window.InstallerStepsProgress || {};
|
||||
const Tooltips = window.InstallerTooltips || null;
|
||||
|
||||
const {
|
||||
INSTALLATION_STEPS,
|
||||
clampStep,
|
||||
isUpgradeMode
|
||||
} = Context;
|
||||
|
||||
const {
|
||||
formState,
|
||||
dispatchStateChange,
|
||||
applyBodyDefaults,
|
||||
applyLockPayload,
|
||||
clearInstallLock,
|
||||
clearInstallId,
|
||||
isInstallLocked,
|
||||
syncInstallLockFlag,
|
||||
getInstallLock,
|
||||
getLockedDatabase
|
||||
} = State;
|
||||
|
||||
const {
|
||||
isValidEmail,
|
||||
isValidPort,
|
||||
isValidHostnameInput,
|
||||
isValidPassword
|
||||
} = Validation;
|
||||
|
||||
const {
|
||||
clearFieldErrors,
|
||||
setFieldError,
|
||||
bindErrorClear,
|
||||
updateDatabaseSelection,
|
||||
setupResetButtons,
|
||||
setupAccordion,
|
||||
openAccordion,
|
||||
disableControls,
|
||||
generateSecretKey,
|
||||
copyToClipboard,
|
||||
setTooltipText,
|
||||
resetTooltipText,
|
||||
updateReviewSummary
|
||||
} = UI;
|
||||
|
||||
let reviewListener = null;
|
||||
|
||||
const bindInputToState = (input, key) => {
|
||||
if (!input) return;
|
||||
const update = () => {
|
||||
formState[key] = input.value;
|
||||
dispatchStateChange?.(key);
|
||||
};
|
||||
input.addEventListener('input', update);
|
||||
input.addEventListener('change', update);
|
||||
update();
|
||||
};
|
||||
|
||||
const lockDatabaseSelection = (root, lockedDatabase) => {
|
||||
if (lockedDatabase) {
|
||||
const radios = root.querySelectorAll('input[name="database"]');
|
||||
radios.forEach((radio) => {
|
||||
const isLockedChoice = radio.value === lockedDatabase;
|
||||
const card = radio.closest('.selector-card');
|
||||
radio.disabled = !isLockedChoice;
|
||||
if (card) {
|
||||
card.classList.toggle('is-disabled', !isLockedChoice);
|
||||
}
|
||||
if (isLockedChoice) {
|
||||
radio.checked = true;
|
||||
updateDatabaseSelection?.(radio, root);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const bindDatabaseSelection = (root) => {
|
||||
const radios = root.querySelectorAll('input[name="database"]');
|
||||
radios.forEach((radio) => {
|
||||
radio.addEventListener('change', () => {
|
||||
formState.database = radio.value;
|
||||
updateDatabaseSelection?.(radio, root);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const hydrateStep1State = (root) => {
|
||||
State.setStateIfEmpty?.('appDomain', root.querySelector('#hostname')?.value);
|
||||
State.setStateIfEmpty?.('database', root.querySelector('input[name="database"]:checked')?.value);
|
||||
State.setStateIfEmpty?.('httpPort', root.querySelector('#http-port')?.value);
|
||||
State.setStateIfEmpty?.('httpsPort', root.querySelector('#https-port')?.value);
|
||||
State.setStateIfEmpty?.('emailCertificates', root.querySelector('#ssl-email')?.value);
|
||||
State.setStateIfEmpty?.('assistantOpenAIKey', root.querySelector('#assistant-openai-key')?.value);
|
||||
};
|
||||
|
||||
const applyStep1State = (root) => {
|
||||
const hostname = root.querySelector('#hostname');
|
||||
if (hostname && formState.appDomain) hostname.value = formState.appDomain;
|
||||
|
||||
const httpPort = root.querySelector('#http-port');
|
||||
if (httpPort && formState.httpPort) httpPort.value = formState.httpPort;
|
||||
|
||||
const httpsPort = root.querySelector('#https-port');
|
||||
if (httpsPort && formState.httpsPort) httpsPort.value = formState.httpsPort;
|
||||
|
||||
const sslEmail = root.querySelector('#ssl-email');
|
||||
if (sslEmail && formState.emailCertificates) sslEmail.value = formState.emailCertificates;
|
||||
|
||||
const assistantKey = root.querySelector('#assistant-openai-key');
|
||||
if (assistantKey && formState.assistantOpenAIKey) {
|
||||
assistantKey.value = formState.assistantOpenAIKey;
|
||||
}
|
||||
|
||||
if (formState.database) {
|
||||
const radio = root.querySelector(`input[name="database"][value="${formState.database}"]`);
|
||||
if (radio) {
|
||||
radio.checked = true;
|
||||
updateDatabaseSelection?.(radio, root);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const initStep1 = (root) => {
|
||||
if (!root) return;
|
||||
syncInstallLockFlag?.();
|
||||
applyLockPayload?.();
|
||||
applyBodyDefaults?.();
|
||||
hydrateStep1State(root);
|
||||
applyStep1State(root);
|
||||
|
||||
if (isInstallLocked?.()) {
|
||||
openAccordion?.(root);
|
||||
disableControls?.(root);
|
||||
return;
|
||||
}
|
||||
|
||||
const lockedDatabase = getLockedDatabase?.() || '';
|
||||
if (lockedDatabase) {
|
||||
lockDatabaseSelection(root, lockedDatabase);
|
||||
} else {
|
||||
bindDatabaseSelection(root);
|
||||
}
|
||||
|
||||
const hostname = root.querySelector('#hostname');
|
||||
const httpPort = root.querySelector('#http-port');
|
||||
const httpsPort = root.querySelector('#https-port');
|
||||
const sslEmail = root.querySelector('#ssl-email');
|
||||
const assistantKey = root.querySelector('#assistant-openai-key');
|
||||
|
||||
bindInputToState(hostname, 'appDomain');
|
||||
bindInputToState(httpPort, 'httpPort');
|
||||
bindInputToState(httpsPort, 'httpsPort');
|
||||
bindInputToState(sslEmail, 'emailCertificates');
|
||||
bindInputToState(assistantKey, 'assistantOpenAIKey');
|
||||
|
||||
bindErrorClear?.(hostname);
|
||||
bindErrorClear?.(httpPort);
|
||||
bindErrorClear?.(httpsPort);
|
||||
bindErrorClear?.(sslEmail);
|
||||
bindErrorClear?.(assistantKey);
|
||||
|
||||
const checked = root.querySelector('input[name="database"]:checked');
|
||||
if (checked) {
|
||||
updateDatabaseSelection?.(checked, root);
|
||||
}
|
||||
|
||||
setupResetButtons?.(root);
|
||||
setupAccordion?.(root);
|
||||
Tooltips?.setupTooltipPortals?.(root);
|
||||
};
|
||||
|
||||
const hydrateStep2State = (root) => {
|
||||
const value = root.querySelector('#secret-key')?.value;
|
||||
if (formState.opensslKey) return;
|
||||
if (value) {
|
||||
formState.opensslKey = value;
|
||||
}
|
||||
};
|
||||
|
||||
const applyStep2State = (root) => {
|
||||
const input = root.querySelector('#secret-key');
|
||||
if (input && formState.opensslKey) {
|
||||
input.value = formState.opensslKey;
|
||||
}
|
||||
};
|
||||
|
||||
const initStep2 = (root) => {
|
||||
if (!root) return;
|
||||
syncInstallLockFlag?.();
|
||||
applyLockPayload?.();
|
||||
applyBodyDefaults?.();
|
||||
hydrateStep2State(root);
|
||||
if (!isUpgradeMode?.() && (!formState.opensslKey || !formState.opensslKey.trim())) {
|
||||
formState.opensslKey = generateSecretKey?.();
|
||||
dispatchStateChange?.('opensslKey');
|
||||
}
|
||||
applyStep2State(root);
|
||||
|
||||
const input = root.querySelector('#secret-key');
|
||||
if (input) {
|
||||
bindInputToState(input, 'opensslKey');
|
||||
bindErrorClear?.(input);
|
||||
}
|
||||
|
||||
const copyButton = root.querySelector('[data-copy-target]');
|
||||
const tooltipWrapper = copyButton?.closest('.tooltip-wrapper');
|
||||
|
||||
if (tooltipWrapper) {
|
||||
tooltipWrapper.addEventListener('mouseenter', () => resetTooltipText?.(tooltipWrapper));
|
||||
tooltipWrapper.addEventListener('focusin', () => resetTooltipText?.(tooltipWrapper));
|
||||
}
|
||||
|
||||
if (copyButton) {
|
||||
copyButton.addEventListener('click', () => {
|
||||
const targetId = copyButton.getAttribute('data-copy-target');
|
||||
const targetInput = targetId ? root.querySelector(`#${targetId}`) : null;
|
||||
const value = targetInput?.value || '';
|
||||
copyToClipboard?.(value, targetInput);
|
||||
copyButton.blur();
|
||||
|
||||
if (tooltipWrapper) {
|
||||
const successText = tooltipWrapper.dataset.tooltipSuccess || 'Copied';
|
||||
setTooltipText?.(tooltipWrapper, successText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const regenerateButton = root.querySelector('[data-regenerate-target]');
|
||||
if (regenerateButton && !isInstallLocked?.()) {
|
||||
regenerateButton.addEventListener('click', () => {
|
||||
const targetId = regenerateButton.getAttribute('data-regenerate-target');
|
||||
const targetInput = targetId ? root.querySelector(`#${targetId}`) : null;
|
||||
if (!targetInput) return;
|
||||
regenerateButton.classList.remove('is-rotating');
|
||||
void regenerateButton.offsetWidth;
|
||||
regenerateButton.classList.add('is-rotating');
|
||||
const handleAnimationEnd = () => {
|
||||
regenerateButton.classList.remove('is-rotating');
|
||||
};
|
||||
regenerateButton.addEventListener('animationend', handleAnimationEnd, { once: true });
|
||||
targetInput.value = generateSecretKey?.();
|
||||
targetInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
if (isInstallLocked?.()) {
|
||||
disableControls?.(root);
|
||||
}
|
||||
};
|
||||
|
||||
const hydrateStep3State = (root) => {
|
||||
State.setStateIfEmpty?.('accountEmail', root.querySelector('#account-email')?.value);
|
||||
State.setStateIfEmpty?.('accountPassword', root.querySelector('#account-password')?.value);
|
||||
};
|
||||
|
||||
const applyStep3State = (root) => {
|
||||
const email = root.querySelector('#account-email');
|
||||
if (email && formState.accountEmail) email.value = formState.accountEmail;
|
||||
|
||||
const password = root.querySelector('#account-password');
|
||||
if (password && formState.accountPassword) password.value = formState.accountPassword;
|
||||
};
|
||||
|
||||
const initStep3 = (root) => {
|
||||
if (!root) return;
|
||||
syncInstallLockFlag?.();
|
||||
applyLockPayload?.();
|
||||
applyBodyDefaults?.();
|
||||
hydrateStep3State(root);
|
||||
applyStep3State(root);
|
||||
|
||||
const email = root.querySelector('#account-email');
|
||||
const password = root.querySelector('#account-password');
|
||||
const passwordToggle = root.querySelector('[data-password-toggle="account-password"]');
|
||||
|
||||
bindInputToState(email, 'accountEmail');
|
||||
bindInputToState(password, 'accountPassword');
|
||||
|
||||
bindErrorClear?.(email);
|
||||
bindErrorClear?.(password);
|
||||
|
||||
if (password && passwordToggle) {
|
||||
passwordToggle.addEventListener('click', () => {
|
||||
const isVisible = passwordToggle.classList.toggle('is-visible');
|
||||
password.type = isVisible ? 'text' : 'password';
|
||||
passwordToggle.setAttribute('aria-label', isVisible ? 'Hide password' : 'Show password');
|
||||
});
|
||||
}
|
||||
|
||||
if (isInstallLocked?.()) {
|
||||
disableControls?.(root);
|
||||
}
|
||||
};
|
||||
|
||||
const initStep4 = (root) => {
|
||||
if (!root) return;
|
||||
syncInstallLockFlag?.();
|
||||
applyLockPayload?.();
|
||||
applyBodyDefaults?.();
|
||||
updateReviewSummary?.(root);
|
||||
if (reviewListener) {
|
||||
document.removeEventListener('installer:state-change', reviewListener);
|
||||
}
|
||||
reviewListener = () => updateReviewSummary?.(root);
|
||||
document.addEventListener('installer:state-change', reviewListener);
|
||||
if (isInstallLocked?.()) {
|
||||
disableControls?.(root);
|
||||
}
|
||||
};
|
||||
|
||||
const initStep = (step, container) => {
|
||||
if (!container) return;
|
||||
const root = container.querySelector('.step-layout') || container;
|
||||
const normalized = clampStep?.(step) ?? 1;
|
||||
Tooltips?.cleanupTooltipPortals?.();
|
||||
if (normalized !== 4 && reviewListener) {
|
||||
document.removeEventListener('installer:state-change', reviewListener);
|
||||
reviewListener = null;
|
||||
}
|
||||
if (normalized !== 5) {
|
||||
Progress.cleanupInstallFlow?.();
|
||||
}
|
||||
if (normalized === 1) initStep1(root);
|
||||
if (normalized === 2) initStep2(root);
|
||||
if (normalized === 3) initStep3(root);
|
||||
if (normalized === 4) initStep4(root);
|
||||
if (normalized === 5) Progress.initStep5?.(root);
|
||||
};
|
||||
|
||||
window.InstallerSteps = {
|
||||
initStep1,
|
||||
initStep2,
|
||||
initStep3,
|
||||
initStep4,
|
||||
initStep5: Progress.initStep5,
|
||||
installationSteps: INSTALLATION_STEPS || [],
|
||||
isInstallLocked,
|
||||
getInstallLock,
|
||||
clearInstallLock,
|
||||
initStep,
|
||||
validateStep: (step, container) => {
|
||||
const root = container?.querySelector('.step-layout') || container;
|
||||
const normalized = clampStep?.(step) ?? 1;
|
||||
if (normalized === 1) {
|
||||
clearFieldErrors?.(root);
|
||||
let valid = true;
|
||||
const hostname = root?.querySelector('#hostname');
|
||||
const httpPort = root?.querySelector('#http-port');
|
||||
const httpsPort = root?.querySelector('#https-port');
|
||||
const sslEmail = root?.querySelector('#ssl-email');
|
||||
|
||||
if (!hostname || !hostname.value.trim()) {
|
||||
setFieldError?.(hostname, 'Please enter your Appwrite hostname');
|
||||
valid = false;
|
||||
} else if (!isValidHostnameInput?.(hostname.value.trim())) {
|
||||
setFieldError?.(hostname, 'Please enter a valid hostname');
|
||||
valid = false;
|
||||
}
|
||||
|
||||
const parsePort = (input, label) => {
|
||||
const value = input?.value;
|
||||
if (!value || !isValidPort?.(value)) {
|
||||
setFieldError?.(input, `Please enter a valid ${label} port (1-65535)`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!parsePort(httpPort, 'HTTP')) valid = false;
|
||||
if (!parsePort(httpsPort, 'HTTPS')) valid = false;
|
||||
|
||||
if (!sslEmail || !sslEmail.value.trim()) {
|
||||
setFieldError?.(sslEmail, 'Please enter an email address for SSL certificates');
|
||||
valid = false;
|
||||
} else if (!isValidEmail?.(sslEmail.value.trim())) {
|
||||
setFieldError?.(sslEmail, 'Please enter a valid email address');
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
openAccordion?.(root);
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
if (normalized === 2) {
|
||||
clearFieldErrors?.(root);
|
||||
const secretKey = root?.querySelector('#secret-key');
|
||||
const secretValue = secretKey?.value.trim() || '';
|
||||
if (!secretKey || !secretValue) {
|
||||
setFieldError?.(secretKey, 'Please enter or generate a secret API key');
|
||||
return false;
|
||||
}
|
||||
if (secretValue.length > 64) {
|
||||
setFieldError?.(secretKey, 'Secret API key must be 1-64 characters');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized === 3) {
|
||||
clearFieldErrors?.(root);
|
||||
let valid = true;
|
||||
const email = root?.querySelector('#account-email');
|
||||
const password = root?.querySelector('#account-password');
|
||||
|
||||
if (!email || !email.value.trim()) {
|
||||
setFieldError?.(email, 'This field is required');
|
||||
valid = false;
|
||||
} else if (!isValidEmail?.(email.value.trim())) {
|
||||
setFieldError?.(email, 'Please enter a valid email address');
|
||||
valid = false;
|
||||
}
|
||||
|
||||
const passwordValue = password?.value ?? '';
|
||||
if (!password || !/\S/.test(passwordValue)) {
|
||||
setFieldError?.(password, 'This field is required');
|
||||
valid = false;
|
||||
} else if (!isValidPassword?.(passwordValue)) {
|
||||
setFieldError?.(password, 'Password must be at least 8 characters long');
|
||||
valid = false;
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,77 @@
|
||||
(() => {
|
||||
const tooltipPortals = new Set();
|
||||
|
||||
const positionTooltipPortal = (tooltip, anchor) => {
|
||||
if (!tooltip || !anchor) return;
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const tooltipRect = tooltip.getBoundingClientRect();
|
||||
const offset = Number(tooltip.dataset.tooltipOffset || 6);
|
||||
const padding = 8;
|
||||
let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
|
||||
left = Math.max(padding, Math.min(left, window.innerWidth - tooltipRect.width - padding));
|
||||
const top = rect.bottom + offset;
|
||||
tooltip.style.left = `${left}px`;
|
||||
tooltip.style.top = `${top}px`;
|
||||
};
|
||||
|
||||
const attachTooltipPortal = (tooltip) => {
|
||||
if (!tooltip || tooltip.dataset.portalInitialized === 'true') return;
|
||||
const anchor = tooltip.parentElement;
|
||||
if (!anchor) return;
|
||||
|
||||
tooltip.dataset.portalInitialized = 'true';
|
||||
tooltip.classList.add('tooltip-portal');
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
const show = () => {
|
||||
tooltip.classList.add('is-open');
|
||||
positionTooltipPortal(tooltip, anchor);
|
||||
};
|
||||
const hide = () => {
|
||||
tooltip.classList.remove('is-open');
|
||||
};
|
||||
const refresh = () => {
|
||||
if (tooltip.classList.contains('is-open')) {
|
||||
positionTooltipPortal(tooltip, anchor);
|
||||
}
|
||||
};
|
||||
|
||||
anchor.addEventListener('mouseenter', show);
|
||||
anchor.addEventListener('mouseleave', hide);
|
||||
anchor.addEventListener('focusin', show);
|
||||
anchor.addEventListener('focusout', hide);
|
||||
window.addEventListener('scroll', refresh, true);
|
||||
window.addEventListener('resize', refresh);
|
||||
|
||||
tooltipPortals.add({
|
||||
tooltip,
|
||||
cleanup: () => {
|
||||
anchor.removeEventListener('mouseenter', show);
|
||||
anchor.removeEventListener('mouseleave', hide);
|
||||
anchor.removeEventListener('focusin', show);
|
||||
anchor.removeEventListener('focusout', hide);
|
||||
window.removeEventListener('scroll', refresh, true);
|
||||
window.removeEventListener('resize', refresh);
|
||||
if (tooltip.parentElement) {
|
||||
tooltip.parentElement.removeChild(tooltip);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const setupTooltipPortals = (root) => {
|
||||
if (!root) return;
|
||||
const portalTooltips = root.querySelectorAll('.tooltip[data-tooltip-portal]');
|
||||
portalTooltips.forEach((tooltip) => attachTooltipPortal(tooltip));
|
||||
};
|
||||
|
||||
const cleanupTooltipPortals = () => {
|
||||
tooltipPortals.forEach((entry) => entry.cleanup());
|
||||
tooltipPortals.clear();
|
||||
};
|
||||
|
||||
window.InstallerTooltips = {
|
||||
setupTooltipPortals,
|
||||
cleanupTooltipPortals
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
$isUpgrade = $isUpgrade ?? false;
|
||||
$lockedDatabase = $lockedDatabase ?? null;
|
||||
$defaultAppDomain = $defaultAppDomain ?? 'localhost';
|
||||
$defaultHttpPort = $defaultHttpPort ?? '80';
|
||||
$defaultHttpsPort = $defaultHttpsPort ?? '443';
|
||||
$defaultEmailCertificates = $defaultEmailCertificates ?? '';
|
||||
$defaultAssistantOpenAIKey = $defaultAssistantOpenAIKey ?? '';
|
||||
$defaultDatabase = $defaultDatabase ?? 'mongodb';
|
||||
$selectedDatabase = $lockedDatabase ?: $defaultDatabase;
|
||||
$isDatabaseLocked = !empty($lockedDatabase);
|
||||
$mongoDisabled = $isDatabaseLocked && $selectedDatabase !== 'mongodb';
|
||||
$mariaDisabled = $isDatabaseLocked && $selectedDatabase !== 'mariadb';
|
||||
$postgresDisabled = $isDatabaseLocked && $selectedDatabase !== 'postgresql';
|
||||
$hostnameValue = htmlspecialchars((string) $defaultAppDomain, ENT_QUOTES, 'UTF-8');
|
||||
$httpPortValue = htmlspecialchars((string) $defaultHttpPort, ENT_QUOTES, 'UTF-8');
|
||||
$httpsPortValue = htmlspecialchars((string) $defaultHttpsPort, ENT_QUOTES, 'UTF-8');
|
||||
$sslEmailValue = htmlspecialchars((string) $defaultEmailCertificates, ENT_QUOTES, 'UTF-8');
|
||||
$assistantOpenAIKeyValue = htmlspecialchars((string) $defaultAssistantOpenAIKey, ENT_QUOTES, 'UTF-8');
|
||||
?>
|
||||
<div class="step-layout" data-step="1">
|
||||
<div class="stack-xl">
|
||||
<div class="stack-xxxs">
|
||||
<h1 class="typography-title-s text-neutral-primary"><?php echo $isUpgrade ? 'Update your app' : 'Setup your app'; ?></h1>
|
||||
<p class="typography-text-m-400 text-neutral-secondary">
|
||||
<?php echo $isUpgrade ? 'Review your current settings before updating.' : 'Choose where your app will live and how it will store data'; ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stack-xl">
|
||||
<div class="input-group stack-xs">
|
||||
<label for="hostname" class="label-text typography-text-m-500 text-neutral-secondary">Hostname</label>
|
||||
<input
|
||||
type="text"
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
class="input-field typography-text-m-400 text-neutral-primary"
|
||||
placeholder="localhost"
|
||||
value="<?php echo $hostnameValue; ?>"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="input-group stack-xs">
|
||||
<label class="label-text typography-text-m-500 text-neutral-secondary">Database</label>
|
||||
<div class="selector-group<?php echo $isDatabaseLocked ? ' is-locked' : ''; ?>">
|
||||
<label class="selector-card <?php echo ($selectedDatabase === 'mongodb') ? 'selected' : ''; ?><?php echo $mongoDisabled ? ' is-disabled has-tooltip' : ''; ?>"<?php echo $mongoDisabled ? ' aria-disabled="true"' : ''; ?>>
|
||||
<input type="radio" name="database" value="mongodb" <?php echo ($selectedDatabase === 'mongodb') ? 'checked' : ''; ?> class="sr-only" <?php echo $mongoDisabled ? 'disabled' : ''; ?>>
|
||||
<div class="selector-content">
|
||||
<div class="typography-text-m-500 text-neutral-primary">MongoDB</div>
|
||||
<div class="typography-text-xs-400 text-neutral-secondary">Document based database</div>
|
||||
</div>
|
||||
<img src="installer/icons/mongodb.svg" alt="MongoDB" class="selector-icon">
|
||||
<?php if ($mongoDisabled) { ?>
|
||||
<span class="tooltip tooltip-db-locked typography-text-m-400 text-on-invert" role="tooltip" data-tooltip-portal="true">Database cannot be changed after initial setup.</span>
|
||||
<?php } ?>
|
||||
</label>
|
||||
|
||||
<label class="selector-card <?php echo ($selectedDatabase === 'mariadb') ? 'selected' : ''; ?><?php echo $mariaDisabled ? ' is-disabled has-tooltip' : ''; ?>"<?php echo $mariaDisabled ? ' aria-disabled="true"' : ''; ?>>
|
||||
<input type="radio" name="database" value="mariadb" <?php echo ($selectedDatabase === 'mariadb') ? 'checked' : ''; ?> class="sr-only" <?php echo $mariaDisabled ? 'disabled' : ''; ?>>
|
||||
<div class="selector-content">
|
||||
<div class="typography-text-m-500 text-neutral-primary">MariaDB</div>
|
||||
<div class="typography-text-xs-400 text-neutral-secondary">Relational SQL database</div>
|
||||
</div>
|
||||
<img src="installer/icons/mariadb.svg" alt="MariaDB" class="selector-icon">
|
||||
<?php if ($mariaDisabled) { ?>
|
||||
<span class="tooltip tooltip-db-locked typography-text-m-400 text-on-invert" role="tooltip" data-tooltip-portal="true">Database cannot be changed after initial setup.</span>
|
||||
<?php } ?>
|
||||
</label>
|
||||
|
||||
<label class="selector-card <?php echo ($selectedDatabase === 'postgresql') ? 'selected' : ''; ?><?php echo $postgresDisabled ? ' is-disabled has-tooltip' : ''; ?>"<?php echo $postgresDisabled ? ' aria-disabled="true"' : ''; ?>>
|
||||
<input type="radio" name="database" value="postgresql" <?php echo ($selectedDatabase === 'postgresql') ? 'checked' : ''; ?> class="sr-only" <?php echo $postgresDisabled ? 'disabled' : ''; ?>>
|
||||
<div class="selector-content">
|
||||
<div class="typography-text-m-500 text-neutral-primary">PostgreSQL</div>
|
||||
<div class="typography-text-xs-400 text-neutral-secondary">Relational SQL database</div>
|
||||
</div>
|
||||
<img src="installer/icons/postgresql.svg" alt="PostgreSQL" class="selector-icon">
|
||||
<?php if ($postgresDisabled) { ?>
|
||||
<span class="tooltip tooltip-db-locked typography-text-m-400 text-on-invert" role="tooltip" data-tooltip-portal="true">Database cannot be changed after initial setup.</span>
|
||||
<?php } ?>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion">
|
||||
<button
|
||||
type="button"
|
||||
class="accordion-toggle"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="label-text typography-text-m-500 text-neutral-secondary">Advanced settings</span>
|
||||
<span class="accordion-chevron" data-open="false">
|
||||
<?php include __DIR__ . '/../../icons/chevron-down.svg'; ?>
|
||||
</span>
|
||||
</button>
|
||||
<div class="accordion-content">
|
||||
<div class="input-row">
|
||||
<div class="input-group stack-xs">
|
||||
<label for="http-port" class="label-text typography-text-m-500 text-neutral-secondary">HTTP port</label>
|
||||
<input
|
||||
type="number"
|
||||
id="http-port"
|
||||
name="httpPort"
|
||||
class="input-field typography-text-m-400 text-neutral-primary"
|
||||
placeholder="<?php echo $httpPortValue; ?>"
|
||||
value="<?php echo $httpPortValue; ?>"
|
||||
data-default="<?php echo $httpPortValue; ?>"
|
||||
min="1"
|
||||
max="65535"
|
||||
step="1"
|
||||
>
|
||||
</div>
|
||||
<button type="button" class="button secondary" data-reset-target="http-port" disabled>
|
||||
<span class="button-text typography-text-m-500">Reset</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<div class="input-group stack-xs">
|
||||
<label for="https-port" class="label-text typography-text-m-500 text-neutral-secondary">HTTPS port</label>
|
||||
<input
|
||||
type="number"
|
||||
id="https-port"
|
||||
name="httpsPort"
|
||||
class="input-field typography-text-m-400 text-neutral-primary"
|
||||
placeholder="<?php echo $httpsPortValue; ?>"
|
||||
value="<?php echo $httpsPortValue; ?>"
|
||||
data-default="<?php echo $httpsPortValue; ?>"
|
||||
min="1"
|
||||
max="65535"
|
||||
step="1"
|
||||
>
|
||||
</div>
|
||||
<button type="button" class="button secondary" data-reset-target="https-port" disabled>
|
||||
<span class="button-text typography-text-m-500">Reset</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<div class="input-group stack-xs">
|
||||
<label for="ssl-email" class="label-text typography-text-m-500 text-neutral-secondary">SSL certificate email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="ssl-email"
|
||||
name="sslEmail"
|
||||
class="input-field typography-text-m-400 text-neutral-primary"
|
||||
placeholder="you@example.com"
|
||||
value="<?php echo $sslEmailValue; ?>"
|
||||
data-default="<?php echo $sslEmailValue; ?>"
|
||||
>
|
||||
</div>
|
||||
<button type="button" class="button secondary" data-reset-target="ssl-email" disabled>
|
||||
<span class="button-text typography-text-m-500">Reset</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<div class="input-group stack-xs">
|
||||
<label for="assistant-openai-key" class="label-text typography-text-m-500 text-neutral-secondary">
|
||||
OpenAI key
|
||||
<span class="label-optional typography-text-m-400 text-neutral-tertiary">optional</span>
|
||||
<span class="tooltip-wrapper">
|
||||
<button type="button" class="label-info-button" aria-label="OpenAI key info">
|
||||
<?php include __DIR__ . '/../../icons/info.svg'; ?>
|
||||
</button>
|
||||
<span class="tooltip tooltip-assistant typography-text-m-400 text-on-invert" role="tooltip">Add your OpenAI key to enable Appwrite Assistant. The key is stored locally and won’t be shared.</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="assistant-openai-key"
|
||||
name="assistantOpenAIKey"
|
||||
class="input-field typography-text-m-400 text-neutral-primary"
|
||||
placeholder="Enter OpenAI key for Appwrite Assistant"
|
||||
value="<?php echo $assistantOpenAIKeyValue; ?>"
|
||||
data-default="<?php echo $assistantOpenAIKeyValue; ?>"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
$isUpgrade = $isUpgrade ?? false;
|
||||
$defaultSecretKey = $defaultSecretKey ?? '';
|
||||
$secretKeyValue = htmlspecialchars((string) $defaultSecretKey, ENT_QUOTES, 'UTF-8');
|
||||
?>
|
||||
<div class="step-layout" data-step="2">
|
||||
<div class="stack-xl">
|
||||
<div class="stack-xxxs">
|
||||
<h1 class="typography-title-s text-neutral-primary">Secure your app</h1>
|
||||
<p class="typography-text-m-400 text-neutral-secondary">
|
||||
<?php echo $isUpgrade ? 'Review your existing secret key or generate a new one.' : 'Your server\'s private secret key for encryption. Generate a random secure key, or provide your own.'; ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stack-xl">
|
||||
<div class="inline-alert inline-alert--warning">
|
||||
<div class="inline-alert-content">
|
||||
<div class="inline-alert-icon" aria-hidden="true">
|
||||
<?php include __DIR__ . '/../../icons/warning.svg'; ?>
|
||||
</div>
|
||||
<div class="inline-alert-text">
|
||||
<div class="inline-alert-title typography-text-m-500 text-warning">Save your key</div>
|
||||
<div class="inline-alert-description typography-text-m-400 text-neutral-primary">You won't be able to see this key again. Copy it somewhere safe before continuing.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<div class="input-group stack-xs">
|
||||
<label for="secret-key" class="label-text typography-text-m-500 text-neutral-secondary">Secret API key</label>
|
||||
<div class="input-action">
|
||||
<input
|
||||
type="text"
|
||||
id="secret-key"
|
||||
name="secretKey"
|
||||
class="input-action-input typography-text-m-400 text-neutral-primary"
|
||||
value="<?php echo $secretKeyValue; ?>"
|
||||
minlength="1"
|
||||
maxlength="64"
|
||||
>
|
||||
<div class="input-action-buttons">
|
||||
<span class="tooltip-wrapper" data-tooltip-default="Copy" data-tooltip-success="Copied">
|
||||
<button type="button" class="input-icon-button" aria-label="Copy secret key" aria-describedby="copy-tooltip" data-copy-target="secret-key">
|
||||
<?php include __DIR__ . '/../../icons/copy.svg'; ?>
|
||||
</button>
|
||||
<span id="copy-tooltip" class="tooltip typography-text-m-400 text-on-invert" role="tooltip">Copy</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="icon-button" aria-label="Regenerate secret key" data-regenerate-target="secret-key">
|
||||
<?php include __DIR__ . '/../../icons/refresh.svg'; ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
$defaultAccountEmail = $defaultAccountEmail ?? '';
|
||||
$accountEmailValue = htmlspecialchars($defaultAccountEmail, ENT_QUOTES, 'UTF-8');
|
||||
?>
|
||||
<div class="step-layout" data-step="3">
|
||||
<div class="stack-xl">
|
||||
<div class="stack-xxxs">
|
||||
<h1 class="typography-title-s text-neutral-primary">Create your account</h1>
|
||||
<p class="typography-text-m-400 text-neutral-secondary">
|
||||
Set up the email and password for your Appwrite account. You can use these
|
||||
credentials to sign in later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stack-l">
|
||||
<div class="input-group stack-xs">
|
||||
<label for="account-email" class="label-text typography-text-m-500 text-neutral-secondary">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="account-email"
|
||||
name="accountEmail"
|
||||
class="input-field typography-text-m-400 text-neutral-primary"
|
||||
placeholder="you@example.com"
|
||||
value="<?php echo $accountEmailValue; ?>"
|
||||
autocomplete="email"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="input-group stack-xs">
|
||||
<label for="account-password"
|
||||
class="label-text typography-text-m-500 text-neutral-secondary">Password</label>
|
||||
<div class="input-action">
|
||||
<input
|
||||
type="password"
|
||||
id="account-password"
|
||||
name="accountPassword"
|
||||
class="input-action-input typography-text-m-400 text-neutral-primary"
|
||||
placeholder="Enter password"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<div class="input-action-buttons">
|
||||
<button type="button" class="input-icon-button password-toggle" aria-label="Show password" data-password-toggle="account-password">
|
||||
<span class="password-toggle-icon" data-password-icon="hide">
|
||||
<?php include __DIR__ . '/../../icons/eye-off.svg'; ?>
|
||||
</span>
|
||||
<span class="password-toggle-icon" data-password-icon="show">
|
||||
<?php include __DIR__ . '/../../icons/eye.svg'; ?>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-helper typography-caption-400 text-neutral-secondary">
|
||||
<span class="field-helper-icon">
|
||||
<?php include __DIR__ . '/../../icons/info.svg'; ?>
|
||||
</span>
|
||||
<span class="field-helper-text">Password must be at least 8 characters long</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
$isUpgrade = $isUpgrade ?? false;
|
||||
$lockedDatabase = $lockedDatabase ?? null;
|
||||
$defaultAppDomain = $defaultAppDomain ?? 'localhost';
|
||||
$defaultHttpPort = $defaultHttpPort ?? '80';
|
||||
$defaultHttpsPort = $defaultHttpsPort ?? '443';
|
||||
$defaultEmailCertificates = $defaultEmailCertificates ?? '';
|
||||
$defaultSecretKey = $defaultSecretKey ?? '';
|
||||
$defaultDatabase = $defaultDatabase ?? 'mongodb';
|
||||
$selectedDatabase = $lockedDatabase ?: $defaultDatabase;
|
||||
$defaultDatabaseLabel = match ($selectedDatabase) {
|
||||
'mariadb' => 'MariaDB',
|
||||
'postgresql' => 'PostgreSQL',
|
||||
default => 'MongoDB',
|
||||
};
|
||||
$badgeLabel = $defaultSecretKey !== '' ? 'Generated' : 'Missing';
|
||||
$badgeClass = $defaultSecretKey !== '' ? 'badge-success' : 'badge-warning';
|
||||
?>
|
||||
<div class="step-layout" data-step="4">
|
||||
<div class="stack-xl">
|
||||
<div class="stack-xxxs">
|
||||
<h1 class="typography-title-s text-neutral-primary"><?php echo $isUpgrade ? 'Review your update' : 'Review your setup'; ?></h1>
|
||||
<p class="typography-text-m-400 text-neutral-secondary">
|
||||
<?php echo $isUpgrade ? 'Confirm your settings before updating Appwrite.' : 'Check your settings below before completing the installation.'; ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stack-xl">
|
||||
<div class="review-card">
|
||||
<div class="stack-s">
|
||||
<div class="review-row">
|
||||
<div class="typography-text-m-500 text-neutral-primary" data-review-value="appDomain">
|
||||
<?php echo htmlspecialchars((string) $defaultAppDomain, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</div>
|
||||
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Hostname</div>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<div class="typography-text-m-500 text-neutral-primary" data-review-value="database">
|
||||
<?php echo htmlspecialchars((string) $defaultDatabaseLabel, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</div>
|
||||
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Database</div>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<div class="typography-text-m-500 text-neutral-primary" data-review-value="httpPort">
|
||||
<?php echo htmlspecialchars((string) $defaultHttpPort, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</div>
|
||||
<div class="review-label typography-text-xs-400 text-neutral-tertiary">HTTP port</div>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<div class="typography-text-m-500 text-neutral-primary" data-review-value="httpsPort">
|
||||
<?php echo htmlspecialchars((string) $defaultHttpsPort, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</div>
|
||||
<div class="review-label typography-text-xs-400 text-neutral-tertiary">HTTPS port</div>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<div class="typography-text-m-500 text-neutral-primary" data-review-value="emailCertificates">
|
||||
<?php echo htmlspecialchars((string) $defaultEmailCertificates, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</div>
|
||||
<div class="review-label typography-text-xs-400 text-neutral-tertiary">SSL certificate email</div>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="badge badge-neutral typography-text-xs-400" data-review-assistant-badge>Disabled</span>
|
||||
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Appwrite Assistant</div>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="badge <?php echo $badgeClass; ?> typography-text-xs-400" data-review-badge>
|
||||
<?php echo htmlspecialchars((string) $badgeLabel, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</span>
|
||||
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Secret API key</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
$isUpgrade = $isUpgrade ?? false;
|
||||
?>
|
||||
<div class="step-layout install-layout" data-step="5">
|
||||
<div class="install-card">
|
||||
<div class="install-panel">
|
||||
<div class="install-header">
|
||||
<div class="typography-text-m-400 text-neutral-primary">
|
||||
<?php echo $isUpgrade ? 'Updating your app…' : 'Installing your app…'; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="install-list" data-install-list></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template id="install-row-template">
|
||||
<div class="install-row" data-status="in-progress">
|
||||
<div class="install-row-main">
|
||||
<div class="install-row-label">
|
||||
<span class="install-icon" aria-hidden="true">
|
||||
<span class="install-icon-spinner">
|
||||
<?php include __DIR__ . '/../../icons/install-spinner.svg'; ?>
|
||||
</span>
|
||||
<span class="install-icon-check">
|
||||
<?php include __DIR__ . '/../../icons/install-check.svg'; ?>
|
||||
</span>
|
||||
<span class="install-icon-error">
|
||||
<?php include __DIR__ . '/../../icons/exclamation-circle.svg'; ?>
|
||||
</span>
|
||||
</span>
|
||||
<span class="install-text typography-text-m-400 text-neutral-primary" data-install-text></span>
|
||||
</div>
|
||||
<button type="button" class="install-row-toggle" aria-expanded="false" data-install-toggle>
|
||||
<?php include __DIR__ . '/../../icons/chevron-down.svg'; ?>
|
||||
</button>
|
||||
</div>
|
||||
<div class="install-row-details">
|
||||
<div class="install-row-details-inner">
|
||||
<pre class="install-error-code typography-text-xs-400" data-install-trace></pre>
|
||||
<div class="install-error-actions">
|
||||
<button type="button" class="button secondary" data-install-retry>
|
||||
<span class="button-text typography-text-m-500">Retry</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="button primary is-hidden" data-install-console>
|
||||
<span class="button-text typography-text-m-500">Navigate to Console</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -9,16 +9,16 @@ use Appwrite\Event\Certificate;
|
||||
use Appwrite\Event\Database as EventDatabase;
|
||||
use Appwrite\Event\Delete;
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Event\Execution;
|
||||
use Appwrite\Event\Func;
|
||||
use Appwrite\Event\Mail;
|
||||
use Appwrite\Event\Messaging;
|
||||
use Appwrite\Event\Migration;
|
||||
use Appwrite\Event\Publisher\Usage as UsagePublisher;
|
||||
use Appwrite\Event\Realtime;
|
||||
use Appwrite\Event\Screenshot;
|
||||
use Appwrite\Event\StatsUsage;
|
||||
use Appwrite\Event\Webhook;
|
||||
use Appwrite\Platform\Appwrite;
|
||||
use Appwrite\Usage\Context;
|
||||
use Appwrite\Utopia\Database\Documents\User;
|
||||
use Executor\Executor;
|
||||
use Swoole\Runtime;
|
||||
@@ -43,6 +43,7 @@ use Utopia\Pools\Group;
|
||||
use Utopia\Queue\Broker\Pool as BrokerPool;
|
||||
use Utopia\Queue\Message;
|
||||
use Utopia\Queue\Publisher;
|
||||
use Utopia\Queue\Queue;
|
||||
use Utopia\Queue\Server;
|
||||
use Utopia\Registry\Registry;
|
||||
use Utopia\Storage\Device\Telemetry as TelemetryDevice;
|
||||
@@ -59,7 +60,8 @@ Server::setResource('register', fn () => $register);
|
||||
Server::setResource('authorization', function () {
|
||||
$authorization = new Authorization();
|
||||
$authorization->disable();
|
||||
return $authorization;
|
||||
|
||||
return $authorization;
|
||||
}, []);
|
||||
|
||||
Server::setResource('dbForPlatform', function (Cache $cache, Registry $register, Authorization $authorization) {
|
||||
@@ -71,9 +73,7 @@ Server::setResource('dbForPlatform', function (Cache $cache, Registry $register,
|
||||
->setDatabase(APP_DATABASE)
|
||||
->setAuthorization($authorization)
|
||||
->setNamespace('_console')
|
||||
->setDocumentType('users', User::class)
|
||||
;
|
||||
|
||||
->setDocumentType('users', User::class);
|
||||
|
||||
return $dbForPlatform;
|
||||
}, ['cache', 'register', 'authorization']);
|
||||
@@ -112,7 +112,7 @@ Server::setResource('dbForProject', function (Cache $cache, Registry $register,
|
||||
if (\in_array($dsn->getHost(), $sharedTables)) {
|
||||
$database
|
||||
->setSharedTables(true)
|
||||
->setTenant((int)$project->getSequence())
|
||||
->setTenant($project->getSequence())
|
||||
->setNamespace($dsn->getParam('namespace'));
|
||||
} else {
|
||||
$database
|
||||
@@ -152,7 +152,7 @@ Server::setResource('getProjectDB', function (Group $pools, Database $dbForPlatf
|
||||
if (\in_array($dsn->getHost(), $sharedTables)) {
|
||||
$database
|
||||
->setSharedTables(true)
|
||||
->setTenant((int)$project->getSequence())
|
||||
->setTenant($project->getSequence())
|
||||
->setNamespace($dsn->getParam('namespace'));
|
||||
} else {
|
||||
$database
|
||||
@@ -174,7 +174,7 @@ Server::setResource('getProjectDB', function (Group $pools, Database $dbForPlatf
|
||||
if (\in_array($dsn->getHost(), $sharedTables)) {
|
||||
$database
|
||||
->setSharedTables(true)
|
||||
->setTenant((int)$project->getSequence())
|
||||
->setTenant($project->getSequence())
|
||||
->setNamespace($dsn->getParam('namespace'));
|
||||
} else {
|
||||
$database
|
||||
@@ -194,9 +194,10 @@ Server::setResource('getProjectDB', function (Group $pools, Database $dbForPlatf
|
||||
|
||||
Server::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
|
||||
$database = null;
|
||||
|
||||
return function (?Document $project = null) use ($pools, $cache, $database, $authorization) {
|
||||
if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
|
||||
$database->setTenant((int)$project->getSequence());
|
||||
$database->setTenant($project->getSequence());
|
||||
return $database;
|
||||
}
|
||||
|
||||
@@ -211,9 +212,8 @@ Server::setResource('getLogsDB', function (Group $pools, Cache $cache, Authoriza
|
||||
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER)
|
||||
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES_WORKER);
|
||||
|
||||
// set tenant
|
||||
if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') {
|
||||
$database->setTenant((int)$project->getSequence());
|
||||
$database->setTenant($project->getSequence());
|
||||
}
|
||||
|
||||
return $database;
|
||||
@@ -228,6 +228,7 @@ Server::setResource('auditRetention', function (Document $project) {
|
||||
if ($project->getId() === 'console') {
|
||||
return DateTime::addSeconds(new \DateTime(), -1 * System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE', 15778800)); // 6 months
|
||||
}
|
||||
|
||||
return DateTime::addSeconds(new \DateTime(), -1 * System::getEnv('_APP_MAINTENANCE_RETENTION_AUDIT', 1209600)); // 14 days
|
||||
}, ['project']);
|
||||
|
||||
@@ -253,7 +254,7 @@ Server::setResource('redis', function () {
|
||||
$pass = System::getEnv('_APP_REDIS_PASS', '');
|
||||
|
||||
$redis = new \Redis();
|
||||
@$redis->pconnect($host, (int)$port);
|
||||
@$redis->pconnect($host, (int) $port);
|
||||
if ($pass) {
|
||||
$redis->auth($pass);
|
||||
}
|
||||
@@ -270,7 +271,6 @@ Server::setResource('timelimit', function (\Redis $redis) {
|
||||
|
||||
Server::setResource('log', fn () => new Log());
|
||||
|
||||
|
||||
Server::setResource('publisher', function (Group $pools) {
|
||||
return new BrokerPool(publisher: $pools->get('publisher'));
|
||||
}, ['pools']);
|
||||
@@ -287,10 +287,6 @@ Server::setResource('publisherMigrations', function (BrokerPool $publisher) {
|
||||
return $publisher;
|
||||
}, ['publisher']);
|
||||
|
||||
Server::setResource('publisherStatsUsage', function (BrokerPool $publisher) {
|
||||
return $publisher;
|
||||
}, ['publisher']);
|
||||
|
||||
Server::setResource('publisherMessaging', function (BrokerPool $publisher) {
|
||||
return $publisher;
|
||||
}, ['publisher']);
|
||||
@@ -311,9 +307,13 @@ Server::setResource('consumerStatsUsage', function (BrokerPool $consumer) {
|
||||
return $consumer;
|
||||
}, ['consumer']);
|
||||
|
||||
Server::setResource('queueForStatsUsage', function (Publisher $publisher) {
|
||||
return new StatsUsage($publisher);
|
||||
}, ['publisher']);
|
||||
Server::setResource('usage', function () {
|
||||
return new Context();
|
||||
}, []);
|
||||
Server::setResource('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher(
|
||||
$publisher,
|
||||
new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
|
||||
), ['publisher']);
|
||||
|
||||
Server::setResource('queueForDatabase', function (Publisher $publisher) {
|
||||
return new EventDatabase($publisher);
|
||||
@@ -355,10 +355,6 @@ Server::setResource('queueForFunctions', function (Publisher $publisher) {
|
||||
return new Func($publisher);
|
||||
}, ['publisher']);
|
||||
|
||||
Server::setResource('queueForExecutions', function (Publisher $publisher) {
|
||||
return new Execution($publisher);
|
||||
}, ['publisher']);
|
||||
|
||||
Server::setResource('queueForRealtime', function () {
|
||||
return new Realtime();
|
||||
}, []);
|
||||
@@ -488,11 +484,13 @@ Server::setResource('getAudit', function (Database $dbForPlatform, callable $get
|
||||
return function (Document $project) use ($dbForPlatform, $getProjectDB) {
|
||||
if ($project->isEmpty() || $project->getId() === 'console') {
|
||||
$adapter = new AdapterDatabase($dbForPlatform);
|
||||
|
||||
return new UtopiaAudit($adapter);
|
||||
}
|
||||
|
||||
$dbForProject = $getProjectDB($project);
|
||||
$adapter = new AdapterDatabase($dbForProject);
|
||||
|
||||
return new UtopiaAudit($adapter);
|
||||
};
|
||||
}, ['dbForPlatform', 'getProjectDB']);
|
||||
@@ -509,7 +507,7 @@ $pools = $register->get('pools');
|
||||
$platform = new Appwrite();
|
||||
$args = $platform->getEnv('argv');
|
||||
|
||||
if (!isset($args[1])) {
|
||||
if (! isset($args[1])) {
|
||||
Console::error('Missing worker name');
|
||||
Console::exit(1);
|
||||
}
|
||||
@@ -534,14 +532,18 @@ try {
|
||||
'workersNum' => System::getEnv('_APP_WORKERS_NUM', 1),
|
||||
'connection' => $pools->get('consumer')->pop()->getResource(),
|
||||
'workerName' => strtolower($workerName) ?? null,
|
||||
'queueName' => $queueName
|
||||
'queueName' => $queueName,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Console::error($e->getMessage() . ', File: ' . $e->getFile() . ', Line: ' . $e->getLine());
|
||||
Console::error($e->getMessage() . ', File: ' . $e->getFile() . ', Line: ' . $e->getLine());
|
||||
}
|
||||
|
||||
$worker = $platform->getWorker();
|
||||
|
||||
Server::setResource('bus', function ($register) use ($worker) {
|
||||
return $register->get('bus')->setResolver(fn (string $name) => $worker->getResource($name));
|
||||
}, ['register']);
|
||||
|
||||
$worker
|
||||
->error()
|
||||
->inject('error')
|
||||
@@ -550,11 +552,11 @@ $worker
|
||||
->inject('pools')
|
||||
->inject('project')
|
||||
->inject('authorization')
|
||||
->action(function (Throwable $error, ?Logger $logger, Log $log, Group $pools, Document $project, Authorization $authorization) use ($worker, $queueName) {
|
||||
->action(function (Throwable $error, ?Logger $logger, Log $log, Group $pools, Document $project, Authorization $authorization) use ($queueName) {
|
||||
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
|
||||
|
||||
if ($logger) {
|
||||
$log->setNamespace("appwrite-worker");
|
||||
$log->setNamespace('appwrite-worker');
|
||||
$log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
|
||||
$log->setVersion($version);
|
||||
$log->setType(Log::TYPE_ERROR);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
exec php /usr/src/code/app/cli.php time-travel "$@"
|
||||
@@ -13,13 +13,17 @@
|
||||
"test": "vendor/bin/phpunit",
|
||||
"lint": "vendor/bin/pint --test --config pint.json",
|
||||
"format": "vendor/bin/pint --config pint.json",
|
||||
"analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G",
|
||||
"bench": "vendor/bin/phpbench run --report=benchmark",
|
||||
"check": "./vendor/bin/phpstan analyse -c phpstan.neon"
|
||||
"check": "./vendor/bin/phpstan analyse -c phpstan.neon",
|
||||
"installer:clean": "php src/Appwrite/Platform/Installer/Server.php --clean",
|
||||
"installer:dev": "docker compose build && composer installer:clean && php src/Appwrite/Platform/Installer/Server.php --docker"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Appwrite\\": "src/Appwrite",
|
||||
"Executor\\": "src/Executor"
|
||||
"Executor\\": "src/Executor",
|
||||
"Utopia\\Bus\\": "src/Utopia/Bus"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
@@ -57,7 +61,7 @@
|
||||
"utopia-php/compression": "0.1.*",
|
||||
"utopia-php/config": "1.*",
|
||||
"utopia-php/console": "0.1.*",
|
||||
"utopia-php/database": "5.*",
|
||||
"utopia-php/database": "dev-fix-collection-recreate as 5.3.15",
|
||||
"utopia-php/detector": "0.2.*",
|
||||
"utopia-php/domains": "1.*",
|
||||
"utopia-php/emails": "0.6.*",
|
||||
@@ -69,12 +73,13 @@
|
||||
"utopia-php/locale": "0.8.*",
|
||||
"utopia-php/logger": "0.6.*",
|
||||
"utopia-php/messaging": "0.20.*",
|
||||
"utopia-php/migration": "1.6.*",
|
||||
"utopia-php/migration": "1.7.*",
|
||||
"utopia-php/platform": "0.7.*",
|
||||
"utopia-php/pools": "1.*",
|
||||
"utopia-php/span": "1.1.*",
|
||||
"utopia-php/preloader": "0.2.*",
|
||||
"utopia-php/queue": "0.15.*",
|
||||
"utopia-php/servers": "0.2.5",
|
||||
"utopia-php/registry": "0.5.*",
|
||||
"utopia-php/storage": "1.0.*",
|
||||
"utopia-php/system": "0.10.*",
|
||||
@@ -89,18 +94,25 @@
|
||||
"spomky-labs/otphp": "11.*",
|
||||
"webonyx/graphql-php": "14.11.*",
|
||||
"league/csv": "9.14.*",
|
||||
"enshrined/svg-sanitize": "0.22.*"
|
||||
"enshrined/svg-sanitize": "0.22.*",
|
||||
"utopia-php/di": "0.1.0"
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/utopia-php/database"
|
||||
}
|
||||
],
|
||||
"require-dev": {
|
||||
"ext-fileinfo": "*",
|
||||
"appwrite/sdk-generator": "*",
|
||||
"brianium/paratest": "7.*",
|
||||
"phpunit/phpunit": "12.*",
|
||||
"swoole/ide-helper": "6.*",
|
||||
"phpstan/phpstan": "1.12.*",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"textalk/websocket": "1.5.*",
|
||||
"laravel/pint": "1.*",
|
||||
"phpbench/phpbench": "1.*"
|
||||
"czproject/git-php": "4.*",
|
||||
"laravel/pint": "1.*"
|
||||
},
|
||||
"provide": {
|
||||
"ext-phpiredis": "*"
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# Dev tools for local development only.
|
||||
# This file is automatically loaded by `docker compose` alongside docker-compose.yml.
|
||||
# CI sets COMPOSE_FILE=docker-compose.yml explicitly, so these services are excluded from test runs.
|
||||
|
||||
services:
|
||||
appwrite-mongo-express:
|
||||
profiles: ["mongodb"]
|
||||
image: mongo-express
|
||||
container_name: appwrite-mongo-express
|
||||
networks:
|
||||
- appwrite
|
||||
ports:
|
||||
- "8082:8081"
|
||||
environment:
|
||||
ME_CONFIG_MONGODB_URL: "mongodb://root:${_APP_DB_ROOT_PASS}@appwrite-mongodb:27017/?replicaSet=rs0&directConnection=true"
|
||||
ME_CONFIG_BASICAUTH_USERNAME: ${_APP_DB_USER}
|
||||
ME_CONFIG_BASICAUTH_PASSWORD: ${_APP_DB_PASS}
|
||||
depends_on:
|
||||
- mongodb
|
||||
|
||||
adminer:
|
||||
image: adminer
|
||||
container_name: appwrite-adminer
|
||||
restart: always
|
||||
ports:
|
||||
- 9506:8080
|
||||
networks:
|
||||
- appwrite
|
||||
- gateway
|
||||
environment:
|
||||
- ADMINER_DESIGN=pepa-linha
|
||||
- ADMINER_DEFAULT_SERVER=mariadb
|
||||
- ADMINER_DEFAULT_USERNAME=root
|
||||
- ADMINER_DEFAULT_PASSWORD=rootsecretpassword
|
||||
- ADMINER_DEFAULT_DB=appwrite
|
||||
configs:
|
||||
- source: adminer-index.php
|
||||
target: /var/www/html/index.php
|
||||
mode: 0755
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.constraint-label-stack=appwrite"
|
||||
- "traefik.docker.network=gateway"
|
||||
- "traefik.http.services.appwrite_adminer.loadbalancer.server.port=8080"
|
||||
- "traefik.http.routers.appwrite_adminer_http.entrypoints=appwrite_web"
|
||||
- "traefik.http.routers.appwrite_adminer_http.rule=Host(`mysql.localhost`)"
|
||||
- "traefik.http.routers.appwrite_adminer_http.service=appwrite_adminer"
|
||||
- "traefik.http.routers.appwrite_adminer_https.entrypoints=appwrite_websecure"
|
||||
- "traefik.http.routers.appwrite_adminer_https.rule=Host(`mysql.localhost`)"
|
||||
- "traefik.http.routers.appwrite_adminer_https.service=appwrite_adminer"
|
||||
- "traefik.http.routers.appwrite_adminer_https.tls=true"
|
||||
|
||||
redis-insight:
|
||||
image: redis/redisinsight:latest
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- appwrite
|
||||
- gateway
|
||||
environment:
|
||||
- RI_PRE_SETUP_DATABASES_PATH=/mnt/connections.json
|
||||
configs:
|
||||
- source: redisinsight-connections.json
|
||||
target: /mnt/connections.json
|
||||
mode: 0755
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.constraint-label-stack=appwrite"
|
||||
- "traefik.docker.network=gateway"
|
||||
- "traefik.http.services.appwrite_redisinsight.loadbalancer.server.port=5540"
|
||||
- "traefik.http.routers.appwrite_redisinsight_http.entrypoints=appwrite_web"
|
||||
- "traefik.http.routers.appwrite_redisinsight_http.rule=Host(`redis.localhost`)"
|
||||
- "traefik.http.routers.appwrite_redisinsight_http.service=appwrite_redisinsight"
|
||||
- "traefik.http.routers.appwrite_redisinsight_https.entrypoints=appwrite_websecure"
|
||||
- "traefik.http.routers.appwrite_redisinsight_https.rule=Host(`redis.localhost`)"
|
||||
- "traefik.http.routers.appwrite_redisinsight_https.service=appwrite_redisinsight"
|
||||
- "traefik.http.routers.appwrite_redisinsight_https.tls=true"
|
||||
ports:
|
||||
- "8081:5540"
|
||||
|
||||
graphql-explorer:
|
||||
container_name: appwrite-graphql-explorer
|
||||
image: appwrite/altair:0.3.0
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- appwrite
|
||||
ports:
|
||||
- "9509:3000"
|
||||
environment:
|
||||
- SERVER_URL=http://localhost/v1/graphql
|
||||
|
||||
configs:
|
||||
redisinsight-connections.json:
|
||||
content: |
|
||||
[
|
||||
{
|
||||
"compressor": "NONE",
|
||||
"id": "104dc90a-21ef-4d5e-8912-b30baabb152f",
|
||||
"host": "redis",
|
||||
"port": 6379,
|
||||
"name": "redis:6379",
|
||||
"db": 0,
|
||||
"username": "default",
|
||||
"password": null,
|
||||
"connectionType": "STANDALONE",
|
||||
"nameFromProvider": null,
|
||||
"provider": "REDIS",
|
||||
"lastConnection": "2025-10-16T09:22:02.591Z",
|
||||
"modules": [
|
||||
{
|
||||
"name": "ReJSON",
|
||||
"version": 20808,
|
||||
"semanticVersion": "2.8.8"
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"version": 21015,
|
||||
"semanticVersion": "2.10.15"
|
||||
}
|
||||
],
|
||||
"tls": false,
|
||||
"tlsServername": null,
|
||||
"verifyServerCert": null,
|
||||
"caCert": null,
|
||||
"clientCert": null,
|
||||
"ssh": false,
|
||||
"sshOptions": null,
|
||||
"forceStandalone": false,
|
||||
"tags": []
|
||||
}
|
||||
]
|
||||
|
||||
adminer-index.php:
|
||||
content: |
|
||||
<?php
|
||||
if(!count($$_GET)) {
|
||||
$$_POST['auth'] = [
|
||||
'server' => $$_ENV['ADMINER_DEFAULT_SERVER'],
|
||||
'driver' => 'server', /* seems to autodetect the driver from server settings */
|
||||
'username' => $$_ENV['ADMINER_DEFAULT_USERNAME'],
|
||||
'password' => $$_ENV['ADMINER_DEFAULT_PASSWORD'],
|
||||
'db' => $$_ENV['ADMINER_DEFAULT_DB'],
|
||||
];
|
||||
}
|
||||
include './adminer.php';
|
||||
@@ -10,6 +10,15 @@ x-logging: &x-logging
|
||||
max-file: "5"
|
||||
max-size: "10m"
|
||||
|
||||
x-build: &x-build
|
||||
build:
|
||||
context: .
|
||||
target: development
|
||||
args:
|
||||
DEBUG: false
|
||||
TESTING: true
|
||||
VERSION: dev
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:3.6
|
||||
@@ -50,15 +59,13 @@ services:
|
||||
|
||||
appwrite:
|
||||
container_name: appwrite
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
image: appwrite-dev
|
||||
build:
|
||||
context: .
|
||||
target: development
|
||||
args:
|
||||
DEBUG: false
|
||||
TESTING: true
|
||||
VERSION: dev
|
||||
healthcheck:
|
||||
test: ["CMD", "doctor"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
ports:
|
||||
- 9501:80
|
||||
networks:
|
||||
@@ -101,10 +108,10 @@ services:
|
||||
- ./dev:/usr/src/code/dev
|
||||
|
||||
depends_on:
|
||||
- ${_APP_DB_HOST:-mongodb}
|
||||
- redis
|
||||
- coredns
|
||||
# - clamav
|
||||
redis:
|
||||
condition: service_healthy
|
||||
coredns:
|
||||
condition: service_started
|
||||
entrypoint:
|
||||
- php
|
||||
- -e
|
||||
@@ -152,7 +159,6 @@ services:
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_DB_ADAPTER
|
||||
- _APP_SMTP_HOST
|
||||
- _APP_SMTP_PORT
|
||||
- _APP_SMTP_SECURE
|
||||
@@ -261,7 +267,7 @@ services:
|
||||
|
||||
appwrite-realtime:
|
||||
entrypoint: realtime
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-realtime
|
||||
image: appwrite-dev
|
||||
restart: unless-stopped
|
||||
@@ -305,7 +311,6 @@ services:
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_DB_ADAPTER
|
||||
- _APP_USAGE_STATS
|
||||
- _APP_LOGGING_CONFIG
|
||||
- _APP_LOGGING_CONFIG_REALTIME
|
||||
@@ -314,7 +319,7 @@ services:
|
||||
|
||||
appwrite-worker-audits:
|
||||
entrypoint: worker-audits
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-worker-audits
|
||||
image: appwrite-dev
|
||||
networks:
|
||||
@@ -340,13 +345,12 @@ services:
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_DB_ADAPTER
|
||||
- _APP_LOGGING_CONFIG
|
||||
- _APP_DATABASE_SHARED_TABLES
|
||||
|
||||
appwrite-worker-webhooks:
|
||||
entrypoint: worker-webhooks
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-worker-webhooks
|
||||
image: appwrite-dev
|
||||
networks:
|
||||
@@ -371,7 +375,6 @@ services:
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_DB_ADAPTER
|
||||
- _APP_REDIS_HOST
|
||||
- _APP_REDIS_PORT
|
||||
- _APP_REDIS_USER
|
||||
@@ -382,7 +385,7 @@ services:
|
||||
|
||||
appwrite-worker-deletes:
|
||||
entrypoint: worker-deletes
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-worker-deletes
|
||||
image: appwrite-dev
|
||||
networks:
|
||||
@@ -414,7 +417,6 @@ services:
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_DB_ADAPTER
|
||||
- _APP_STORAGE_DEVICE
|
||||
- _APP_STORAGE_S3_ACCESS_KEY
|
||||
- _APP_STORAGE_S3_SECRET
|
||||
@@ -448,7 +450,7 @@ services:
|
||||
|
||||
appwrite-worker-databases:
|
||||
entrypoint: worker-databases
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-worker-databases
|
||||
image: appwrite-dev
|
||||
networks:
|
||||
@@ -474,7 +476,6 @@ services:
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_DB_ADAPTER
|
||||
- _APP_LOGGING_CONFIG
|
||||
- _APP_WORKERS_NUM
|
||||
- _APP_QUEUE_NAME
|
||||
@@ -482,7 +483,7 @@ services:
|
||||
|
||||
appwrite-worker-builds:
|
||||
entrypoint: worker-builds
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-worker-builds
|
||||
image: appwrite-dev
|
||||
networks:
|
||||
@@ -513,7 +514,6 @@ services:
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_DB_ADAPTER
|
||||
- _APP_LOGGING_CONFIG
|
||||
- _APP_VCS_GITHUB_APP_NAME
|
||||
- _APP_VCS_GITHUB_PRIVATE_KEY
|
||||
@@ -558,7 +558,7 @@ services:
|
||||
|
||||
appwrite-worker-screenshots:
|
||||
entrypoint: worker-screenshots
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-worker-screenshots
|
||||
image: appwrite-dev
|
||||
networks:
|
||||
@@ -621,7 +621,7 @@ services:
|
||||
|
||||
appwrite-worker-certificates:
|
||||
entrypoint: worker-certificates
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-worker-certificates
|
||||
image: appwrite-dev
|
||||
networks:
|
||||
@@ -658,13 +658,12 @@ services:
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_DB_ADAPTER
|
||||
- _APP_LOGGING_CONFIG
|
||||
- _APP_DATABASE_SHARED_TABLES
|
||||
|
||||
appwrite-worker-executions:
|
||||
entrypoint: worker-executions
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-worker-executions
|
||||
image: appwrite-dev
|
||||
networks:
|
||||
@@ -694,7 +693,7 @@ services:
|
||||
|
||||
appwrite-worker-functions:
|
||||
entrypoint: worker-functions
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-worker-functions
|
||||
image: appwrite-dev
|
||||
networks:
|
||||
@@ -722,7 +721,6 @@ services:
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_DB_ADAPTER
|
||||
- _APP_FUNCTIONS_TIMEOUT
|
||||
- _APP_SITES_TIMEOUT
|
||||
- _APP_COMPUTE_BUILD_TIMEOUT
|
||||
@@ -739,7 +737,7 @@ services:
|
||||
|
||||
appwrite-worker-mails:
|
||||
entrypoint: worker-mails
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-worker-mails
|
||||
image: appwrite-dev
|
||||
networks:
|
||||
@@ -781,7 +779,7 @@ services:
|
||||
|
||||
appwrite-worker-messaging:
|
||||
entrypoint: worker-messaging
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-worker-messaging
|
||||
restart: unless-stopped
|
||||
image: appwrite-dev
|
||||
@@ -808,7 +806,6 @@ services:
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_DB_ADAPTER
|
||||
- _APP_LOGGING_CONFIG
|
||||
- _APP_SMS_FROM
|
||||
- _APP_SMS_PROVIDER
|
||||
@@ -839,7 +836,7 @@ services:
|
||||
|
||||
appwrite-worker-migrations:
|
||||
entrypoint: worker-migrations
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-worker-migrations
|
||||
restart: unless-stopped
|
||||
image: appwrite-dev
|
||||
@@ -875,7 +872,6 @@ services:
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_DB_ADAPTER
|
||||
- _APP_LOGGING_CONFIG
|
||||
- _APP_MIGRATIONS_FIREBASE_CLIENT_ID
|
||||
- _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET
|
||||
@@ -885,7 +881,7 @@ services:
|
||||
|
||||
appwrite-task-maintenance:
|
||||
entrypoint: maintenance
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-task-maintenance
|
||||
image: appwrite-dev
|
||||
networks:
|
||||
@@ -918,7 +914,6 @@ services:
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_DB_ADAPTER
|
||||
- _APP_MAINTENANCE_INTERVAL
|
||||
- _APP_MAINTENANCE_RETENTION_EXECUTION
|
||||
- _APP_MAINTENANCE_RETENTION_CACHE
|
||||
@@ -932,7 +927,7 @@ services:
|
||||
|
||||
appwrite-task-interval:
|
||||
entrypoint: interval
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-task-interval
|
||||
image: appwrite-dev
|
||||
networks:
|
||||
@@ -973,7 +968,7 @@ services:
|
||||
appwrite-task-stats-resources:
|
||||
container_name: appwrite-task-stats-resources
|
||||
entrypoint: stats-resources
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
image: appwrite-dev
|
||||
networks:
|
||||
- appwrite
|
||||
@@ -994,7 +989,6 @@ services:
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_DB_ADAPTER
|
||||
- _APP_REDIS_HOST
|
||||
- _APP_REDIS_PORT
|
||||
- _APP_REDIS_USER
|
||||
@@ -1006,7 +1000,7 @@ services:
|
||||
|
||||
appwrite-worker-stats-resources:
|
||||
entrypoint: worker-stats-resources
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-worker-stats-resources
|
||||
image: appwrite-dev
|
||||
networks:
|
||||
@@ -1028,7 +1022,6 @@ services:
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_DB_ADAPTER
|
||||
- _APP_REDIS_HOST
|
||||
- _APP_REDIS_PORT
|
||||
- _APP_REDIS_USER
|
||||
@@ -1040,7 +1033,7 @@ services:
|
||||
|
||||
appwrite-worker-stats-usage:
|
||||
entrypoint: worker-stats-usage
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-worker-stats-usage
|
||||
image: appwrite-dev
|
||||
networks:
|
||||
@@ -1062,7 +1055,6 @@ services:
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_DB_ADAPTER
|
||||
- _APP_REDIS_HOST
|
||||
- _APP_REDIS_PORT
|
||||
- _APP_REDIS_USER
|
||||
@@ -1074,7 +1066,7 @@ services:
|
||||
|
||||
appwrite-task-scheduler-functions:
|
||||
entrypoint: schedule-functions
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-task-scheduler-functions
|
||||
image: appwrite-dev
|
||||
networks:
|
||||
@@ -1100,12 +1092,11 @@ services:
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_DB_ADAPTER
|
||||
- _APP_DATABASE_SHARED_TABLES
|
||||
|
||||
appwrite-task-scheduler-executions:
|
||||
entrypoint: schedule-executions
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-task-scheduler-executions
|
||||
image: appwrite-dev
|
||||
networks:
|
||||
@@ -1131,11 +1122,11 @@ services:
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_DB_ADAPTER
|
||||
- _APP_DATABASE_SHARED_TABLES
|
||||
|
||||
appwrite-task-scheduler-messages:
|
||||
entrypoint: schedule-messages
|
||||
<<: *x-logging
|
||||
<<: [*x-logging, *x-build]
|
||||
container_name: appwrite-task-scheduler-messages
|
||||
image: appwrite-dev
|
||||
networks:
|
||||
@@ -1161,7 +1152,6 @@ services:
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_DB_ADAPTER
|
||||
- _APP_DATABASE_SHARED_TABLES
|
||||
|
||||
appwrite-assistant:
|
||||
@@ -1255,6 +1245,11 @@ services:
|
||||
- MYSQL_PASSWORD=${_APP_DB_PASS}
|
||||
- MARIADB_AUTO_UPGRADE=1
|
||||
command: "mysqld --innodb-flush-method=fsync"
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
mongodb:
|
||||
profiles: ["mongodb"]
|
||||
@@ -1293,20 +1288,7 @@ services:
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
appwrite-mongo-express:
|
||||
profiles: ["mongodb"]
|
||||
image: mongo-express
|
||||
container_name: appwrite-mongo-express
|
||||
networks:
|
||||
- appwrite
|
||||
ports:
|
||||
- "8082:8081"
|
||||
environment:
|
||||
ME_CONFIG_MONGODB_URL: "mongodb://root:${_APP_DB_ROOT_PASS}@appwrite-mongodb:27017/?replicaSet=rs0&directConnection=true"
|
||||
ME_CONFIG_BASICAUTH_USERNAME: ${_APP_DB_USER}
|
||||
ME_CONFIG_BASICAUTH_PASSWORD: ${_APP_DB_PASS}
|
||||
depends_on:
|
||||
- mongodb
|
||||
|
||||
|
||||
postgresql:
|
||||
profiles: ["postgresql"]
|
||||
@@ -1319,7 +1301,7 @@ services:
|
||||
networks:
|
||||
- appwrite
|
||||
volumes:
|
||||
- appwrite-postgresql:/var/lib/postgresql/data:rw
|
||||
- appwrite-postgresql:/var/lib/postgresql:rw
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
@@ -1327,6 +1309,11 @@ services:
|
||||
- POSTGRES_USER=${_APP_DB_USER}
|
||||
- POSTGRES_PASSWORD=${_APP_DB_PASS}
|
||||
command: "postgres"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${_APP_DB_USER}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
redis:
|
||||
image: redis:7.4.7-alpine
|
||||
@@ -1343,6 +1330,11 @@ services:
|
||||
- appwrite
|
||||
volumes:
|
||||
- appwrite-redis:/data:rw
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
coredns: # DNS server for testing purposes (Proxy APIs)
|
||||
image: coredns/coredns:1.12.4
|
||||
@@ -1414,78 +1406,8 @@ services:
|
||||
networks:
|
||||
- appwrite
|
||||
|
||||
adminer:
|
||||
image: adminer
|
||||
container_name: appwrite-adminer
|
||||
<<: *x-logging
|
||||
restart: always
|
||||
ports:
|
||||
- 9506:8080
|
||||
networks:
|
||||
- appwrite
|
||||
- gateway
|
||||
environment:
|
||||
- ADMINER_DESIGN=pepa-linha
|
||||
- ADMINER_DEFAULT_SERVER=mariadb
|
||||
- ADMINER_DEFAULT_USERNAME=root
|
||||
- ADMINER_DEFAULT_PASSWORD=rootsecretpassword
|
||||
- ADMINER_DEFAULT_DB=appwrite
|
||||
configs:
|
||||
- source: adminer-index.php
|
||||
target: /var/www/html/index.php
|
||||
mode: 0755
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.constraint-label-stack=appwrite"
|
||||
- "traefik.docker.network=gateway"
|
||||
- "traefik.http.services.appwrite_adminer.loadbalancer.server.port=8080"
|
||||
- "traefik.http.routers.appwrite_adminer_http.entrypoints=appwrite_web"
|
||||
- "traefik.http.routers.appwrite_adminer_http.rule=Host(`mysql.localhost`)"
|
||||
- "traefik.http.routers.appwrite_adminer_http.service=appwrite_adminer"
|
||||
- "traefik.http.routers.appwrite_adminer_https.entrypoints=appwrite_websecure"
|
||||
- "traefik.http.routers.appwrite_adminer_https.rule=Host(`mysql.localhost`)"
|
||||
- "traefik.http.routers.appwrite_adminer_https.service=appwrite_adminer"
|
||||
- "traefik.http.routers.appwrite_adminer_https.tls=true"
|
||||
|
||||
redis-insight:
|
||||
image: redis/redisinsight:latest
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- appwrite
|
||||
- gateway
|
||||
environment:
|
||||
- RI_PRE_SETUP_DATABASES_PATH=/mnt/connections.json
|
||||
configs:
|
||||
- source: redisinsight-connections.json
|
||||
target: /mnt/connections.json
|
||||
mode: 0755
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.constraint-label-stack=appwrite"
|
||||
- "traefik.docker.network=gateway"
|
||||
- "traefik.http.services.appwrite_redisinsight.loadbalancer.server.port=5540"
|
||||
- "traefik.http.routers.appwrite_redisinsight_http.entrypoints=appwrite_web"
|
||||
- "traefik.http.routers.appwrite_redisinsight_http.rule=Host(`redis.localhost`)"
|
||||
- "traefik.http.routers.appwrite_redisinsight_http.service=appwrite_redisinsight"
|
||||
- "traefik.http.routers.appwrite_redisinsight_https.entrypoints=appwrite_websecure"
|
||||
- "traefik.http.routers.appwrite_redisinsight_https.rule=Host(`redis.localhost`)"
|
||||
- "traefik.http.routers.appwrite_redisinsight_https.service=appwrite_redisinsight"
|
||||
- "traefik.http.routers.appwrite_redisinsight_https.tls=true"
|
||||
ports:
|
||||
- "8081:5540"
|
||||
|
||||
graphql-explorer:
|
||||
container_name: appwrite-graphql-explorer
|
||||
image: appwrite/altair:0.3.0
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- appwrite
|
||||
ports:
|
||||
- "9509:3000"
|
||||
environment:
|
||||
- SERVER_URL=http://localhost/v1/graphql
|
||||
|
||||
# Dev Tools End ------------------------------------------------------------------------------------------
|
||||
# Dev tools (adminer, redis-insight, mongo-express, graphql-explorer)
|
||||
# are defined in docker-compose.override.yml
|
||||
|
||||
networks:
|
||||
gateway:
|
||||
@@ -1498,60 +1420,7 @@ networks:
|
||||
runtimes:
|
||||
name: runtimes
|
||||
|
||||
configs:
|
||||
redisinsight-connections.json:
|
||||
content: |
|
||||
[
|
||||
{
|
||||
"compressor": "NONE",
|
||||
"id": "104dc90a-21ef-4d5e-8912-b30baabb152f",
|
||||
"host": "redis",
|
||||
"port": 6379,
|
||||
"name": "redis:6379",
|
||||
"db": 0,
|
||||
"username": "default",
|
||||
"password": null,
|
||||
"connectionType": "STANDALONE",
|
||||
"nameFromProvider": null,
|
||||
"provider": "REDIS",
|
||||
"lastConnection": "2025-10-16T09:22:02.591Z",
|
||||
"modules": [
|
||||
{
|
||||
"name": "ReJSON",
|
||||
"version": 20808,
|
||||
"semanticVersion": "2.8.8"
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"version": 21015,
|
||||
"semanticVersion": "2.10.15"
|
||||
}
|
||||
],
|
||||
"tls": false,
|
||||
"tlsServername": null,
|
||||
"verifyServerCert": null,
|
||||
"caCert": null,
|
||||
"clientCert": null,
|
||||
"ssh": false,
|
||||
"sshOptions": null,
|
||||
"forceStandalone": false,
|
||||
"tags": []
|
||||
}
|
||||
]
|
||||
|
||||
adminer-index.php:
|
||||
content: |
|
||||
<?php
|
||||
if(!count($$_GET)) {
|
||||
$$_POST['auth'] = [
|
||||
'server' => $$_ENV['ADMINER_DEFAULT_SERVER'],
|
||||
'driver' => 'server', /* seems to autodetect the driver from server settings */
|
||||
'username' => $$_ENV['ADMINER_DEFAULT_USERNAME'],
|
||||
'password' => $$_ENV['ADMINER_DEFAULT_PASSWORD'],
|
||||
'db' => $$_ENV['ADMINER_DEFAULT_DB'],
|
||||
];
|
||||
}
|
||||
include './adminer.php';
|
||||
|
||||
volumes:
|
||||
appwrite-mariadb:
|
||||
|
||||
@@ -20,10 +20,16 @@ client = Client()
|
||||
### Make Your First Request
|
||||
Once your SDK object is set, create any of the Appwrite service objects and choose any request to send. Full documentation for any service method you would like to use can be found in your SDK documentation or in the [API References](https://appwrite.io/docs) section.
|
||||
|
||||
All service methods return typed Pydantic models, so you can access response fields as attributes:
|
||||
|
||||
```python
|
||||
users = Users(client)
|
||||
|
||||
result = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien")
|
||||
user = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien")
|
||||
|
||||
print(user.name) # "Walter O'Brien"
|
||||
print(user.email) # "email@example.com"
|
||||
print(user.id) # The generated user ID
|
||||
```
|
||||
|
||||
### Full Example
|
||||
@@ -43,7 +49,60 @@ client = Client()
|
||||
|
||||
users = Users(client)
|
||||
|
||||
result = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien")
|
||||
user = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien")
|
||||
|
||||
print(user.name) # Access fields as attributes
|
||||
print(user.to_dict()) # Convert to dictionary if needed
|
||||
```
|
||||
|
||||
### Type Safety with Models
|
||||
|
||||
The Appwrite Python SDK provides type safety when working with database rows through generic methods. Methods like `get_row`, `list_rows`, and others accept a `model_type` parameter that allows you to specify your custom Pydantic model for full type safety.
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from appwrite.client import Client
|
||||
from appwrite.services.tables_db import TablesDB
|
||||
|
||||
# Define your custom model matching your table schema
|
||||
class Post(BaseModel):
|
||||
postId: int
|
||||
authorId: int
|
||||
title: str
|
||||
content: str
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
isPublished: bool
|
||||
excerpt: Optional[str] = None
|
||||
|
||||
client = Client()
|
||||
# ... configure your client ...
|
||||
|
||||
tables_db = TablesDB(client)
|
||||
|
||||
# Fetch a single row with type safety
|
||||
row = tables_db.get_row(
|
||||
database_id="your-database-id",
|
||||
table_id="your-table-id",
|
||||
row_id="your-row-id",
|
||||
model_type=Post # Pass your custom model type
|
||||
)
|
||||
|
||||
print(row.data.title) # Fully typed - IDE autocomplete works
|
||||
print(row.data.postId) # int type, not Any
|
||||
print(row.data.createdAt) # datetime type
|
||||
|
||||
# Fetch multiple rows with type safety
|
||||
result = tables_db.list_rows(
|
||||
database_id="your-database-id",
|
||||
table_id="your-table-id",
|
||||
model_type=Post
|
||||
)
|
||||
|
||||
for row in result.rows:
|
||||
print(f"{row.data.title} by {row.data.authorId}")
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
@@ -52,7 +111,8 @@ The Appwrite Python SDK raises `AppwriteException` object with `message`, `code`
|
||||
```python
|
||||
users = Users(client)
|
||||
try:
|
||||
result = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien")
|
||||
user = users.create(ID.unique(), email = "email@example.com", phone = "+123456789", password = "password", name = "Walter O'Brien")
|
||||
print(user.name)
|
||||
except AppwriteException as e:
|
||||
print(e.message)
|
||||
```
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@appwrite.io/repo",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@appwrite.io/repo"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"$schema":"vendor/phpbench/phpbench/phpbench.schema.json",
|
||||
"runner.bootstrap": "vendor/autoload.php",
|
||||
"runner.path": "tests",
|
||||
"runner.file_pattern": "*Bench.php"
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
includes:
|
||||
- phpstan-baseline.neon
|
||||
|
||||
parameters:
|
||||
level: 8
|
||||
level: max
|
||||
paths:
|
||||
- src/Appwrite/Transformation
|
||||
- src
|
||||
- app
|
||||
- bin
|
||||
- tests
|
||||
bootstrapFiles:
|
||||
- app/init/constants.php
|
||||
scanDirectories:
|
||||
- vendor/swoole/ide-helper
|
||||
excludePaths:
|
||||
- tests/resources
|
||||
- app/sdks
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
colors="true"
|
||||
processIsolation="false"
|
||||
stopOnFailure="false"
|
||||
stopOnError="false"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
>
|
||||
<extensions>
|
||||
@@ -33,6 +34,7 @@
|
||||
<directory>./tests/e2e/Services/Storage</directory>
|
||||
<directory>./tests/e2e/Services/Tokens</directory>
|
||||
<directory>./tests/e2e/Services/Webhooks</directory>
|
||||
<directory>./tests/e2e/Services/ProjectWebhooks</directory>
|
||||
<directory>./tests/e2e/Services/Messaging</directory>
|
||||
<directory>./tests/e2e/Services/Migrations</directory>
|
||||
<file>./tests/e2e/Services/Functions/FunctionsBase.php</file>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Bus\Events;
|
||||
|
||||
use Utopia\Bus\Event;
|
||||
|
||||
class ExecutionCompleted implements Event
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $execution
|
||||
* @param array<string, mixed> $project
|
||||
* @param array<string, mixed> $spec
|
||||
* @param array<string, mixed> $resource
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $execution,
|
||||
public readonly array $project,
|
||||
public readonly array $spec = [],
|
||||
public readonly array $resource = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Bus\Events;
|
||||
|
||||
use Appwrite\Utopia\Request;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Bus\Event;
|
||||
|
||||
class RequestCompleted implements Event
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $project
|
||||
* @param array<string, mixed> $deployment
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $project,
|
||||
public readonly Request $request,
|
||||
public readonly Response $response,
|
||||
public readonly array $deployment = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Bus\Listeners;
|
||||
|
||||
use Appwrite\Bus\Events\ExecutionCompleted;
|
||||
use Appwrite\Event\Execution;
|
||||
use Utopia\Bus\Listener;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Queue\Publisher;
|
||||
|
||||
class Log extends Listener
|
||||
{
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'log';
|
||||
}
|
||||
|
||||
public static function getEvents(): array
|
||||
{
|
||||
return [ExecutionCompleted::class];
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->desc('Persists execution logs to database via queue')
|
||||
->inject('publisher')
|
||||
->callback($this->handle(...));
|
||||
}
|
||||
|
||||
public function handle(ExecutionCompleted $event, Publisher $publisher): void
|
||||
{
|
||||
$queueForExecutions = new Execution($publisher);
|
||||
$queueForExecutions
|
||||
->setExecution(new Document($event->execution))
|
||||
->setProject(new Document($event->project))
|
||||
->trigger();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Bus\Listeners;
|
||||
|
||||
use Appwrite\Bus\Events\ExecutionCompleted;
|
||||
use Appwrite\Bus\Events\RequestCompleted;
|
||||
use Appwrite\Event\Message\Usage as UsageMessage;
|
||||
use Appwrite\Event\Publisher\Usage as Publisher;
|
||||
use Appwrite\Usage\Context;
|
||||
use Utopia\Bus\Event;
|
||||
use Utopia\Bus\Listener;
|
||||
use Utopia\Database\Document;
|
||||
|
||||
class Usage extends Listener
|
||||
{
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'usage';
|
||||
}
|
||||
|
||||
public static function getEvents(): array
|
||||
{
|
||||
return [
|
||||
ExecutionCompleted::class,
|
||||
RequestCompleted::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->desc('Records usage metrics')
|
||||
->inject('publisherForUsage')
|
||||
->inject('usage')
|
||||
->callback($this->handle(...));
|
||||
}
|
||||
|
||||
public function handle(Event $event, Publisher $publisherForUsage, Context $usage): void
|
||||
{
|
||||
match (true) {
|
||||
$event instanceof ExecutionCompleted => $this->handleExecutionCompleted($event, $publisherForUsage),
|
||||
$event instanceof RequestCompleted => $this->handleRequestCompleted($event, $usage),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function handleExecutionCompleted(ExecutionCompleted $event, Publisher $publisherForUsage): void
|
||||
{
|
||||
$execution = new Document($event->execution);
|
||||
$resource = new Document($event->resource);
|
||||
|
||||
// Non-SSR sites don't record execution metrics
|
||||
if ($execution->getAttribute('resourceType') === 'sites' && $resource->getAttribute('adapter') !== 'ssr') {
|
||||
return;
|
||||
}
|
||||
$project = new Document($event->project);
|
||||
$spec = $event->spec;
|
||||
|
||||
$resourceType = $execution->getAttribute('resourceType', '');
|
||||
$resourceInternalId = $execution->getAttribute('resourceInternalId', '');
|
||||
$duration = $execution->getAttribute('duration', 0);
|
||||
|
||||
$compute = (int)($duration * 1000);
|
||||
$mbSeconds = (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $duration * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT));
|
||||
|
||||
$context = (new Context())
|
||||
->addMetric(METRIC_EXECUTIONS, 1)
|
||||
->addMetric(str_replace(['{resourceType}'], [$resourceType], METRIC_RESOURCE_TYPE_EXECUTIONS), 1)
|
||||
->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$resourceType, $resourceInternalId], METRIC_RESOURCE_TYPE_ID_EXECUTIONS), 1)
|
||||
->addMetric(METRIC_EXECUTIONS_COMPUTE, $compute)
|
||||
->addMetric(str_replace(['{resourceType}'], [$resourceType], METRIC_RESOURCE_TYPE_EXECUTIONS_COMPUTE), $compute)
|
||||
->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$resourceType, $resourceInternalId], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_COMPUTE), $compute)
|
||||
->addMetric(METRIC_EXECUTIONS_MB_SECONDS, $mbSeconds)
|
||||
->addMetric(str_replace(['{resourceType}'], [$resourceType], METRIC_RESOURCE_TYPE_EXECUTIONS_MB_SECONDS), $mbSeconds)
|
||||
->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [$resourceType, $resourceInternalId], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS), $mbSeconds);
|
||||
|
||||
$message = new UsageMessage(
|
||||
project: $project,
|
||||
metrics: $context->getMetrics(),
|
||||
reduce: $context->getReduce()
|
||||
);
|
||||
|
||||
$publisherForUsage->enqueue($message);
|
||||
}
|
||||
|
||||
private function handleRequestCompleted(RequestCompleted $event, Context $usage): void
|
||||
{
|
||||
$fileSize = 0;
|
||||
$file = $event->request->getFiles('file');
|
||||
if (!empty($file)) {
|
||||
$fileSize = (\is_array($file['size']) && isset($file['size'][0])) ? $file['size'][0] : $file['size'];
|
||||
}
|
||||
|
||||
$deployment = new Document($event->deployment);
|
||||
|
||||
$inbound = $event->request->getSize() + $fileSize;
|
||||
$outbound = $event->response->getSize();
|
||||
|
||||
if ($deployment->getAttribute('resourceType') === 'sites') {
|
||||
$siteInternalId = $deployment->getAttribute('resourceInternalId', '');
|
||||
$usage
|
||||
->addMetric(METRIC_SITES_REQUESTS, 1)
|
||||
->addMetric(METRIC_SITES_INBOUND, $inbound)
|
||||
->addMetric(METRIC_SITES_OUTBOUND, $outbound)
|
||||
->addMetric(str_replace('{siteInternalId}', $siteInternalId, METRIC_SITES_ID_REQUESTS), 1)
|
||||
->addMetric(str_replace('{siteInternalId}', $siteInternalId, METRIC_SITES_ID_INBOUND), $inbound)
|
||||
->addMetric(str_replace('{siteInternalId}', $siteInternalId, METRIC_SITES_ID_OUTBOUND), $outbound);
|
||||
} else {
|
||||
$usage
|
||||
->addMetric(METRIC_NETWORK_REQUESTS, 1)
|
||||
->addMetric(METRIC_NETWORK_INBOUND, $inbound)
|
||||
->addMetric(METRIC_NETWORK_OUTBOUND, $outbound);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -725,7 +725,9 @@ class Event
|
||||
$events = $pairedEvents;
|
||||
}
|
||||
// mirrored events can have duplicates in case of smaller events
|
||||
return array_unique($events);
|
||||
// array unique can turns list to hasmap in case duplicates present
|
||||
// so forcing array value will turn this to array list always
|
||||
return array_values(array_unique($events));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Event\Message;
|
||||
|
||||
abstract class Base
|
||||
{
|
||||
/**
|
||||
* Serialize message to array for queue
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
abstract public function toArray(): array;
|
||||
|
||||
/**
|
||||
* Deserialize message from array
|
||||
*
|
||||
* @param array $data
|
||||
* @return static
|
||||
*/
|
||||
abstract public static function fromArray(array $data): static;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Event\Message;
|
||||
|
||||
use Utopia\Database\Document;
|
||||
|
||||
class Usage extends Base
|
||||
{
|
||||
/**
|
||||
* @param Document $project
|
||||
* @param array<array{key: string, value: int}> $metrics
|
||||
* @param array<Document> $reduce
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Document $project,
|
||||
public readonly array $metrics,
|
||||
public readonly array $reduce = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'project' => [
|
||||
'$id' => $this->project->getId(),
|
||||
'$sequence' => $this->project->getSequence(),
|
||||
'database' => $this->project->getAttribute('database', ''),
|
||||
],
|
||||
'metrics' => $this->metrics,
|
||||
'reduce' => array_map(fn (Document $doc) => $doc->getArrayCopy(), $this->reduce),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $data
|
||||
* @return static
|
||||
*/
|
||||
public static function fromArray(array $data): static
|
||||
{
|
||||
return new self(
|
||||
project: new Document($data['project'] ?? []),
|
||||
metrics: $data['metrics'] ?? [],
|
||||
reduce: array_map(fn (array $doc) => new Document($doc), $data['reduce'] ?? []),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Event\Publisher;
|
||||
|
||||
use Appwrite\Event\Message\Base as BaseMessage;
|
||||
use Utopia\Queue\Publisher;
|
||||
use Utopia\Queue\Queue;
|
||||
|
||||
readonly class Base
|
||||
{
|
||||
public function __construct(
|
||||
protected Publisher $publisher
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a message to the queue
|
||||
*/
|
||||
public function publish(Queue $queue, BaseMessage $message): string|bool
|
||||
{
|
||||
$payload = $message->toArray();
|
||||
|
||||
return $this->publisher->enqueue($queue, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of a queue
|
||||
*/
|
||||
public function getQueueSize(Queue $queue, bool $failed = false): int
|
||||
{
|
||||
return $this->publisher->getQueueSize($queue, $failed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Event\Publisher;
|
||||
|
||||
use Appwrite\Event\Message\Usage as UsageMessage;
|
||||
use Utopia\Console;
|
||||
use Utopia\Queue\Publisher;
|
||||
use Utopia\Queue\Queue;
|
||||
|
||||
readonly class Usage extends Base
|
||||
{
|
||||
public function __construct(
|
||||
Publisher $publisher,
|
||||
protected Queue $queue
|
||||
) {
|
||||
parent::__construct($publisher);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a usage message
|
||||
*/
|
||||
public function enqueue(UsageMessage $message): string|bool
|
||||
{
|
||||
try {
|
||||
return $this->publish($this->queue, $message);
|
||||
} catch (\Throwable $th) {
|
||||
Console::error('[Usage] Failed to publish usage message: ' . $th->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of the usage queue
|
||||
*/
|
||||
public function getSize(bool $failed = false): int
|
||||
{
|
||||
return $this->getQueueSize($this->queue, $failed);
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Event;
|
||||
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Queue\Publisher;
|
||||
use Utopia\System\System;
|
||||
|
||||
class StatsUsage extends Event
|
||||
{
|
||||
protected array $metrics = [];
|
||||
protected array $reduce = [];
|
||||
protected array $disabled = [];
|
||||
|
||||
protected bool $critical = false;
|
||||
|
||||
public function __construct(protected Publisher $publisher)
|
||||
{
|
||||
parent::__construct($publisher);
|
||||
|
||||
$this
|
||||
->setQueue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME))
|
||||
->setClass(System::getEnv('_APP_STATS_USAGE_CLASS_NAME', Event::STATS_USAGE_CLASS_NAME));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add reduce.
|
||||
*
|
||||
* @param Document $document
|
||||
* @return self
|
||||
*/
|
||||
public function addReduce(Document $document): self
|
||||
{
|
||||
$this->reduce[] = $document;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add metric.
|
||||
*
|
||||
* @param string $key
|
||||
* @param int $value
|
||||
* @return self
|
||||
*/
|
||||
public function addMetric(string $key, int $value): self
|
||||
{
|
||||
$this->metrics[] = [
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set disabled metrics.
|
||||
*
|
||||
* @param string $key
|
||||
* @return self
|
||||
*/
|
||||
public function disableMetric(string $key): self
|
||||
{
|
||||
$this->disabled[] = $key;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the payload for the event
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function preparePayload(): array
|
||||
{
|
||||
return [
|
||||
'project' => $this->getProject(),
|
||||
'reduce' => $this->reduce,
|
||||
'metrics' => \array_filter($this->metrics, function ($metric) {
|
||||
foreach ($this->disabled as $disabledMetric) {
|
||||
if (\str_ends_with($metric['key'], $disabledMetric)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
public function reset(): Event
|
||||
{
|
||||
$this->metrics = [];
|
||||
parent::reset();
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,7 @@ class Exception extends \Exception
|
||||
|
||||
/** Sites */
|
||||
public const string SITE_NOT_FOUND = 'site_not_found';
|
||||
public const string SITE_ALREADY_EXISTS = 'site_already_exists';
|
||||
public const string SITE_TEMPLATE_NOT_FOUND = 'site_template_not_found';
|
||||
|
||||
/** Functions */
|
||||
@@ -306,6 +307,7 @@ class Exception extends \Exception
|
||||
|
||||
/** Webhooks */
|
||||
public const string WEBHOOK_NOT_FOUND = 'webhook_not_found';
|
||||
public const string WEBHOOK_ALREADY_EXISTS = 'webhook_already_exists';
|
||||
|
||||
/** Router */
|
||||
public const string ROUTER_HOST_NOT_FOUND = 'router_host_not_found';
|
||||
@@ -365,6 +367,7 @@ class Exception extends \Exception
|
||||
|
||||
/** Message */
|
||||
public const string MESSAGE_NOT_FOUND = 'message_not_found';
|
||||
public const string MESSAGE_ALREADY_EXISTS = 'message_already_exists';
|
||||
public const string MESSAGE_MISSING_TARGET = 'message_missing_target';
|
||||
public const string MESSAGE_ALREADY_SENT = 'message_already_sent';
|
||||
public const string MESSAGE_ALREADY_PROCESSING = 'message_already_processing';
|
||||
|
||||
@@ -510,25 +510,13 @@ class Realtime extends MessagingAdapter
|
||||
$collectionId = $payload->getAttribute('$collectionId', '');
|
||||
$resourceId = $tableId ?: $collectionId;
|
||||
$channels = [];
|
||||
// backward compat(tablesdb will have databases channels + tablesdb prefixed channels)
|
||||
if ($parts[0] === 'databases' || $parts[0] === 'tablesdb') {
|
||||
$prefix = 'databases';
|
||||
|
||||
$channels = self::getDatabaseChannels('legacy', $database->getId(), $resourceId, $payload->getId(), $prefix);
|
||||
|
||||
$channels = array_unique([
|
||||
...$channels,
|
||||
...self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId(), $prefix)
|
||||
]);
|
||||
}
|
||||
|
||||
// prefixed channels -> tablesdb
|
||||
if ($parts[0] !== 'databases') {
|
||||
$channels = array_unique([
|
||||
...$channels,
|
||||
...self::getDatabaseChannels($parts[0], $database->getId(), $resourceId, $payload->getId()),
|
||||
]);
|
||||
}
|
||||
// sending legacy + tablesdb events to both legacy and tablesdb
|
||||
$channels = array_values(array_unique(array_merge(
|
||||
self::getDatabaseChannels('legacy', $database->getId(), $resourceId, $payload->getId(), 'databases'),
|
||||
self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId(), 'databases'),
|
||||
self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId())
|
||||
)));
|
||||
|
||||
$roles = $collection->getAttribute('documentSecurity', false)
|
||||
? \array_merge($collection->getRead(), $payload->getRead())
|
||||
@@ -620,6 +608,7 @@ class Realtime extends MessagingAdapter
|
||||
$channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents";
|
||||
$channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents.{$payloadId}";
|
||||
break;
|
||||
|
||||
case 'tablesdb':
|
||||
$channels[] = 'rows';
|
||||
$channels[] = "{$basePrefix}.{$databaseId}.tables.{$resourceId}.rows";
|
||||
|
||||