mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4bb57bb79 | ||
|
|
861cf5efb8 | ||
|
|
cf34f9fea9 | ||
|
|
92a6d30e26 | ||
|
|
23e8705b03 | ||
|
|
3240a4670a | ||
|
|
3bf83d40e9 | ||
|
|
c3368f553e | ||
|
|
27def94d8b | ||
|
|
2c139929ac | ||
|
|
b957f797da | ||
|
|
393500d565 | ||
|
|
a108837e82 | ||
|
|
859e88753f | ||
|
|
5160b44c4b | ||
|
|
17a9485fb2 | ||
|
|
d3c44f3d6c | ||
|
|
d49ba5281d | ||
|
|
c107c80bd8 | ||
|
|
cf90e775bf | ||
|
|
0a46096c41 | ||
|
|
289a44ae79 | ||
|
|
827e9bc343 | ||
|
|
f2ffaeb823 | ||
|
|
6f594f4b60 | ||
|
|
326be042f1 | ||
|
|
5b824fb745 | ||
|
|
cf6cb126b5 | ||
|
|
302df1edd2 | ||
|
|
e212509ed3 | ||
|
|
d3e3755d6d | ||
|
|
948ce6e529 | ||
|
|
d117dc231e | ||
|
|
eefa53e5bd | ||
|
|
db89fbfc52 | ||
|
|
97ec5d115c | ||
|
|
722a6cf50f | ||
|
|
d039dcfee5 | ||
|
|
d09aa11f2e | ||
|
|
8a2264546d | ||
|
|
0801e1dc44 | ||
|
|
a04d5fa5e0 | ||
|
|
e1280e8cc7 | ||
|
|
70884c2e30 | ||
|
|
917e95e3d7 | ||
|
|
27222bbb0d | ||
|
|
3720034464 | ||
|
|
de6cab1f9a | ||
|
|
2d1fbc2611 | ||
|
|
0afc70218f | ||
|
|
8d3abbc5f0 | ||
|
|
7ec14bc7db | ||
|
|
2843010a97 | ||
|
|
ad4e21ee61 | ||
|
|
4de39ab6f2 | ||
|
|
fbb646b8eb | ||
|
|
46f75ecd6f | ||
|
|
f68caa3bbd | ||
|
|
0109bf5b72 | ||
|
|
c69fb52d5c | ||
|
|
3a26f1f1c2 | ||
|
|
f1b94a5b69 | ||
|
|
e3fce9191a | ||
|
|
2c0a1b9bbb | ||
|
|
0d7417f858 | ||
|
|
2eee098c66 | ||
|
|
890d4b3c16 | ||
|
|
af9a7c6345 | ||
|
|
6fff1fd75f | ||
|
|
e6b7b08bda | ||
|
|
f4c3cbd53d | ||
|
|
c0c2f35266 | ||
|
|
ccea980753 | ||
|
|
2bddebd576 | ||
|
|
b9866ddbeb | ||
|
|
6fc79a7184 | ||
|
|
50039571b6 | ||
|
|
1216f20567 | ||
|
|
0752ea761c | ||
|
|
98461088f8 | ||
|
|
d4a3224aa3 | ||
|
|
197ce6faff | ||
|
|
c98e293879 | ||
|
|
ebbe382596 | ||
|
|
01d28eb2c5 | ||
|
|
7d1c2e7f0a | ||
|
|
22b499af20 | ||
|
|
7208f86723 | ||
|
|
2974a8b01c | ||
|
|
7f05a707bd | ||
|
|
1d056253ef | ||
|
|
a22dc5facb | ||
|
|
aeafd4ac79 | ||
|
|
14861df6b2 | ||
|
|
34a7595a6d | ||
|
|
b2108c0216 | ||
|
|
5a5a449210 | ||
|
|
0405de30bd | ||
|
|
40c2799b06 | ||
|
|
4bcdeefe68 | ||
|
|
b6ba6dd90e | ||
|
|
954f8b7b8d | ||
|
|
24d700673c | ||
|
|
c6d64029e0 | ||
|
|
40ff866598 | ||
|
|
36888e35d3 | ||
|
|
30432b931a | ||
|
|
e2174a14cf | ||
|
|
d3cb89d8c9 | ||
|
|
c8625b3396 | ||
|
|
b576f01d12 | ||
|
|
a87cafee3c | ||
|
|
1c51fde163 | ||
|
|
b05699db77 | ||
|
|
163e48ab27 | ||
|
|
0b41d992a4 | ||
|
|
c2ddfd6867 | ||
|
|
81b9dc1f06 | ||
|
|
5506ddb2c2 | ||
|
|
c74469b01a | ||
|
|
b706693f82 | ||
|
|
cd800148d5 | ||
|
|
900b0d7aa6 | ||
|
|
7e21c1f888 | ||
|
|
00d6f9c230 | ||
|
|
4c03080efa | ||
|
|
432a11d289 | ||
|
|
e9e233404b | ||
|
|
8101d42604 | ||
|
|
88cedd43bc | ||
|
|
d3a2934d9b | ||
|
|
13a36b6199 | ||
|
|
a155464d46 | ||
|
|
40d1c998bb | ||
|
|
b4837d49b6 | ||
|
|
e23042b42e | ||
|
|
240a733285 | ||
|
|
bb202be12c | ||
|
|
8c4cb601c9 | ||
|
|
64a7c7b7c4 | ||
|
|
2dd2887ff7 | ||
|
|
4bbe8a2789 | ||
|
|
3e9ceb673b | ||
|
|
9c4d1ef535 | ||
|
|
bf57518262 | ||
|
|
d48fb11de6 | ||
|
|
7ed87f760b | ||
|
|
ee37be16f0 | ||
|
|
a8cfaeefbd | ||
|
|
58cb8ee048 | ||
|
|
a72fbd1d47 | ||
|
|
f6408db74e | ||
|
|
df20993d8b | ||
|
|
708fe1bc4e | ||
|
|
e782fa832e | ||
|
|
c947c2c989 | ||
|
|
3de4613b7d | ||
|
|
def7290c48 | ||
|
|
1335b46404 | ||
|
|
0e15ac98a9 | ||
|
|
f127b2509d | ||
|
|
15134c99bf | ||
|
|
8262e3193e | ||
|
|
98a21c4ac0 | ||
|
|
c52eb1b903 | ||
|
|
d6a71e50bb | ||
|
|
a4b6ee0dd2 | ||
|
|
bb61103080 | ||
|
|
9bbc76b2dd | ||
|
|
918aba9c80 | ||
|
|
3ceba9e3b3 | ||
|
|
a480679a2d | ||
|
|
e99ae40e93 | ||
|
|
3cd50fbaf3 | ||
|
|
60dd1453d4 | ||
|
|
07781cd681 | ||
|
|
2387d8dcc9 | ||
|
|
723608f4db | ||
|
|
bd1dcde7b0 | ||
|
|
5b8e41e66d | ||
|
|
acc4796b99 | ||
|
|
106e85d6ca | ||
|
|
0ffc32f19e | ||
|
|
d897cc5dd0 | ||
|
|
6d2f277ce2 | ||
|
|
86f80b29c0 | ||
|
|
39243735e8 | ||
|
|
b3ff9d74b7 | ||
|
|
7b7604440f | ||
|
|
382d2b3c14 | ||
|
|
eda5379c17 | ||
|
|
d2fee51844 | ||
|
|
8ac4bad196 | ||
|
|
f140d7221c | ||
|
|
631f0bda1b | ||
|
|
1121314582 | ||
|
|
dda6b82f0f | ||
|
|
6c757c3fc8 | ||
|
|
eba194936e | ||
|
|
a7bdac16da | ||
|
|
716a8d06e2 | ||
|
|
e95cf44fec | ||
|
|
8f8efcb349 | ||
|
|
b188942436 | ||
|
|
9b7e9a98c8 | ||
|
|
d6f7fb6155 | ||
|
|
bd91f5e192 | ||
|
|
971a1b5071 | ||
|
|
ac5eecb7f3 | ||
|
|
3ae076b73e | ||
|
|
5394ccc45c | ||
|
|
3649109f40 | ||
|
|
0ff0ea7743 | ||
|
|
8be115096f | ||
|
|
e66fd2373f | ||
|
|
0e2cce152d | ||
|
|
101e0bc930 | ||
|
|
ea078250bf | ||
|
|
db79f8e484 | ||
|
|
6422f2d513 | ||
|
|
c60626f772 | ||
|
|
cafdaf7b50 | ||
|
|
bb0f56d758 | ||
|
|
7438db7a90 | ||
|
|
948f771838 | ||
|
|
82e34f014d | ||
|
|
9e4c3f8ba3 | ||
|
|
e6acd7386a | ||
|
|
b04e8b046a | ||
|
|
3262b984b7 | ||
|
|
71af88c73c | ||
|
|
2275f65169 | ||
|
|
71b7d6367f | ||
|
|
08369d840b | ||
|
|
94236133b5 | ||
|
|
84b48541bd | ||
|
|
a33d02f522 | ||
|
|
f94d4aa7d9 | ||
|
|
89cfb0c634 | ||
|
|
c9096e9eed | ||
|
|
859ca207c5 | ||
|
|
58fc122f0d | ||
|
|
0e990da961 | ||
|
|
db8236cc97 | ||
|
|
d731ccf873 | ||
|
|
6493d66901 | ||
|
|
9e7f38f473 | ||
|
|
a568ca0f99 | ||
|
|
41e7228a9f | ||
|
|
4568639718 | ||
|
|
73ecff0bd7 | ||
|
|
f2f023880f | ||
|
|
f2093e9204 | ||
|
|
23497dd371 | ||
|
|
37619fb2a3 | ||
|
|
f976686eba | ||
|
|
7af3721543 | ||
|
|
93edec0d01 | ||
|
|
54b61f2e24 | ||
|
|
184ed77493 | ||
|
|
8234b72fc3 | ||
|
|
cc9921945e | ||
|
|
ac6f23a41f | ||
|
|
973d046d33 | ||
|
|
dcb50cde8f | ||
|
|
08beb55853 | ||
|
|
bb0220e163 | ||
|
|
f3e4c8c6d0 | ||
|
|
44254671bb | ||
|
|
bcc6cc8f00 | ||
|
|
84549a1c31 | ||
|
|
0276420213 | ||
|
|
6353a08fd5 | ||
|
|
c265fa308a | ||
|
|
d3f72bc33a | ||
|
|
700fc4d27e | ||
|
|
9c7f30a150 | ||
|
|
85e45b30f5 | ||
|
|
5483e32eab | ||
|
|
f63c7d8142 | ||
|
|
5a2071f852 | ||
|
|
238ed12e28 | ||
|
|
d1afe6c3cf | ||
|
|
e58dd48e80 | ||
|
|
69f87d4dcd | ||
|
|
7b0dd11f7f | ||
|
|
620934fbf4 | ||
|
|
182fc723f1 | ||
|
|
5d3c5b6873 | ||
|
|
f20445bab7 | ||
|
|
8ad1a58f98 | ||
|
|
17e475236a | ||
|
|
b948eaaaa6 | ||
|
|
9b0c034247 | ||
|
|
b0dc9c7f1b | ||
|
|
e79abd43e3 | ||
|
|
36c9c478ec | ||
|
|
90d01b87ea | ||
|
|
b0823449bc | ||
|
|
760d5d346d | ||
|
|
4e11d1dd0f | ||
|
|
5e8c66142d | ||
|
|
f80016e4e6 | ||
|
|
c8fdbdf8c9 | ||
|
|
a04267a4e3 | ||
|
|
80a081d62a | ||
|
|
bb131c6690 | ||
|
|
4a4a131226 | ||
|
|
26cff23af4 | ||
|
|
189dae25ef | ||
|
|
b2f4a86224 | ||
|
|
122811eb7a | ||
|
|
bc98dacf65 | ||
|
|
f00445254d | ||
|
|
6686e3a0d7 | ||
|
|
5bcb425926 | ||
|
|
b4e9fa7688 | ||
|
|
594cbe03ef | ||
|
|
ec04afe0ad | ||
|
|
539c928920 | ||
|
|
a470857d4e | ||
|
|
dea40ca8be | ||
|
|
3d593f7cd7 | ||
|
|
1d36811dd6 | ||
|
|
4d53faf5b6 | ||
|
|
c101fe74ab | ||
|
|
b4fad80a8c | ||
|
|
41e0049045 | ||
|
|
4aaa09bb04 | ||
|
|
3f2c45be26 | ||
|
|
f7202955ef | ||
|
|
07432b0967 | ||
|
|
065eeda72a | ||
|
|
65ce9a67ef | ||
|
|
4996d7f37f | ||
|
|
86adf4ebc0 | ||
|
|
9a4a0ac9f1 | ||
|
|
2543179ae7 | ||
|
|
07a049c8ac | ||
|
|
0990fc2667 | ||
|
|
98bb203c84 | ||
|
|
0e82a0aed6 | ||
|
|
3dc9d521b4 | ||
|
|
4dbbc7c7c9 | ||
|
|
31f22bc339 | ||
|
|
87f0294f73 | ||
|
|
df9c55daf9 | ||
|
|
9246aff4e4 | ||
|
|
21c37e6d35 | ||
|
|
cbdad4a65e | ||
|
|
91ecf54746 | ||
|
|
4bcfe6c7f4 | ||
|
|
3ada6f1569 | ||
|
|
64402b01db | ||
|
|
6226db73d4 | ||
|
|
6ab2a2a6cc | ||
|
|
ba03fd7965 | ||
|
|
1c188d6325 | ||
|
|
8b1226aa1b | ||
|
|
2c1d320bba | ||
|
|
b6414442dd | ||
|
|
f5470f123f | ||
|
|
ea9a44c9ad | ||
|
|
881ee90e76 | ||
|
|
fbb570fb36 | ||
|
|
e781b60bcb | ||
|
|
78a071ae54 | ||
|
|
9bcd77c85b | ||
|
|
960bbb6965 | ||
|
|
46a56e474b | ||
|
|
666b60bd29 | ||
|
|
63f128457e | ||
|
|
5e8e9a852d | ||
|
|
3166ad933c | ||
|
|
7e5f64e04b | ||
|
|
908c02fd99 | ||
|
|
38fcc6d6bd | ||
|
|
16652347c6 | ||
|
|
5f28b988ec | ||
|
|
160631b158 | ||
|
|
3ab9923f0c | ||
|
|
cc799e069f | ||
|
|
9a7d3bac5f | ||
|
|
0a9a4f629d | ||
|
|
32c2c0c1a5 | ||
|
|
508d652194 | ||
|
|
471ba94ba2 | ||
|
|
66e24a07d4 | ||
|
|
5ffac54c86 | ||
|
|
c5b12b61d9 | ||
|
|
8d5e9ef9cf | ||
|
|
25fe9a3cdf | ||
|
|
05425d20a7 | ||
|
|
ff10e47d67 | ||
|
|
6c19dfbafd | ||
|
|
360cbf48af | ||
|
|
bbe93defe9 | ||
|
|
5e01c13b31 | ||
|
|
4b37f35803 | ||
|
|
775c9c49e2 | ||
|
|
e35260924e | ||
|
|
90aad8918c | ||
|
|
46974beba1 | ||
|
|
5e9dc33be1 | ||
|
|
1e271f188e | ||
|
|
f333597754 | ||
|
|
3b33d16356 | ||
|
|
2289d61d3c | ||
|
|
8e02f38bad | ||
|
|
5b65a6bc78 | ||
|
|
872d2c01d4 | ||
|
|
b7f199ddbc | ||
|
|
906c00af97 | ||
|
|
f00d52f879 | ||
|
|
7a1a885e89 | ||
|
|
ff8c275317 | ||
|
|
b3bf979cac | ||
|
|
eb9e316ed5 | ||
|
|
224ea5f7f8 | ||
|
|
c1dacab4c2 | ||
|
|
8d0b8d4905 | ||
|
|
e7190f442e | ||
|
|
b593eb5630 |
No files matched your search
@@ -0,0 +1,49 @@
|
||||
name: AI Provider Integration Tests
|
||||
|
||||
# Hits real provider APIs with cheap models, so this workflow is
|
||||
# manually triggered only. Each provider's test reads its credential
|
||||
# from PUTER_TEST_AI_<PROVIDER>_API_KEY and skips itself silently if
|
||||
# the secret is missing.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ai-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run AI provider integration tests
|
||||
# Vitest treats positional args as filename substring filters.
|
||||
# The pattern matches every `*.integration.test.ts` file and
|
||||
# nothing else — the regular backend test suite stays out.
|
||||
run: npm run test:backend -- integration.test
|
||||
env:
|
||||
CI: 'true'
|
||||
PUTER_TEST_AI_CLAUDE_API_KEY: ${{ secrets.PUTER_TEST_AI_CLAUDE_API_KEY }}
|
||||
PUTER_TEST_AI_OPENAI_API_KEY: ${{ secrets.PUTER_TEST_AI_OPENAI_API_KEY }}
|
||||
PUTER_TEST_AI_GEMINI_API_KEY: ${{ secrets.PUTER_TEST_AI_GEMINI_API_KEY }}
|
||||
PUTER_TEST_AI_GROQ_API_KEY: ${{ secrets.PUTER_TEST_AI_GROQ_API_KEY }}
|
||||
PUTER_TEST_AI_MISTRAL_API_KEY: ${{ secrets.PUTER_TEST_AI_MISTRAL_API_KEY }}
|
||||
PUTER_TEST_AI_DEEPSEEK_API_KEY: ${{ secrets.PUTER_TEST_AI_DEEPSEEK_API_KEY }}
|
||||
PUTER_TEST_AI_XAI_API_KEY: ${{ secrets.PUTER_TEST_AI_XAI_API_KEY }}
|
||||
PUTER_TEST_AI_OPENROUTER_API_KEY: ${{ secrets.PUTER_TEST_AI_OPENROUTER_API_KEY }}
|
||||
PUTER_TEST_AI_TOGETHER_API_KEY: ${{ secrets.PUTER_TEST_AI_TOGETHER_API_KEY }}
|
||||
PUTER_TEST_AI_MOONSHOT_API_KEY: ${{ secrets.PUTER_TEST_AI_MOONSHOT_API_KEY }}
|
||||
PUTER_TEST_AI_ZAI_API_KEY: ${{ secrets.PUTER_TEST_AI_ZAI_API_KEY }}
|
||||
PUTER_TEST_AI_ELEVENLABS_API_KEY: ${{ secrets.PUTER_TEST_AI_ELEVENLABS_API_KEY }}
|
||||
@@ -0,0 +1,79 @@
|
||||
name: Backend Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths-ignore:
|
||||
- 'src/docs/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- ref: ${{ github.base_ref }}
|
||||
artifact: base
|
||||
- ref: ${{ github.head_ref }}
|
||||
artifact: pr
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ matrix.ref }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ matrix.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run backend tests with coverage
|
||||
run: npm run test:backend -- --coverage
|
||||
env:
|
||||
CI: 'true'
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-coverage-${{ matrix.artifact }}
|
||||
path: src/backend/coverage/
|
||||
retention-days: 14
|
||||
|
||||
report-coverage:
|
||||
needs: test
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download PR coverage
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: backend-coverage-pr
|
||||
path: src/backend/coverage
|
||||
|
||||
- name: Download base coverage
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: backend-coverage-base
|
||||
path: coverage-base
|
||||
|
||||
- name: Report coverage on PR
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
json-summary-path: src/backend/coverage/coverage-summary.json
|
||||
json-final-path: src/backend/coverage/coverage-final.json
|
||||
json-summary-compare-path: coverage-base/coverage-summary.json
|
||||
@@ -6,80 +6,96 @@ name: Docker Image CI
|
||||
# picks in the Actions UI. Used to fast-forward :latest/:main when the
|
||||
# branch has moved ahead of the most recent release tag.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '[0-9][0-9].[0-9][0-9]'
|
||||
- '[0-9][0-9].[0-9][0-9].[0-9]*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_latest:
|
||||
description: 'Push :latest tag'
|
||||
type: boolean
|
||||
default: true
|
||||
push_main:
|
||||
description: 'Push :main tag'
|
||||
type: boolean
|
||||
default: true
|
||||
push:
|
||||
tags:
|
||||
- '[0-9][0-9].[0-9][0-9]'
|
||||
- '[0-9][0-9].[0-9][0-9].[0-9]*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_latest:
|
||||
description: 'Push :latest tag'
|
||||
type: boolean
|
||||
default: true
|
||||
push_main:
|
||||
description: 'Push :main tag'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Validate calver tag
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
if ! [[ "$REF_NAME" =~ ^[0-9]{2}\.[0-9]{2}(\.[0-9]+)?$ ]]; then
|
||||
echo "Tag '$REF_NAME' does not match YY.MM or YY.MM.p calver format."
|
||||
exit 1
|
||||
fi
|
||||
steps:
|
||||
- name: Validate calver tag
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
if ! [[ "$REF_NAME" =~ ^[0-9]{2}\.[0-9]{2}(\.[0-9]+)?$ ]]; then
|
||||
echo "Tag '$REF_NAME' does not match YY.MM or YY.MM.p calver format."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GitHub Package Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Log in to GitHub Package Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||
# type=ref,event=tag only emits on tag pushes, so manual runs skip
|
||||
# the calver tag and just publish whichever of latest / main the
|
||||
# dispatch inputs asked for. On a tag push, both default to true.
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'push' || inputs.push_latest }}
|
||||
type=raw,value=main,enable=${{ github.event_name == 'push' || inputs.push_main }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
heyputer/puter
|
||||
# type=ref,event=tag only emits on tag pushes, so manual runs skip
|
||||
# the calver tag and just publish whichever of latest / main the
|
||||
# dispatch inputs asked for. On a tag push, both default to true.
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'push' || inputs.push_latest }}
|
||||
type=raw,value=main,enable=${{ github.event_name == 'push' || inputs.push_main }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Sync README to Docker Hub
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
repository: heyputer/puter
|
||||
readme-filepath: ./README.md
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Docs Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/docs/**'
|
||||
|
||||
jobs:
|
||||
build-docs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build docs
|
||||
run: npm run build
|
||||
working-directory: src/docs
|
||||
@@ -70,3 +70,5 @@ coverage/
|
||||
undefined
|
||||
servers.json
|
||||
config.*.json
|
||||
|
||||
volatile/
|
||||
@@ -0,0 +1,101 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for AI coding agents working in this repository. Read this before making changes. FOLLOW GUIDANCE AS CLOSELY AS POSSIBLE. If you think the guidance is wrong, raise an issue or flag a maintainer — don't just do what you think is right. This is the source of truth for how we write code, tests, and docs in this repo.
|
||||
|
||||
## Documentation Index
|
||||
|
||||
Use these as the source of truth before exploring further:
|
||||
|
||||
- [README.md](README.md) — project overview and quickstart.
|
||||
- [doc/architecture.md](doc/architecture.md) — backend layered stack (controllers → drivers → services → stores → clients), `PuterServer` wiring, `Context` (ALS), and extensions.
|
||||
- [doc/self-hosting.md](doc/self-hosting.md) — running Puter outside hosted infra.
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — testing, security, AI-assisted code, PR conventions, Boy Scout Rule.
|
||||
- [SECURITY.md](SECURITY.md) — how to report vulnerabilities (do not file them publicly).
|
||||
- [BUG-BOUNTY.md](BUG-BOUNTY.md) — bounty program scope.
|
||||
- [TRADEMARK.md](TRADEMARK.md) — trademark usage.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
The backend is organized as a layered stack inspired by Controller–Service–Repository with dependency injection. Every layer only depends on the layers beneath it, and `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires the whole thing together.
|
||||
|
||||
### Layers (top → bottom)
|
||||
|
||||
| Layer | Lives in | Responsibility |
|
||||
| --------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Controllers** | [src/backend/controllers/](src/backend/controllers/) | Route handlers. Parse + validate input, apply per-route gates (auth, subdomain, rate limit, body parsers via `RouteOptions`), call services, format responses. |
|
||||
| **Drivers** | [src/backend/drivers/](src/backend/drivers/) | Optional. RPC-style handlers exposed over `/drivers/*`. Thin shells that validate RPC inputs and call into services/stores. |
|
||||
| **Services** | [src/backend/services/](src/backend/services/) | Business logic. Assume the caller is already authenticated/authorized — services do not run auth gates themselves. |
|
||||
| **Stores** | [src/backend/stores/](src/backend/stores/) | Persistence. Wraps clients with the domain shape services consume (rows, entities, KV namespaces). |
|
||||
| **Clients** | [src/backend/clients/](src/backend/clients/) | Adapters for external/internal services (sql, redis, s3, dynamo, email, event bus). Knows protocols, not domain concepts. |
|
||||
| **Config** | `config.*.json` → `IConfig` | Flat, typed config object every layer receives at construction. |
|
||||
|
||||
Each layer receives the layers beneath it through its constructor. Dependencies are explicit and traceable from `PuterServer`.
|
||||
|
||||
### Cross-layer rules
|
||||
|
||||
- **Don't reach across layers.** Controllers do not poke clients directly; services do not register routes. If you want to, the abstraction is wrong — fix the abstraction.
|
||||
- **Don't call sideways within a layer for code reuse.** If two services need the same logic, lift it into a util/helper. Services should not depend on other services for shared code.
|
||||
- **Prefer explicit arguments over `Context` (ALS).** Reach for [Context](src/backend/core/context.ts) only when the value is genuinely request-scoped and would otherwise thread through many layers. Today it's used sparingly — mostly for `actor` and `req`.
|
||||
|
||||
### Extensions
|
||||
|
||||
Extensions live in [extensions/](extensions/) and parallel the layered stack. They are for **non-crucial parts of the system** — things Puter still works without if removed. If a feature is load-bearing for clients, it belongs in core, not in an extension (see [whoami](extensions/whoami.ts) as the cautionary example).
|
||||
|
||||
The `extension` global ([src/backend/extensions.ts](src/backend/extensions.ts)) exposes:
|
||||
|
||||
- `extension.registerClient/Store/Service/Driver/Controller(name, Class)` for first-class additions.
|
||||
- `extension.on(event, handler)` and `extension.get/post/put/delete/patch/use(path, opts?, handler)` for the lightweight common case. `opts` is the same `RouteOptions` controllers use.
|
||||
- `extension.import('client' | 'store' | 'service' | 'controller' | 'driver')` returns a lazy proxy to instantiated objects. `extension.config` exposes live config.
|
||||
|
||||
### Language & file conventions
|
||||
|
||||
- **Modules:** We transpile and build as needed — write ES modules, not CommonJS.
|
||||
- **TypeScript preferred for new files.** Existing JS is fine; convert opportunistically when you're already touching a file.
|
||||
- **Reuse types and code before adding duplicates.** Before defining a type, search for an existing one; extend it if close. Before writing a helper or repeating logic, search for an existing utility, helper, or implementation and reuse or extend it. Only add a new type or helper when nothing suitable exists.
|
||||
- **Make new types findable.** Co-locate them with the layer/module that owns them, export from the obvious entry point, and use a descriptive `PascalCase` name. Don't hide types in random files where future readers won't grep them.
|
||||
- **Naming:** `camelCase` for variables/functions, `PascalCase` for classes and for files containing a class (`AuthService.ts`, `KVStoreDriver.ts`).
|
||||
|
||||
### Comments
|
||||
|
||||
Keep comments light. Prefer self-documenting code — clear names, small functions, obvious flow. Add a comment **only** when:
|
||||
|
||||
- The _why_ is non-obvious (a hidden constraint, a workaround, a subtle invariant).
|
||||
- A non-trivial usage detail would otherwise trip the next reader.
|
||||
|
||||
If comments need to be more than a line, its probably too long, and when it does need multiline comments, use multiline `/** ... */` JSDoc style for consistency. Use `//` for single-line comments.
|
||||
|
||||
Don't restate what the code already says. Don't write comments that reference the current task or PR or version of something — those rot.
|
||||
|
||||
**No ticket references in code or test comments.** Don't write `PUT-1234`, `AUTH-3`, `GUI-2`, `// fix for FOO-99`, `// per ROLLOUT-1`, or section headers like `// -- PUT-1020: ... --`. The ticket exists in git history and the issue tracker; the code should describe the _why_ in domain terms ("logout cascade transparently invalidates...") not project-management terms ("the cascade from AUTH-3..."). Same rule for test-block names and section dividers — describe what's being tested, not which ticket asked for it.
|
||||
|
||||
**Use plain ASCII `-` in comment section dividers, not box-drawing characters.** Write `// -- Section name --`, not `// ── Section name ──`. The `─` (U+2500) character renders inconsistently in some editors and diff tools and adds nothing over `-`.
|
||||
|
||||
### Tests
|
||||
|
||||
When adding new behavior (function, endpoint, driver method, branch of logic), add a test for it.
|
||||
|
||||
- Use Vitest for unit and integration tests. Test files go next to the code they test, named `*.test.ts` or `*.test.js`.
|
||||
- **Mock data, not methods.** Stub the inputs (fixtures, fake rows, sample payloads) rather than mocking the function under test or the layer beneath it. Over-mocking produces tests that pass while production breaks. If you must mock, mock at a real boundary (a client/external service), not within the same layer you're testing.
|
||||
- Prefer test server over mocking deps. We have existing test utilities for running full test server to boot deps based on config. (see [src/backend/testUtil.ts](src/backend/testUtil.ts)).
|
||||
- **Hit a real database / real client where reasonable.** Integration shapes catch the things unit tests with mocks miss.
|
||||
- **Regression tests for bug fixes.** A test that fails before your fix and passes after is the cheapest insurance against the bug coming back.
|
||||
- If something is genuinely hard to test (UI animation, third-party glue), skip it but say so in the PR.
|
||||
|
||||
### Security & privacy
|
||||
|
||||
Before opening a PR, scan the diff for:
|
||||
|
||||
- Logs, error messages, or responses leaking internal paths, secrets, tokens, env vars, or other users' data.
|
||||
- Debug routes, test credentials, commented-out auth checks.
|
||||
- Endpoints returning more than the caller actually needs.
|
||||
|
||||
When in doubt, return less. Auth-, permission-, or data-export-related changes deserve an explicit callout in the PR description.
|
||||
|
||||
### Working rules of thumb
|
||||
|
||||
- **Run it, don't just compile it.** "It type-checks" is not "it works." Exercise the code path end-to-end at least once.
|
||||
- **Read the neighbors before writing.** Match the shape of similar things already in the tree. If you genuinely think the existing pattern is wrong, raise it — don't quietly diverge.
|
||||
- **Boy Scout Rule, proportional to the change.** Fix the obvious typo, dead import, or missing typehint in files you're already touching. Don't ride a refactor along with a bug fix — that just makes review harder.
|
||||
- **Understand what you commit.** AI assistance is fine; shipping code you couldn't defend in review is not.
|
||||
+35
-2
@@ -6,7 +6,7 @@ We at **Puter** are committed to maintaining a secure experience for our users a
|
||||
|
||||
The following are in scope for this program:
|
||||
|
||||
* **The Puter open-source project** (available at [github.com/HeyPuter](https://github.com/HeyPuter/puter))
|
||||
* **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
|
||||
* **`puter.com`**
|
||||
* **`api.puter.com`**
|
||||
|
||||
@@ -17,18 +17,51 @@ Out-of-scope:
|
||||
* Denial of Service (DoS), spam, or volumetric attacks.
|
||||
* Physical security issues.
|
||||
|
||||
## Known Non-Issues (Please Check Before Submitting)
|
||||
|
||||
The following have already been reviewed and determined **not to be vulnerabilities**. Reports that only re-describe one of these are **not eligible for a reward** and will be closed as non-issues — even if they include new code references. Please review this list before submitting:
|
||||
|
||||
* **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is generally out of scope. Two exceptions we *do* evaluate on their merits — please report these: (a) attacker-controlled content served **inline** (e.g. an HTML `Content-Type`) from API file/response endpoints, and (b) anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host.
|
||||
* **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
|
||||
* **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
|
||||
* **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
|
||||
* **App metadata or app user-count "leaks".** This information is currently public by design.
|
||||
* **General "token in a URL" / token-lifetime designs** — signed directory URLs exposing children, a write signature implying read, or app tokens outliving a web session. These are current intended behaviors.
|
||||
* **Missing PKCE, unverified `id_token` signatures, or other OIDC hardening.** Provider tokens are obtained through a server-to-server authorization-code exchange with the provider's token endpoint over TLS, so the resulting `id_token` / userinfo claims are trusted from that channel rather than from local JWKS signature verification — the callback never accepts a caller-supplied `id_token`. Adding local signature / `aud` / `iss` / `exp` checks is welcome defense-in-depth (please open a GitHub issue/PR), but their absence is not an account-takeover vector on its own.
|
||||
* **JWT "algorithm confusion" / unpinned `algorithms` in `jwt.verify`.** Puter's session tokens are HMAC-signed (HS256) with a server-side secret, and there is no asymmetric public key anywhere in the verification path, so `alg`-substitution attacks do not apply. Explicitly pinning `algorithms` is a fine hardening PR, but it is not a vulnerability.
|
||||
* **Static-source "SQL injection" in internal pagination.** Findings such as `LIMIT ${limit}` in list/notification queries: the limit is numerically coerced and clamped before it reaches the query, so it is not reachable with attacker-controlled string input. Hardening PRs to the internal stores are welcome, but these are not exploitable as reported.
|
||||
* **Deprecated `saveTo*` GUI app messages.** The legacy `saveToDesktop` / `saveToDocuments` / etc. app-IPC handlers can create — never overwrite — new files in standard user folders. The behavior is non-destructive, path-traversal-safe, and deprecated (slated for removal); it is not treated as a sandbox escape.
|
||||
* **Best-practice suggestions** such as login/registration username enumeration (kept intentionally for UX) or unauthenticated unsubscribe links (industry norm).
|
||||
* **Rate-limiting suggestions for TURN credential issuance** (intentional; not billed per tunnel).
|
||||
|
||||
If you believe you have a **genuinely new** exploit chain that defeats one of these rationales (for example, demonstrating a sensitive credential that really is reachable on `api.puter.com`), say so explicitly and show why the reasoning above does not apply.
|
||||
|
||||
## Rules of Engagement
|
||||
|
||||
To participate, you must:
|
||||
|
||||
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code or screenshots where applicable.
|
||||
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code, screenshots, or a screen recording (see *Proof of Reproduction* below).
|
||||
2. **Do no harm**: Do not exfiltrate, modify, or delete data. Only access your own account or test data.
|
||||
3. **Respect availability**: Do not perform denial-of-service attacks or automated scans that degrade service.
|
||||
4. **Follow disclosure policy**: Do not publicly disclose vulnerabilities until we have confirmed and patched the issue.
|
||||
5. **Act in good faith**: Make every effort to avoid privacy violations, destruction of data, and interruption or degradation of services.
|
||||
6. **Check the Known Non-Issues list**: Reports matching an item in the "Known Non-Issues" section above are not eligible and will be closed.
|
||||
|
||||
Reports that do not meet these guidelines may not be eligible for a reward.
|
||||
|
||||
## Proof of Reproduction
|
||||
|
||||
Reports must demonstrate a **working, reproducible exploit with real impact** — not a theoretical or static-source-review finding. Please include:
|
||||
|
||||
* Exact steps to reproduce, the relevant request/response or code path, and the commit or version you tested.
|
||||
* The **observed** result versus the **expected** result.
|
||||
* For client-side, UI, or authentication-flow bugs: a short screen recording (≤ 2 minutes) showing the exploit working end-to-end on a real Puter instance.
|
||||
* For server-side bugs: a runnable proof-of-concept.
|
||||
|
||||
Reports based solely on reading the source ("source review only, not tested") or unverified AI/LLM-generated reports are the **lowest triage priority and are generally not eligible**. If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting.
|
||||
|
||||
Please submit **one issue per report**. Bundled "audit packs" of many speculative findings will be declined; send each confirmed issue separately.
|
||||
|
||||
## Reporting Process
|
||||
|
||||
To report a vulnerability, email us at: **[[email protected]](mailto:[email protected])**.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Contributing to Puter Backend
|
||||
|
||||
Thanks for contributing. These rules aren't strictly enforced — but following them makes every PR easier. If anything's unclear, ping a core maintainer or open the PR and ask.
|
||||
|
||||
New to the backend? Start with [doc/architecture.md](doc/architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Test it. Run it.
|
||||
|
||||
Run the affected code path end-to-end before opening a PR. "It builds" is not "it works."
|
||||
|
||||
Add tests for new behavior, endpoints, or bug fixes. If something's genuinely hard to test, say so in the PR.
|
||||
|
||||
## 2. Follow existing patterns
|
||||
|
||||
Match the shape of similar code already in the repo. [doc/architecture.md](doc/architecture.md) is the source of truth for layers, wiring, and naming. If you think a pattern is wrong, raise it — don't quietly diverge.
|
||||
|
||||
## 3. Don't expose system or user information
|
||||
|
||||
Scan your diff for stray logs, debug routes, internal paths, secrets, tokens, or user data in errors/responses. When in doubt, return less. Flag any auth, permission, or data-export changes in the PR description.
|
||||
|
||||
For private security reports, see [SECURITY.md](SECURITY.md).
|
||||
|
||||
## 4. AI-assisted code is fine — understood code is required
|
||||
|
||||
Don't commit code you couldn't have written, debugged, or defended yourself. Read the diff, run it, and be ready to explain it in review.
|
||||
|
||||
## 5. Update docs for API changes
|
||||
|
||||
If you change puter-js APIs (drivers or endpoints used by puter-js), update [developer docs](src/docs/). puter-js itself may also need updating.
|
||||
|
||||
## 6. Boy Scout Rule — leave it 1% better
|
||||
|
||||

|
||||
|
||||
Fix the typo, the dead import, the missing test, the bit you had to read twice. Keep cleanup proportional to the change — no refactors riding along on bug fixes.
|
||||
|
||||
---
|
||||
|
||||
## Opening a PR
|
||||
|
||||
- One thing per PR where possible.
|
||||
- Describe **what** and **why**; the diff shows how.
|
||||
- Mention how you tested user-visible changes.
|
||||
- Drafts welcome.
|
||||
|
||||
---
|
||||
|
||||
Questions? Message a core maintainer. Welcome aboard.
|
||||
@@ -25,7 +25,15 @@
|
||||
|
||||
## Puter
|
||||
|
||||
Puter is an advanced, open-source, self-hostable internet computer designed to be feature-rich, fast, and highly extensible. Puter's goal is to provide you with every app and feature you need to work, create, and play under one roof.
|
||||
Puter is an advanced, open-source, self-hostable internet computer designed to be feature-rich, fast, and highly extensible.
|
||||
|
||||
### For Users
|
||||
Puter's goal is to provide you with every app and feature you need to work, create, and play under one roof. From a simple [Notepad](https://online-notepad.com) and [Voice Recorder](https://voice-recorder.com) to [Spreadsheet](https://apps.puter.com/app/spreadsheet) and [Camera](https://online-camera.com), Puter wants to be the all-in-one solution for your digital life.
|
||||
|
||||
|
||||
### For Developers
|
||||
|
||||
Puter provides everything you need to build and publish web apps and games. From [AI](https://developer.puter.com/ai/) to [Cloud Storage](https://developer.puter.com/object-storage/) and [Database](https://developer.puter.com/key-value-database/) to [Serverless Workers](https://developer.puter.com/serverless-workers/), Puter has you covered. Puter also helps you get users! Once you build your app, you can publish it on our [App Store](https://apps.puter.com/) to reach and monetize users.
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -40,7 +48,7 @@ cd puter
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
**→** This should launch Puter at http://puter.localhost:4100 (or the next available port).
|
||||
**→** This should launch Puter at http://puter.localhost:4100
|
||||
|
||||
|
||||
<br/>
|
||||
@@ -59,8 +67,6 @@ curl -fsSL https://puter.com/selfhost | sh
|
||||
irm https://puter.com/selfhost?os=windows | iex
|
||||
```
|
||||
|
||||
<br/>
|
||||
|
||||
**→** For more details, see [Self-Hosting Puter](./doc/self-hosting.md).
|
||||
|
||||
<br/>
|
||||
@@ -80,7 +86,7 @@ Connect with the maintainers and community through these channels:
|
||||
- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter)
|
||||
- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/)
|
||||
- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter)
|
||||
- Security issues? [[email protected]](mailto:[email protected])
|
||||
- Security issues or abuse reports? [[email protected]](mailto:[email protected])
|
||||
- Email maintainers at [[email protected]](mailto:[email protected])
|
||||
|
||||
We are always happy to help you with any questions you may have. Don't hesitate to ask!
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"domain": "puter.localhost",
|
||||
"cookie_name": "puter_auth_token",
|
||||
"jwt_secret": "dev-jwt-secret-change-me",
|
||||
"jwt_secret_v2": "dev-jwt-secret-v2-change-me",
|
||||
"allow_v1_tokens": true,
|
||||
"url_signature_secret": "dev-url-signature-secret-change-me",
|
||||
"allow_all_host_values": true,
|
||||
"allow_no_host_header": true,
|
||||
@@ -21,6 +23,7 @@
|
||||
"default_user_group": "78b1b1dd-c959-44d2-b02c-8735671f9997",
|
||||
"default_temp_group": "b7220104-7905-4985-b996-649fdcdb3c8f",
|
||||
"storage_capacity": 104857600,
|
||||
"disable_user_signup": false,
|
||||
"strict_email_verification_required": false,
|
||||
"gui_assets_root": "./src/gui",
|
||||
"puterjs_root": "./src/puter-js/dist",
|
||||
|
||||
+39
-4
@@ -16,6 +16,12 @@
|
||||
// `dev` opens a browser on boot, skips blocked-email checks, and runs the
|
||||
// dev-time webpack watcher; `prod` serves pre-built bundles.
|
||||
"env": "dev",
|
||||
// Console output format. `json` replaces the global console so every call
|
||||
// emits one structured JSON line (level, timestamp, msg, and the active
|
||||
// request's trace id) — one event per call, so a line-oriented log
|
||||
// collector can't split stack traces across events and level filtering
|
||||
// works. Unset (the default) leaves console output human-readable.
|
||||
"log_format": "text",
|
||||
"version": "0.0.0",
|
||||
// Stable identity for this server node — used by pager alerts and
|
||||
// graceful-shutdown coordination.
|
||||
@@ -67,10 +73,17 @@
|
||||
|
||||
// ── Auth / session ──────────────────────────────────────────────────
|
||||
// ALWAYS replace these for any public install — `openssl rand -hex 64`.
|
||||
// `jwt_secret` is legacy verify-only; new tokens sign with `jwt_secret_v2`.
|
||||
"jwt_secret": "change-me",
|
||||
"jwt_secret_v2": "change-me",
|
||||
// Set false to retire v1 tokens entirely (ROLLOUT-1).
|
||||
"allow_v1_tokens": true,
|
||||
"url_signature_secret": "change-me",
|
||||
"cookie_name": "puter_auth_token",
|
||||
"min_pass_length": 6,
|
||||
// When true, anonymous users must log in instead of creating temp or
|
||||
// permanent accounts.
|
||||
"disable_user_signup": false,
|
||||
"allow_system_login": false,
|
||||
"strict_email_verification_required": false,
|
||||
"captcha": {
|
||||
@@ -155,17 +168,30 @@
|
||||
|
||||
// ── Database ────────────────────────────────────────────────────────
|
||||
"database": {
|
||||
// `sqlite` for single-node / dev; `mysql` for self-host with MariaDB.
|
||||
// `sqlite` for single-node/dev, `mysql` for MariaDB/MySQL,
|
||||
// or `postgres` for PostgreSQL.
|
||||
//
|
||||
// NOTE: `postgres` support is a community contribution and is not
|
||||
// exercised by Puter.com production. It boots, passes its own
|
||||
// integration tests against pgmock, and runs the common user/app/
|
||||
// fsentry/session/permission/OIDC flows — but less-traveled code
|
||||
// paths may surface MySQL/SQLite-isms that haven't been ported yet.
|
||||
// Expect rough edges and please file issues if you hit one. For
|
||||
// production self-hosting today, `mysql` (MariaDB) and `sqlite` are
|
||||
// the supported defaults.
|
||||
"engine": "sqlite",
|
||||
// sqlite — file path on disk
|
||||
"path": "volatile/runtime/puter-database.sqlite",
|
||||
"targetVersion": 0,
|
||||
// mysql — connection details
|
||||
// mysql/postgres — connection details. PostgreSQL defaults to port
|
||||
// 5432 when `engine` is `postgres`; MySQL/MariaDB normally use 3306.
|
||||
"host": "",
|
||||
"port": 3306,
|
||||
"user": "",
|
||||
"password": "",
|
||||
"database": "",
|
||||
// postgres may also use a URL instead of host/user/password fields:
|
||||
// "connectionString": "postgres://puter:secret@localhost:5432/puter",
|
||||
// Optional read-replica pool. Reads route here when populated.
|
||||
"replica": {
|
||||
"host": "",
|
||||
@@ -174,9 +200,10 @@
|
||||
"password": "",
|
||||
"database": ""
|
||||
}
|
||||
// mysql self-host bootstrap: set `migrationPaths` to apply the bundled
|
||||
// schema on first boot. Idempotent — safe to leave on.
|
||||
// mysql/postgres self-host bootstrap: set `migrationPaths` to apply the
|
||||
// bundled schema on first boot. Idempotent — safe to leave on.
|
||||
// "migrationPaths": ["./src/backend/clients/database/migrations/mysql"]
|
||||
// "migrationPaths": ["./src/backend/clients/database/migrations/postgres"]
|
||||
},
|
||||
|
||||
// ── DynamoDB (KV store) ─────────────────────────────────────────────
|
||||
@@ -242,12 +269,20 @@
|
||||
// ─ Chat / completion ─
|
||||
"claude": { "apiKey": "" },
|
||||
"openai-completion": { "apiKey": "" },
|
||||
"azure-openai": {
|
||||
"apiKey": "",
|
||||
"apiURL": ""
|
||||
},
|
||||
"gemini": { "apiKey": "" },
|
||||
"groq": { "apiKey": "" },
|
||||
"deepseek": { "apiKey": "" },
|
||||
"mistral": { "apiKey": "" },
|
||||
"xai": { "apiKey": "" },
|
||||
"moonshot": { "apiKey": "" },
|
||||
"minimax": {
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://api.minimax.io/v1"
|
||||
},
|
||||
"openrouter": {
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://openrouter.ai/api/v1"
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Backend Architecture
|
||||
|
||||
Loosely inspired by the Controller–Service–Repository pattern with dependency injection. The backend is organized as a stack of layers where each layer only depends on the layers beneath it, and `PuterServer` ([src/backend/server.ts](../src/backend/server.ts)) instantiates each layer in order and hands the instances down to the next.
|
||||
|
||||
## Layers
|
||||
|
||||
```mermaid
|
||||
block-beta
|
||||
columns 1
|
||||
REQ["HTTP request"]
|
||||
CTRL["Controllers — route handlers, gates, I/O shaping"]
|
||||
DRV["Drivers (optional) — RPC handlers on /drivers/*"]
|
||||
SVC["Services — business logic, no auth"]
|
||||
STR["Stores — persistence / domain shapes"]
|
||||
CLI["Clients — sql, redis, s3, dynamo, email, …"]
|
||||
CFG["Config — IConfig"]
|
||||
|
||||
REQ --> CTRL
|
||||
CTRL --> DRV
|
||||
DRV --> SVC
|
||||
SVC --> STR
|
||||
STR --> CLI
|
||||
CLI --> CFG
|
||||
|
||||
style REQ fill:#0ea5e9,stroke:#0369a1,color:#fff
|
||||
style CTRL fill:#1d4ed8,stroke:#1e3a8a,color:#fff
|
||||
style DRV fill:#2563eb,stroke:#1e40af,color:#fff
|
||||
style SVC fill:#4f46e5,stroke:#3730a3,color:#fff
|
||||
style STR fill:#7c3aed,stroke:#5b21b6,color:#fff
|
||||
style CLI fill:#9333ea,stroke:#6b21a8,color:#fff
|
||||
style CFG fill:#334155,stroke:#1e293b,color:#fff
|
||||
```
|
||||
|
||||
Each layer only depends on the layers beneath it, and every dependency is injected through the constructor by `PuterServer`. Extensions sit alongside this stack and can register into any layer — see [Extensions](#extensions) below.
|
||||
|
||||
| Layer | Lives in | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| **Controllers** | [src/backend/controllers/](../src/backend/controllers/) | Route handlers. Parse + validate input, apply per-route gates (auth, subdomain, rate limit, body parsers — see `RouteOptions`), call into services, format responses. |
|
||||
| **Drivers** | [src/backend/drivers/](../src/backend/drivers/) | Optional. RPC-style handlers exposed over the `/drivers/*` surface (`puter-kvstore`, `puter-chat-completion`, …). A driver is a thin shell that validates RPC inputs and calls into services/stores; controllers can hold a typed reference to drivers when they need the same logic over HTTP. |
|
||||
| **Services** | [src/backend/services/](../src/backend/services/) | Business logic. Assume the caller is already authenticated/authorized — services do not run auth gates themselves. |
|
||||
| **Stores** | [src/backend/stores/](../src/backend/stores/) | Persistence and storage logic. Wraps clients with the domain shape services consume (rows, entities, KV namespaces). |
|
||||
| **Clients** | [src/backend/clients/](../src/backend/clients/) | Adapters for external/internal services (sql, redis, s3, dynamodb, email, event bus, …). Knows protocols, not domain concepts. |
|
||||
| **Config** | `config.*.json` → `IConfig` | The flat, typed config object every layer receives at construction. |
|
||||
|
||||
Each layer receives the layers beneath it through its constructor, so dependencies are explicit and traceable from `PuterServer`. A controller does not reach into a client directly; if it needs one, the right move is usually a service.
|
||||
|
||||
## Entry point: `PuterServer`
|
||||
|
||||
`PuterServer` is the bootstrap. It:
|
||||
|
||||
1. Loads any configured extension directories (`config.extensions`) so extensions can register before instantiation begins.
|
||||
2. Instantiates each layer in order — clients → stores → services → drivers → controllers — merging in anything extensions have registered for that layer.
|
||||
3. Wires global middleware, mounts controller routes through `PuterRouter` (which translates `RouteOptions` into the gate/parser middleware chain), and mounts extension routes through the same materializer.
|
||||
4. Fires `onServerStart` hooks across every layer once HTTP is listening, and `onServerPrepareShutdown` / `onServerShutdown` on the way down.
|
||||
|
||||
## Context (ALS)
|
||||
|
||||
We use [`Context`](../src/backend/core/context.ts) — backed by `AsyncLocalStorage` — to carry per-request state without threading it through every function signature. It is used **sparingly**, mostly for `actor` and `req`. The request-context middleware opens a scope per request after the auth probe runs; anything inside a request handler can call `Context.get('actor')` / `Context.get('req')` instead of plumbing it as an argument.
|
||||
|
||||
Prefer explicit arguments. Reach for `Context` only when the value is truly request-scoped and would otherwise need to thread through many layers.
|
||||
|
||||
## Extensions
|
||||
|
||||
Extensions live alongside core ([packages/puter/extensions/](../extensions/)) and parallel the layered stack. They are meant for **non-crucial parts of the system** — things Puter still works without if removed.
|
||||
|
||||
- **Good extensions**: [thumbnails](../extensions/thumbnails.ts), [serverInfo](../extensions/serverInfo.ts), [devWatcher](../extensions/devWatcher.ts) — opt-in features cleanly bolted on.
|
||||
- **Should probably be core**: [metering](../extensions/metering.ts), [appTelemetry](../extensions/appTelemetry.ts) — clients now expect these to be present, so the "extension" framing is misleading.
|
||||
- **Shouldn't have been an extension**: [whoami](../extensions/whoami.ts) — it's load-bearing for every authenticated client. Keep this one in mind as a cautionary example when deciding whether something belongs in an extension.
|
||||
|
||||
### Extension API
|
||||
|
||||
The `extension` global ([src/backend/extensions.ts](../src/backend/extensions.ts)) exposes:
|
||||
|
||||
- **Layer registration** for first-class additions:
|
||||
- `extension.registerClient(name, ClientClass)`
|
||||
- `extension.registerStore(name, StoreClass)`
|
||||
- `extension.registerService(name, ServiceClass)`
|
||||
- `extension.registerDriver(name, DriverClass)`
|
||||
- `extension.registerController(name, ControllerClass)`
|
||||
- **Lightweight wrappers** for the common case where a full class isn't worth it:
|
||||
- `extension.on(event, handler)` — subscribe to event-bus events.
|
||||
- `extension.get(path, opts?, handler)` / `.post` / `.put` / `.delete` / `.patch` / `.head` / `.options` / `.all` / `.use` — register routes. The `opts` shape is the same `RouteOptions` controllers use, so `subdomain`, `requireAuth`, `adminOnly`, body parsers, etc. all work identically.
|
||||
- **Cross-layer access**: `extension.import('client' | 'store' | 'service' | 'controller' | 'driver')` returns a lazy proxy to instantiated objects, and `extension.config` exposes the live config.
|
||||
|
||||
```ts
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
|
||||
const services = extension.import('service');
|
||||
|
||||
extension.get('/healthcheck/deep', { subdomain: 'api', adminOnly: true }, async (_req, res) => {
|
||||
res.json({ ok: await services.health.runDeepCheck() });
|
||||
});
|
||||
|
||||
extension.on('user.signup', (_key, data) => {
|
||||
console.log('new user', data.user.username);
|
||||
});
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **TypeScript preferred** in new code where feasible. Existing JS is fine; convert opportunistically when you're already touching a file.
|
||||
- **`camelCase`** for variable/function names; **`PascalCase`** for classes and for files that contain a class (`AuthService.ts`, `KVStoreDriver.ts`).
|
||||
- **Deduplicate**. If two services need the same logic, lift it into a util/helper rather than calling sideways across the same layer — services should not depend on other services for code reuse.
|
||||
- **Don't reach across layers.** Controllers do not poke clients directly; services do not register routes. If you find yourself wanting to, that's usually a signal the abstraction is wrong.
|
||||
@@ -113,7 +113,7 @@ Terhubung dengan maintainer dan komunitas melalui saluran-saluran berikut:
|
||||
- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/)
|
||||
- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter)
|
||||
- Isu keamanan? [[email protected]](mailto:[email protected])
|
||||
- Email maintainers di [[email protected]](mailto:[email protected])
|
||||
- Email pengelola di [[email protected]](mailto:[email protected])
|
||||
|
||||
Kami selalu senang membantu Anda dengan pertanyaan apa pun yang Anda miliki. Jangan ragu untuk bertanya!
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ Puterは[**puter.com**](https://puter.com)でホストサービスとして利
|
||||
|
||||
メンテナーやコミュニティと以下のチャンネルを通じてつながりましょう:
|
||||
|
||||
- バグ報告や機能リクエストがありますか? [issueを開く](https://github.com/HeyPuter/puter/issues/new/choose) してください。
|
||||
- バグ報告や機能リクエストは [新規Issue](https://github.com/HeyPuter/puter/issues/new/choose) まで
|
||||
- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u)
|
||||
- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter)
|
||||
- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/)
|
||||
|
||||
+95
-3
@@ -14,7 +14,7 @@ Generates secrets, writes `.env` + `puter/config/config.json`, downloads `docker
|
||||
|
||||
- **Docker** with the `compose` plugin.
|
||||
- A **domain** with DNS access — you need a wildcard record (`*.your-domain.com` → server IP). Puter routes by subdomain (`api.<domain>`, `site.<domain>`, `app.<domain>`).
|
||||
- Optional: **TLS certs** (or `certbot` to grab them — see Step 4).
|
||||
- Optional: **TLS certs** (or `certbot` to grab them — see Step 3).
|
||||
|
||||
## What's running
|
||||
|
||||
@@ -48,6 +48,7 @@ MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
|
||||
MARIADB_PASSWORD=$(openssl rand -hex 32)
|
||||
S3_SECRET_KEY=$(openssl rand -hex 32)
|
||||
JWT_SECRET=$(openssl rand -hex 64)
|
||||
JWT_SECRET_V2=$(openssl rand -hex 64)
|
||||
URL_SIGNATURE_SECRET=$(openssl rand -hex 64)
|
||||
|
||||
cat > .env <<EOF
|
||||
@@ -78,6 +79,8 @@ cat > puter/config/config.json <<EOF
|
||||
"private_app_hosting_domain_alt": "dev.puter.localhost",
|
||||
|
||||
"jwt_secret": "$JWT_SECRET",
|
||||
"jwt_secret_v2": "$JWT_SECRET_V2",
|
||||
"allow_v1_tokens": true,
|
||||
"url_signature_secret": "$URL_SIGNATURE_SECRET",
|
||||
|
||||
"database": {
|
||||
@@ -131,8 +134,9 @@ Replace `puter.localhost`, `site.puter.localhost`, `host.puter.localhost`, `dev.
|
||||
|
||||
Why these knobs:
|
||||
|
||||
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start=gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
|
||||
- `database.migrationPaths` — Puter applies the bundled MySQL schema on boot. `mysql_mig_1.sql` (tables) and `mysql_mig_2.sql` (default apps: editor, viewer, pdf, camera, player, recorder, git, dev-center, puter-linux). Idempotent — safe to re-run.
|
||||
- `jwt_secret` + `jwt_secret_v2` — Puter signs new auth tokens with `jwt_secret_v2` (v2 token format, `kid: 'v2'` JWT header). `jwt_secret` is verify-only and lets tokens minted before this version (cookies still in users' browsers, stored API tokens) keep working. `allow_v1_tokens: true` keeps that fallback enabled. Fresh installs can leave it as-is; future versions will flip the flag to `false` and retire `jwt_secret` entirely once legacy tokens have drained.
|
||||
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start:gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
|
||||
- `database.migrationPaths` — Puter applies the bundled MySQL/MariaDB schema on boot. The migration files are idempotent, so it is safe to leave this configured across restarts.
|
||||
- `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS.
|
||||
- `dynamo.aws` keys are dummies; DynamoDB-local doesn't validate them but the AWS SDK requires _something_. **Note:** DynamoDB uses `access_key` / `secret_key` (snake_case); S3 below uses `accessKeyId` / `secretAccessKey` (camelCase). Not interchangeable.
|
||||
- `providers.ollama.enabled: false` — Puter auto-probes a local Ollama at `127.0.0.1:11434` by default; without one running you'd see `ECONNREFUSED` on every boot. To run a bundled Ollama, see [Optional: local LLM (Ollama)](#optional-local-llm-ollama) below.
|
||||
@@ -195,6 +199,32 @@ Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`.
|
||||
"s3": { "s3Config": { "publicEndpoint": "https://s3.puter.local", ... } }
|
||||
```
|
||||
|
||||
## Running behind your own reverse proxy
|
||||
|
||||
The bundled `puter-nginx` mirrors production and is the supported default — it terminates TLS (Step 3) and forwards every Host to Puter. But plenty of self-hosters already run their own edge proxy (Caddy, Traefik, HAProxy, a cloud load balancer, another nginx). You can put Puter behind it instead; Puter doesn't terminate TLS itself in any setup, so "your proxy does TLS" is just a matter of pointing it at the Puter container and getting a handful of details right.
|
||||
|
||||
You can keep the bundled nginx as the single hop your proxy talks to, or bypass it and forward straight to the Puter container on port `4100` (uncomment the `4100:4100` mapping under the `puter` service in [docker-compose.yml](../docker-compose.yml), or attach your proxy to the compose network). Either works — what matters is the rules below.
|
||||
|
||||
**The rules that actually matter** (getting any of these wrong is what causes the redirect loops and "Invalid Host header" failures people hit):
|
||||
|
||||
1. **Don't rewrite the `Host` header.** Puter routes entirely on Host — `api.<domain>`, `site.<domain>`, `app.<domain>` are all distinguished by the incoming host. Forward the original host through unchanged. Do **not** rewrite external hostnames to an internal name like `puter.local`; that forces you to remap every subdomain by hand and still breaks signed-URL and CORS checks. Most proxies preserve Host by default — just don't override it.
|
||||
|
||||
2. **The external domain must equal `domain` in your `config.json`.** If users reach the box at `puter.example.com`, then `domain` must be `puter.example.com` and the hosting domains must be its real subdomains (`site.puter.example.com`, `app.puter.example.com`, …) — exactly as the installer/`config.json` lays them out. Puter rejects hosts it doesn't recognize with `Invalid Host header`.
|
||||
|
||||
3. **Set `protocol` to match the public scheme.** When your proxy terminates TLS, set `"protocol": "https"` (installer: `PUTER_PROTOCOL=https`). Puter builds its origins, redirects, signed S3 URLs, and OIDC callback URLs from this — leave it `http` behind an HTTPS proxy and you get redirect loops, broken logins, and mixed-content errors.
|
||||
|
||||
4. **Forward the standard proxy headers.** Puter needs:
|
||||
- `Host` — the original request host (rule 1).
|
||||
- `X-Forwarded-Proto` — the **external** scheme (`https`), so Puter knows TLS was terminated upstream.
|
||||
- `X-Forwarded-For` — the real client IP (rate limiting + audit logs).
|
||||
- `Upgrade` / `Connection` — passed through for WebSocket / socket.io upgrades, or the realtime connection silently fails.
|
||||
|
||||
5. **Set `trust_proxy` to the number of hops in front of Puter.** One external proxy talking directly to Puter → `"trust_proxy": 1` (installer: `PUTER_TRUST_PROXY=1`). A second proxy in front (e.g. Cloudflare → your proxy → Puter) → `2`. This is the count of proxies between the client and Puter, **including** the bundled nginx if you keep it. Too low and `req.ip` becomes a proxy address (breaks rate limiting); never set `true` (it trusts every hop and makes `X-Forwarded-For` forgeable).
|
||||
|
||||
6. **Route the wildcard to Puter.** Your proxy must forward `*.<domain>` (covering `api.*`, `app.*`, and `s3.<domain>`) and `*.site.<domain>` (and any other hosting domains) to Puter — same wildcard DNS as Step 2. Puter does the per-subdomain routing internally; the proxy just needs to hand it the traffic with the Host intact.
|
||||
|
||||
With those in place the rest of the stack is unchanged — `docker compose up -d` as below.
|
||||
|
||||
## Step 4 — Bring it up
|
||||
|
||||
```bash
|
||||
@@ -228,6 +258,26 @@ Change it in Settings after first login.
|
||||
|
||||
All optional. Drop any of the blocks below into `puter/config/config.json` and `docker compose restart puter`. See [config.template.jsonc](../config.template.jsonc) for the full list. Per-key documentation lives in [src/backend/types.ts](../src/backend/types.ts).
|
||||
|
||||
### PostgreSQL database
|
||||
|
||||
> **Community contribution — expect rough edges.** PostgreSQL support was contributed by the community and is not run by Puter.com production. It boots, applies the bundled schema, and exercises the common user/app/fsentry/session/permission/OIDC flows in the integration tests, but less-traveled SQL call sites may still need porting. If you hit a query that doesn't work on Postgres, please open an issue. For production self-hosting today, MariaDB/MySQL and SQLite are the supported defaults.
|
||||
|
||||
The bundled Docker Compose stack still defaults to MariaDB. To use PostgreSQL instead, run a PostgreSQL service yourself, point Puter at it, and use the PostgreSQL migration path:
|
||||
|
||||
```json
|
||||
"database": {
|
||||
"engine": "postgres",
|
||||
"host": "postgres",
|
||||
"port": 5432,
|
||||
"user": "puter",
|
||||
"password": "...",
|
||||
"database": "puter",
|
||||
"migrationPaths": ["/opt/puter/dist/src/backend/clients/database/migrations/postgres"]
|
||||
}
|
||||
```
|
||||
|
||||
You may use `"connectionString": "postgres://puter:...@postgres:5432/puter"` instead of the host/user/password fields. Puter only needs normal PostgreSQL connection details and the migration path; CloudNativePG is one possible Kubernetes operator, but no operator-specific manifests are required by Puter.
|
||||
|
||||
### Email (SMTP)
|
||||
|
||||
Used for password resets, email confirmation, and notifications. Without it those flows silently fail.
|
||||
@@ -260,6 +310,39 @@ To require email confirmation before login, also set `"strict_email_verification
|
||||
|
||||
Add `https://puter.<your-domain>/auth/oidc/callback/login` to the OAuth client's authorized redirect URIs in the Google Cloud Console. For non-Google providers, replace `google` with a custom id and supply `authorization_endpoint` / `token_endpoint` / `userinfo_endpoint` explicitly.
|
||||
|
||||
### Sign in with Apple
|
||||
|
||||
```json
|
||||
"oidc": {
|
||||
"providers": {
|
||||
"apple": {
|
||||
"client_id": "com.example.your-service-id",
|
||||
"team_id": "YOUR_TEAM_ID",
|
||||
"key_id": "YOUR_KEY_ID",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `client_id` is your Apple Services ID. `team_id`, `key_id`, and `private_key` come from the Apple Developer Portal (Keys section — create a key with "Sign in with Apple" enabled). The `private_key` is the contents of the `.p8` file Apple provides. Add `https://puter.<your-domain>/auth/oidc/callback/login` and `https://puter.<your-domain>/auth/oidc/callback/signup` as return URLs in the Apple Services ID configuration.
|
||||
|
||||
### Sign in with Microsoft
|
||||
|
||||
```json
|
||||
"oidc": {
|
||||
"providers": {
|
||||
"microsoft": {
|
||||
"client_id": "YOUR_APPLICATION_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET_VALUE",
|
||||
"tenant_id": "YOUR_TENANT_ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Register an app in the Azure Portal (Microsoft Entra ID → App registrations). The `client_id` is the Application (client) ID, `client_secret` is a client secret value, and `tenant_id` is the Directory (tenant) ID. Use `common` as `tenant_id` to allow any Microsoft account (personal and organizational); omit it to default to `common`. Add `https://puter.<your-domain>/auth/oidc/callback/login` and `https://puter.<your-domain>/auth/oidc/callback/signup` as redirect URIs under Authentication in the app registration.
|
||||
|
||||
### AI providers
|
||||
|
||||
Any provider with a key set is auto-enabled. Same shape as `ollama` above:
|
||||
@@ -296,6 +379,15 @@ Built-in proof-of-work captcha — no external service needed.
|
||||
|
||||
`difficulty` is one of `easy` / `medium` / `hard`.
|
||||
|
||||
### Disable new signups
|
||||
|
||||
Force visitors to log in with an existing account instead of creating a
|
||||
temporary or permanent one.
|
||||
|
||||
```json
|
||||
"disable_user_signup": true
|
||||
```
|
||||
|
||||
### Block disposable email TLDs
|
||||
|
||||
Only enforced when `env: "prod"`.
|
||||
|
||||
+13
-10
@@ -58,7 +58,10 @@ services:
|
||||
fi
|
||||
wait $$SERVER_PID
|
||||
volumes:
|
||||
- ./puter/data/valkey:/data
|
||||
# `:z` is an SELinux relabel hint for Fedora/RHEL hosts (no-op
|
||||
# everywhere else) — without it those distros deny container
|
||||
# access to the bind mount and the service loops on EACCES.
|
||||
- ./puter/data/valkey:/data:z
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD-SHELL", "valkey-cli -p 6379 cluster info | grep -q cluster_state:ok"]
|
||||
@@ -77,7 +80,7 @@ services:
|
||||
MARIADB_USER: ${MARIADB_USER:-puter}
|
||||
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-puter-change-me}
|
||||
volumes:
|
||||
- ./puter/data/mariadb:/var/lib/mysql
|
||||
- ./puter/data/mariadb:/var/lib/mysql:z
|
||||
healthcheck:
|
||||
# `healthcheck.sh` ships with the mariadb image; --connect verifies
|
||||
# the server is accepting auth, not just listening on the socket.
|
||||
@@ -102,7 +105,7 @@ services:
|
||||
- "-dbPath"
|
||||
- "/home/dynamodblocal/data"
|
||||
volumes:
|
||||
- ./puter/data/dynamo:/home/dynamodblocal/data
|
||||
- ./puter/data/dynamo:/home/dynamodblocal/data:z
|
||||
|
||||
s3:
|
||||
# RustFS — S3-compatible object storage. Drop-in alternative:
|
||||
@@ -114,7 +117,7 @@ services:
|
||||
RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY:-puter}
|
||||
RUSTFS_SECRET_KEY: ${S3_SECRET_KEY:-puter-secret-change-me}
|
||||
volumes:
|
||||
- ./puter/data/s3:/data
|
||||
- ./puter/data/s3:/data:z
|
||||
# Internal-only — browsers reach RustFS via nginx (`s3.<domain>`),
|
||||
# which preserves the Host header for S3 signature validation and
|
||||
# rides the same TLS termination as Puter. Uncomment to also expose
|
||||
@@ -180,7 +183,7 @@ services:
|
||||
container_name: puter-ollama
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./puter/data/ollama:/root/.ollama
|
||||
- ./puter/data/ollama:/root/.ollama:z
|
||||
# Uncomment to expose Ollama directly on the host (`localhost:11434`)
|
||||
# for `ollama` CLI / OpenAI-API compatible tools. Internal-only by default.
|
||||
# ports:
|
||||
@@ -257,11 +260,11 @@ services:
|
||||
PGID: 1000
|
||||
volumes:
|
||||
# Drop your config.json here — see selfhosted/full-stack.md.
|
||||
- ./puter/config:/etc/puter
|
||||
- ./puter/config:/etc/puter:z
|
||||
# Persistent runtime data (anything your config points at /var/puter).
|
||||
- ./puter/data/puter:/var/puter
|
||||
- ./puter/data/puter:/var/puter:z
|
||||
healthcheck:
|
||||
test: wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1
|
||||
test: wget --no-verbose --tries=1 --spider http://localhost:4100/ || exit 1
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
@@ -279,9 +282,9 @@ services:
|
||||
# Uncomment when you enable TLS in nginx/nginx.conf:
|
||||
# - "${HTTPS_PORT:-443}:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro,z
|
||||
# TLS certs (fullchain.pem + privkey.pem). Read-only inside.
|
||||
- ./puter/tls:/etc/nginx/tls:ro
|
||||
- ./puter/tls:/etc/nginx/tls:ro,z
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- --tries=1 --timeout=2 http://localhost/ || exit 1"]
|
||||
interval: 10s
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||||
// Importing the module registers the `appTelemetry` driver into the shared
|
||||
// extensionStore, so `setupTestServer` instantiates it on `server.drivers`.
|
||||
import { AppTelemetryDriver } from './appTelemetry.ts';
|
||||
|
||||
let server: PuterServer;
|
||||
let driver: AppTelemetryDriver;
|
||||
|
||||
// `get_users` reads `Context.get('actor')` for the ownership gate. Wrap
|
||||
// actor-dependent calls in `runWithContext` so the ALS lookup resolves.
|
||||
const callWithActor = <T>(
|
||||
actor: { user: { uuid: string; id?: number } } | undefined,
|
||||
fn: () => Promise<T>,
|
||||
) => runWithContext({ actor }, fn);
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
driver = (server.drivers as unknown as Record<string, AppTelemetryDriver>)
|
||||
.appTelemetry;
|
||||
// Guard: the driver must have been wired onto the server. If this is
|
||||
// undefined the extension didn't register, and every test below would
|
||||
// fail with a confusing "cannot read property of undefined".
|
||||
expect(driver).toBeInstanceOf(AppTelemetryDriver);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
const seedOwnedApp = async (prefix: string) => {
|
||||
const owner = await server.stores.user.create({
|
||||
username: `${prefix}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `${prefix}_${slug}`,
|
||||
title: `${prefix} ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
},
|
||||
{ ownerUserId: owner.id as number },
|
||||
);
|
||||
return { owner, app: app! };
|
||||
};
|
||||
|
||||
describe('appTelemetry driver — get_users', () => {
|
||||
it('throws HttpError(400) when app_uuid is missing', async () => {
|
||||
await expect(driver.get_users({})).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('app_uuid'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws HttpError(400) for a non-numeric limit', async () => {
|
||||
await expect(
|
||||
driver.get_users({ app_uuid: 'app-anything', limit: 'banana' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('limit'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws HttpError(400) when offset exceeds the allowed maximum', async () => {
|
||||
await expect(
|
||||
driver.get_users({ app_uuid: 'app-anything', offset: 1_000_001 }),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws HttpError(404) when the app cannot be found', async () => {
|
||||
await expect(
|
||||
driver.get_users({ app_uuid: 'app-does-not-exist' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 404,
|
||||
message: 'App not found',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws HttpError(403) when the caller does not own the app', async () => {
|
||||
// Seed an owner + their app, then call as a *different* user. The
|
||||
// real permission service should reject: the caller has no
|
||||
// `apps-of-user:<owner>:write`.
|
||||
const { app } = await seedOwnedApp('owner');
|
||||
const intruder = await server.stores.user.create({
|
||||
username: `intruder_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
|
||||
await callWithActor(
|
||||
{ user: { uuid: intruder.uuid, id: intruder.id as number } },
|
||||
async () => {
|
||||
await expect(
|
||||
driver.get_users({ app_uuid: app.uid }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
message: 'Permission denied',
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty list for an owned app with no authenticated users', async () => {
|
||||
const { owner, app } = await seedOwnedApp('owner2');
|
||||
|
||||
const result = await callWithActor(
|
||||
{ user: { uuid: owner.uuid, id: owner.id as number } },
|
||||
() => driver.get_users({ app_uuid: app.uid }),
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appTelemetry driver — user_count', () => {
|
||||
it('throws HttpError(400) when app_uuid is missing', async () => {
|
||||
await expect(driver.user_count({})).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws HttpError(404) for an unknown app_uuid', async () => {
|
||||
await expect(
|
||||
driver.user_count({ app_uuid: 'app-not-here' }),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('returns 0 for an app with no authenticated users', async () => {
|
||||
const { app } = await seedOwnedApp('appcount');
|
||||
await expect(driver.user_count({ app_uuid: app.uid })).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
+67
-48
@@ -1,10 +1,16 @@
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import type { Actor } from '@heyputer/backend/src/core/actor';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http';
|
||||
import { PuterDriver } from '@heyputer/backend/src/drivers/types';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
|
||||
const clients = extension.import('client');
|
||||
const stores = extension.import('store');
|
||||
const services = extension.import('service');
|
||||
// App-telemetry lets an app owner enumerate the users who have
|
||||
// authenticated into their app. v1 shipped this as a driver on the
|
||||
// `app-telemetry` interface (methods `get_users` / `user_count`) and
|
||||
// puter-js's `puter.apps(...).getUsers()` still calls it that way
|
||||
// (`puter.drivers.call('app-telemetry', 'app-telemetry', 'get_users', …)`).
|
||||
// This is the v2 port of that driver — same interface/method/return shapes
|
||||
// so existing puter-js callers work unchanged.
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const MAX_LIMIT = 1000;
|
||||
@@ -40,81 +46,94 @@ const parseIntParam = (
|
||||
return parsed;
|
||||
};
|
||||
|
||||
extension.get(
|
||||
'/app-telemetry/users',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
async (req, res) => {
|
||||
const { app_uuid } = req.query as Record<string, string>;
|
||||
/**
|
||||
* Driver exposing the `app-telemetry` interface.
|
||||
*
|
||||
* The `/drivers/call` permission gate checks
|
||||
* `service:app-telemetry:ii:app-telemetry`, which every actor already holds
|
||||
* via the blanket `service` grant (hardcoded-permissions.js +
|
||||
* `default_implicit_user_app_permissions`). The real authorization — "is the
|
||||
* caller the app owner?" — is enforced inside `get_users` below, exactly as
|
||||
* v1 did.
|
||||
*/
|
||||
export class AppTelemetryDriver extends PuterDriver {
|
||||
readonly driverInterface = 'app-telemetry';
|
||||
readonly driverName = 'app-telemetry';
|
||||
readonly isDefault = true;
|
||||
|
||||
/** Users who have authenticated into the given app (owner-only). */
|
||||
async get_users({
|
||||
app_uuid,
|
||||
limit,
|
||||
offset,
|
||||
}: {
|
||||
app_uuid?: string;
|
||||
limit?: unknown;
|
||||
offset?: unknown;
|
||||
} = {}): Promise<Array<{ user: string; user_uuid: string }>> {
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
|
||||
const safeLimit = parseIntParam(req.query.limit, {
|
||||
const safeLimit = parseIntParam(limit, {
|
||||
key: 'limit',
|
||||
min: 1,
|
||||
max: MAX_LIMIT,
|
||||
fallback: DEFAULT_LIMIT,
|
||||
});
|
||||
const safeOffset = parseIntParam(req.query.offset, {
|
||||
const safeOffset = parseIntParam(offset, {
|
||||
key: 'offset',
|
||||
min: 0,
|
||||
max: MAX_OFFSET,
|
||||
fallback: 0,
|
||||
});
|
||||
|
||||
const app = await stores.app.getByUid(app_uuid);
|
||||
const app = await this.stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
|
||||
// `apps-of-user:<uuid>:write` — the implicator keys on the owner's
|
||||
// UUID, not the numeric id. Look up the owner explicitly. v1 got
|
||||
// this for free because its entity-storage layer eager-joined the
|
||||
// owner row; v2's AppStore.getByUid returns the raw row with only
|
||||
// `owner_user_id` populated.
|
||||
// The `apps-of-user:<uuid>:write` implicator keys on the owner's
|
||||
// UUID, not the numeric id. Look up the owner explicitly — the raw
|
||||
// app row only carries `owner_user_id`. (v1 got the owner for free
|
||||
// because its entity-storage layer eager-joined the owner row.)
|
||||
const ownerId = (app as { owner_user_id?: number }).owner_user_id;
|
||||
if (!ownerId) throw new HttpError(404, 'App owner not found');
|
||||
const owner = (await stores.user.getById(ownerId)) as {
|
||||
uuid?: string;
|
||||
} | null;
|
||||
const owner = await this.stores.user.getById(ownerId);
|
||||
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
|
||||
|
||||
const actor = Context.get('actor');
|
||||
const ownsApp = await services.permission
|
||||
.check(actor!, `apps-of-user:${owner.uuid}:write`)
|
||||
if (!actor) throw new HttpError(401, 'Authentication required');
|
||||
const ownsApp = await this.services.permission
|
||||
.check(actor as Actor, `apps-of-user:${owner.uuid}:write`)
|
||||
.catch(() => false);
|
||||
if (!ownsApp) throw new HttpError(403, 'Permission denied');
|
||||
|
||||
const users = await clients.db.read(
|
||||
const users = (await this.clients.db.read(
|
||||
`SELECT u.username, u.uuid FROM user_to_app_permissions p
|
||||
INNER JOIN user u ON p.user_id = u.id
|
||||
WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ?
|
||||
ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id
|
||||
LIMIT ? OFFSET ?`,
|
||||
[(app as Record<string, unknown>).id, safeLimit, safeOffset],
|
||||
);
|
||||
INNER JOIN ${this.clients.db.quoteIdentifier('user')} u ON p.user_id = u.id
|
||||
WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ?
|
||||
ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id
|
||||
LIMIT ? OFFSET ?`,
|
||||
[(app as { id: number }).id, safeLimit, safeOffset],
|
||||
)) as Array<{ username: string; uuid: string }>;
|
||||
|
||||
res.json(
|
||||
(users as Array<{ username: string; uuid: string }>).map((e) => ({
|
||||
user: e.username,
|
||||
user_uuid: e.uuid,
|
||||
})),
|
||||
);
|
||||
},
|
||||
);
|
||||
return users.map((e) => ({ user: e.username, user_uuid: e.uuid }));
|
||||
}
|
||||
|
||||
extension.get(
|
||||
'/app-telemetry/user-count',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
async (req, res) => {
|
||||
const { app_uuid } = req.query as Record<string, string>;
|
||||
/** Count of users who have authenticated into the given app. */
|
||||
async user_count({
|
||||
app_uuid,
|
||||
}: { app_uuid?: string } = {}): Promise<number> {
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
|
||||
const app = await stores.app.getByUid(app_uuid);
|
||||
const app = await this.stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
|
||||
const [row] = (await clients.db.read(
|
||||
const [row] = (await this.clients.db.read(
|
||||
`SELECT COUNT(*) AS n FROM user_to_app_permissions
|
||||
WHERE permission = 'flag:app-is-authenticated' AND app_id = ?`,
|
||||
[(app as Record<string, unknown>).id],
|
||||
WHERE permission = 'flag:app-is-authenticated' AND app_id = ?`,
|
||||
[(app as { id: number }).id],
|
||||
)) as Array<{ n: number }>;
|
||||
|
||||
res.json({ count: row?.n ?? 0 });
|
||||
},
|
||||
);
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
extension.registerDriver('appTelemetry', AppTelemetryDriver);
|
||||
@@ -1,11 +1,11 @@
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { PuterService } from '@heyputer/backend/src/services/types.js';
|
||||
import { nativeImport } from '@heyputer/backend/src/util/nativeImport.js';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { PuterService } from '@heyputer/backend/src/services/types.js';
|
||||
import { nativeImport } from '@heyputer/backend/src/util/nativeImport.js';
|
||||
|
||||
const requireFromHere = createRequire(__filename);
|
||||
const webpack = requireFromHere('webpack') as typeof import('webpack');
|
||||
@@ -121,9 +121,7 @@ const defaultWebpackEntries: WebpackEntry[] = [
|
||||
|
||||
class DevWatcherService extends PuterService {
|
||||
#processes: Array<{ name: string; proc: ChildProcess }> = [];
|
||||
#watchers: Array<{
|
||||
close: (handler: (err?: Error | null) => void) => void;
|
||||
}> = [];
|
||||
#watchers: ReturnType<ReturnType<typeof webpack>['watch']>[] = [];
|
||||
#started = false;
|
||||
#packageRoot = findPackageRoot();
|
||||
|
||||
@@ -159,7 +157,7 @@ class DevWatcherService extends PuterService {
|
||||
this.#watchers.map(
|
||||
(watcher) =>
|
||||
new Promise<void>((resolve) => {
|
||||
watcher.close((err) => {
|
||||
watcher?.close((err) => {
|
||||
if (err) {
|
||||
console.warn(
|
||||
'[devwatch] failed to close webpack watcher:',
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||||
import { handleInstalledApps } from './installedApps.ts';
|
||||
|
||||
interface CapturedResponse {
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
const makeReq = (query: Record<string, unknown> = {}): Request =>
|
||||
({ query }) as unknown as Request;
|
||||
|
||||
const makeRes = () => {
|
||||
const captured: CapturedResponse = { body: undefined };
|
||||
const res = {
|
||||
json: vi.fn((value: unknown) => {
|
||||
captured.body = value;
|
||||
return res;
|
||||
}),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
};
|
||||
|
||||
let server: PuterServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
const seedUser = async () => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
return server.stores.user.create({
|
||||
username: `iauser_${slug}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
};
|
||||
|
||||
const seedApp = async (ownerUserId: number) => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
return server.stores.app.create(
|
||||
{
|
||||
name: `iaapp_${slug}`,
|
||||
title: `Installed App ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
},
|
||||
{ ownerUserId },
|
||||
);
|
||||
};
|
||||
|
||||
const grantInstalled = async (appId: number, userId: number) => {
|
||||
// Mimic the `flag:app-is-authenticated` perm row the handler joins on.
|
||||
await server.clients.db.write(
|
||||
`INSERT INTO user_to_app_permissions (user_id, app_id, permission, extra) VALUES (?, ?, ?, ?)`,
|
||||
[userId, appId, 'flag:app-is-authenticated', null],
|
||||
);
|
||||
};
|
||||
|
||||
describe('installedApps extension — handleInstalledApps', () => {
|
||||
it('throws HttpError(401) when no actor is on the context', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
runWithContext({ actor: undefined }, () =>
|
||||
handleInstalledApps(makeReq({}), res),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
});
|
||||
|
||||
it('throws HttpError(400) when orderBy is not in the allowlist', async () => {
|
||||
const user = await seedUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
runWithContext(
|
||||
{
|
||||
actor: {
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
},
|
||||
},
|
||||
() =>
|
||||
handleInstalledApps(
|
||||
// SQL injection attempt — must be rejected.
|
||||
makeReq({ orderBy: 'apps.id; DROP TABLE apps;--' }),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('Invalid orderBy'),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty list for a user with no installed apps', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleInstalledApps(makeReq({}), res),
|
||||
);
|
||||
|
||||
expect(captured.body).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns the caller’s installed apps with an iconUrl field', async () => {
|
||||
const user = await seedUser();
|
||||
const app = await seedApp(user.id as number);
|
||||
await grantInstalled(app!.id as number, user.id as number);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleInstalledApps(makeReq({}), res),
|
||||
);
|
||||
|
||||
const list = captured.body as Array<Record<string, unknown>>;
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].uid).toBe(app!.uid);
|
||||
expect(list[0].name).toBe(app!.name);
|
||||
expect(list[0].title).toBe(app!.title);
|
||||
// index_url is required so the dashboard can derive a hostname title
|
||||
// for anonymous (app-…) apps.
|
||||
expect(list[0].index_url).toBe(app!.index_url);
|
||||
expect(Object.prototype.hasOwnProperty.call(list[0], 'iconUrl')).toBe(
|
||||
true,
|
||||
);
|
||||
// An owned app is not external, and the raw owner id must not leak.
|
||||
expect(list[0].external).toBe(false);
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('flags apps with no owner_user_id as external', async () => {
|
||||
const user = await seedUser();
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
// createFromOrigin bootstraps an app with owner_user_id = null.
|
||||
const app = await server.stores.app.createFromOrigin(
|
||||
`app-${slug}`,
|
||||
`https://external-${slug}.example.com`,
|
||||
);
|
||||
await grantInstalled(app!.id as number, user.id as number);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleInstalledApps(makeReq({}), res),
|
||||
);
|
||||
|
||||
const list = captured.body as Array<Record<string, unknown>>;
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].external).toBe(true);
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('clamps page/limit to safe ranges (page>=1, 1<=limit<=100)', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
// page=0 and limit=999 should be clamped without throwing.
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() =>
|
||||
handleInstalledApps(
|
||||
makeReq({ page: 0, limit: 999, desc: '1' }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
|
||||
expect(Array.isArray(captured.body)).toBe(true);
|
||||
});
|
||||
});
|
||||
+45
-33
@@ -1,3 +1,4 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
@@ -20,38 +21,36 @@ const ORDER_BY_FIELD_MAP: Record<string, string> = {
|
||||
installed_at: 'installed_at',
|
||||
};
|
||||
|
||||
extension.get(
|
||||
'/installedApps',
|
||||
{ subdomain: 'api', requireUserActor: true },
|
||||
async (req, res) => {
|
||||
const actor = Context.get('actor');
|
||||
if (!actor?.user?.id)
|
||||
throw new HttpError(401, 'Authentication required');
|
||||
export const handleInstalledApps = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const actor = Context.get('actor');
|
||||
if (!actor?.user?.id) throw new HttpError(401, 'Authentication required');
|
||||
|
||||
const orderBy = String(req.query.orderBy ?? 'installed_at');
|
||||
if (!(ALLOWED_ORDER_BY as readonly string[]).includes(orderBy)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Invalid orderBy. Allowed: ${ALLOWED_ORDER_BY.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const page = Math.max(Number(req.query.page) || 1, 1);
|
||||
const limit = Math.min(
|
||||
Math.max(Number(req.query.limit) || 100, 1),
|
||||
100,
|
||||
const orderBy = String(req.query.orderBy ?? 'installed_at');
|
||||
if (!(ALLOWED_ORDER_BY as readonly string[]).includes(orderBy)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Invalid orderBy. Allowed: ${ALLOWED_ORDER_BY.join(', ')}`,
|
||||
);
|
||||
const offset = (page - 1) * limit;
|
||||
const orderByField = ORDER_BY_FIELD_MAP[orderBy];
|
||||
const sortDirection = req.query.desc ? 'DESC' : 'ASC';
|
||||
}
|
||||
|
||||
const installedApps = (await clients.db.read(
|
||||
`SELECT
|
||||
const page = Math.max(Number(req.query.page) || 1, 1);
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 100);
|
||||
const offset = (page - 1) * limit;
|
||||
const orderByField = ORDER_BY_FIELD_MAP[orderBy];
|
||||
const sortDirection = req.query.desc ? 'DESC' : 'ASC';
|
||||
|
||||
const installedApps = (await clients.db.read(
|
||||
`SELECT
|
||||
apps.name,
|
||||
apps.uid,
|
||||
apps.title,
|
||||
apps.description,
|
||||
apps.icon,
|
||||
apps.index_url,
|
||||
apps.owner_user_id,
|
||||
MIN(perm.dt) AS installed_at
|
||||
FROM apps
|
||||
LEFT JOIN user_to_app_permissions AS perm ON apps.id = perm.app_id
|
||||
@@ -60,15 +59,28 @@ extension.get(
|
||||
ORDER BY ${orderByField} ${sortDirection}
|
||||
LIMIT ?
|
||||
OFFSET ?`,
|
||||
[actor.user.id, limit, offset],
|
||||
)) as Array<Record<string, unknown>>;
|
||||
[actor.user.id, limit, offset],
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
const apiBaseUrl = extension.config.api_base_url as string | undefined;
|
||||
res.json(
|
||||
installedApps.map((app) => ({
|
||||
...app,
|
||||
const apiBaseUrl = extension.config.api_base_url as string | undefined;
|
||||
res.json(
|
||||
installedApps.map((app) => {
|
||||
// An app with no owner_user_id (null/empty) isn't owned by a Puter
|
||||
// user — it's an "external" app. Derive a flag and don't leak the
|
||||
// raw owner id to the client.
|
||||
const { owner_user_id, ...rest } = app;
|
||||
const external = owner_user_id == null || owner_user_id === '';
|
||||
return {
|
||||
...rest,
|
||||
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
|
||||
})),
|
||||
);
|
||||
},
|
||||
external,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
extension.get(
|
||||
'/installedApps',
|
||||
{ subdomain: 'api', requireUserActor: true, allowFullAccessToken: true },
|
||||
handleInstalledApps,
|
||||
);
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||||
import {
|
||||
handleMeteringAllCosts,
|
||||
handleMeteringGlobalUsage,
|
||||
handleMeteringUsage,
|
||||
handleMeteringUsageForApp,
|
||||
} from './metering.ts';
|
||||
|
||||
interface CapturedResponse {
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
const makeReq = (
|
||||
init: { params?: Record<string, unknown> } = {},
|
||||
): Request =>
|
||||
({
|
||||
params: init.params ?? {},
|
||||
query: {},
|
||||
}) as unknown as Request;
|
||||
|
||||
const makeRes = () => {
|
||||
const captured: CapturedResponse = { body: undefined };
|
||||
const res = {
|
||||
json: vi.fn((value: unknown) => {
|
||||
captured.body = value;
|
||||
return res;
|
||||
}),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
};
|
||||
|
||||
let server: PuterServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
const seedUser = async () => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
return server.stores.user.create({
|
||||
username: `muser_${slug}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
};
|
||||
|
||||
describe('metering extension — handleMeteringUsage', () => {
|
||||
it('throws HttpError(401) when no user actor is on the context', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
runWithContext({ actor: undefined }, () =>
|
||||
handleMeteringUsage(makeReq(), res),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
});
|
||||
|
||||
it('returns usage details merged with allowanceInfo for an authenticated user', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleMeteringUsage(makeReq(), res),
|
||||
);
|
||||
|
||||
// We don't assert the inner shape (provider-specific) — only that
|
||||
// the handler returned a JSON object that carries `allowanceInfo`.
|
||||
expect(typeof captured.body).toBe('object');
|
||||
expect(captured.body).not.toBeNull();
|
||||
expect(
|
||||
(captured.body as Record<string, unknown>).allowanceInfo,
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('metering extension — handleMeteringUsageForApp', () => {
|
||||
it('throws HttpError(401) when no user actor is on the context', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
runWithContext({ actor: undefined }, () =>
|
||||
handleMeteringUsageForApp(
|
||||
makeReq({ params: { appIdOrName: 'any' } }),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
});
|
||||
|
||||
it('throws HttpError(400) when no appId is supplied', async () => {
|
||||
const user = await seedUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() =>
|
||||
handleMeteringUsageForApp(
|
||||
makeReq({ params: { appIdOrName: '' } }),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws HttpError(404) when looking up an unknown app by name', async () => {
|
||||
const user = await seedUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() =>
|
||||
handleMeteringUsageForApp(
|
||||
makeReq({ params: { appIdOrName: 'no-such-app' } }),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('metering extension — handleMeteringGlobalUsage', () => {
|
||||
it('returns the global usage payload from MeteringService', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await handleMeteringGlobalUsage(makeReq(), res);
|
||||
// Just confirm a JSON body was returned. Inner shape comes from
|
||||
// MeteringService and is covered elsewhere.
|
||||
expect(captured.body).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('metering extension — handleMeteringAllCosts', () => {
|
||||
it('returns a { costs: [...] } payload', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await handleMeteringAllCosts(makeReq(), res);
|
||||
const body = captured.body as { costs: unknown };
|
||||
expect(Array.isArray(body.costs)).toBe(true);
|
||||
});
|
||||
|
||||
it('caches the costs catalogue across calls (same array reference)', async () => {
|
||||
const a = makeRes();
|
||||
const b = makeRes();
|
||||
await handleMeteringAllCosts(makeReq(), a.res);
|
||||
await handleMeteringAllCosts(makeReq(), b.res);
|
||||
|
||||
const costsA = (a.captured.body as { costs: unknown[] }).costs;
|
||||
const costsB = (b.captured.body as { costs: unknown[] }).costs;
|
||||
// Cache fields the same array instance — this is the property we
|
||||
// actually want to lock down (no rewalk of every driver/controller
|
||||
// per request).
|
||||
expect(costsA).toBe(costsB);
|
||||
});
|
||||
});
|
||||
+69
-48
@@ -5,6 +5,7 @@ import {
|
||||
driversContainers,
|
||||
} from '@heyputer/backend/src/exports';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
const services = extension.import('service');
|
||||
const clients = extension.import('client');
|
||||
@@ -45,70 +46,90 @@ function collectAllCosts(): Record<string, unknown>[] {
|
||||
return all;
|
||||
}
|
||||
|
||||
export const handleMeteringUsage = async (
|
||||
_req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const actor = Context.get('actor');
|
||||
if (!actor?.user) throw new HttpError(401, 'Authentication required');
|
||||
|
||||
const [actorUsage, allowanceInfo] = await Promise.all([
|
||||
services.metering.getActorCurrentMonthUsageDetails(actor),
|
||||
services.metering.getAllowedUsage(actor),
|
||||
]);
|
||||
res.json({ ...actorUsage, allowanceInfo });
|
||||
};
|
||||
|
||||
export const handleMeteringUsageForApp = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const actor = Context.get('actor');
|
||||
if (!actor?.user) throw new HttpError(401, 'Authentication required');
|
||||
|
||||
let appId = String(req.params.appIdOrName ?? '');
|
||||
if (!appId) throw new HttpError(400, 'appId parameter is required');
|
||||
|
||||
// If not a UUID-shaped app UID, look up by name
|
||||
if (!appId.startsWith('app-')) {
|
||||
const appRows = (await clients.db.read(
|
||||
'SELECT `uid` FROM `apps` WHERE `name` = ? LIMIT 1',
|
||||
[appId],
|
||||
)) as Array<{ uid: string }>;
|
||||
if (appRows.length > 0) {
|
||||
appId = appRows[0].uid;
|
||||
} else {
|
||||
throw new HttpError(404, 'App not found');
|
||||
}
|
||||
}
|
||||
|
||||
const appUsage =
|
||||
await services.metering.getActorCurrentMonthAppUsageDetails(
|
||||
actor,
|
||||
appId,
|
||||
);
|
||||
res.json(appUsage);
|
||||
};
|
||||
|
||||
export const handleMeteringGlobalUsage = async (
|
||||
_req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const globalUsage = await services.metering.getGlobalUsage();
|
||||
res.json(globalUsage);
|
||||
};
|
||||
|
||||
// First hit walks the registries; subsequent hits serve the in-memory cache.
|
||||
export const handleMeteringAllCosts = async (
|
||||
_req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
if (!cachedAllCosts) {
|
||||
cachedAllCosts = collectAllCosts();
|
||||
}
|
||||
res.json({ costs: cachedAllCosts });
|
||||
};
|
||||
|
||||
extension.get(
|
||||
'/metering/usage',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
async (req, res) => {
|
||||
const actor = Context.get('actor');
|
||||
if (!actor?.user) throw new HttpError(401, 'Authentication required');
|
||||
|
||||
const [actorUsage, allowanceInfo] = await Promise.all([
|
||||
services.metering.getActorCurrentMonthUsageDetails(actor),
|
||||
services.metering.getAllowedUsage(actor),
|
||||
]);
|
||||
res.json({ ...actorUsage, allowanceInfo });
|
||||
},
|
||||
handleMeteringUsage,
|
||||
);
|
||||
|
||||
extension.get(
|
||||
'/metering/usage/:appIdOrName',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
async (req, res) => {
|
||||
const actor = Context.get('actor');
|
||||
if (!actor?.user) throw new HttpError(401, 'Authentication required');
|
||||
|
||||
let appId = String(req.params.appIdOrName ?? '');
|
||||
if (!appId) throw new HttpError(400, 'appId parameter is required');
|
||||
|
||||
// If not a UUID-shaped app UID, look up by name
|
||||
if (!appId.startsWith('app-')) {
|
||||
const appRows = (await clients.db.read(
|
||||
'SELECT `uid` FROM `apps` WHERE `name` = ? LIMIT 1',
|
||||
[appId],
|
||||
)) as Array<{ uid: string }>;
|
||||
if (appRows.length > 0) {
|
||||
appId = appRows[0].uid;
|
||||
} else {
|
||||
throw new HttpError(404, 'App not found');
|
||||
}
|
||||
}
|
||||
|
||||
const appUsage =
|
||||
await services.metering.getActorCurrentMonthAppUsageDetails(
|
||||
actor,
|
||||
appId,
|
||||
);
|
||||
res.json(appUsage);
|
||||
},
|
||||
handleMeteringUsageForApp,
|
||||
);
|
||||
|
||||
extension.get(
|
||||
'/metering/globalUsage',
|
||||
{ subdomain: 'api', adminOnly: true },
|
||||
async (_req, res) => {
|
||||
const globalUsage = await services.metering.getGlobalUsage();
|
||||
res.json(globalUsage);
|
||||
},
|
||||
handleMeteringGlobalUsage,
|
||||
);
|
||||
|
||||
// First hit walks the registries; subsequent hits serve the in-memory cache.
|
||||
extension.get(
|
||||
'/metering/allCosts',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
async (_req, res) => {
|
||||
if (!cachedAllCosts) {
|
||||
cachedAllCosts = collectAllCosts();
|
||||
}
|
||||
res.json({ costs: cachedAllCosts });
|
||||
},
|
||||
handleMeteringAllCosts,
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "commonjs"
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { handleServerInfo } from './serverInfo.ts';
|
||||
|
||||
interface CapturedResponse {
|
||||
body: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
const makeRes = () => {
|
||||
const captured: CapturedResponse = { body: undefined };
|
||||
const res = {
|
||||
json: vi.fn((value: Record<string, unknown>) => {
|
||||
captured.body = value;
|
||||
return res;
|
||||
}),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
};
|
||||
|
||||
describe('serverInfo extension — handleServerInfo', () => {
|
||||
it('returns the full server info payload', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await handleServerInfo({} as Request, res);
|
||||
|
||||
expect(captured.body).toBeDefined();
|
||||
const body = captured.body!;
|
||||
|
||||
// OS section
|
||||
const os = body.os as Record<string, unknown>;
|
||||
expect(typeof os.platform).toBe('string');
|
||||
expect(typeof os.type).toBe('string');
|
||||
expect(typeof os.release).toBe('string');
|
||||
expect(os.pretty).toBe(`${os.type} ${os.release}`);
|
||||
|
||||
// CPU section
|
||||
const cpu = body.cpu as Record<string, unknown>;
|
||||
expect(typeof cpu.model).toBe('string');
|
||||
expect(typeof cpu.cores).toBe('number');
|
||||
expect((cpu.cores as number) > 0).toBe(true);
|
||||
|
||||
// RAM — totalGB / freeGB are stringified two-decimal values
|
||||
const ram = body.ram as Record<string, unknown>;
|
||||
expect(typeof ram.total).toBe('number');
|
||||
expect(typeof ram.free).toBe('number');
|
||||
expect(ram.totalGB).toMatch(/^\d+\.\d{2}$/);
|
||||
expect(ram.freeGB).toMatch(/^\d+\.\d{2}$/);
|
||||
|
||||
// Uptime fields are numeric (seconds/days/hours/minutes) plus pretty.
|
||||
const uptime = body.uptime as Record<string, unknown>;
|
||||
expect(typeof uptime.seconds).toBe('number');
|
||||
expect(typeof uptime.days).toBe('number');
|
||||
expect(typeof uptime.hours).toBe('number');
|
||||
expect(typeof uptime.minutes).toBe('number');
|
||||
expect(uptime.pretty).toMatch(/^\d+d \d+h \d+m$/);
|
||||
|
||||
// Disk may fall through to N/A when statfs throws (e.g. unsupported
|
||||
// platforms), so accept either the success shape or the fallback.
|
||||
const disk = body.disk as Record<string, unknown>;
|
||||
expect(['N/A']).toContain(disk.total === 'N/A' ? 'N/A' : 'N/A'); // keep shape-only assertion
|
||||
expect(typeof disk.total).toBe('string');
|
||||
expect(typeof disk.free).toBe('string');
|
||||
expect(typeof disk.used).toBe('string');
|
||||
|
||||
expect(Array.isArray(body.loadavg)).toBe(true);
|
||||
expect(typeof body.hostname).toBe('string');
|
||||
});
|
||||
|
||||
it('falls back to N/A disk stats when statfs throws', async () => {
|
||||
const fs = await import('node:fs/promises');
|
||||
const statfsSpy = vi
|
||||
.spyOn(fs.default, 'statfs')
|
||||
.mockRejectedValue(new Error('statfs unsupported'));
|
||||
const errSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await handleServerInfo({} as Request, res);
|
||||
|
||||
const disk = (captured.body as Record<string, unknown>).disk as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
expect(disk).toEqual({ total: 'N/A', free: 'N/A', used: 'N/A' });
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
|
||||
statfsSpy.mockRestore();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
+65
-59
@@ -1,67 +1,73 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
|
||||
export const handleServerInfo = async (
|
||||
_req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const osData = {
|
||||
platform: os.platform(),
|
||||
type: os.type(),
|
||||
release: os.release(),
|
||||
pretty: `${os.type()} ${os.release()}`,
|
||||
};
|
||||
|
||||
const cpus = os.cpus();
|
||||
const cpuData = {
|
||||
model: cpus[0]?.model || 'Unknown',
|
||||
cores: cpus.length,
|
||||
};
|
||||
|
||||
const ramData = {
|
||||
total: os.totalmem(),
|
||||
free: os.freemem(),
|
||||
totalGB: (os.totalmem() / 1073741824).toFixed(2),
|
||||
freeGB: (os.freemem() / 1073741824).toFixed(2),
|
||||
};
|
||||
|
||||
const uptimeSeconds = os.uptime();
|
||||
const uptimeData = {
|
||||
seconds: uptimeSeconds,
|
||||
days: Math.floor(uptimeSeconds / 86400),
|
||||
hours: Math.floor((uptimeSeconds % 86400) / 3600),
|
||||
minutes: Math.floor((uptimeSeconds % 3600) / 60),
|
||||
pretty: `${Math.floor(uptimeSeconds / 86400)}d ${Math.floor((uptimeSeconds % 86400) / 3600)}h ${Math.floor((uptimeSeconds % 3600) / 60)}m`,
|
||||
};
|
||||
|
||||
let diskData: Record<string, string> = {
|
||||
total: 'N/A',
|
||||
free: 'N/A',
|
||||
used: 'N/A',
|
||||
};
|
||||
try {
|
||||
const stats = await fs.statfs('/');
|
||||
const totalGB = (stats.blocks * stats.bsize) / 1073741824;
|
||||
const freeGB = (stats.bfree * stats.bsize) / 1073741824;
|
||||
const usedGB = (totalGB - freeGB).toFixed(2);
|
||||
diskData = {
|
||||
total: totalGB.toFixed(2),
|
||||
free: freeGB.toFixed(2),
|
||||
used: usedGB,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Disk stats error:', err);
|
||||
}
|
||||
|
||||
res.json({
|
||||
os: osData,
|
||||
cpu: cpuData,
|
||||
ram: ramData,
|
||||
uptime: uptimeData,
|
||||
disk: diskData,
|
||||
loadavg: os.loadavg(),
|
||||
hostname: os.hostname(),
|
||||
});
|
||||
};
|
||||
|
||||
extension.get(
|
||||
'/serverInfo',
|
||||
{ subdomain: 'api', adminOnly: true },
|
||||
async (_req, res) => {
|
||||
const osData = {
|
||||
platform: os.platform(),
|
||||
type: os.type(),
|
||||
release: os.release(),
|
||||
pretty: `${os.type()} ${os.release()}`,
|
||||
};
|
||||
|
||||
const cpus = os.cpus();
|
||||
const cpuData = {
|
||||
model: cpus[0]?.model || 'Unknown',
|
||||
cores: cpus.length,
|
||||
};
|
||||
|
||||
const ramData = {
|
||||
total: os.totalmem(),
|
||||
free: os.freemem(),
|
||||
totalGB: (os.totalmem() / 1073741824).toFixed(2),
|
||||
freeGB: (os.freemem() / 1073741824).toFixed(2),
|
||||
};
|
||||
|
||||
const uptimeSeconds = os.uptime();
|
||||
const uptimeData = {
|
||||
seconds: uptimeSeconds,
|
||||
days: Math.floor(uptimeSeconds / 86400),
|
||||
hours: Math.floor((uptimeSeconds % 86400) / 3600),
|
||||
minutes: Math.floor((uptimeSeconds % 3600) / 60),
|
||||
pretty: `${Math.floor(uptimeSeconds / 86400)}d ${Math.floor((uptimeSeconds % 86400) / 3600)}h ${Math.floor((uptimeSeconds % 3600) / 60)}m`,
|
||||
};
|
||||
|
||||
let diskData: Record<string, string> = {
|
||||
total: 'N/A',
|
||||
free: 'N/A',
|
||||
used: 'N/A',
|
||||
};
|
||||
try {
|
||||
const stats = await fs.statfs('/');
|
||||
const totalGB = (stats.blocks * stats.bsize) / 1073741824;
|
||||
const freeGB = (stats.bfree * stats.bsize) / 1073741824;
|
||||
const usedGB = (totalGB - freeGB).toFixed(2);
|
||||
diskData = {
|
||||
total: totalGB.toFixed(2),
|
||||
free: freeGB.toFixed(2),
|
||||
used: usedGB,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Disk stats error:', err);
|
||||
}
|
||||
|
||||
res.json({
|
||||
os: osData,
|
||||
cpu: cpuData,
|
||||
ram: ramData,
|
||||
uptime: uptimeData,
|
||||
disk: diskData,
|
||||
loadavg: os.loadavg(),
|
||||
hostname: os.hostname(),
|
||||
});
|
||||
},
|
||||
handleServerInfo,
|
||||
);
|
||||
@@ -0,0 +1,320 @@
|
||||
import {
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
type S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||||
import {
|
||||
handleFsRemoveNodeThumbnail,
|
||||
handleThumbnailCreated,
|
||||
handleThumbnailRead,
|
||||
handleThumbnailUploadPrepare,
|
||||
} from './thumbnails.ts';
|
||||
|
||||
// 1x1 transparent PNG — smallest valid image sharp will accept.
|
||||
const TINY_PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
||||
|
||||
const BUCKET = 'puter-local';
|
||||
|
||||
const streamToBuffer = async (
|
||||
body: { transformToByteArray: () => Promise<Uint8Array> } | undefined,
|
||||
): Promise<Buffer> => {
|
||||
if (!body) throw new Error('s3 GetObject returned no body');
|
||||
return Buffer.from(await body.transformToByteArray());
|
||||
};
|
||||
|
||||
describe('thumbnails extension — handleThumbnailCreated', () => {
|
||||
let server: PuterServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
it('uploads a valid data: URL thumbnail to S3 and rewrites event.url to an s3:// pointer', async () => {
|
||||
const s3 = server.clients.s3.get();
|
||||
const event: Record<string, unknown> = {
|
||||
url: `data:image/png;base64,${TINY_PNG_BASE64}`,
|
||||
};
|
||||
|
||||
await handleThumbnailCreated(event, { s3, bucketName: BUCKET });
|
||||
|
||||
expect(typeof event.url).toBe('string');
|
||||
const newUrl = event.url as string;
|
||||
expect(newUrl.startsWith(`s3://${BUCKET}/`)).toBe(true);
|
||||
|
||||
const key = newUrl.slice(`s3://${BUCKET}/`.length);
|
||||
const obj = await s3.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: key }),
|
||||
);
|
||||
expect(obj.ContentType).toBe('image/png');
|
||||
|
||||
const expected = Buffer.from(TINY_PNG_BASE64, 'base64');
|
||||
const actual = await streamToBuffer(obj.Body as never);
|
||||
expect(actual.equals(expected)).toBe(true);
|
||||
});
|
||||
|
||||
it('sets event.url to null when the data: URL does not decode to a valid image', async () => {
|
||||
const s3 = server.clients.s3.get();
|
||||
const event: Record<string, unknown> = {
|
||||
url: `data:image/png;base64,${Buffer.from('not an image').toString('base64')}`,
|
||||
};
|
||||
|
||||
await handleThumbnailCreated(event, { s3, bucketName: BUCKET });
|
||||
|
||||
expect(event.url).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves event.url untouched when the URL is not a data: URL', async () => {
|
||||
const s3 = server.clients.s3.get();
|
||||
const original = 'https://example.com/thumb.png';
|
||||
const event: Record<string, unknown> = { url: original };
|
||||
|
||||
await handleThumbnailCreated(event, { s3, bucketName: BUCKET });
|
||||
|
||||
expect(event.url).toBe(original);
|
||||
});
|
||||
|
||||
it('returns without writing to S3 when event.url is missing', async () => {
|
||||
const s3 = server.clients.s3.get();
|
||||
const event: Record<string, unknown> = {};
|
||||
|
||||
await handleThumbnailCreated(event, { s3, bucketName: BUCKET });
|
||||
|
||||
expect(event.url).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('thumbnails extension — handleThumbnailUploadPrepare', () => {
|
||||
let server: PuterServer;
|
||||
let s3Presign: S3Client;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
s3Presign = server.clients.s3.getForPresign();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
it('returns early when event has no items array', async () => {
|
||||
const event: Record<string, unknown> = {};
|
||||
await handleThumbnailUploadPrepare(event, {
|
||||
s3Presign,
|
||||
bucketName: BUCKET,
|
||||
});
|
||||
// No items property added — handler is a no-op.
|
||||
expect(event).toEqual({});
|
||||
});
|
||||
|
||||
it('throws when items array contains a non-object entry', async () => {
|
||||
await expect(
|
||||
handleThumbnailUploadPrepare(
|
||||
{ items: ['not-an-object'] } as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
{ s3Presign, bucketName: BUCKET },
|
||||
),
|
||||
).rejects.toThrow('thumbnail.upload.prepare item is invalid');
|
||||
});
|
||||
|
||||
it('skips items without a contentType (no upload URL minted)', async () => {
|
||||
const item: Record<string, unknown> = { contentType: '' };
|
||||
await handleThumbnailUploadPrepare(
|
||||
{ items: [item] },
|
||||
{ s3Presign, bucketName: BUCKET },
|
||||
);
|
||||
expect(item.uploadUrl).toBeUndefined();
|
||||
expect(item.thumbnailUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips items whose size exceeds the max thumbnail bytes', async () => {
|
||||
const item: Record<string, unknown> = {
|
||||
contentType: 'image/png',
|
||||
size: 999_999_999,
|
||||
};
|
||||
await handleThumbnailUploadPrepare(
|
||||
{ items: [item] },
|
||||
{ s3Presign, bucketName: BUCKET },
|
||||
);
|
||||
expect(item.uploadUrl).toBeUndefined();
|
||||
expect(item.thumbnailUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('mints a presigned uploadUrl and an s3:// thumbnailUrl for valid items', async () => {
|
||||
const item: Record<string, unknown> = {
|
||||
contentType: 'image/png',
|
||||
size: 1024,
|
||||
};
|
||||
await handleThumbnailUploadPrepare(
|
||||
{ items: [item] },
|
||||
{ s3Presign, bucketName: BUCKET },
|
||||
);
|
||||
expect(typeof item.uploadUrl).toBe('string');
|
||||
expect((item.uploadUrl as string).startsWith('http')).toBe(true);
|
||||
expect(typeof item.thumbnailUrl).toBe('string');
|
||||
expect(
|
||||
(item.thumbnailUrl as string).startsWith(`s3://${BUCKET}/`),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('thumbnails extension — handleThumbnailRead', () => {
|
||||
let server: PuterServer;
|
||||
let s3: S3Client;
|
||||
let s3Presign: S3Client;
|
||||
|
||||
const stubDb = { write: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
s3 = server.clients.s3.get();
|
||||
s3Presign = server.clients.s3.getForPresign();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
it('rewrites an s3:// thumbnail into a presigned https URL', async () => {
|
||||
// Seed an object so the presigned URL points at something real
|
||||
// (the signer itself doesn't validate existence, but this keeps
|
||||
// the test honest).
|
||||
const key = 'thumb-read-test';
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: key,
|
||||
Body: Buffer.from(TINY_PNG_BASE64, 'base64'),
|
||||
ContentType: 'image/png',
|
||||
}),
|
||||
);
|
||||
|
||||
const entry: Record<string, unknown> = {
|
||||
thumbnail: `s3://${BUCKET}/${key}`,
|
||||
};
|
||||
await handleThumbnailRead(entry, {
|
||||
s3,
|
||||
s3Presign,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
|
||||
db: stubDb,
|
||||
});
|
||||
|
||||
expect(typeof entry.thumbnail).toBe('string');
|
||||
expect((entry.thumbnail as string).startsWith('http')).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the thumbnail untouched when not s3/https/data', async () => {
|
||||
const entry: Record<string, unknown> = { thumbnail: 'about:blank' };
|
||||
await handleThumbnailRead(entry, {
|
||||
s3,
|
||||
s3Presign,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
|
||||
db: stubDb,
|
||||
});
|
||||
expect(entry.thumbnail).toBe('about:blank');
|
||||
});
|
||||
|
||||
it('returns early when the thumbnail is missing or non-string', async () => {
|
||||
const entry: Record<string, unknown> = {};
|
||||
await handleThumbnailRead(entry, {
|
||||
s3,
|
||||
s3Presign,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
|
||||
db: stubDb,
|
||||
});
|
||||
expect(entry.thumbnail).toBeUndefined();
|
||||
});
|
||||
|
||||
it('migrates an inline data: URL by uploading to S3 and updating the DB row', async () => {
|
||||
const entry: Record<string, unknown> = {
|
||||
uuid: 'fs-entry-uuid',
|
||||
thumbnail: `data:image/png;base64,${TINY_PNG_BASE64}`,
|
||||
};
|
||||
|
||||
await handleThumbnailRead(entry, {
|
||||
s3,
|
||||
s3Presign,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
|
||||
db: stubDb,
|
||||
});
|
||||
|
||||
// The handler should have replaced the data URL with a signed
|
||||
// S3 URL and kicked off the DB migration write.
|
||||
expect(typeof entry.thumbnail).toBe('string');
|
||||
expect((entry.thumbnail as string).startsWith('http')).toBe(true);
|
||||
// Allow the best-effort write microtask to settle.
|
||||
await Promise.resolve();
|
||||
expect(stubDb.write).toHaveBeenCalledWith(
|
||||
'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?',
|
||||
[expect.stringMatching(/^s3:\/\//), 'fs-entry-uuid'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('thumbnails extension — handleFsRemoveNodeThumbnail', () => {
|
||||
let server: PuterServer;
|
||||
let s3: S3Client;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
s3 = server.clients.s3.get();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
it('deletes the S3 object referenced by an s3:// thumbnail URL', async () => {
|
||||
const key = 'thumb-remove-test';
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: key,
|
||||
Body: Buffer.from(TINY_PNG_BASE64, 'base64'),
|
||||
ContentType: 'image/png',
|
||||
}),
|
||||
);
|
||||
|
||||
await handleFsRemoveNodeThumbnail(
|
||||
{ target: { thumbnail: `s3://${BUCKET}/${key}` } },
|
||||
{ s3 },
|
||||
);
|
||||
|
||||
// GetObject should now error because the key was deleted.
|
||||
await expect(
|
||||
s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key })),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('is a no-op when the target has no thumbnail', async () => {
|
||||
// Should not throw or attempt a delete.
|
||||
await handleFsRemoveNodeThumbnail({ target: {} }, { s3 });
|
||||
});
|
||||
|
||||
it('is a no-op when the thumbnail URL is not an s3:// pointer', async () => {
|
||||
await handleFsRemoveNodeThumbnail(
|
||||
{ target: { thumbnail: 'https://cdn.example.com/x.png' } },
|
||||
{ s3 },
|
||||
);
|
||||
});
|
||||
});
|
||||
+112
-67
@@ -95,11 +95,14 @@ async function decodeAndValidateThumbnail(
|
||||
return { mimeType, data };
|
||||
}
|
||||
|
||||
// ── thumbnail.created ───────────────────────────────────────────────
|
||||
// -- thumbnail.created -----------------------------------------------
|
||||
// Intercept data-URL thumbnails before they hit the DB: upload to S3
|
||||
// and replace the URL with an s3:// pointer.
|
||||
|
||||
extension.on('thumbnail.created', async (event: Record<string, unknown>) => {
|
||||
export async function handleThumbnailCreated(
|
||||
event: Record<string, unknown>,
|
||||
deps: { s3: S3Client; bucketName: string },
|
||||
): Promise<void> {
|
||||
const url = event.url;
|
||||
if (typeof url !== 'string' || !url.startsWith('data:')) return;
|
||||
|
||||
@@ -110,69 +113,70 @@ extension.on('thumbnail.created', async (event: Record<string, unknown>) => {
|
||||
}
|
||||
|
||||
const key = crypto.randomUUID();
|
||||
event.url = `s3://${thumbnailBucketName}/${key}`;
|
||||
event.url = `s3://${deps.bucketName}/${key}`;
|
||||
|
||||
await getClient().send(
|
||||
await deps.s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: thumbnailBucketName,
|
||||
Bucket: deps.bucketName,
|
||||
Key: key,
|
||||
Body: decoded.data,
|
||||
ContentType: decoded.mimeType,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── thumbnail.upload.prepare ────────────────────────────────────────
|
||||
// Generate pre-signed upload URLs so the client can PUT directly to S3.
|
||||
export const handleThumbnailUploadPrepare = async (
|
||||
event: Record<string, unknown>,
|
||||
deps: { s3Presign: S3Client; bucketName: string },
|
||||
): Promise<void> => {
|
||||
if (!event || !Array.isArray(event.items)) return;
|
||||
const presignClient = deps.s3Presign;
|
||||
|
||||
extension.on(
|
||||
'thumbnail.upload.prepare',
|
||||
async (event: Record<string, unknown>) => {
|
||||
if (!event || !Array.isArray(event.items)) return;
|
||||
const presignClient = getPresignClient();
|
||||
|
||||
for (const item of event.items as Array<Record<string, unknown>>) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
throw new Error('thumbnail.upload.prepare item is invalid');
|
||||
}
|
||||
|
||||
const contentType =
|
||||
typeof item.contentType === 'string'
|
||||
? item.contentType.trim()
|
||||
: '';
|
||||
if (!contentType) continue;
|
||||
|
||||
if (item.size !== undefined) {
|
||||
const size = Number(item.size);
|
||||
if (
|
||||
!Number.isFinite(size) ||
|
||||
size < 0 ||
|
||||
size > MAX_THUMBNAIL_BYTES
|
||||
)
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = crypto.randomUUID();
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: thumbnailBucketName,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
});
|
||||
item.uploadUrl = await getSignedUrl(presignClient, command, {
|
||||
expiresIn: 900,
|
||||
});
|
||||
item.thumbnailUrl = `s3://${thumbnailBucketName}/${key}`;
|
||||
for (const item of event.items as Array<Record<string, unknown>>) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
throw new Error('thumbnail.upload.prepare item is invalid');
|
||||
}
|
||||
|
||||
const contentType =
|
||||
typeof item.contentType === 'string' ? item.contentType.trim() : '';
|
||||
if (!contentType) continue;
|
||||
|
||||
if (item.size !== undefined) {
|
||||
const size = Number(item.size);
|
||||
if (
|
||||
!Number.isFinite(size) ||
|
||||
size < 0 ||
|
||||
size > MAX_THUMBNAIL_BYTES
|
||||
)
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = crypto.randomUUID();
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: deps.bucketName,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
});
|
||||
item.uploadUrl = await getSignedUrl(presignClient, command, {
|
||||
expiresIn: 900,
|
||||
});
|
||||
item.thumbnailUrl = `s3://${deps.bucketName}/${key}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const handleThumbnailRead = async (
|
||||
entry: Record<string, unknown>,
|
||||
deps: {
|
||||
s3: S3Client;
|
||||
s3Presign: S3Client;
|
||||
bucketName: string;
|
||||
bucketEndpoint: string;
|
||||
db: { write: (sql: string, params: unknown[]) => Promise<unknown> };
|
||||
},
|
||||
);
|
||||
|
||||
// ── thumbnail.read ──────────────────────────────────────────────────
|
||||
// Convert s3:// or legacy https:// thumbnails to signed URLs.
|
||||
|
||||
extension.on('thumbnail.read', async (entry: Record<string, unknown>) => {
|
||||
): Promise<void> => {
|
||||
const thumb = entry.thumbnail;
|
||||
if (typeof thumb !== 'string' || !thumb) return;
|
||||
const presignClient = getPresignClient();
|
||||
const presignClient = deps.s3Presign;
|
||||
|
||||
if (thumb.startsWith('s3://')) {
|
||||
const [bucket, key] = thumb.slice(5).split('/');
|
||||
@@ -183,7 +187,7 @@ extension.on('thumbnail.read', async (entry: Record<string, unknown>) => {
|
||||
);
|
||||
} else if (
|
||||
thumb.startsWith('https') &&
|
||||
thumb.includes(new URL(extensionBucketEndpoint).hostname)
|
||||
thumb.includes(new URL(deps.bucketEndpoint).hostname)
|
||||
) {
|
||||
// Legacy format — remove after full migration
|
||||
const [bucket, key] = new URL(thumb).pathname.slice(1).split('/');
|
||||
@@ -196,11 +200,11 @@ extension.on('thumbnail.read', async (entry: Record<string, unknown>) => {
|
||||
// Inline data-URL migration: upload to S3 and update the DB entry.
|
||||
const key = crypto.randomUUID();
|
||||
const { mimeType, data } = base64ParseDataUrl(thumb);
|
||||
const newUrl = `s3://${thumbnailBucketName}/${key}`;
|
||||
const newUrl = `s3://${deps.bucketName}/${key}`;
|
||||
|
||||
await getClient().send(
|
||||
await deps.s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: thumbnailBucketName,
|
||||
Bucket: deps.bucketName,
|
||||
Key: key,
|
||||
Body: data,
|
||||
ContentType: mimeType,
|
||||
@@ -210,7 +214,7 @@ extension.on('thumbnail.read', async (entry: Record<string, unknown>) => {
|
||||
// Best-effort async DB update
|
||||
const uuid = entry.uuid ?? entry.uid;
|
||||
if (uuid) {
|
||||
clients.db
|
||||
deps.db
|
||||
.write(
|
||||
'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?',
|
||||
[newUrl, uuid],
|
||||
@@ -222,24 +226,65 @@ extension.on('thumbnail.read', async (entry: Record<string, unknown>) => {
|
||||
|
||||
entry.thumbnail = await getSignedUrl(
|
||||
presignClient,
|
||||
new GetObjectCommand({ Bucket: thumbnailBucketName, Key: key }),
|
||||
new GetObjectCommand({ Bucket: deps.bucketName, Key: key }),
|
||||
{ expiresIn: 604800 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleFsRemoveNodeThumbnail = async (
|
||||
payload: { target: Record<string, unknown> },
|
||||
deps: { s3: S3Client },
|
||||
): Promise<void> => {
|
||||
const thumbnailUrl = payload.target.thumbnail as string | undefined;
|
||||
if (!thumbnailUrl || !thumbnailUrl.startsWith('s3://')) return;
|
||||
|
||||
const [bucket, key] = thumbnailUrl.slice(5).split('/');
|
||||
await deps.s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
|
||||
};
|
||||
|
||||
extension.on(
|
||||
'thumbnail.created',
|
||||
async (_key, event: Record<string, unknown>) => {
|
||||
await handleThumbnailCreated(event, {
|
||||
s3: getClient(),
|
||||
bucketName: thumbnailBucketName,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// -- thumbnail.upload.prepare ----------------------------------------
|
||||
// Generate pre-signed upload URLs so the client can PUT directly to S3.
|
||||
|
||||
extension.on(
|
||||
'thumbnail.upload.prepare',
|
||||
async (_key, event: Record<string, unknown>) => {
|
||||
await handleThumbnailUploadPrepare(event, {
|
||||
s3Presign: getPresignClient(),
|
||||
bucketName: thumbnailBucketName,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// -- thumbnail.read --------------------------------------------------
|
||||
// Convert s3:// or legacy https:// thumbnails to signed URLs.
|
||||
|
||||
extension.on('thumbnail.read', async (_key, entry: Record<string, unknown>) => {
|
||||
await handleThumbnailRead(entry, {
|
||||
s3: getClient(),
|
||||
s3Presign: getPresignClient(),
|
||||
bucketName: thumbnailBucketName,
|
||||
bucketEndpoint: extensionBucketEndpoint,
|
||||
db: clients.db,
|
||||
});
|
||||
});
|
||||
|
||||
// ── fs.remove.node ──────────────────────────────────────────────────
|
||||
// -- fs.remove.node --------------------------------------------------
|
||||
// Delete S3 thumbnail when the file is removed.
|
||||
|
||||
extension.on(
|
||||
'fs.remove.node',
|
||||
async ({ target }: { target: Record<string, unknown> }) => {
|
||||
const thumbnailUrl = target.thumbnail as string | undefined;
|
||||
if (!thumbnailUrl || !thumbnailUrl.startsWith('s3://')) return;
|
||||
|
||||
const [bucket, key] = thumbnailUrl.slice(5).split('/');
|
||||
await getClient().send(
|
||||
new DeleteObjectCommand({ Bucket: bucket, Key: key }),
|
||||
);
|
||||
async (_key, payload: { target: Record<string, unknown> }) => {
|
||||
await handleFsRemoveNodeThumbnail(payload, { s3: getClient() });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||||
import { handleWhoami } from './whoami.ts';
|
||||
|
||||
interface CapturedResponse {
|
||||
statusCode: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
const makeReq = (query: Record<string, unknown> = {}): Request =>
|
||||
({ query }) as unknown as Request;
|
||||
|
||||
const makeRes = () => {
|
||||
const captured: CapturedResponse = { statusCode: 200, body: undefined };
|
||||
const res = {
|
||||
json: vi.fn((value: unknown) => {
|
||||
captured.body = value;
|
||||
return res;
|
||||
}),
|
||||
status: vi.fn((code: number) => {
|
||||
captured.statusCode = code;
|
||||
return res;
|
||||
}),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
};
|
||||
|
||||
let server: PuterServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer({
|
||||
// Feature flag allowlist is enforced in the handler. We seed
|
||||
// both an allow-listed flag and an internal flag to verify the
|
||||
// internal one never reaches the response.
|
||||
feature_flags: {
|
||||
create_shortcut: true,
|
||||
payment_bypass: true,
|
||||
},
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
const seedUser = async () => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
return server.stores.user.create({
|
||||
username: `wuser_${slug}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'hashedpw',
|
||||
email: `${slug}@example.com`,
|
||||
});
|
||||
};
|
||||
|
||||
describe('whoami extension — handleWhoami', () => {
|
||||
it('returns 401 when no actor is on the context', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext({ actor: undefined }, () =>
|
||||
handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
expect(captured.statusCode).toBe(401);
|
||||
expect(captured.body).toEqual({ error: 'Authentication required' });
|
||||
});
|
||||
|
||||
it('returns 404 when the actor’s user no longer exists', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{
|
||||
actor: {
|
||||
user: { uuid: 'ghost-uuid', id: 99_999_999 },
|
||||
},
|
||||
},
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(captured.body).toEqual({ error: 'User not found' });
|
||||
});
|
||||
|
||||
it('returns full user details for a user actor', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.username).toBe(user.username);
|
||||
expect(body.uuid).toBe(user.uuid);
|
||||
expect(body.email).toBe(user.email);
|
||||
expect(body.is_temp).toBe(false);
|
||||
expect(body.oidc_only).toBe(false);
|
||||
// is_user_token is present (true) for user actors.
|
||||
expect(body.is_user_token).toBe(true);
|
||||
// `directories` is only sent to user actors — confirm it’s present.
|
||||
expect(body.directories).toBeDefined();
|
||||
// taskbar_items is only sent to user actors.
|
||||
expect(body).toHaveProperty('taskbar_items');
|
||||
});
|
||||
|
||||
it('only forwards allow-listed feature flags', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const flags = (captured.body as Record<string, unknown>)
|
||||
.feature_flags as Record<string, boolean>;
|
||||
// Allowed flag is forwarded as a coerced boolean.
|
||||
expect(flags.create_shortcut).toBe(true);
|
||||
// Internal flag must never leak.
|
||||
expect(flags.payment_bypass).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips desktop_bg_* and human_readable_age fields for app actors', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{
|
||||
actor: {
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
},
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.app_name).toBe('app-test-actor');
|
||||
// is_user_token is stripped for app actors.
|
||||
expect(body.is_user_token).toBeUndefined();
|
||||
expect(body.desktop_bg_url).toBeUndefined();
|
||||
expect(body.desktop_bg_color).toBeUndefined();
|
||||
expect(body.desktop_bg_fit).toBeUndefined();
|
||||
expect(body.human_readable_age).toBeUndefined();
|
||||
// Directories are user-only.
|
||||
expect(body.directories).toBeUndefined();
|
||||
});
|
||||
|
||||
it('redacts tmp_password from metadata for user actors', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.updateMetadata(user.id as number, {
|
||||
tmp_password: 'bootstrap-secret',
|
||||
hasDevAccountAccess: true,
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
const metadata = body.metadata as Record<string, unknown>;
|
||||
expect(metadata.tmp_password).toBeUndefined();
|
||||
// Other metadata keys still reach the user's own client.
|
||||
expect(metadata.hasDevAccountAccess).toBe(true);
|
||||
expect(body.hasDevAccountAccess).toBe(true);
|
||||
});
|
||||
|
||||
it('never sends user metadata to app actors', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.updateMetadata(user.id as number, {
|
||||
tmp_password: 'bootstrap-secret',
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{
|
||||
actor: {
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
},
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.metadata).toBeUndefined();
|
||||
expect(JSON.stringify(body)).not.toContain('bootstrap-secret');
|
||||
});
|
||||
|
||||
it('marks the user as oidc_only when password is null', async () => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const oidcUser = await server.stores.user.create({
|
||||
username: `oidc_${slug}`,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${slug}@oidc.test`,
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{
|
||||
actor: {
|
||||
user: {
|
||||
uuid: oidcUser.uuid,
|
||||
id: oidcUser.id as number,
|
||||
},
|
||||
},
|
||||
},
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.oidc_only).toBe(true);
|
||||
// No email yet means temp account.
|
||||
expect(body.is_temp).toBe(false);
|
||||
});
|
||||
});
|
||||
+172
-149
@@ -1,6 +1,7 @@
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { getTaskbarItems } from '@heyputer/backend/src/util/taskbarItems.js';
|
||||
import type { Request, Response } from 'express';
|
||||
import TimeAgo from 'javascript-time-ago';
|
||||
import localeEn from 'javascript-time-ago/locale/en';
|
||||
|
||||
@@ -23,156 +24,178 @@ const CLIENT_VISIBLE_FEATURE_FLAGS: ReadonlySet<string> = new Set([
|
||||
'prompt_user_when_navigation_away_from_puter',
|
||||
]);
|
||||
|
||||
export const handleWhoami = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const actor = Context.get('actor');
|
||||
if (!actor?.user?.id) {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const isUser = !actor.app;
|
||||
const user = await stores.user.getById(actor.user.id);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const oidcOnly = user.password === null;
|
||||
const ALLOWED_ICON_SIZES = new Set([16, 32, 64, 128, 256, 512]);
|
||||
const rawIconSize =
|
||||
typeof req.query?.icon_size === 'string'
|
||||
? Number(req.query.icon_size)
|
||||
: undefined;
|
||||
const iconSize =
|
||||
rawIconSize !== undefined && ALLOWED_ICON_SIZES.has(rawIconSize)
|
||||
? rawIconSize
|
||||
: undefined;
|
||||
const noIcons = !iconSize;
|
||||
|
||||
// Feature flags come from `config.feature_flags`. We only forward keys
|
||||
// listed in CLIENT_VISIBLE_FEATURE_FLAGS so internal flags can't leak.
|
||||
// Non-boolean values (e.g. `"true"` as a string) are coerced so the
|
||||
// client never has to guess.
|
||||
const rawFlags = extension.config.feature_flags ?? {};
|
||||
const feature_flags: Record<string, boolean> = {};
|
||||
for (const [k, v] of Object.entries(rawFlags)) {
|
||||
if (CLIENT_VISIBLE_FEATURE_FLAGS.has(k)) {
|
||||
feature_flags[k] = Boolean(v);
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = user.metadata ? { ...user.metadata } : user.metadata;
|
||||
if (metadata) delete metadata.tmp_password;
|
||||
|
||||
const details: Record<string, unknown> = {
|
||||
username: user.username,
|
||||
uuid: user.uuid,
|
||||
email: user.email,
|
||||
unconfirmed_email: user.email,
|
||||
email_confirmed: user.email_confirmed || user.username === 'admin',
|
||||
requires_email_confirmation: user.requires_email_confirmation,
|
||||
phone: user.phone,
|
||||
requires_phone_verification: user.requires_phone_verification,
|
||||
requires_card_verification: user.requires_card_verification,
|
||||
desktop_bg_url: user.desktop_bg_url,
|
||||
desktop_bg_color: user.desktop_bg_color,
|
||||
desktop_bg_fit: user.desktop_bg_fit,
|
||||
is_temp: user.password === null && user.email === null,
|
||||
is_user_token: true,
|
||||
oidc_only: oidcOnly,
|
||||
taskbar_items: isUser
|
||||
? await getTaskbarItems(
|
||||
user,
|
||||
{
|
||||
clients,
|
||||
stores,
|
||||
services,
|
||||
apiBaseUrl: String(extension.config.api_base_url ?? ''),
|
||||
},
|
||||
{ iconSize, noIcons },
|
||||
)
|
||||
: undefined,
|
||||
otp: !!user.otp_enabled,
|
||||
feature_flags,
|
||||
human_readable_age: user.timestamp
|
||||
? timeago.format(new Date(user.timestamp as string))
|
||||
: null,
|
||||
metadata,
|
||||
hasDevAccountAccess: !!user.metadata?.hasDevAccountAccess,
|
||||
};
|
||||
|
||||
// OIDC revalidate URL for password-less accounts
|
||||
if (oidcOnly) {
|
||||
try {
|
||||
const provider = await services.oidc.getLinkedProviderForUser(
|
||||
user.id as number,
|
||||
);
|
||||
if (provider) {
|
||||
const origin = (extension.config.origin ?? '').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
details.oidc_revalidate_url = `${origin}/auth/oidc/${provider}/start?flow=revalidate&user_uuid=${encodeURIComponent(user.uuid)}`;
|
||||
}
|
||||
} catch {
|
||||
// OIDC not configured
|
||||
}
|
||||
}
|
||||
|
||||
// Directories — only sent to user actors
|
||||
if (isUser) {
|
||||
const directories: Record<string, unknown> = {};
|
||||
const nameToProp: Record<string, string> = {
|
||||
desktop_uuid: `/${user.username}/Desktop`,
|
||||
appdata_uuid: `/${user.username}/AppData`,
|
||||
documents_uuid: `/${user.username}/Documents`,
|
||||
pictures_uuid: `/${user.username}/Pictures`,
|
||||
videos_uuid: `/${user.username}/Videos`,
|
||||
trash_uuid: `/${user.username}/Trash`,
|
||||
};
|
||||
for (const k in nameToProp) {
|
||||
directories[nameToProp[k]] = user[k];
|
||||
}
|
||||
details.directories = directories;
|
||||
}
|
||||
|
||||
// Last activity
|
||||
if (user.last_activity_ts) {
|
||||
try {
|
||||
details.last_activity_ts = Math.round(
|
||||
new Date(user.last_activity_ts as string).getTime() / 1000,
|
||||
);
|
||||
} catch {
|
||||
/* ignore parse error */
|
||||
}
|
||||
}
|
||||
|
||||
// Strip sensitive fields for app actors
|
||||
if (!isUser) {
|
||||
const canReadEmail = await services.permission
|
||||
.check(actor, `user:${user.uuid}:email:read`)
|
||||
.catch(() => false);
|
||||
if (!canReadEmail) {
|
||||
delete details.email;
|
||||
delete details.unconfirmed_email;
|
||||
}
|
||||
delete details.desktop_bg_url;
|
||||
delete details.desktop_bg_color;
|
||||
delete details.desktop_bg_fit;
|
||||
delete details.human_readable_age;
|
||||
delete details.is_user_token;
|
||||
delete details.metadata;
|
||||
}
|
||||
|
||||
if (actor.app) {
|
||||
details.app_name = actor.app.uid;
|
||||
}
|
||||
|
||||
try {
|
||||
await clients.event.emitAndWait(
|
||||
'whoami.details',
|
||||
{ user, details, isUser },
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
|
||||
const subscription = details.subscription as
|
||||
| { offering?: Record<string, unknown> }
|
||||
| undefined;
|
||||
if (subscription?.offering) {
|
||||
delete subscription.offering.group;
|
||||
delete subscription.offering.benefits;
|
||||
delete subscription.offering.price_id;
|
||||
}
|
||||
|
||||
res.json(details);
|
||||
};
|
||||
|
||||
extension.get(
|
||||
'/whoami',
|
||||
{ subdomain: 'api', requireAuth: true, allowUnconfirmed: true },
|
||||
async (req, res) => {
|
||||
const actor = Context.get('actor');
|
||||
if (!actor?.user?.id) {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const isUser = !actor.app;
|
||||
const user = await stores.user.getById(actor.user.id);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const oidcOnly = user.password === null;
|
||||
const ALLOWED_ICON_SIZES = new Set([16, 32, 64, 128, 256, 512]);
|
||||
const rawIconSize =
|
||||
typeof req.query?.icon_size === 'string'
|
||||
? Number(req.query.icon_size)
|
||||
: undefined;
|
||||
const iconSize =
|
||||
rawIconSize !== undefined && ALLOWED_ICON_SIZES.has(rawIconSize)
|
||||
? rawIconSize
|
||||
: undefined;
|
||||
const noIcons = !iconSize;
|
||||
|
||||
// Feature flags come from `config.feature_flags`. We only forward keys
|
||||
// listed in CLIENT_VISIBLE_FEATURE_FLAGS so internal flags can't leak.
|
||||
// Non-boolean values (e.g. `"true"` as a string) are coerced so the
|
||||
// client never has to guess.
|
||||
const rawFlags = extension.config.feature_flags ?? {};
|
||||
const feature_flags: Record<string, boolean> = {};
|
||||
for (const [k, v] of Object.entries(rawFlags)) {
|
||||
if (CLIENT_VISIBLE_FEATURE_FLAGS.has(k)) {
|
||||
feature_flags[k] = Boolean(v);
|
||||
}
|
||||
}
|
||||
|
||||
const details: Record<string, unknown> = {
|
||||
username: user.username,
|
||||
uuid: user.uuid,
|
||||
email: user.email,
|
||||
unconfirmed_email: user.email,
|
||||
email_confirmed: user.email_confirmed || user.username === 'admin',
|
||||
requires_email_confirmation: user.requires_email_confirmation,
|
||||
desktop_bg_url: user.desktop_bg_url,
|
||||
desktop_bg_color: user.desktop_bg_color,
|
||||
desktop_bg_fit: user.desktop_bg_fit,
|
||||
is_temp: user.password === null && user.email === null,
|
||||
oidc_only: oidcOnly,
|
||||
taskbar_items: isUser
|
||||
? await getTaskbarItems(
|
||||
user,
|
||||
{
|
||||
clients,
|
||||
stores,
|
||||
services,
|
||||
apiBaseUrl: String(
|
||||
extension.config.api_base_url ?? '',
|
||||
),
|
||||
},
|
||||
{ iconSize, noIcons },
|
||||
)
|
||||
: undefined,
|
||||
otp: !!user.otp_enabled,
|
||||
feature_flags,
|
||||
human_readable_age: user.timestamp
|
||||
? timeago.format(new Date(user.timestamp as string))
|
||||
: null,
|
||||
};
|
||||
|
||||
// OIDC revalidate URL for password-less accounts
|
||||
if (oidcOnly) {
|
||||
try {
|
||||
const providers = await services.oidc.getEnabledProviderIds();
|
||||
const provider = providers?.[0];
|
||||
if (provider) {
|
||||
const callbackUrl =
|
||||
services.oidc.getCallbackUrl?.('login') ?? '';
|
||||
const origin = callbackUrl.replace(
|
||||
/\/auth\/oidc\/callback\/login$/,
|
||||
'',
|
||||
);
|
||||
details.oidc_revalidate_url = `${origin}/auth/oidc/${provider}/start?flow=revalidate&user_uuid=${encodeURIComponent(user.uuid)}`;
|
||||
}
|
||||
} catch {
|
||||
// OIDC not configured
|
||||
}
|
||||
}
|
||||
|
||||
// Directories — only sent to user actors
|
||||
if (isUser) {
|
||||
const directories: Record<string, unknown> = {};
|
||||
const nameToProp: Record<string, string> = {
|
||||
desktop_uuid: `/${user.username}/Desktop`,
|
||||
appdata_uuid: `/${user.username}/AppData`,
|
||||
documents_uuid: `/${user.username}/Documents`,
|
||||
pictures_uuid: `/${user.username}/Pictures`,
|
||||
videos_uuid: `/${user.username}/Videos`,
|
||||
trash_uuid: `/${user.username}/Trash`,
|
||||
};
|
||||
for (const k in nameToProp) {
|
||||
directories[nameToProp[k]] = user[k];
|
||||
}
|
||||
details.directories = directories;
|
||||
}
|
||||
|
||||
// Last activity
|
||||
if (user.last_activity_ts) {
|
||||
try {
|
||||
details.last_activity_ts = Math.round(
|
||||
new Date(user.last_activity_ts as string).getTime() / 1000,
|
||||
);
|
||||
} catch {
|
||||
/* ignore parse error */
|
||||
}
|
||||
}
|
||||
|
||||
// Strip sensitive fields for app actors
|
||||
if (!isUser) {
|
||||
const canReadEmail = await services.permission
|
||||
.check(actor, `user:${user.uuid}:email:read`)
|
||||
.catch(() => false);
|
||||
if (!canReadEmail) {
|
||||
delete details.email;
|
||||
delete details.unconfirmed_email;
|
||||
}
|
||||
delete details.desktop_bg_url;
|
||||
delete details.desktop_bg_color;
|
||||
delete details.desktop_bg_fit;
|
||||
delete details.human_readable_age;
|
||||
}
|
||||
|
||||
if (actor.app) {
|
||||
details.app_name = actor.app.uid;
|
||||
}
|
||||
|
||||
try {
|
||||
await clients.event.emitAndWait(
|
||||
'whoami.details',
|
||||
{ user, details, isUser },
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
|
||||
res.json(details);
|
||||
},
|
||||
handleWhoami,
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { handleWorkerSandboxPage } from './workerSandbox.ts';
|
||||
|
||||
interface CapturedResponse {
|
||||
contentType: string | undefined;
|
||||
body: string | undefined;
|
||||
}
|
||||
|
||||
const makeRes = () => {
|
||||
const captured: CapturedResponse = {
|
||||
contentType: undefined,
|
||||
body: undefined,
|
||||
};
|
||||
const res = {
|
||||
type: vi.fn((mime: string) => {
|
||||
captured.contentType = mime;
|
||||
return res;
|
||||
}),
|
||||
send: vi.fn((value: string) => {
|
||||
captured.body = value;
|
||||
return res;
|
||||
}),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
};
|
||||
|
||||
describe('workerSandbox extension — handleWorkerSandboxPage', () => {
|
||||
it('sends the playground HTML page with text/html content type', () => {
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
handleWorkerSandboxPage({} as Request, res);
|
||||
|
||||
expect(captured.contentType).toBe('html');
|
||||
expect(typeof captured.body).toBe('string');
|
||||
expect(captured.body).toContain('<!doctype html>');
|
||||
expect(captured.body).toContain('Puter Worker Sandbox Playground');
|
||||
// The page is meant to load the public puter SDK
|
||||
expect(captured.body).toContain('https://js.puter.com/v2/');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
|
||||
const page = `
|
||||
@@ -89,10 +90,12 @@ const page = `
|
||||
</html>
|
||||
`;
|
||||
|
||||
export const handleWorkerSandboxPage = (_req: Request, res: Response): void => {
|
||||
res.type('html').send(page);
|
||||
};
|
||||
|
||||
extension.get(
|
||||
'/',
|
||||
{ requireAuth: false, subdomain: 'worker-sandbox' },
|
||||
(_req, res) => {
|
||||
res.type('html').send(page);
|
||||
},
|
||||
handleWorkerSandboxPage,
|
||||
);
|
||||
+6
-1
@@ -113,7 +113,10 @@ if ($writeConfig) {
|
||||
$mariadbRootPw = New-HexSecret 32
|
||||
$mariadbPw = New-HexSecret 32
|
||||
$s3SecretKey = New-HexSecret 32
|
||||
# Two JWT secrets: $jwtSecret is verify-only for legacy v1 tokens
|
||||
# already in circulation; $jwtSecretV2 signs every new token.
|
||||
$jwtSecret = New-HexSecret 64
|
||||
$jwtSecretV2 = New-HexSecret 64
|
||||
$urlSigSecret = New-HexSecret 64
|
||||
|
||||
$envContent = @"
|
||||
@@ -142,6 +145,8 @@ S3_BUCKET=puter-local
|
||||
private_app_hosting_domain = "app.$PuterDomain"
|
||||
private_app_hosting_domain_alt = "dev.$PuterDomain"
|
||||
jwt_secret = $jwtSecret
|
||||
jwt_secret_v2 = $jwtSecretV2
|
||||
allow_v1_tokens = $true
|
||||
url_signature_secret = $urlSigSecret
|
||||
database = [ordered]@{
|
||||
engine = 'mysql'
|
||||
@@ -200,4 +205,4 @@ Write-Log " cd $PuterDir; docker compose logs -f puter"
|
||||
Write-Log ''
|
||||
Write-Log "open http://${PuterDomain}:${PuterPort} once the puter container is healthy."
|
||||
Write-Log 'first-boot admin password is logged once — grab it with:'
|
||||
Write-Log " cd $PuterDir; docker compose logs puter | Select-String tmp_password"
|
||||
Write-Log " cd $PuterDir; docker compose logs puter | Select-String password"
|
||||
+41
-11
@@ -16,11 +16,20 @@
|
||||
# the compose file + brings the stack up. Set PUTER_FORCE=1 to overwrite.
|
||||
#
|
||||
# Tunable env vars:
|
||||
# PUTER_DIR install directory (default: ./puter-selfhosted)
|
||||
# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch)
|
||||
# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost)
|
||||
# PUTER_PORT HTTP port for nginx (default: 80)
|
||||
# PUTER_FORCE set to 1 to overwrite existing .env / config.json
|
||||
# PUTER_DIR install directory (default: ./puter-selfhosted)
|
||||
# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch)
|
||||
# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost)
|
||||
# PUTER_PORT HTTP port for nginx (default: 80)
|
||||
# PUTER_PROTOCOL public scheme: http | https (default: http)
|
||||
# Set https when a TLS-terminating reverse proxy (Caddy,
|
||||
# Traefik, nginx, a cloud LB) sits in front — Puter then
|
||||
# builds https:// origins, S3 URLs, and redirects. See
|
||||
# "Running behind your own reverse proxy" in doc/self-hosting.md.
|
||||
# PUTER_TRUST_PROXY number of reverse-proxy hops in front (default: 1)
|
||||
# 1 = the bundled nginx (or a single external proxy);
|
||||
# 2 = two hops (e.g. Cloudflare → your proxy → Puter).
|
||||
# PUTER_ENV prod | dev (default: prod)
|
||||
# PUTER_FORCE set to 1 to overwrite existing .env / config.json
|
||||
|
||||
set -eu
|
||||
|
||||
@@ -28,6 +37,9 @@ PUTER_DIR="${PUTER_DIR:-puter-selfhosted}"
|
||||
PUTER_URL="${PUTER_URL:-https://raw.githubusercontent.com/HeyPuter/puter/main}"
|
||||
PUTER_DOMAIN="${PUTER_DOMAIN:-puter.localhost}"
|
||||
PUTER_PORT="${PUTER_PORT:-80}"
|
||||
PUTER_PROTOCOL="${PUTER_PROTOCOL:-http}"
|
||||
PUTER_TRUST_PROXY="${PUTER_TRUST_PROXY:-1}"
|
||||
PUTER_ENV="${PUTER_ENV:-prod}"
|
||||
PUTER_FORCE="${PUTER_FORCE:-0}"
|
||||
|
||||
log() { printf '\033[1;36m[puter-install]\033[0m %s\n' "$*"; }
|
||||
@@ -50,6 +62,18 @@ docker compose version >/dev/null 2>&1 \
|
||||
mkdir -p "$PUTER_DIR"
|
||||
cd "$PUTER_DIR"
|
||||
mkdir -p puter/config puter/data puter/tls
|
||||
# Pre-create per-service data dirs and make them writable by any UID.
|
||||
# Several upstream images run as non-root inside the container (rustfs
|
||||
# uses UID 10001; dynamo is pinned to 1000 in compose), and rustfs's
|
||||
# entrypoint runs as that same non-root user so it can't chown an
|
||||
# already-existing bind-mounted dir. On hosts where the user that ran
|
||||
# this script has a UID that doesn't match — or where docker is running
|
||||
# rootless — those containers loop on EACCES at startup. 0777 on the
|
||||
# bind-mount roots sidesteps the mismatch without guessing each image's
|
||||
# internal UID. (Docker Desktop on macOS/Windows papers over this with
|
||||
# its VM layer; native Linux docker on Debian/Alpine doesn't.)
|
||||
mkdir -p puter/data/valkey puter/data/mariadb puter/data/dynamo puter/data/s3 puter/data/puter
|
||||
chmod 0777 puter/data/valkey puter/data/mariadb puter/data/dynamo puter/data/s3 puter/data/puter
|
||||
log "install dir: $(pwd)"
|
||||
|
||||
# ── Step 3: docker-compose.yml + nginx config ──────────────────────
|
||||
@@ -80,7 +104,11 @@ if [ "$write_config" = "1" ]; then
|
||||
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
|
||||
MARIADB_PASSWORD=$(openssl rand -hex 32)
|
||||
S3_SECRET_KEY=$(openssl rand -hex 32)
|
||||
# Two JWT secrets: `jwt_secret` is verify-only for legacy v1 tokens
|
||||
# already in circulation; `jwt_secret_v2` signs every new token.
|
||||
# Both are required at boot (`jwt_secret` only when verifying v1).
|
||||
JWT_SECRET=$(openssl rand -hex 64)
|
||||
JWT_SECRET_V2=$(openssl rand -hex 64)
|
||||
URL_SIGNATURE_SECRET=$(openssl rand -hex 64)
|
||||
|
||||
cat > .env <<EOF
|
||||
@@ -101,9 +129,9 @@ EOF
|
||||
cat > puter/config/config.json <<EOF
|
||||
{
|
||||
"domain": "$PUTER_DOMAIN",
|
||||
"protocol": "http",
|
||||
"protocol": "$PUTER_PROTOCOL",
|
||||
"pub_port": $PUTER_PORT,
|
||||
"env": "prod",
|
||||
"env": "$PUTER_ENV",
|
||||
|
||||
"static_hosting_domain": "site.$PUTER_DOMAIN",
|
||||
"static_hosting_domain_alt": "host.$PUTER_DOMAIN",
|
||||
@@ -111,6 +139,8 @@ EOF
|
||||
"private_app_hosting_domain_alt": "dev.$PUTER_DOMAIN",
|
||||
|
||||
"jwt_secret": "$JWT_SECRET",
|
||||
"jwt_secret_v2": "$JWT_SECRET_V2",
|
||||
"allow_v1_tokens": true,
|
||||
"url_signature_secret": "$URL_SIGNATURE_SECRET",
|
||||
|
||||
"database": {
|
||||
@@ -141,7 +171,7 @@ EOF
|
||||
"s3": {
|
||||
"s3Config": {
|
||||
"endpoint": "http://s3:9000",
|
||||
"publicEndpoint": "http://s3.$PUTER_DOMAIN",
|
||||
"publicEndpoint": "$PUTER_PROTOCOL://s3.$PUTER_DOMAIN",
|
||||
"accessKeyId": "puter",
|
||||
"secretAccessKey": "$S3_SECRET_KEY",
|
||||
"region": "us-east-1",
|
||||
@@ -155,7 +185,7 @@ EOF
|
||||
"ollama": { "enabled": false }
|
||||
},
|
||||
|
||||
"trust_proxy": 1
|
||||
"trust_proxy": $PUTER_TRUST_PROXY
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
@@ -169,6 +199,6 @@ log "stack starting. first boot takes ~30s while MariaDB initialises."
|
||||
log "follow puter logs:"
|
||||
log " cd $PUTER_DIR && docker compose logs -f puter"
|
||||
log ""
|
||||
log "open http://$PUTER_DOMAIN:$PUTER_PORT once the puter container is healthy."
|
||||
log "open $PUTER_PROTOCOL://$PUTER_DOMAIN:$PUTER_PORT once the puter container is healthy."
|
||||
log "first-boot admin password is logged once — grab it with:"
|
||||
log " cd $PUTER_DIR && docker compose logs puter | grep tmp_password"
|
||||
log " cd $PUTER_DIR && docker compose logs puter | grep password"
|
||||
Generated
+4906
-4474
File diff suppressed because it is too large.
Load diff
+22
-12
@@ -13,6 +13,7 @@
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@stylistic/eslint-plugin": "^5.3.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.1",
|
||||
@@ -22,6 +23,7 @@
|
||||
"chalk": "^4.1.0",
|
||||
"clean-css": "^5.3.2",
|
||||
"dotenv": "^16.4.5",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
@@ -36,25 +38,24 @@
|
||||
"typescript": "^5.4.5",
|
||||
"uglify-js": "^3.17.4",
|
||||
"vite-plugin-static-copy": "^3.3.0",
|
||||
"vitest": "^4.1.5",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.1",
|
||||
"yaml": "^2.8.1"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "npx vitest run --config=src/backend/vitest.config.ts && node src/backend/tools/test.mjs",
|
||||
"test:puterjs-api": "vitest run tests/puterJsApiTests",
|
||||
"test:backend": "npm run build:ts; vitest run --config=src/backend/vitest.config.ts",
|
||||
"test:backend-coverage": "npm run build:ts; vitest run --config=src/backend/vitest.config.ts",
|
||||
"start=gui": "nodemon --exec \"node dev-server.js\" ",
|
||||
"test:backend": "npm run setupExtensions && vitest run --config src/backend/vitest.config.ts ",
|
||||
"test:backend:postgres": "npm run setupExtensions && PUTER_TEST_DB_ENGINE=postgres vitest run --config src/backend/vitest.config.ts ",
|
||||
"start:gui": "nodemon --exec \"node dev-server.js\" ",
|
||||
"start": "node --enable-source-maps -r ./dist/src/backend/telemetry.js ./dist/src/backend/index.js",
|
||||
"prestart": "npm run build:ts",
|
||||
"dev": "npm run build:ts && node --enable-source-maps -r ./dist/src/backend/telemetry.js ./dist/src/backend/index.js",
|
||||
"build": "npm run build:ts && cd src/gui && node ./build.js && cd ../puter-js && npm run build",
|
||||
"prestart": "npm run setupExtensions && npm run build:ts",
|
||||
"dev": "npm start",
|
||||
"build": "npm run setupExtensions && npm run build:ts && cd src/gui && node ./build.js && cd ../puter-js && npm run build",
|
||||
"build:workerLib": "cd src/puter-js && npm run build && cd ../worker && npm run build",
|
||||
"check-translations": "node tools/check-translations.js",
|
||||
"prepare": "husky",
|
||||
"build:ts": "tsc -p tsconfig.build.json && node ./tools/write-dist-package-json.mjs",
|
||||
"postinstall": "node ./tools/extensionSetup.mjs"
|
||||
"setupExtensions": "node ./tools/extensionSetup.mjs"
|
||||
},
|
||||
"workspaces": [
|
||||
"src/*",
|
||||
@@ -76,12 +77,21 @@
|
||||
"ai": "^6.0.73",
|
||||
"dedent": "^1.5.3",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"libphonenumber-js": "1.13.6",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sharp": "^0.34.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@google/genai": false,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import { createHash } from 'node:crypto';
|
||||
import type { IConfig } from '../../types';
|
||||
import { PuterClient } from '../types';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
// -- Types ------------------------------------------------------------
|
||||
|
||||
export interface AlarmFields {
|
||||
error?: Error;
|
||||
@@ -49,7 +49,7 @@ interface Alarm {
|
||||
noAlert?: boolean;
|
||||
}
|
||||
|
||||
type PagerSeverity = 'critical' | 'error' | 'warning' | 'info';
|
||||
export type PagerSeverity = 'critical' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface AlertPayload {
|
||||
id: string;
|
||||
@@ -73,7 +73,7 @@ interface KnownErrorRule {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
// -- Helpers ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deterministic short identifier derived from an alarm ID.
|
||||
@@ -166,7 +166,7 @@ function cleanFields(fields: AlarmFields): Record<string, string> {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── AlarmClient ──────────────────────────────────────────────────────
|
||||
// -- AlarmClient ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Manages system alarms and dispatches alerts to external paging
|
||||
@@ -184,7 +184,7 @@ export class AlarmClient extends PuterClient {
|
||||
super(config);
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────
|
||||
// -- Lifecycle ----------------------------------------------------
|
||||
|
||||
override async onServerStart(): Promise<void> {
|
||||
const pagerConf = this.config.pager;
|
||||
@@ -228,13 +228,23 @@ export class AlarmClient extends PuterClient {
|
||||
console.log('[alarm] entering drain mode — suppressing new alarms');
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
// -- Public API ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create or update an alarm. If the alarm ID already exists, the
|
||||
* occurrence count is incremented and a repeat alert is dispatched.
|
||||
*
|
||||
* `severity` is the PagerDuty severity for this alarm; omit for the
|
||||
* default 'critical'. Use 'info' / 'warning' for expected-but-worth-
|
||||
* tracking signals (e.g. a user hitting a rate limit) so they record
|
||||
* and de-dupe like any other alarm but don't page on-call.
|
||||
*/
|
||||
create(id: string, message: string, fields: AlarmFields = {}): void {
|
||||
create(
|
||||
id: string,
|
||||
message: string,
|
||||
fields: AlarmFields = {},
|
||||
severity?: PagerSeverity,
|
||||
): void {
|
||||
if (this.draining) {
|
||||
if (!this.drainLogged) {
|
||||
this.drainLogged = true;
|
||||
@@ -256,6 +266,7 @@ export class AlarmClient extends PuterClient {
|
||||
shortId: shortId(id),
|
||||
message,
|
||||
fields,
|
||||
severity,
|
||||
started: Date.now(),
|
||||
timestamps: [Date.now()],
|
||||
occurrences: [],
|
||||
@@ -298,7 +309,7 @@ export class AlarmClient extends PuterClient {
|
||||
this.knownErrors = rules;
|
||||
}
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────
|
||||
// -- Internals ----------------------------------------------------
|
||||
|
||||
private recordOccurrence(
|
||||
alarm: Alarm,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Optional ClickHouse client.
|
||||
*
|
||||
* ClickHouse is NOT a hard dependency. Core Puter runs without it — the app
|
||||
* stats read path (open / unique-user counts) falls back to the primary SQL
|
||||
* database. `this.clients.clickhouse` is therefore typed as optional and is
|
||||
* `undefined` in default and self-hosted setups; callers MUST branch on its
|
||||
* presence and fall back to SQL.
|
||||
*
|
||||
* A production deployment can register a real ClickHouse client via an
|
||||
* extension (`extension.registerClient('clickhouse', client)`) to offload the
|
||||
* analytics queries off the primary database and keep the stats path fast at
|
||||
* scale. When registered, the instance flows into `this.clients.clickhouse`
|
||||
* everywhere it's typed.
|
||||
*
|
||||
* Only the surface the stats path actually consumes is declared here. Extend
|
||||
* this interface (don't widen to `any`) when a new query shape is needed.
|
||||
*/
|
||||
export interface ClickhouseQueryResult {
|
||||
json<T = Record<string, unknown>>(): Promise<T[]>;
|
||||
}
|
||||
|
||||
export interface ClickhouseQueryParams {
|
||||
query: string;
|
||||
query_params?: Record<string, unknown>;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
export interface ClickhouseClient {
|
||||
query(params: ClickhouseQueryParams): Promise<ClickhouseQueryResult>;
|
||||
}
|
||||
@@ -31,6 +31,8 @@ export interface BatchEntry {
|
||||
values: unknown[];
|
||||
}
|
||||
|
||||
type SqlJsonPath = readonly [string, ...string[]];
|
||||
|
||||
/**
|
||||
* Base database client. Subclasses must override every method that throws here.
|
||||
*
|
||||
@@ -101,9 +103,10 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
const cols = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const sql =
|
||||
`INSERT INTO \`${tableName}\` ` +
|
||||
`(${cols.map((c) => `\`${c}\``).join(', ')}) ` +
|
||||
`VALUES (${cols.map(() => '?').join(', ')})`;
|
||||
`INSERT INTO ${this.quoteIdentifier(tableName)} ` +
|
||||
`(${cols.map((c) => this.quoteIdentifier(c)).join(', ')}) ` +
|
||||
`VALUES (${cols.map(() => '?').join(', ')})` +
|
||||
this.returningIdClause();
|
||||
return this.write(sql, values);
|
||||
}
|
||||
|
||||
@@ -161,4 +164,88 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
}
|
||||
return choices.otherwise as T;
|
||||
}
|
||||
|
||||
quoteIdentifier(identifier: string): string {
|
||||
return identifier
|
||||
.split('.')
|
||||
.map((part) => {
|
||||
if (part === '*') return part;
|
||||
return `\`${part.replaceAll('`', '``')}\``;
|
||||
})
|
||||
.join('.');
|
||||
}
|
||||
|
||||
booleanLiteral(value: boolean): string {
|
||||
return value ? '1' : '0';
|
||||
}
|
||||
|
||||
booleanValue(value: boolean): boolean | 0 | 1 {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
|
||||
insertIgnoreInto(tableName: string): string {
|
||||
const table = this.quoteIdentifier(tableName);
|
||||
return this.case({
|
||||
sqlite: `INSERT OR IGNORE INTO ${table}`,
|
||||
postgres: `INSERT INTO ${table}`,
|
||||
otherwise: `INSERT IGNORE INTO ${table}`,
|
||||
});
|
||||
}
|
||||
|
||||
insertIgnoreSuffix(): string {
|
||||
return this.case({
|
||||
postgres: ' ON CONFLICT DO NOTHING',
|
||||
otherwise: '',
|
||||
});
|
||||
}
|
||||
|
||||
upsertClause(
|
||||
conflictColumns: readonly string[],
|
||||
updateColumns: readonly string[],
|
||||
): string {
|
||||
if (updateColumns.length === 0) {
|
||||
throw new Error('upsertClause requires at least one update column');
|
||||
}
|
||||
|
||||
const updateList = updateColumns
|
||||
.map((column) => `${this.quoteIdentifier(column)} = ?`)
|
||||
.join(', ');
|
||||
|
||||
return this.case({
|
||||
mysql: `ON DUPLICATE KEY UPDATE ${updateList}`,
|
||||
otherwise: `ON CONFLICT(${conflictColumns
|
||||
.map((column) => this.quoteIdentifier(column))
|
||||
.join(', ')}) DO UPDATE SET ${updateList}`,
|
||||
});
|
||||
}
|
||||
|
||||
jsonTextExtract(jsonExpression: string, path: SqlJsonPath): string {
|
||||
const sqlitePath = `$${path.map((part) => `.${part}`).join('')}`;
|
||||
return this.case({
|
||||
sqlite: `json_extract(${jsonExpression}, ${this.sqlStringLiteral(sqlitePath)})`,
|
||||
mysql: `JSON_UNQUOTE(JSON_EXTRACT(${jsonExpression}, ${this.sqlStringLiteral(sqlitePath)}))`,
|
||||
postgres: `${jsonExpression} #>> ARRAY[${path
|
||||
.map((part) => this.sqlStringLiteral(part))
|
||||
.join(', ')}]`,
|
||||
otherwise: `JSON_UNQUOTE(JSON_EXTRACT(${jsonExpression}, ${this.sqlStringLiteral(sqlitePath)}))`,
|
||||
});
|
||||
}
|
||||
|
||||
nullCoalesce(...expressions: readonly string[]): string {
|
||||
if (expressions.length === 0) {
|
||||
throw new Error('nullCoalesce requires at least one expression');
|
||||
}
|
||||
return `COALESCE(${expressions.join(', ')})`;
|
||||
}
|
||||
|
||||
returningIdClause(): string {
|
||||
return this.case({
|
||||
postgres: ' RETURNING id',
|
||||
otherwise: '',
|
||||
});
|
||||
}
|
||||
|
||||
protected sqlStringLiteral(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { compareMigrationFilenames } from './MySQLDatabaseClient.js';
|
||||
|
||||
describe('compareMigrationFilenames', () => {
|
||||
it('orders numbered migrations numerically, not lexically', () => {
|
||||
const input = [
|
||||
'mysql_mig_10.sql',
|
||||
'mysql_mig_2.sql',
|
||||
'mysql_mig_1.sql',
|
||||
'mysql_mig_9.sql',
|
||||
'mysql_mig_3.sql',
|
||||
];
|
||||
const sorted = [...input].sort(compareMigrationFilenames);
|
||||
expect(sorted).toEqual([
|
||||
'mysql_mig_1.sql',
|
||||
'mysql_mig_2.sql',
|
||||
'mysql_mig_3.sql',
|
||||
'mysql_mig_9.sql',
|
||||
'mysql_mig_10.sql',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps mig_10 after mig_9 when the real prod set is shuffled', () => {
|
||||
// Reflects the current migrations/mysql/ listing — guards against
|
||||
// a future "let's just rename to padded" suggestion accidentally
|
||||
// re-introducing the lex bug if the rename is incomplete.
|
||||
const real = [
|
||||
'mysql_mig_9.sql',
|
||||
'mysql_mig_3.sql',
|
||||
'mysql_mig_10.sql',
|
||||
'mysql_mig_1.sql',
|
||||
'mysql_mig_7.sql',
|
||||
'mysql_mig_5.sql',
|
||||
'mysql_mig_2.sql',
|
||||
'mysql_mig_4.sql',
|
||||
'mysql_mig_8.sql',
|
||||
'mysql_mig_6.sql',
|
||||
];
|
||||
const sorted = [...real].sort(compareMigrationFilenames);
|
||||
for (let i = 1; i <= sorted.length; i += 1) {
|
||||
expect(sorted[i - 1]).toBe(`mysql_mig_${i}.sql`);
|
||||
}
|
||||
});
|
||||
|
||||
it('sorts non-numeric filenames after numbered ones, lexically among themselves', () => {
|
||||
// Numbered files always run first (they're the canonical history);
|
||||
// unmatched names follow in localeCompare order. Mixing the two
|
||||
// sets prevents a vendor dump from accidentally wedging itself
|
||||
// between mig_4 and mig_5 if it happened to lex-sort there.
|
||||
const mixed = [
|
||||
'mysql_mig_2.sql',
|
||||
'mysql_vendor_dump.sql',
|
||||
'mysql_mig_10.sql',
|
||||
'mysql_bootstrap.sql',
|
||||
'mysql_mig_1.sql',
|
||||
];
|
||||
const sorted = [...mixed].sort(compareMigrationFilenames);
|
||||
expect(sorted).toEqual([
|
||||
'mysql_mig_1.sql',
|
||||
'mysql_mig_2.sql',
|
||||
'mysql_mig_10.sql',
|
||||
'mysql_bootstrap.sql',
|
||||
'mysql_vendor_dump.sql',
|
||||
]);
|
||||
});
|
||||
|
||||
it('is stable for already-sorted input', () => {
|
||||
const sorted = [
|
||||
'mysql_mig_1.sql',
|
||||
'mysql_mig_2.sql',
|
||||
'mysql_mig_10.sql',
|
||||
'mysql_mig_11.sql',
|
||||
];
|
||||
expect([...sorted].sort(compareMigrationFilenames)).toEqual(sorted);
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,11 @@
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve as resolvePath } from 'path';
|
||||
import { createPool, type Pool } from 'mysql2';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
import { SQLBatcher } from './SQLBatcher.js';
|
||||
import { splitMysqlStatements } from './splitMysqlStatements.js';
|
||||
import { compareMigrationFilenames } from './migrationFilenames.js';
|
||||
import type { IConfig } from '../../types';
|
||||
|
||||
const RETRIABLE_ERROR_CODES = new Set([
|
||||
@@ -44,6 +46,8 @@ const RETRIABLE_ERROR_MESSAGES = [
|
||||
'ETIMEDOUT',
|
||||
];
|
||||
|
||||
export { compareMigrationFilenames };
|
||||
|
||||
type PoolConfig = Parameters<typeof createPool>[0];
|
||||
|
||||
enum Configuration {
|
||||
@@ -132,6 +136,10 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
// Query interface
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// The db.* spans measure the logical query, including time queued in
|
||||
// the SQLBatcher — the mysql2 auto-instrumentation only sees the
|
||||
// coalesced multi-statement flush, so per-query latency lives here.
|
||||
@Span('db.read', (query: string) => ({ 'db.statement': query }))
|
||||
override async read(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -141,6 +149,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
return (result[0] as Record<string, unknown>[]) ?? [];
|
||||
}
|
||||
|
||||
@Span('db.pread', (query: string) => ({ 'db.statement': query }))
|
||||
override async pread(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -150,6 +159,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
return (result[0] as Record<string, unknown>[]) ?? [];
|
||||
}
|
||||
|
||||
@Span('db.write', (query: string) => ({ 'db.statement': query }))
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -167,6 +177,9 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
};
|
||||
}
|
||||
|
||||
@Span('db.batchWrite', (entries: unknown[]) => ({
|
||||
'db.batch_size': entries.length,
|
||||
}))
|
||||
override async batchWrite(
|
||||
entries: { statement: string; values: unknown[] }[],
|
||||
): Promise<void> {
|
||||
@@ -192,6 +205,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
}
|
||||
}
|
||||
|
||||
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
|
||||
override async tryHardRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -247,7 +261,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
.filter(
|
||||
(f) => f.endsWith('.sql') && f.startsWith('mysql'),
|
||||
)
|
||||
.sort();
|
||||
.sort(compareMigrationFilenames);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[mysql] migration path is unreadable: ${dir}`,
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { IConfig } from '../../types';
|
||||
import type { PuterServer } from '../../server';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import {
|
||||
createPgMockPostgresDatabaseClient,
|
||||
POSTGRES_TEST_MIGRATIONS_PATH,
|
||||
setupTestServer,
|
||||
} from '../../testUtil.js';
|
||||
import { PostgresDatabaseClient } from './PostgresDatabaseClient.js';
|
||||
|
||||
const postgresUrl = process.env.PUTER_TEST_POSTGRES_URL;
|
||||
const postgresMigrationsPath = POSTGRES_TEST_MIGRATIONS_PATH;
|
||||
const postgresTestSchemaPattern = /^puter_test_[a-f0-9]{32}$/u;
|
||||
const postgresIntegrationTimeoutMs = 180_000;
|
||||
|
||||
let postgresTestSchema: string | undefined;
|
||||
let postgresTestUrl: string | undefined;
|
||||
|
||||
const postgresConfig = (overrides: Partial<IConfig> = {}): IConfig => {
|
||||
if (postgresUrl && !postgresTestUrl) {
|
||||
throw new Error('Postgres test schema was not initialized');
|
||||
}
|
||||
|
||||
const { database: databaseOverrides, ...rootOverrides } = overrides;
|
||||
const database = postgresUrl
|
||||
? {
|
||||
engine: 'postgres' as const,
|
||||
inMemory: false,
|
||||
connectionString: postgresTestUrl,
|
||||
migrationPaths: [postgresMigrationsPath],
|
||||
}
|
||||
: {
|
||||
engine: 'postgres' as const,
|
||||
inMemory: true,
|
||||
migrationPaths: [postgresMigrationsPath],
|
||||
};
|
||||
|
||||
return {
|
||||
port: 0,
|
||||
extensions: [],
|
||||
database: { ...database, ...(databaseOverrides ?? {}) },
|
||||
...rootOverrides,
|
||||
};
|
||||
};
|
||||
|
||||
const quoteTestSchemaIdentifier = (schema: string): string => {
|
||||
if (!postgresTestSchemaPattern.test(schema)) {
|
||||
throw new Error(`Unsafe Postgres test schema name: ${schema}`);
|
||||
}
|
||||
return `"${schema}"`;
|
||||
};
|
||||
|
||||
const postgresConnectionStringForSchema = (
|
||||
connectionString: string,
|
||||
schema: string,
|
||||
): string => {
|
||||
if (!postgresTestSchemaPattern.test(schema)) {
|
||||
throw new Error(`Unsafe Postgres test schema name: ${schema}`);
|
||||
}
|
||||
|
||||
const url = new URL(connectionString);
|
||||
url.searchParams.set('options', `-c search_path=${schema}`);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const createPostgresTestSchema = async (): Promise<void> => {
|
||||
if (!postgresUrl) return;
|
||||
|
||||
const schema = `puter_test_${uuidv4().replaceAll('-', '')}`;
|
||||
const pool = new Pool({ connectionString: postgresUrl });
|
||||
try {
|
||||
await pool.query(`CREATE SCHEMA ${quoteTestSchemaIdentifier(schema)}`);
|
||||
postgresTestSchema = schema;
|
||||
postgresTestUrl = postgresConnectionStringForSchema(
|
||||
postgresUrl,
|
||||
schema,
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
};
|
||||
|
||||
const dropPostgresTestSchema = async (): Promise<void> => {
|
||||
if (!postgresUrl || !postgresTestSchema) return;
|
||||
|
||||
const schema = postgresTestSchema;
|
||||
postgresTestSchema = undefined;
|
||||
postgresTestUrl = undefined;
|
||||
|
||||
const pool = new Pool({ connectionString: postgresUrl });
|
||||
try {
|
||||
await pool.query(
|
||||
`DROP SCHEMA IF EXISTS ${quoteTestSchemaIdentifier(schema)} CASCADE`,
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
};
|
||||
|
||||
describe('PostgresDatabaseClient integration', () => {
|
||||
let server: PuterServer | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
await createPostgresTestSchema();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await server?.shutdown();
|
||||
} finally {
|
||||
server = undefined;
|
||||
await dropPostgresTestSchema();
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
'applies the native migrations idempotently to an empty database',
|
||||
async () => {
|
||||
const config = postgresConfig();
|
||||
const pgMockClient = postgresUrl
|
||||
? undefined
|
||||
: await createPgMockPostgresDatabaseClient(config);
|
||||
try {
|
||||
const firstClient =
|
||||
pgMockClient?.client ?? new PostgresDatabaseClient(config);
|
||||
try {
|
||||
await firstClient.onServerStart();
|
||||
} finally {
|
||||
await firstClient.onServerShutdown();
|
||||
}
|
||||
|
||||
const secondClient =
|
||||
pgMockClient?.createClient() ??
|
||||
new PostgresDatabaseClient(config);
|
||||
try {
|
||||
await secondClient.onServerStart();
|
||||
const [systemUser] = await secondClient.read(
|
||||
'SELECT `id`, `username` FROM `user` WHERE `username` = ?',
|
||||
['system'],
|
||||
);
|
||||
const [devCenter] = await secondClient.read(
|
||||
'SELECT `name`, `index_url` FROM `apps` WHERE `name` = ?',
|
||||
['dev-center'],
|
||||
);
|
||||
|
||||
expect(systemUser).toMatchObject({
|
||||
id: 1,
|
||||
username: 'system',
|
||||
});
|
||||
expect(devCenter).toMatchObject({
|
||||
name: 'dev-center',
|
||||
index_url:
|
||||
'https://builtins.namespaces.puter.com/dev-center',
|
||||
});
|
||||
} finally {
|
||||
await secondClient.onServerShutdown();
|
||||
}
|
||||
} finally {
|
||||
pgMockClient?.destroy();
|
||||
}
|
||||
},
|
||||
postgresIntegrationTimeoutMs,
|
||||
);
|
||||
|
||||
it('starts the server and exercises user, app, fsentry, permission, and session flows', async () => {
|
||||
const userStorageAllowance = 123_456_789;
|
||||
server = await setupTestServer(
|
||||
postgresConfig({
|
||||
no_default_user: false,
|
||||
is_storage_limited: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const admin = await server.stores.user.getByUsername('admin');
|
||||
expect(admin?.username).toBe('admin');
|
||||
|
||||
const username = `pg-${uuidv4().slice(0, 8)}`;
|
||||
const createdUser = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
free_storage: userStorageAllowance,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
await generateDefaultFsentries(
|
||||
server.clients.db,
|
||||
server.stores.user,
|
||||
createdUser,
|
||||
);
|
||||
const user = await server.stores.user.getById(createdUser.id);
|
||||
if (!user) throw new Error('created user was not readable');
|
||||
expect(user.username).toBe(username);
|
||||
await expect(
|
||||
server.stores.fsEntry.getUserStorageAllowance(user.id),
|
||||
).resolves.toMatchObject({
|
||||
max: userStorageAllowance,
|
||||
});
|
||||
|
||||
const otherUsername = `pg-other-${uuidv4().slice(0, 8)}`;
|
||||
const otherUser = await server.stores.user.create({
|
||||
username: otherUsername,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${otherUsername}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
|
||||
const authResult = await server.services.auth.createSessionToken(
|
||||
user,
|
||||
{
|
||||
ip: '127.0.0.1',
|
||||
user_agent: 'postgres-integration-test',
|
||||
},
|
||||
);
|
||||
const authenticated =
|
||||
await server.services.auth.authenticateFromToken(authResult.token);
|
||||
expect(authenticated?.user?.id).toBe(user?.id);
|
||||
|
||||
const devCenter = await server.stores.app.getByName('dev-center');
|
||||
if (!devCenter) throw new Error('dev-center app was not seeded');
|
||||
expect(devCenter.name).toBe('dev-center');
|
||||
|
||||
const documents = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
);
|
||||
if (!documents) throw new Error('Documents directory was not created');
|
||||
expect(documents.isDir).toBe(true);
|
||||
const folder = await server.stores.fsEntry.createNonFileEntry({
|
||||
userId: user.id,
|
||||
parent: documents,
|
||||
name: 'postgres-folder',
|
||||
kind: 'directory',
|
||||
});
|
||||
const renamedFolder = await server.stores.fsEntry.updateEntry(
|
||||
folder.uuid,
|
||||
{
|
||||
name: 'postgres-folder-renamed',
|
||||
path: `/${username}/Documents/postgres-folder-renamed`,
|
||||
},
|
||||
);
|
||||
expect(renamedFolder.name).toBe('postgres-folder-renamed');
|
||||
|
||||
await server.stores.permission.upsertUserAppPerm(
|
||||
user.id,
|
||||
Number(devCenter.id),
|
||||
'driver:postgres-integration',
|
||||
{ ok: true },
|
||||
);
|
||||
await expect(
|
||||
server.stores.permission.hasUserAppPerm(
|
||||
user.id,
|
||||
Number(devCenter.id),
|
||||
'driver:postgres-integration',
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
await server.stores.oidc.link(
|
||||
user.id,
|
||||
'postgres-integration-test',
|
||||
'subject-1',
|
||||
null,
|
||||
);
|
||||
await expect(
|
||||
server.stores.oidc.link(
|
||||
user.id,
|
||||
'postgres-integration-test',
|
||||
'subject-1',
|
||||
null,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
server.stores.oidc.link(
|
||||
otherUser.id,
|
||||
'postgres-integration-test',
|
||||
'subject-1',
|
||||
null,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
legacyCode: 'conflict',
|
||||
});
|
||||
|
||||
const session = await server.stores.session.create(user.id, {
|
||||
meta: { source: 'postgres-integration-test' },
|
||||
});
|
||||
const activeSession = await server.stores.session.getByUuid(
|
||||
session.uuid,
|
||||
);
|
||||
expect(activeSession?.uuid).toBe(session.uuid);
|
||||
|
||||
const workerName = `worker-${uuidv4().slice(0, 8)}`;
|
||||
const workerSession = await server.stores.session.getOrCreateWorker(
|
||||
user.id,
|
||||
{ workerName },
|
||||
);
|
||||
expect(workerSession?.kind).toBe('worker');
|
||||
expect(workerSession?.meta?.worker_name).toBe(workerName);
|
||||
|
||||
await server.stores.session.removeByUuid(session.uuid);
|
||||
await expect(
|
||||
server.stores.session.getByUuid(session.uuid),
|
||||
).resolves.toBeNull();
|
||||
}, postgresIntegrationTimeoutMs);
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { FieldDef, QueryResult } from 'pg';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import { DatabaseClientFactory } from './index.js';
|
||||
import {
|
||||
mapPostgresWriteResult,
|
||||
PostgresDatabaseClient,
|
||||
type PostgresPool,
|
||||
type PostgresPoolClient,
|
||||
} from './PostgresDatabaseClient.js';
|
||||
|
||||
type QueryCall = {
|
||||
text: string;
|
||||
values?: unknown[];
|
||||
};
|
||||
|
||||
const postgresConfig = (): IConfig => ({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
database: {
|
||||
engine: 'postgres',
|
||||
migrationPaths: [],
|
||||
},
|
||||
});
|
||||
|
||||
const postgresReplicaConfig = (): IConfig => ({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
database: {
|
||||
engine: 'postgres',
|
||||
migrationPaths: [],
|
||||
replica: {},
|
||||
},
|
||||
});
|
||||
|
||||
const field = (name: string, dataTypeID: number): FieldDef => ({
|
||||
name,
|
||||
tableID: 0,
|
||||
columnID: 0,
|
||||
dataTypeID,
|
||||
dataTypeSize: -1,
|
||||
dataTypeModifier: -1,
|
||||
format: 'text',
|
||||
});
|
||||
|
||||
const int8Field = (name: string): FieldDef => field(name, 20);
|
||||
const textField = (name: string): FieldDef => field(name, 25);
|
||||
|
||||
const queryResult = (
|
||||
rows: Record<string, unknown>[] = [],
|
||||
rowCount = rows.length,
|
||||
fields: FieldDef[] = [],
|
||||
): QueryResult<Record<string, unknown>> => ({
|
||||
command: '',
|
||||
fields,
|
||||
oid: 0,
|
||||
rowCount,
|
||||
rows,
|
||||
});
|
||||
|
||||
class RecordingPoolClient implements PostgresPoolClient {
|
||||
readonly calls: QueryCall[] = [];
|
||||
released = false;
|
||||
|
||||
constructor(private readonly failOnText?: string) {}
|
||||
|
||||
async query(
|
||||
text: string,
|
||||
values?: unknown[],
|
||||
): Promise<QueryResult<Record<string, unknown>>> {
|
||||
this.calls.push({ text, values });
|
||||
if (this.failOnText && text.includes(this.failOnText)) {
|
||||
throw new Error(`forced query failure: ${text}`);
|
||||
}
|
||||
return queryResult([], text === 'ROLLBACK' ? 0 : 1);
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.released = true;
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingPool implements PostgresPool {
|
||||
readonly calls: QueryCall[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly client: RecordingPoolClient =
|
||||
new RecordingPoolClient(),
|
||||
private readonly nextResult: QueryResult<Record<string, unknown>> =
|
||||
queryResult(),
|
||||
) {}
|
||||
|
||||
async query(
|
||||
text: string,
|
||||
values?: unknown[],
|
||||
): Promise<QueryResult<Record<string, unknown>>> {
|
||||
this.calls.push({ text, values });
|
||||
if (text === 'SELECT 1') return queryResult([{ ok: 1 }], 1);
|
||||
return this.nextResult;
|
||||
}
|
||||
|
||||
async connect(): Promise<PostgresPoolClient> {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async end(): Promise<void> {}
|
||||
}
|
||||
|
||||
describe('PostgresDatabaseClient', () => {
|
||||
it('is selected by the database factory', () => {
|
||||
const client = new DatabaseClientFactory(postgresConfig());
|
||||
expect(client).toBeInstanceOf(PostgresDatabaseClient);
|
||||
});
|
||||
|
||||
it('maps pg write results to the shared WriteResult shape', () => {
|
||||
expect(
|
||||
mapPostgresWriteResult(queryResult([{ id: '42' }], 1)),
|
||||
).toEqual({
|
||||
insertId: 42,
|
||||
affectedRows: 1,
|
||||
anyRowsAffected: true,
|
||||
});
|
||||
|
||||
expect(mapPostgresWriteResult(queryResult([], 0))).toEqual({
|
||||
insertId: 0,
|
||||
affectedRows: 0,
|
||||
anyRowsAffected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('prepares write SQL at the pg boundary', async () => {
|
||||
const pool = new RecordingPool(
|
||||
new RecordingPoolClient(),
|
||||
queryResult([{ id: '7' }], 1),
|
||||
);
|
||||
const client = new PostgresDatabaseClient(postgresConfig(), () => pool);
|
||||
await client.onServerStart();
|
||||
|
||||
const result = await client.write(
|
||||
'INSERT INTO `apps` (`name`) VALUES (?) RETURNING id',
|
||||
['editor'],
|
||||
);
|
||||
|
||||
expect(result.insertId).toBe(7);
|
||||
expect(pool.calls.at(-1)).toEqual({
|
||||
text: 'INSERT INTO "apps" ("name") VALUES ($1) RETURNING id',
|
||||
values: ['editor'],
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes int8 fields on read and primary read rows', async () => {
|
||||
const pool = new RecordingPool(
|
||||
new RecordingPoolClient(),
|
||||
queryResult(
|
||||
[
|
||||
{
|
||||
uuid: 'session-1',
|
||||
created_at: '1710000000',
|
||||
last_activity: '1710000001',
|
||||
expires_at: '1710000002',
|
||||
revoked_at: null,
|
||||
},
|
||||
],
|
||||
1,
|
||||
[
|
||||
textField('uuid'),
|
||||
int8Field('created_at'),
|
||||
int8Field('last_activity'),
|
||||
int8Field('expires_at'),
|
||||
int8Field('revoked_at'),
|
||||
],
|
||||
),
|
||||
);
|
||||
const client = new PostgresDatabaseClient(postgresConfig(), () => pool);
|
||||
await client.onServerStart();
|
||||
|
||||
await expect(client.read('SELECT * FROM `sessions`')).resolves.toEqual(
|
||||
[
|
||||
{
|
||||
uuid: 'session-1',
|
||||
created_at: 1710000000,
|
||||
last_activity: 1710000001,
|
||||
expires_at: 1710000002,
|
||||
revoked_at: null,
|
||||
},
|
||||
],
|
||||
);
|
||||
await expect(client.pread('SELECT * FROM `sessions`')).resolves.toEqual(
|
||||
[
|
||||
{
|
||||
uuid: 'session-1',
|
||||
created_at: 1710000000,
|
||||
last_activity: 1710000001,
|
||||
expires_at: 1710000002,
|
||||
revoked_at: null,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unsafe int8 values instead of losing precision', async () => {
|
||||
const pool = new RecordingPool(
|
||||
new RecordingPoolClient(),
|
||||
queryResult(
|
||||
[{ id: '9007199254740992' }],
|
||||
1,
|
||||
[int8Field('id')],
|
||||
),
|
||||
);
|
||||
const client = new PostgresDatabaseClient(postgresConfig(), () => pool);
|
||||
await client.onServerStart();
|
||||
|
||||
await expect(client.read('SELECT `id` FROM `sessions`')).rejects.toThrow(
|
||||
'safe integer',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes tryHardRead rows returned by a replica', async () => {
|
||||
const primaryPool = new RecordingPool(
|
||||
new RecordingPoolClient(),
|
||||
queryResult([{ created_at: '1' }], 1, [
|
||||
int8Field('created_at'),
|
||||
]),
|
||||
);
|
||||
const replicaPool = new RecordingPool(
|
||||
new RecordingPoolClient(),
|
||||
queryResult([{ created_at: '2' }], 1, [
|
||||
int8Field('created_at'),
|
||||
]),
|
||||
);
|
||||
const pools = [primaryPool, replicaPool];
|
||||
let nextPoolIndex = 0;
|
||||
const client = new PostgresDatabaseClient(
|
||||
postgresReplicaConfig(),
|
||||
() => {
|
||||
const pool = pools[nextPoolIndex];
|
||||
nextPoolIndex += 1;
|
||||
if (!pool) throw new Error('unexpected pool factory call');
|
||||
return pool;
|
||||
},
|
||||
);
|
||||
await client.onServerStart();
|
||||
|
||||
await expect(
|
||||
client.tryHardRead('SELECT `created_at` FROM `sessions`'),
|
||||
).resolves.toEqual([{ created_at: 2 }]);
|
||||
});
|
||||
|
||||
it('normalizes tryHardRead rows returned by primary fallback', async () => {
|
||||
const primaryPool = new RecordingPool(
|
||||
new RecordingPoolClient(),
|
||||
queryResult([{ created_at: '3' }], 1, [
|
||||
int8Field('created_at'),
|
||||
]),
|
||||
);
|
||||
const replicaPool = new RecordingPool(
|
||||
new RecordingPoolClient(),
|
||||
queryResult([], 0, [int8Field('created_at')]),
|
||||
);
|
||||
const pools = [primaryPool, replicaPool];
|
||||
let nextPoolIndex = 0;
|
||||
const client = new PostgresDatabaseClient(
|
||||
postgresReplicaConfig(),
|
||||
() => {
|
||||
const pool = pools[nextPoolIndex];
|
||||
nextPoolIndex += 1;
|
||||
if (!pool) throw new Error('unexpected pool factory call');
|
||||
return pool;
|
||||
},
|
||||
);
|
||||
await client.onServerStart();
|
||||
|
||||
await expect(
|
||||
client.tryHardRead('SELECT `created_at` FROM `sessions`'),
|
||||
).resolves.toEqual([{ created_at: 3 }]);
|
||||
});
|
||||
|
||||
it('runs batch writes in order and commits', async () => {
|
||||
const conn = new RecordingPoolClient();
|
||||
const client = new PostgresDatabaseClient(
|
||||
postgresConfig(),
|
||||
() => new RecordingPool(conn),
|
||||
);
|
||||
await client.onServerStart();
|
||||
|
||||
await client.batchWrite([
|
||||
{
|
||||
statement:
|
||||
'UPDATE `user` SET `username` = ? WHERE `id` = ?',
|
||||
values: ['ada', 1],
|
||||
},
|
||||
{
|
||||
statement: 'DELETE FROM `sessions` WHERE `uuid` = ?',
|
||||
values: ['session-1'],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(conn.calls).toEqual([
|
||||
{ text: 'BEGIN', values: undefined },
|
||||
{
|
||||
text: 'UPDATE "user" SET "username" = $1 WHERE "id" = $2',
|
||||
values: ['ada', 1],
|
||||
},
|
||||
{
|
||||
text: 'DELETE FROM "sessions" WHERE "uuid" = $1',
|
||||
values: ['session-1'],
|
||||
},
|
||||
{ text: 'COMMIT', values: undefined },
|
||||
]);
|
||||
expect(conn.released).toBe(true);
|
||||
});
|
||||
|
||||
it('rolls back and releases the pg connection when a batch write fails', async () => {
|
||||
const conn = new RecordingPoolClient('UPDATE "user"');
|
||||
const client = new PostgresDatabaseClient(
|
||||
postgresConfig(),
|
||||
() => new RecordingPool(conn),
|
||||
);
|
||||
await client.onServerStart();
|
||||
|
||||
await expect(
|
||||
client.batchWrite([
|
||||
{
|
||||
statement:
|
||||
'UPDATE `user` SET `username` = ? WHERE `id` = ?',
|
||||
values: ['ada', 1],
|
||||
},
|
||||
]),
|
||||
).rejects.toThrow('forced query failure');
|
||||
|
||||
expect(conn.calls).toEqual([
|
||||
{ text: 'BEGIN', values: undefined },
|
||||
{
|
||||
text: 'UPDATE "user" SET "username" = $1 WHERE "id" = $2',
|
||||
values: ['ada', 1],
|
||||
},
|
||||
{ text: 'ROLLBACK', values: undefined },
|
||||
]);
|
||||
expect(conn.released).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve as resolvePath } from 'path';
|
||||
import { Pool, type PoolConfig, type QueryResult } from 'pg';
|
||||
import { Span } from '../../util/span.js';
|
||||
import {
|
||||
AbstractDatabaseClient,
|
||||
type BatchEntry,
|
||||
type WriteResult,
|
||||
} from './DatabaseClient';
|
||||
import { compareMigrationFilenames } from './migrationFilenames.js';
|
||||
import { preparePostgresSql } from './preparePostgresSql.js';
|
||||
import { splitPostgresStatements } from './splitPostgresStatements.js';
|
||||
import type { IConfig } from '../../types';
|
||||
|
||||
type PostgresEndpointConfig = {
|
||||
host?: string;
|
||||
port?: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
database?: string;
|
||||
connectionString?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export interface PostgresQueryable {
|
||||
query(query: string, values?: unknown[]): Promise<QueryResult>;
|
||||
}
|
||||
|
||||
export interface PostgresPoolClient extends PostgresQueryable {
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export interface PostgresPool extends PostgresQueryable {
|
||||
connect(): Promise<PostgresPoolClient>;
|
||||
end(): Promise<void>;
|
||||
}
|
||||
|
||||
type PostgresPoolFactory = (poolConfig: PoolConfig) => PostgresPool;
|
||||
|
||||
enum Configuration {
|
||||
SINGLE,
|
||||
REPLICA,
|
||||
}
|
||||
|
||||
const POSTGRES_INT8_OID = 20;
|
||||
const INTEGER_TEXT_PATTERN = /^-?\d+$/u;
|
||||
|
||||
const normalizePostgresInt8 = (
|
||||
value: unknown,
|
||||
columnName: string,
|
||||
): number | null | undefined => {
|
||||
if (value === null || value === undefined) return value;
|
||||
|
||||
const parsed =
|
||||
typeof value === 'bigint'
|
||||
? Number(value)
|
||||
: typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string' && INTEGER_TEXT_PATTERN.test(value)
|
||||
? Number(value)
|
||||
: Number.NaN;
|
||||
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new Error(
|
||||
`[postgres] int8 column ${columnName} is outside JavaScript's safe integer range`,
|
||||
);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const normalizePostgresRows = (
|
||||
result: QueryResult,
|
||||
): Record<string, unknown>[] => {
|
||||
const int8Fields = result.fields.filter(
|
||||
(field) => field.dataTypeID === POSTGRES_INT8_OID,
|
||||
);
|
||||
if (int8Fields.length === 0) {
|
||||
return result.rows as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
return result.rows.map((row) => {
|
||||
const normalized: Record<string, unknown> = { ...row };
|
||||
for (const field of int8Fields) {
|
||||
normalized[field.name] = normalizePostgresInt8(
|
||||
normalized[field.name],
|
||||
field.name,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
};
|
||||
|
||||
export const mapPostgresWriteResult = (result: QueryResult): WriteResult => {
|
||||
const affectedRows = result.rowCount ?? 0;
|
||||
const rowId = result.rows[0]?.id;
|
||||
const insertId =
|
||||
typeof rowId === 'bigint'
|
||||
? rowId
|
||||
: typeof rowId === 'number'
|
||||
? rowId
|
||||
: typeof rowId === 'string' && rowId !== ''
|
||||
? Number(rowId)
|
||||
: 0;
|
||||
const normalizedInsertId =
|
||||
typeof insertId === 'number' && Number.isNaN(insertId) ? 0 : insertId;
|
||||
|
||||
return {
|
||||
insertId: normalizedInsertId,
|
||||
affectedRows,
|
||||
anyRowsAffected: affectedRows > 0,
|
||||
};
|
||||
};
|
||||
|
||||
export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
override readonly engineName = 'postgres';
|
||||
|
||||
private primaryPool!: PostgresPool;
|
||||
private replicaPool!: PostgresPool;
|
||||
private configuration = Configuration.SINGLE;
|
||||
private shutdownStarted = false;
|
||||
private shutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
config: IConfig,
|
||||
private readonly poolFactory: PostgresPoolFactory = (poolConfig) =>
|
||||
new Pool(poolConfig) as unknown as PostgresPool,
|
||||
) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
override async onServerStart(): Promise<void> {
|
||||
const dbConf = this.config.database!;
|
||||
|
||||
this.primaryPool = this.createPool(dbConf);
|
||||
await this.primaryPool.query('SELECT 1');
|
||||
console.log('[postgres] connected to primary');
|
||||
|
||||
if (dbConf.replica) {
|
||||
this.replicaPool = this.createPool(dbConf.replica);
|
||||
await this.replicaPool.query('SELECT 1');
|
||||
this.configuration = Configuration.REPLICA;
|
||||
console.log('[postgres] connected to read-replica');
|
||||
} else {
|
||||
this.replicaPool = this.primaryPool;
|
||||
this.configuration = Configuration.SINGLE;
|
||||
}
|
||||
|
||||
await this.runMigrations();
|
||||
}
|
||||
|
||||
override async onServerPrepareShutdown(): Promise<void> {
|
||||
if (this.shutdownStarted) return;
|
||||
this.shutdownStarted = true;
|
||||
|
||||
const drainMs = 60_000;
|
||||
console.log(
|
||||
`[postgres] draining in-flight queries (${drainMs}ms) before closing pools`,
|
||||
);
|
||||
|
||||
this.shutdownTimer = setTimeout(() => {
|
||||
this.shutdownTimer = null;
|
||||
this.closeCurrentPools().catch((e) =>
|
||||
console.error('[postgres] error closing pools after drain', e),
|
||||
);
|
||||
}, drainMs);
|
||||
|
||||
if (typeof this.shutdownTimer.unref === 'function') {
|
||||
this.shutdownTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
override async onServerShutdown(): Promise<void> {
|
||||
if (this.shutdownTimer) {
|
||||
clearTimeout(this.shutdownTimer);
|
||||
this.shutdownTimer = null;
|
||||
}
|
||||
await this.closeCurrentPools();
|
||||
}
|
||||
|
||||
override quoteIdentifier(identifier: string): string {
|
||||
return identifier
|
||||
.split('.')
|
||||
.map((part) => {
|
||||
if (part === '*') return part;
|
||||
return `"${part.replaceAll('"', '""')}"`;
|
||||
})
|
||||
.join('.');
|
||||
}
|
||||
|
||||
override booleanLiteral(value: boolean): string {
|
||||
return value ? 'TRUE' : 'FALSE';
|
||||
}
|
||||
|
||||
override booleanValue(value: boolean): boolean {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Span('db.read', (query: string) => ({ 'db.statement': query }))
|
||||
override async read(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const result = await this.query(this.replicaPool, query, params);
|
||||
return normalizePostgresRows(result);
|
||||
}
|
||||
|
||||
@Span('db.pread', (query: string) => ({ 'db.statement': query }))
|
||||
override async pread(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const result = await this.query(this.primaryPool, query, params);
|
||||
return normalizePostgresRows(result);
|
||||
}
|
||||
|
||||
@Span('db.write', (query: string) => ({ 'db.statement': query }))
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<WriteResult> {
|
||||
const result = await this.query(this.primaryPool, query, params);
|
||||
return mapPostgresWriteResult(result);
|
||||
}
|
||||
|
||||
@Span('db.batchWrite', (entries: unknown[]) => ({
|
||||
'db.batch_size': entries.length,
|
||||
}))
|
||||
override async batchWrite(entries: BatchEntry[]): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
const conn = await this.primaryPool.connect();
|
||||
try {
|
||||
await conn.query('BEGIN');
|
||||
try {
|
||||
for (const { statement, values } of entries) {
|
||||
await this.query(conn, statement, values);
|
||||
}
|
||||
await conn.query('COMMIT');
|
||||
} catch (err) {
|
||||
await conn.query('ROLLBACK').catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
|
||||
override async tryHardRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
if (this.configuration === Configuration.SINGLE) {
|
||||
return this.read(query, params);
|
||||
}
|
||||
|
||||
const primaryPromise = this.query(this.primaryPool, query, params);
|
||||
try {
|
||||
const replicaResult = await this.query(
|
||||
this.replicaPool,
|
||||
query,
|
||||
params,
|
||||
);
|
||||
if (replicaResult.rows.length > 0) {
|
||||
primaryPromise.catch(() => {});
|
||||
return normalizePostgresRows(replicaResult);
|
||||
}
|
||||
} catch {
|
||||
// fall through to primary
|
||||
}
|
||||
|
||||
const primaryResult = await primaryPromise;
|
||||
return normalizePostgresRows(primaryResult);
|
||||
}
|
||||
|
||||
private async runMigrations(): Promise<void> {
|
||||
const paths = this.config.database?.migrationPaths;
|
||||
if (!paths || paths.length === 0) return;
|
||||
|
||||
const conn = await this.primaryPool.connect();
|
||||
try {
|
||||
for (const rawPath of paths) {
|
||||
const dir = isAbsolute(rawPath)
|
||||
? rawPath
|
||||
: resolvePath(process.cwd(), rawPath);
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(dir)
|
||||
.filter(
|
||||
(f) =>
|
||||
f.endsWith('.sql') && f.startsWith('postgres'),
|
||||
)
|
||||
.sort(compareMigrationFilenames);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[postgres] migration path is unreadable: ${dir}`,
|
||||
{ cause: e },
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log(`[postgres] no migrations in ${dir}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[postgres] running migrations from ${dir}: ${files.length} file(s)`,
|
||||
);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = resolvePath(dir, file);
|
||||
const contents = readFileSync(filePath, 'utf8');
|
||||
const statements = splitPostgresStatements(contents);
|
||||
await conn.query('BEGIN');
|
||||
try {
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
try {
|
||||
await conn.query(statements[i]);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[postgres] failed to apply ${file} at statement ${i}`,
|
||||
{ cause: e },
|
||||
);
|
||||
}
|
||||
}
|
||||
await conn.query('COMMIT');
|
||||
} catch (e) {
|
||||
await conn.query('ROLLBACK').catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
console.log(
|
||||
`[postgres] applied ${file} (${statements.length} statements)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
private createPool(dbConf: PostgresEndpointConfig): PostgresPool {
|
||||
const connectionString = dbConf.connectionString ?? dbConf.url;
|
||||
if (connectionString) {
|
||||
return this.poolFactory({
|
||||
connectionString,
|
||||
max: 30,
|
||||
});
|
||||
}
|
||||
|
||||
return this.poolFactory({
|
||||
host: dbConf.host ?? '127.0.0.1',
|
||||
port: dbConf.port ?? 5432,
|
||||
user: dbConf.user ?? 'postgres',
|
||||
password: dbConf.password ?? '',
|
||||
database: dbConf.database ?? 'puter',
|
||||
max: 30,
|
||||
});
|
||||
}
|
||||
|
||||
private async query(
|
||||
target: PostgresQueryable,
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<QueryResult> {
|
||||
const prepared = preparePostgresSql(query);
|
||||
return target.query(prepared.text, params);
|
||||
}
|
||||
|
||||
private async closeCurrentPools(): Promise<void> {
|
||||
const tasks: Promise<void>[] = [];
|
||||
if (this.primaryPool) tasks.push(this.primaryPool.end());
|
||||
if (this.replicaPool && this.replicaPool !== this.primaryPool) {
|
||||
tasks.push(this.replicaPool.end());
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { basename, extname, join, resolve } from 'path';
|
||||
import { existsSync, mkdirSync, readFileSync } from 'fs';
|
||||
import { basename, dirname, extname, join, resolve } from 'path';
|
||||
import { createContext, runInContext } from 'vm';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
import type { IConfig } from '../../types';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
|
||||
const MIGRATIONS_DIR = resolve(__dirname, './migrations/sqlite');
|
||||
|
||||
@@ -76,6 +77,23 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[41, ['0045_user_oidc_providers.sql']],
|
||||
[42, ['0046_is-private-apps.sql']],
|
||||
[43, ['0047_app-url-updates.sql']],
|
||||
[44, ['0048_old-app-names-unique-tuple.sql']],
|
||||
[45, ['0049_music-player-pdf-player-updates.sql']],
|
||||
[46, ['0050_add_preamble_version.sql']],
|
||||
[47, ['0051_sessions_v2.sql']],
|
||||
[48, ['0052_sessions_v2_lookups.sql']],
|
||||
[49, ['0053_sessions_access_token_uid.sql']],
|
||||
[50, ['0054_sessions_workers.sql']],
|
||||
[50, ['0055_username_nocase_unique.sql']],
|
||||
[51, ['0056_sessions_kind_worker.sql']],
|
||||
[52, ['0057_add_user_reputation.sql']],
|
||||
[53, ['0058_add_phone_verification.sql']],
|
||||
[54, ['0059_add_card_verification.sql']],
|
||||
[55, ['0060_add_card_fingerprint.sql']],
|
||||
[56, ['0061_add_suspended_at.sql']],
|
||||
[57, ['0062_blocked-app-origins.sql']],
|
||||
[58, ['0063_add_suspended_reason.sql']],
|
||||
[59, ['0064_abuse-moderation-events.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
@@ -95,9 +113,15 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
override async onServerStart(): Promise<void> {
|
||||
const Database = (await import('better-sqlite3')).default;
|
||||
|
||||
const dbPath = this.config.database?.path ?? ':memory:';
|
||||
const dbPath = this.config.database?.inMemory
|
||||
? ':memory:'
|
||||
: (this.config.database?.path ?? ':memory:');
|
||||
const isNew = dbPath === ':memory:' || !existsSync(dbPath);
|
||||
|
||||
if (dbPath !== ':memory:') {
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
}
|
||||
|
||||
this.db = new Database(dbPath);
|
||||
|
||||
await this.runMigrations(isNew);
|
||||
@@ -113,6 +137,7 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
// Query interface
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Span('db.read', (query: string) => ({ 'db.statement': query }))
|
||||
override async read(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -133,6 +158,7 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
return this.read(query, params);
|
||||
}
|
||||
|
||||
@Span('db.write', (query: string) => ({ 'db.statement': query }))
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -149,6 +175,9 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
};
|
||||
}
|
||||
|
||||
@Span('db.batchWrite', (entries: unknown[]) => ({
|
||||
'db.batch_size': entries.length,
|
||||
}))
|
||||
override async batchWrite(
|
||||
entries: { statement: string; values: unknown[] }[],
|
||||
): Promise<void> {
|
||||
@@ -453,6 +482,13 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
version: 43,
|
||||
check: () => this.hasColumn('apps', 'is_private'),
|
||||
},
|
||||
{
|
||||
version: 44,
|
||||
check: () =>
|
||||
this.hasRow(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'old_app_names' AND sql LIKE '%UNIQUE%app_uid%name%'",
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
let inferredUserVersion = 0;
|
||||
|
||||
@@ -24,10 +24,12 @@ export {
|
||||
} from './DatabaseClient';
|
||||
export { SqliteDatabaseClient } from './SqliteDatabaseClient';
|
||||
export { MySQLDatabaseClient } from './MySQLDatabaseClient';
|
||||
export { PostgresDatabaseClient } from './PostgresDatabaseClient';
|
||||
|
||||
import type { IConfig } from '../../types';
|
||||
import { AbstractDatabaseClient } from './DatabaseClient';
|
||||
import { MySQLDatabaseClient } from './MySQLDatabaseClient';
|
||||
import { PostgresDatabaseClient } from './PostgresDatabaseClient';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient';
|
||||
|
||||
/**
|
||||
@@ -41,6 +43,8 @@ export const DatabaseClientFactory = class DatabaseClientFactory {
|
||||
switch (engine) {
|
||||
case 'mysql':
|
||||
return new MySQLDatabaseClient(config);
|
||||
case 'postgres':
|
||||
return new PostgresDatabaseClient(config);
|
||||
case 'sqlite':
|
||||
return new SqliteDatabaseClient(config);
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Comparator for migration filenames named `<engine>_mig_<N>.sql`.
|
||||
*
|
||||
* Existing MySQL files use unpadded integers, so lexical sorting places
|
||||
* `*_mig_10.sql` before `*_mig_2.sql`. Pull the trailing integer out and sort
|
||||
* numerically. Anything that does not match the `_<digits>.sql` shape falls
|
||||
* back to lexical comparison and sorts after numbered files.
|
||||
*/
|
||||
export const compareMigrationFilenames = (a: string, b: string): number => {
|
||||
const numericIndex = (name: string): number => {
|
||||
const m = /_(\d+)\.sql$/.exec(name);
|
||||
return m ? Number.parseInt(m[1], 10) : Number.NaN;
|
||||
};
|
||||
const na = numericIndex(a);
|
||||
const nb = numericIndex(b);
|
||||
if (Number.isFinite(na) && Number.isFinite(nb)) {
|
||||
if (na !== nb) return na - nb;
|
||||
} else if (Number.isFinite(na)) {
|
||||
return -1;
|
||||
} else if (Number.isFinite(nb)) {
|
||||
return 1;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
};
|
||||
@@ -24,7 +24,9 @@
|
||||
--
|
||||
|
||||
--
|
||||
-- Idempotent column-ensure helper (used by CALLs below; dropped at end of file)
|
||||
-- Idempotent column-ensure helper. Used by the CALLs below AND by later
|
||||
-- migration files, which CALL it as a one-line idempotent column add. Left
|
||||
-- resident (not dropped at end of file) so it outlives this migration.
|
||||
--
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_col;
|
||||
@@ -60,7 +62,7 @@ CREATE TABLE IF NOT EXISTS `access_token_permissions` (
|
||||
`extra` json DEFAULT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=43870 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('access_token_permissions', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -100,7 +102,7 @@ CREATE TABLE IF NOT EXISTS `ai_usage` (
|
||||
KEY `idx_ai_usage_user_timestamp` (`user_id`,`created_at`),
|
||||
CONSTRAINT `ai_usage_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT `ai_usage_ibfk_2` FOREIGN KEY (`app_id`) REFERENCES `apps` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=935038 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('ai_usage', 'id', '`id` int NOT NULL AUTO_INCREMENT');
|
||||
@@ -131,7 +133,7 @@ CREATE TABLE IF NOT EXISTS `app_filetype_association` (
|
||||
KEY `app_id` (`app_id`),
|
||||
KEY `type` (`type`),
|
||||
CONSTRAINT `app_filetype_association_ibfk_1` FOREIGN KEY (`app_id`) REFERENCES `apps` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=28897 DEFAULT CHARSET=utf8mb3;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('app_filetype_association', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -157,7 +159,7 @@ CREATE TABLE IF NOT EXISTS `app_opens` (
|
||||
KEY `idx_app_opens_app_user` (`app_uid`,`user_id`),
|
||||
CONSTRAINT `app_opens_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT `app_opens_ibfk_3` FOREIGN KEY (`app_uid`) REFERENCES `apps` (`uid`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=14510891 DEFAULT CHARSET=utf8mb3;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('app_opens', '_id', '`_id` bigint unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -231,7 +233,7 @@ CREATE TABLE IF NOT EXISTS `apps` (
|
||||
KEY `idx_apps_listing_timestamp` (`approved_for_listing`,`timestamp` DESC),
|
||||
CONSTRAINT `apps_ibfk_1` FOREIGN KEY (`owner_user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_apps_app_owner` FOREIGN KEY (`app_owner`) REFERENCES `apps` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=169455 DEFAULT CHARSET=utf8mb3;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('apps', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -278,7 +280,7 @@ CREATE TABLE IF NOT EXISTS `audit_dev_to_app_permissions` (
|
||||
KEY `fk_audit_dev_to_app_permissions_app_id` (`app_id`),
|
||||
CONSTRAINT `fk_audit_dev_to_app_permissions_app_id` FOREIGN KEY (`app_id`) REFERENCES `apps` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_audit_dev_to_app_permissions_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=34 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('audit_dev_to_app_permissions', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -314,7 +316,7 @@ CREATE TABLE IF NOT EXISTS `audit_user_to_app_permissions` (
|
||||
KEY `fk_audit_user_to_app_permissions_app_id` (`app_id`),
|
||||
CONSTRAINT `fk_audit_user_to_app_permissions_app_id` FOREIGN KEY (`app_id`) REFERENCES `apps` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_audit_user_to_app_permissions_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=7352860 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('audit_user_to_app_permissions', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -350,7 +352,7 @@ CREATE TABLE IF NOT EXISTS `audit_user_to_group_permissions` (
|
||||
KEY `group_id` (`group_id`),
|
||||
CONSTRAINT `audit_user_to_group_permissions_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT `audit_user_to_group_permissions_ibfk_2` FOREIGN KEY (`group_id`) REFERENCES `group` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('audit_user_to_group_permissions', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -384,7 +386,7 @@ CREATE TABLE IF NOT EXISTS `audit_user_to_user_permissions` (
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `fk_audit_user_to_user_permissions_issuer_user_id` (`issuer_user_id`),
|
||||
KEY `fk_audit_user_to_user_permissions_holder_user_id` (`holder_user_id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=981 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('audit_user_to_user_permissions', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -437,7 +439,7 @@ CREATE TABLE IF NOT EXISTS `feedback` (
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `user_id` (`user_id`),
|
||||
CONSTRAINT `feedback_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=815 DEFAULT CHARSET=utf8mb3;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('feedback', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -503,7 +505,7 @@ CREATE TABLE IF NOT EXISTS `fsentries` (
|
||||
CONSTRAINT `fsentries_ibfk_2` FOREIGN KEY (`parent_id`) REFERENCES `fsentries` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT `fsentries_ibfk_3` FOREIGN KEY (`shortcut_to`) REFERENCES `fsentries` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT `fsentries_ibfk_4` FOREIGN KEY (`associated_app_id`) REFERENCES `apps` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=37893498 DEFAULT CHARSET=latin1;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('fsentries', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -555,7 +557,7 @@ CREATE TABLE IF NOT EXISTS `fsentry_versions` (
|
||||
KEY `user_id` (`user_id`),
|
||||
CONSTRAINT `fsentry_versions_ibfk_1` FOREIGN KEY (`fsentry_id`) REFERENCES `fsentries` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT `fsentry_versions_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=24662498 DEFAULT CHARSET=utf8mb3;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('fsentry_versions', 'id', '`id` bigint NOT NULL AUTO_INCREMENT');
|
||||
@@ -623,7 +625,7 @@ CREATE TABLE IF NOT EXISTS `group` (
|
||||
UNIQUE KEY `uid` (`uid`),
|
||||
KEY `owner_user_id` (`owner_user_id`),
|
||||
CONSTRAINT `group_ibfk_1` FOREIGN KEY (`owner_user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=27 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('group', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -651,7 +653,7 @@ CREATE TABLE IF NOT EXISTS `jct_user_group` (
|
||||
KEY `group_id` (`group_id`),
|
||||
CONSTRAINT `jct_user_group_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT `jct_user_group_ibfk_2` FOREIGN KEY (`group_id`) REFERENCES `group` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=3513197 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('jct_user_group', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -681,7 +683,7 @@ CREATE TABLE IF NOT EXISTS `kv` (
|
||||
KEY `user_id` (`user_id`),
|
||||
KEY `kkey_hash` (`kkey_hash`),
|
||||
CONSTRAINT `kv_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=101649 DEFAULT CHARSET=utf8mb3;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('kv', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -744,7 +746,7 @@ CREATE TABLE IF NOT EXISTS `notification` (
|
||||
UNIQUE KEY `uid` (`uid`),
|
||||
KEY `user_id` (`user_id`),
|
||||
CONSTRAINT `notification_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=164238 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('notification', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -770,7 +772,7 @@ CREATE TABLE IF NOT EXISTS `old_app_names` (
|
||||
KEY `app_uid` (`app_uid`),
|
||||
KEY `old_app_names_app_name` (`name`),
|
||||
CONSTRAINT `old_app_names_ibfk_1` FOREIGN KEY (`app_uid`) REFERENCES `apps` (`uid`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=45405 DEFAULT CHARSET=utf8mb3;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('old_app_names', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -792,7 +794,7 @@ CREATE TABLE IF NOT EXISTS `per_user_credit` (
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `user_id` (`user_id`),
|
||||
CONSTRAINT `per_user_credit_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=388003 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('per_user_credit', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -847,7 +849,7 @@ CREATE TABLE IF NOT EXISTS `sessions` (
|
||||
KEY `fk_sessions_user_id` (`user_id`),
|
||||
KEY `uuid` (`uuid`),
|
||||
CONSTRAINT `fk_sessions_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=2293466 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('sessions', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -875,7 +877,7 @@ CREATE TABLE IF NOT EXISTS `share` (
|
||||
KEY `issuer_user_id` (`issuer_user_id`),
|
||||
KEY `recipient_email` (`recipient_email`),
|
||||
CONSTRAINT `share_ibfk_1` FOREIGN KEY (`issuer_user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=40 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('share', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -904,7 +906,7 @@ CREATE TABLE IF NOT EXISTS `storage_audit` (
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `fk_storage_audit_user_id` (`user_id`),
|
||||
CONSTRAINT `fk_storage_audit_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=22080 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('storage_audit', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -948,7 +950,7 @@ CREATE TABLE IF NOT EXISTS `subdomains` (
|
||||
CONSTRAINT `subdomains_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT `subdomains_ibfk_2` FOREIGN KEY (`root_dir_id`) REFERENCES `fsentries` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT `subdomains_ibfk_3` FOREIGN KEY (`associated_app_id`) REFERENCES `apps` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=171443 DEFAULT CHARSET=utf8mb3;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('subdomains', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -983,7 +985,7 @@ CREATE TABLE IF NOT EXISTS `thread` (
|
||||
KEY `idx_thread_uid` (`uid`),
|
||||
CONSTRAINT `thread_ibfk_1` FOREIGN KEY (`parent_uid`) REFERENCES `thread` (`uid`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT `thread_ibfk_2` FOREIGN KEY (`owner_user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=1987 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('thread', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -1090,7 +1092,7 @@ CREATE TABLE IF NOT EXISTS `user` (
|
||||
KEY `idx_user_signup_origin` (`signup_origin`),
|
||||
KEY `idx_user_signup_server` (`signup_server`),
|
||||
CONSTRAINT `fk_user_referred_by` FOREIGN KEY (`referred_by`) REFERENCES `user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=2987224 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('user', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -1218,7 +1220,7 @@ CREATE TABLE IF NOT EXISTS `user_oidc_providers` (
|
||||
KEY `user_id` (`user_id`),
|
||||
KEY `idx_user_oidc_providers_provider` (`provider`),
|
||||
CONSTRAINT `user_oidc_providers_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=51584 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('user_oidc_providers', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -1319,7 +1321,7 @@ CREATE TABLE IF NOT EXISTS `user_update_audit` (
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `fk_user_update_audit_user_id` (`user_id`),
|
||||
CONSTRAINT `fk_user_update_audit_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=8078 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
CALL _puter_add_col('user_update_audit', 'id', '`id` int unsigned NOT NULL AUTO_INCREMENT');
|
||||
@@ -1333,7 +1335,10 @@ CALL _puter_add_col('user_update_audit', 'new_username', '`new_username` varchar
|
||||
CALL _puter_add_col('user_update_audit', 'reason', '`reason` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL');
|
||||
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_col;
|
||||
-- _puter_add_col is intentionally NOT dropped here: later migration files CALL
|
||||
-- it as a one-line idempotent column add, and migrations replay on every boot
|
||||
-- with no applied-state tracking, so the helper must outlive this file. The
|
||||
-- DROP-before-CREATE at the top keeps this migration itself replay-safe.
|
||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||
|
||||
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Mirrors SQLite migration 0053. Adds the
|
||||
-- `access_token_uid` reverse-lookup column on `sessions` so raw-uuid
|
||||
-- revoke can find the matching session row when only the v2 token_uid
|
||||
-- (no JWT) is presented.
|
||||
--
|
||||
-- Idempotent: each ADD COLUMN / ADD INDEX is guarded so the migration
|
||||
-- directory can be replayed safely.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_sessions_access_token_uid;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_sessions_access_token_uid()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'access_token_uid'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD COLUMN `access_token_uid` VARCHAR(64) DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions'
|
||||
AND INDEX_NAME = 'idx_sessions_access_token_uid'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD INDEX `idx_sessions_access_token_uid` (`access_token_uid`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_sessions_access_token_uid();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_sessions_access_token_uid;
|
||||
@@ -0,0 +1,70 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Worker session uniqueness. Each Puter worker is a separately-deployed
|
||||
-- code unit (its own subdomain), so one user can have many workers under
|
||||
-- the same app — distinguished by `meta.worker_name`. The natural unique
|
||||
-- key for an active worker session is therefore
|
||||
-- (user_id, app_uid, worker_name), and app_uid is allowed NULL for
|
||||
-- user-scoped workers that aren't bound to any specific app.
|
||||
--
|
||||
-- Implemented the same way as `app_unique_key` from mig_9: a VIRTUAL
|
||||
-- generated column that's non-NULL only for the rows under the rule,
|
||||
-- then a UNIQUE INDEX on the column. NULLs don't conflict in MySQL
|
||||
-- UNIQUE indexes, so soft-revoked / non-worker rows fall out
|
||||
-- automatically. IFNULL normalises NULL `app_uid` so two user-scoped
|
||||
-- workers with the same name still dedupe. JSON_UNQUOTE strips the
|
||||
-- JSON value quoting from JSON_EXTRACT so the concatenated key is a
|
||||
-- plain string that matches the SELECT path's binding.
|
||||
--
|
||||
-- Idempotent: each ADD COLUMN / ADD INDEX is guarded so the migration
|
||||
-- directory can be replayed safely.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_sessions_worker_unique;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_sessions_worker_unique()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'worker_unique_key'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD COLUMN `worker_unique_key` VARCHAR(550)
|
||||
GENERATED ALWAYS AS (
|
||||
IF(`kind` = 'worker' AND `revoked_at` IS NULL,
|
||||
CONCAT(`user_id`, '|', IFNULL(`app_uid`, ''), '|',
|
||||
IFNULL(JSON_UNQUOTE(JSON_EXTRACT(`meta`, '$.worker_name')), '')),
|
||||
NULL)
|
||||
) VIRTUAL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions'
|
||||
AND INDEX_NAME = 'idx_sessions_user_worker_active'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD UNIQUE INDEX `idx_sessions_user_worker_active` (`worker_unique_key`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_sessions_worker_unique();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_sessions_worker_unique;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Add 'worker' to the `sessions.kind` ENUM. mig_11 already added the
|
||||
-- worker uniqueness index and the application code in SessionStore
|
||||
-- inserts rows with kind='worker', but the ENUM defined in mig_8 never
|
||||
-- listed 'worker' as a permitted value. Under STRICT_TRANS_TABLES the
|
||||
-- INSERT fails outright; under a relaxed sql_mode the value is coerced
|
||||
-- to '' (and the worker SELECT-by-kind path then misses the row).
|
||||
-- Mirrors SQLite migration 0056.
|
||||
--
|
||||
-- Idempotent: guarded against COLUMN_TYPE so re-running the directory is
|
||||
-- a no-op once 'worker' is in the ENUM.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_sessions_kind_worker;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_sessions_kind_worker()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions'
|
||||
AND COLUMN_NAME = 'kind'
|
||||
AND FIND_IN_SET('worker', REPLACE(REPLACE(REPLACE(COLUMN_TYPE, 'enum(', ''), ')', ''), '''', '')) > 0
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
MODIFY COLUMN `kind`
|
||||
ENUM('web', 'app', 'access_token', 'asset', 'worker')
|
||||
NOT NULL DEFAULT 'web';
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_sessions_kind_worker();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_sessions_kind_worker;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- SMS phone verification columns. Mirrors SQLite migration 0058. `phone` is the
|
||||
-- E.164 number collected during verification (indexed like `email`);
|
||||
-- `requires_phone_verification` gates account use for low-reputation signups
|
||||
-- (not indexed, mirroring `requires_email_confirmation`).
|
||||
--
|
||||
-- Idempotent: column adds use _puter_add_col (defined in mig_1, which leaves it
|
||||
-- resident for later migrations); the index add is guarded against
|
||||
-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
|
||||
|
||||
CALL _puter_add_col('user', 'phone', '`phone` varchar(20) DEFAULT NULL');
|
||||
CALL _puter_add_col('user', 'requires_phone_verification', '`requires_phone_verification` tinyint(1) NOT NULL DEFAULT ''0''');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_phone_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_user_phone_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND INDEX_NAME = 'idx_user_phone'
|
||||
) THEN
|
||||
ALTER TABLE `user` ADD INDEX `idx_user_phone` (`phone`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_user_phone_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_phone_index;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Credit-card verification column. Mirrors SQLite migration 0059.
|
||||
-- `requires_card_verification` gates account use for low-reputation signups;
|
||||
-- the card itself never touches our DB, so this is the only column (not
|
||||
-- indexed, mirroring `requires_phone_verification`).
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (from mig_1) so the
|
||||
-- directory replays safely.
|
||||
|
||||
CALL _puter_add_col('user', 'requires_card_verification', '`requires_card_verification` tinyint(1) NOT NULL DEFAULT ''0''');
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Card fingerprint column. Mirrors SQLite migration 0060. `card_fingerprint`
|
||||
-- is the Stripe card fingerprint (stable per card number) recorded when a user
|
||||
-- clears card verification — the card sibling of `phone`, indexed like it so
|
||||
-- admin tooling can find the accounts that verified with a given card. The card
|
||||
-- itself never touches our DB, only Stripe's fingerprint for it.
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- leaves it resident for later migrations); the index add is guarded against
|
||||
-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
|
||||
|
||||
CALL _puter_add_col('user', 'card_fingerprint', '`card_fingerprint` varchar(128) DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_card_fingerprint_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_user_card_fingerprint_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND INDEX_NAME = 'idx_user_card_fingerprint'
|
||||
) THEN
|
||||
ALTER TABLE `user` ADD INDEX `idx_user_card_fingerprint` (`card_fingerprint`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_user_card_fingerprint_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_card_fingerprint_index;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Suspended-at column. Mirrors SQLite migration 0061. `suspended_at` is when
|
||||
-- the account was suspended, as unix seconds (NULL while not suspended) — the
|
||||
-- timestamp sibling of the boolean `suspended` flag, indexed so the
|
||||
-- signup-abuse harness can count an IP's recently-suspended accounts.
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- leaves it resident for later migrations); the index add is guarded against
|
||||
-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
|
||||
|
||||
CALL _puter_add_col('user', 'suspended_at', '`suspended_at` bigint DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_suspended_at_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_user_suspended_at_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND INDEX_NAME = 'idx_user_suspended_at'
|
||||
) THEN
|
||||
ALTER TABLE `user` ADD INDEX `idx_user_suspended_at` (`suspended_at`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_user_suspended_at_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_suspended_at_index;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Admin-managed blocklist of app origins. Mirrors SQLite migration 0062.
|
||||
-- An app whose `index_url` host (or a request origin) matches an entry is
|
||||
-- denied access to Puter resources: it cannot obtain an app token and
|
||||
-- already-issued app tokens are rejected on each request. `include_subdomains
|
||||
-- = 1` also blocks every subdomain of `domain`. Enforced in AuthService via
|
||||
-- AppOriginBlocklistService.
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `blocked_app_origins` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`domain` VARCHAR(255) NOT NULL,
|
||||
`include_subdomains` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`reason` TEXT DEFAULT NULL,
|
||||
`created_by` VARCHAR(255) DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_blocked_app_origins_domain` (`domain`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Suspension reason column. Mirrors SQLite migration 0063. Why an account was
|
||||
-- suspended (NULL while not suspended) — companion to the boolean `suspended`
|
||||
-- flag and `suspended_at` timestamp. Constrained at the application layer to a
|
||||
-- fixed set of reasons (see extensions/admin suspension_reasons.js).
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- leaves it resident for later migrations).
|
||||
|
||||
CALL _puter_add_col('user', 'suspended_reason', '`suspended_reason` VARCHAR(64) DEFAULT NULL');
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Append-only log of admin moderation actions. Mirrors SQLite migration 0064.
|
||||
-- Used to measure the abuse system's false-positive rate: `unsuspend` (admin
|
||||
-- unblock) and `admin_create_user` are the false-positive signals; `suspend`
|
||||
-- gives the denominator. Preserves history the `user` suspension columns lose
|
||||
-- on unsuspend. `created_at` is unix seconds (matching `user.suspended_at`).
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `abuse_moderation_events` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`action` VARCHAR(32) NOT NULL,
|
||||
`target_user_id` BIGINT DEFAULT NULL,
|
||||
`target_username` VARCHAR(255) DEFAULT NULL,
|
||||
`admin_username` VARCHAR(255) DEFAULT NULL,
|
||||
`reason` TEXT DEFAULT NULL,
|
||||
`source` VARCHAR(64) DEFAULT NULL,
|
||||
`created_at` BIGINT NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_abuse_moderation_events_created_at` (`created_at`),
|
||||
KEY `idx_abuse_moderation_events_action` (`action`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- Add UNIQUE(app_uid, name) on `old_app_names` to mirror the SQLite
|
||||
-- schema (after migration 0048) and let AppStore use ON DUPLICATE KEY
|
||||
-- UPDATE to refresh the timestamp when the same app re-records the
|
||||
-- same old name. The MySQL table previously had no uniqueness on these
|
||||
-- columns, so we deduplicate first (keeping the most recent row per
|
||||
-- (app_uid, name) pair) before adding the constraint.
|
||||
--
|
||||
-- Idempotent: a stored procedure inspects INFORMATION_SCHEMA before
|
||||
-- adding the index, so re-running the migration directory is safe.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_dedup_old_app_names;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_dedup_old_app_names()
|
||||
BEGIN
|
||||
DELETE oa FROM `old_app_names` oa
|
||||
JOIN `old_app_names` ob
|
||||
ON oa.`app_uid` = ob.`app_uid`
|
||||
AND oa.`name` = ob.`name`
|
||||
AND (oa.`timestamp` < ob.`timestamp`
|
||||
OR (oa.`timestamp` = ob.`timestamp` AND oa.`id` < ob.`id`));
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_dedup_old_app_names();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_dedup_old_app_names;
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_unique_old_app_names;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_unique_old_app_names()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'old_app_names'
|
||||
AND INDEX_NAME = 'unique_old_app_names_app_uid_name'
|
||||
) THEN
|
||||
ALTER TABLE `old_app_names`
|
||||
ADD UNIQUE KEY `unique_old_app_names_app_uid_name` (`app_uid`, `name`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_unique_old_app_names();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_unique_old_app_names;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- Refresh PDF and Player to point at hosted icons and updated index_urls,
|
||||
-- and add the new Music Player app. The Player app moves to
|
||||
-- simple-player.puter.com so the player.puter.com hostname can be reused
|
||||
-- by the Music Player entry inserted below. Mirrors SQLite migration 0049.
|
||||
--
|
||||
-- Idempotent: UPDATEs are no-ops once rows are in the target state and
|
||||
-- INSERT IGNORE will skip the Music Player row if its uid/name already
|
||||
-- exists, so re-running the migration directory is safe.
|
||||
|
||||
UPDATE `apps`
|
||||
SET `index_url` = 'https://pdf.puter.com',
|
||||
`icon` = 'data:image/png;base64,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 truncated
|
||||
WHERE `uid` = 'app-3920851d-bda8-479b-9407-8517293c7d44';
|
||||
|
||||
UPDATE `apps`
|
||||
SET `index_url` = 'https://simple-player.puter.com',
|
||||
`icon` = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAACXBIWXMAAAsTAAALEwEAmpwYAAAgAElEQVR4nO1de5AcxXnHAr8xccVxVeJKnH9STqUq5dj/JPkjcR6VKlclVS5b6HHSPXRvvU4PngJsLIx5CwEyNkHYDuJlY5kkhjjYYMxJgE4CA5GNsEAgC0kn6XS3O+/dmZ3HfqmvZ3qvd7a7p2cfdyfjqfpqd6Z7+vH9fv19X/f07J533vwf7wGAd6Wc9244mA4vAoALGDn/XaMEwSHRy6JzWjdJx7AjFyje8p6lS5eePz4+fsHLL7/83mPHjn3g0KFDF549e/bC8fHxCycmJj6IcvDgwQ/j9cOHD38E5ciRIxfR7y+88AKRmZmZjxQKhYuOHj36e/iJaXgP3ovl4T00/fDhwx/B8jEdP1EOHDhA0lBo+fR+FEzH6yjs9SeeeOJDtI1U8Hz37t3vwz6h7Ny5872oF+xvDj2eO4MlaSh2sHZgxy3L+rhhGH9tWdZoFEXXBUFwRxAEO33ff8zzvJ+5rrvPdd1XPM876LruId/3j1QqlXcqlcoJ/PQ8723P897yPO+Y53knfN/H6yd935/EzyTfCUxLrp/2ff8MfnqeN+l53nGmPLzndKVSOYN5MY2K67rHk3RMI/czed5JBNNPoST1HUvkLWy37/vHkrqOJf143fM87NNrQRD80vf9Vz3Pe6lSqez1ff+JIAh2RVH0jSiKtoVhuNVxnH5d1z9jmubH0rqk1uG8hXakGYqjRtf1iyuVyh2e570ShqFdrVZD+N2hdIRhGEZRZAZBsC8Iglsdx/n8kSNH3s/oe9GCIALbkK1bty6amZn5p3K5/B3f96fTnapWqxBFUQQAQRRFQfIZJlJ3AACRKIqqyWftCMOwiteppM95wpSB37PyVdP5kjoa6k4k3WbhOXvggEDBYlM6wbx1B2YPggCt062FQuEvGP3Pn2tgA5Xp6el/K5fLL6GC6tsd0Q4RhVIioKS/s+cq10V50vnpuaisqkB4BJblzXtNdCQDhSUXEqNGiiiKfMdx/nNycvIzbCA55wEefj958uQfm6b5A4a0dIRXm1F0nntUCdAOoUe7y83TT2o96UAKw9B1XfdOXdc/muCiHFy2BfxSqXSx7/tnk0ZSM56pLFEae7Rb8ekys9pED9FonQ8CMNfQMhD3gSdhGL5hWdbfd9wlsKamVCrdQnWRNGbBjJpm2oBHs4DMlyRxSpCQoFIqldZ3jAR0MWfr1q0XWJb1XayUBm/NKr/Tysyq/7dFotmAE0ql0nYWr7aCj/7FcZyHE/Bz+fk5UsS8t6E6f30nlhix8Tzv7gS389tFAGL2bdv+DgV/roDE76rAtpMAUc56O00+lfITEhCX4DjOV9tCAlpAuVy+ggc+7bwMNFEekSJlaVlKUc2Xl3wR8ynrX/qeLJHVLdKLjHDJmgVxB5VKpaslEtAbNU37XBCQ6TyZm+ZlqioB2jEqVEZjs4SoZpBMRoCs+7LakMfK0LWYMAwtXdc/y1rxPOCTAOLJJ5+8yPf9g0mBJOCTsK+pkd1OUjQLalUhn0pb8/ZBZhFUyhXdh0vJiRWYwIdsLKa5Rr9lWbdn+f2sTuRlMK/jc+Fnqxyzn+US8oCft7w8xBRYWUICwzA2sJiqgE/MxdTU1Kc9z3OT0V9V9W95mKqqJBUL0izhIgXQRcDJ6lWJAVTKysovyhMEAXEF+AT01Vdf/biyFaAEMAyDRP1hGGIAkKtDeZSg2un5ljAMiXSyjnbrIggCYgVs274UMcX9FlngE4YcPXr0k5VKxUiP/nNN2glaOAcE6ED/yYwgCIL/o+BLrQDNVCgUNiXgh+dixxcCmUKFvJ3WLZZNXYHrup9XjgU8z3sqYQ4hwHwr/1wlQNhm3aXLS9fBqy8IArI4VKlU7pESgJqGU6dOfdLzPHuhmP9WlZgHiLDNoMnKSgPXjrrp/WxZ1A2EYfgabs8TugFm6rc4Gf0E/GYbxruvlbJ4SptrIoaCOnlgqoAtEl4+VQJxhC4M+ZqmfZoN9NMEIP7f87yrEgKg/VdqsCrYWfeIOp63/rxtjXIoVLWOPO1upR8qbfB9n1qBARbrNAEIKzzPe5D1/7+TsO06QLeM0u4yJWkkDgiC4HZuHEB9Am7sDILgxSQzboJsuqHt7uC7SQJFgsjysWl0PSAMw6elASAAfAj33CdRI+5EbWuDOyHtMJXhAhJWj3n0z8ODCnUBvu+/CQDvawgE6UmpVPpEEARkAcj3/ToCpL+zktWgvB3qlEI7UXYgUH6zIMt0LmuDTBDLxKpPHTt27KM8AhD/7zjOZ6nvb4YAWcSYD5EpOMwBMmNOc/U9fV+WLnnlqNSXIeQxvu/77vT09KcaZgI0KPA871/Z0a/aWVnj846adpOnFVIGkrbmIUAWieZAcKMIqbtcLpMdxLt37z6ft/OnKyFA1Cr4qgRRIZrK6G6HgsM2laMyKOaSAL7v19yA53mLG2YCdF5YqVT6RAT4bZFEGcK0gPO91fo6kTdP3/BapVKhW8V6G54MUgK4rjuSECDkFcZey9tYmeLZdLb8doGQtzy/zXW3C3BWRyI9KRCgcTGIIcD6JFONAKLCRUTgNVKls7yOtRN4Xnv8jOsqpMgqR3Z/Vnk8nYtEhBEVxDTBdkRIgHK5fHmSKchSWKuSdwSmzyuVCvlEf4rf6XnWKMvbpyAHibIAlxFHtfxmhRLA9/01MgJsORcIgN+TlS0CPH0DKM/InQsCBDksU1adbSTAmJAAjuNcmyZAp0Rm8mbP6TWq0Ar4vgfVCODA/pfh5tvugutv3AaPfP8xePvosYQUAVQ8T8kS+PUKItLpfs+jkOcB5XL5UiEBSqXS1+aKAE2ymID806fGYVnXIHT1jELv0BgsWzEMQyMb4Xvfeww03SJ5/MDDKY/yaK/8lhMAMU2mgZfJgsDrk0zBQlNGpeIDPtqeKWiwbuMW6OnfAEOrL4eB0UthaPVlMDByCSxfOQSXXXEtTBz4BXiJm5grYCvzqK+suhMdUAJcKSSAbds3sARgC6bneTqaziu6V6Vcz4sBPXDgZejuWwMDw5fCwNBmGBzaBKtGNkH/6GYYHNkMPavWQ3ffWrj+htvhV6/9mtyDD4zQGjQDUkWhbWwekd7ylNdKG1mcWEFME2y3yFzADawLSBech3E84oiUlK30AJL2w/MvHIAVPauhf2gjrBraCP3Dm4jQ72gJBkY2w4reNdDdtxq+tXMXTE0XYmvguzERSJnNk1FlBMoGEC8tb/2i8kREYAjAtQD0Vz9upgTgjV5VxWRZjqyyZQR4jhBgDawaricAS4RZMmwiccLI2kvg6Z8/Bz4+Fq9GUKlQa1DhtL25vuYltSx/M8TJ0jt1Afiir4wA29IuQLVymfkRlaFOrlkC7H1+PyEAjnIeAfoGNzAk2AyDo5dA7+BGWLZyFLZefyscfuNIMltAt5A9SlWBbUU3mE4D1Tz647WdV06KAGIL4DjOHXNBgPz3+uC6/qwF6F1TAxyJwI58JMAsCfA65ttIrMGK3rWwsm817Nz5AMxMa/Fswcey3VxtF/UnS1/zKdQF4FqPkADlcvmuNAHQZ9IAav6EIcDzSQwgMP0sCdJCLQROG9euvxK+/+h/gW05pFyv7ILnlhIXMP+AtQn0mriumx0DOI5zZx4CtIsc2WXUE6CLIQB1AyoEqFkGtAb962HJ8kG44sqtsG/iF8QlVKsRuG4Z8J3YrDY22/csfTanH3UCZLmA7clrRAF7MyuyzvDO8zSSvb/+PG0B1tQBnpY06L0DY0TI+cAY9KAMjpEYoatnLbEIt23/Jvzm2CSpA1cgy+Uyt18ivWQNElk/s0imWr5MxwwBxC7AcZzbqQWQNbwVYRrEbTyfUCkL0L26BnQW+DwhZEhIsSq5Z8mKIVg1sAEefGg3GMlqIttOVUBV8onuU9NF/vJzE0BmAVQ7kTdfPgKMQh+HAPUBoIQMA7El6EXpXx8TYmgjdK9aBxcvG4ArtlwHL774KgT4VlU1Ao+4BUpecR95ZrtTA6kjBKC/CNJJAqiyX0QAnAYuX4kEqAc7yxWkz6lb6E0JxhMYYyztGiRu4dDrb0BE3tkPSHyQp58LkQDSINCyrG80S4BWJNu0IgEqDAFGGgM7hVhAZBV6UyToGdwAPYMbYXn3CHT3r4G77/kOnDh1OnYLlUqD+zoXRCkItCzrm2kCYGfzdrj9CqpAuezFBHiungDsyl96NsCzArzrvVxrEE8b8XPpihEYXXs5/PgnzxIi4rMFDBJROgxa2+6hBHAcZ0umBSiXywEFvhkCtLOz8XUkQKWOADSql7mA1ggwVqsDZwt9aBFWjsC1190Kr73+Zm01sVx2wSVxAT8+SOuQ7adIt3n1nsZKIEoE2JEmgKixssrygJxVBiVAqeTVxQC9NJqXuAKVGUFfBgn6BmMhVmZkM3T1roGe/vWw875dMD1drD1yLpdL3H4qgqOk5xbAJ5gmBLgyMwjkWQBZY0V5RN/zEsB1vRoB6DSwlwCTPw6QrRH0CiwAWw4uKePy8tIVozC67jJ48ic/I64Af8bf9RrdgkslpYe8hGgXAaSzANu2b8siQN6Km72nJl69C8BnAWQayAFe9FRQ1TX08VYNBYJEwDwre1fDlqu/Bi/se5FMG8MoAsctNQDWVN/bKIwFuKolCzDXncFlWayzXHMBB2B592gNsPQzAfqAiBVenjRpBlL50mU0lhdbA9yN1E02oayBbdu/BW+9fTxxCz6U0C1U4iB2vgmgNAuwbXubiguYa0ESYLCFx57n4mcBvFGfBg8fBcuAHGAkT14uSUYvIUvUw6Ob4eFHflhbTcTYoOwuHAuQNQ2861wgAFoAHvD1O4PUwBxQAFpmOdhzJNGqwY2wZNkgbLnyq7Bv30sQBPj7BQH+mcOCIIB0FmDb9rcwU6lLine truncated
|
||||
WHERE `uid` = 'app-11edfba2-1ed3-4e22-8573-47e88fb87d70';
|
||||
|
||||
INSERT IGNORE INTO `apps` (
|
||||
`uid`, `owner_user_id`, `icon`, `name`, `title`, `description`,
|
||||
`index_url`, `godmode`, `maximize_on_start`, `background`,
|
||||
`approved_for_listing`, `approved_for_opening_items`, `approved_for_incentive_program`,
|
||||
`tags`, `timestamp`
|
||||
) VALUES (
|
||||
'app-d7e9471f-e441-4d72-a5ab-75e96573b76b',
|
||||
1,
|
||||
'data:image/png;base64,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 truncated
|
||||
'music-player',
|
||||
'Music Player',
|
||||
'A free music player app in the browser.',
|
||||
'https://player.puter.com',
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
NULL,
|
||||
'2026-05-10 00:00:00'
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
--
|
||||
-- Idempotent: the ADD COLUMN is guarded by an INFORMATION_SCHEMA check,
|
||||
-- so re-running the migration directory is safe (required — the runner
|
||||
-- has no per-file tracking).
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_subdomains_preamble_version;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_subdomains_preamble_version()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'subdomains' AND COLUMN_NAME = 'preamble_version'
|
||||
) THEN
|
||||
ALTER TABLE `subdomains`
|
||||
ADD COLUMN `preamble_version` varchar(64) DEFAULT NULL;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_subdomains_preamble_version();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_subdomains_preamble_version;
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Extend `sessions` so a single row can represent any token kind
|
||||
-- (web/app/access_token/asset), carry display metadata for the
|
||||
-- manage-sessions UI, and be soft-revoked with row-level expiry.
|
||||
-- Mirrors SQLite migration 0050.
|
||||
--
|
||||
-- Idempotent: each ADD COLUMN / ADD INDEX is guarded by an
|
||||
-- INFORMATION_SCHEMA check, so re-running the migration directory
|
||||
-- is safe (required — the runner has no per-file tracking).
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_extend_sessions_v2;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_extend_sessions_v2()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'kind'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD COLUMN `kind` ENUM('web', 'app', 'access_token', 'asset')
|
||||
NOT NULL DEFAULT 'web';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'label'
|
||||
) THEN
|
||||
ALTER TABLE `sessions` ADD COLUMN `label` VARCHAR(255) DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'parent_session_id'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD COLUMN `parent_session_id` VARCHAR(64) DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'last_ip'
|
||||
) THEN
|
||||
ALTER TABLE `sessions` ADD COLUMN `last_ip` VARCHAR(64) DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'last_user_agent'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD COLUMN `last_user_agent` VARCHAR(512) DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'revoked_at'
|
||||
) THEN
|
||||
ALTER TABLE `sessions` ADD COLUMN `revoked_at` BIGINT DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'expires_at'
|
||||
) THEN
|
||||
ALTER TABLE `sessions` ADD COLUMN `expires_at` BIGINT DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions'
|
||||
AND INDEX_NAME = 'idx_sessions_user_revoked'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD INDEX `idx_sessions_user_revoked` (`user_id`, `revoked_at`);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions'
|
||||
AND INDEX_NAME = 'idx_sessions_parent'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD INDEX `idx_sessions_parent` (`parent_session_id`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_extend_sessions_v2();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_extend_sessions_v2;
|
||||
@@ -0,0 +1,130 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Composite-key lookups + audit columns. Mirrors
|
||||
-- SQLite migration 0052. MySQL has no partial unique indexes, so the
|
||||
-- "at most one active row per (user_id, app_uid)" / "one active row
|
||||
-- per legacy_token_uid" semantics are encoded via VIRTUAL generated
|
||||
-- columns that are NULL when the row isn't subject to the rule —
|
||||
-- MySQL allows multiple NULLs in a UNIQUE index, so non-applicable
|
||||
-- rows don't conflict.
|
||||
--
|
||||
-- Idempotent: each ADD COLUMN / ADD INDEX is guarded so the migration
|
||||
-- directory can be replayed safely.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_sessions_v2_lookups;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_sessions_v2_lookups()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'app_uid'
|
||||
) THEN
|
||||
ALTER TABLE `sessions` ADD COLUMN `app_uid` VARCHAR(64) DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'legacy_token_uid'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD COLUMN `legacy_token_uid` VARCHAR(64) DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'created_via'
|
||||
) THEN
|
||||
ALTER TABLE `sessions` ADD COLUMN `created_via` VARCHAR(32) DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'auth_id'
|
||||
) THEN
|
||||
ALTER TABLE `sessions` ADD COLUMN `auth_id` VARCHAR(64) DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
-- Generated discriminant: non-NULL only for active app-authorization rows,
|
||||
-- so UNIQUE(app_unique_key) enforces "one active app session per
|
||||
-- (user_id, app_uid)" while permitting any number of revoked rows.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'app_unique_key'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD COLUMN `app_unique_key` VARCHAR(150)
|
||||
GENERATED ALWAYS AS (
|
||||
IF(`kind` = 'app' AND `revoked_at` IS NULL,
|
||||
CONCAT(`user_id`, '|', `app_uid`),
|
||||
NULL)
|
||||
) VIRTUAL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'legacy_token_unique_key'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD COLUMN `legacy_token_unique_key` VARCHAR(64)
|
||||
GENERATED ALWAYS AS (
|
||||
IF(`revoked_at` IS NULL, `legacy_token_uid`, NULL)
|
||||
) VIRTUAL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions'
|
||||
AND INDEX_NAME = 'idx_sessions_user_app_active'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD UNIQUE INDEX `idx_sessions_user_app_active` (`app_unique_key`);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions'
|
||||
AND INDEX_NAME = 'idx_sessions_legacy_token_active'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD UNIQUE INDEX `idx_sessions_legacy_token_active`
|
||||
(`legacy_token_unique_key`);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions'
|
||||
AND INDEX_NAME = 'idx_sessions_kind_user'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD INDEX `idx_sessions_kind_user` (`kind`, `user_id`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_sessions_v2_lookups();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_sessions_v2_lookups;
|
||||
@@ -0,0 +1,724 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
uuid varchar(36) NOT NULL UNIQUE,
|
||||
username varchar(50) UNIQUE,
|
||||
email varchar(256),
|
||||
password varchar(225),
|
||||
free_storage bigint,
|
||||
max_subdomains integer,
|
||||
taskbar_items text,
|
||||
desktop_uuid varchar(36),
|
||||
appdata_uuid varchar(36),
|
||||
documents_uuid varchar(36),
|
||||
pictures_uuid varchar(36),
|
||||
videos_uuid varchar(36),
|
||||
trash_uuid varchar(36),
|
||||
trash_id integer,
|
||||
appdata_id integer,
|
||||
desktop_id integer,
|
||||
documents_id integer,
|
||||
pictures_id integer,
|
||||
videos_id integer,
|
||||
referrer varchar(64),
|
||||
desktop_bg_url text,
|
||||
desktop_bg_color varchar(20),
|
||||
desktop_bg_fit varchar(16),
|
||||
pass_recovery_token varchar(36),
|
||||
requires_email_confirmation boolean NOT NULL DEFAULT FALSE,
|
||||
email_confirm_code varchar(8),
|
||||
email_confirm_token varchar(36),
|
||||
email_confirmed boolean NOT NULL DEFAULT FALSE,
|
||||
dev_first_name varchar(100),
|
||||
dev_last_name varchar(100),
|
||||
dev_paypal varchar(100),
|
||||
dev_approved_for_incentive_program boolean DEFAULT FALSE,
|
||||
dev_joined_incentive_program boolean DEFAULT FALSE,
|
||||
suspended boolean,
|
||||
unsubscribed boolean NOT NULL DEFAULT FALSE,
|
||||
"timestamp" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_activity_ts timestamp,
|
||||
referral_code varchar(16) UNIQUE,
|
||||
referred_by integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
unconfirmed_change_email varchar(256),
|
||||
change_email_confirm_token varchar(256),
|
||||
otp_secret text,
|
||||
otp_enabled boolean DEFAULT FALSE,
|
||||
otp_recovery_codes text,
|
||||
stripe_customer_id varchar(40),
|
||||
public_uuid varchar(36),
|
||||
public_id integer,
|
||||
clean_email varchar(256),
|
||||
audit_metadata jsonb,
|
||||
signup_ip varchar(45),
|
||||
signup_ip_forwarded varchar(45),
|
||||
signup_user_agent varchar(512),
|
||||
signup_origin varchar(255),
|
||||
signup_server varchar(255),
|
||||
metadata jsonb DEFAULT '{}'::jsonb,
|
||||
reputation smallint DEFAULT 100
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_email ON "user" (email);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_pass_recovery_token ON "user" (pass_recovery_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_referrer ON "user" (referrer);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_email_confirm_token ON "user" (email_confirm_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_last_activity_ts ON "user" (last_activity_ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_referred_by ON "user" (referred_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_stripe_customer_id ON "user" (stripe_customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_clean_email ON "user" (clean_email);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_signup_ip ON "user" (signup_ip);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_signup_ip_forwarded ON "user" (signup_ip_forwarded);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_signup_user_agent ON "user" (signup_user_agent);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_signup_origin ON "user" (signup_origin);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_signup_server ON "user" (signup_server);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS apps (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
uid varchar(40) NOT NULL UNIQUE,
|
||||
owner_user_id integer REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
icon text,
|
||||
name varchar(100) NOT NULL UNIQUE,
|
||||
title varchar(100) NOT NULL,
|
||||
description text,
|
||||
godmode boolean DEFAULT FALSE,
|
||||
maximize_on_start boolean DEFAULT FALSE,
|
||||
index_url text NOT NULL,
|
||||
approved_for_listing boolean DEFAULT FALSE,
|
||||
approved_for_opening_items boolean DEFAULT FALSE,
|
||||
approved_for_incentive_program boolean DEFAULT FALSE,
|
||||
"timestamp" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_review timestamp,
|
||||
tags varchar(255),
|
||||
app_owner integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
background boolean DEFAULT FALSE,
|
||||
metadata jsonb,
|
||||
protected boolean DEFAULT FALSE,
|
||||
is_private boolean DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_owner_user_id ON apps (owner_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_app_owner ON apps (app_owner);
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_owner_timestamp ON apps (owner_user_id, "timestamp" DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_listing_timestamp ON apps (approved_for_listing, "timestamp" DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "group" (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
uid varchar(40) UNIQUE,
|
||||
owner_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
extra jsonb,
|
||||
metadata jsonb,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_owner_user_id ON "group" (owner_user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_filetype_association (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
app_id integer NOT NULL REFERENCES apps (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
type varchar(60) NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_filetype_association_app_id ON app_filetype_association (app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_filetype_association_type ON app_filetype_association (type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_opens (
|
||||
_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
app_uid varchar(40) NOT NULL REFERENCES apps (uid) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
ts integer NOT NULL,
|
||||
human_ts timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_opens_user_id ON app_opens (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_opens_app_uid ON app_opens (app_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_opens_uid_ts ON app_opens (app_uid, ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_opens_app_user ON app_opens (app_uid, user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fsentries (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
uuid varchar(36) NOT NULL UNIQUE,
|
||||
bucket varchar(50),
|
||||
bucket_region varchar(30),
|
||||
public_token varchar(36) UNIQUE,
|
||||
file_request_token varchar(36) UNIQUE,
|
||||
is_shortcut boolean DEFAULT FALSE,
|
||||
shortcut_to integer REFERENCES fsentries (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
parent_id integer REFERENCES fsentries (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
associated_app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
is_dir boolean DEFAULT FALSE,
|
||||
layout varchar(30),
|
||||
sort_by varchar(20),
|
||||
sort_order varchar(10),
|
||||
is_public boolean,
|
||||
thumbnail text,
|
||||
immutable boolean NOT NULL DEFAULT FALSE,
|
||||
name varchar(767) NOT NULL,
|
||||
metadata text,
|
||||
modified integer NOT NULL,
|
||||
created integer,
|
||||
accessed integer,
|
||||
size bigint,
|
||||
symlink_path varchar(260),
|
||||
is_symlink boolean DEFAULT FALSE,
|
||||
parent_uid varchar(36),
|
||||
path varchar(4096),
|
||||
CONSTRAINT fsentries_parent_name_unique UNIQUE (parent_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_name ON fsentries (name);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_modified ON fsentries (modified);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_parent_id ON fsentries (parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_is_dir ON fsentries (is_dir);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_user_id ON fsentries (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_shortcut_to ON fsentries (shortcut_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_associated_app_id ON fsentries (associated_app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_bucket ON fsentries (bucket);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_bucket_region ON fsentries (bucket_region);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_parent_uid ON fsentries (parent_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_path ON fsentries (path);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_accessed ON fsentries (accessed);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_user_parent_name ON fsentries (user_id, parent_uid, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentries_parent_uid_name ON fsentries (parent_uid, name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fsentry_versions (
|
||||
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
fsentry_id integer NOT NULL REFERENCES fsentries (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
fsentry_uuid varchar(36) NOT NULL,
|
||||
version_id varchar(60) NOT NULL,
|
||||
user_id integer REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
message text,
|
||||
ts_epoch integer
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentry_versions_fsentry_id ON fsentry_versions (fsentry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentry_versions_fsentry_uuid ON fsentry_versions (fsentry_uuid);
|
||||
CREATE INDEX IF NOT EXISTS idx_fsentry_versions_user_id ON fsentry_versions (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subdomains (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
uuid varchar(40) UNIQUE,
|
||||
subdomain varchar(64) NOT NULL UNIQUE,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
root_dir_id integer REFERENCES fsentries (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
associated_app_id integer REFERENCES apps (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
ts timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||
app_owner integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
protected boolean DEFAULT FALSE,
|
||||
domain varchar(265),
|
||||
database_id varchar(40),
|
||||
preamble_version varchar(64)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_subdomains_user_id ON subdomains (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subdomains_root_dir_id ON subdomains (root_dir_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subdomains_associated_app_id ON subdomains (associated_app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subdomains_app_owner ON subdomains (app_owner);
|
||||
CREATE INDEX IF NOT EXISTS idx_subdomains_domain ON subdomains (domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_subdomains_root_user ON subdomains (root_dir_id, user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subdomains_app_user ON subdomains (associated_app_id, user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kv (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
app varchar(40),
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
kkey_hash bigint NOT NULL,
|
||||
kkey text NOT NULL,
|
||||
value text,
|
||||
migrated boolean DEFAULT FALSE,
|
||||
"expireAt" timestamp,
|
||||
CONSTRAINT kv_app_user_kkey_hash_unique UNIQUE (app, user_id, kkey_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_app ON kv (app);
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_user_id ON kv (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_kkey_hash ON kv (kkey_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
uuid varchar(40) NOT NULL,
|
||||
meta jsonb,
|
||||
created_at bigint DEFAULT 0,
|
||||
last_activity bigint DEFAULT 0,
|
||||
kind varchar(32) NOT NULL DEFAULT 'web'
|
||||
CHECK (kind IN ('web', 'app', 'access_token', 'asset', 'worker')),
|
||||
label varchar(255),
|
||||
parent_session_id varchar(64),
|
||||
last_ip varchar(64),
|
||||
last_user_agent varchar(512),
|
||||
revoked_at bigint,
|
||||
expires_at bigint,
|
||||
app_uid varchar(64),
|
||||
legacy_token_uid varchar(64),
|
||||
created_via varchar(32),
|
||||
auth_id varchar(64),
|
||||
access_token_uid varchar(64)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_uuid ON sessions (uuid);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_revoked ON sessions (user_id, revoked_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions (parent_session_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_user_app_active
|
||||
ON sessions (user_id, app_uid)
|
||||
WHERE kind = 'app' AND revoked_at IS NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_legacy_token_active
|
||||
ON sessions (legacy_token_uid)
|
||||
WHERE legacy_token_uid IS NOT NULL AND revoked_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_kind_user ON sessions (kind, user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_access_token_uid
|
||||
ON sessions (access_token_uid)
|
||||
WHERE access_token_uid IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_user_worker_active
|
||||
ON sessions (user_id, COALESCE(app_uid, ''), (meta #>> ARRAY['worker_name']))
|
||||
WHERE kind = 'worker' AND revoked_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_to_app_permissions (
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
app_id integer NOT NULL REFERENCES apps (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
permission varchar(255) NOT NULL,
|
||||
extra jsonb,
|
||||
dt timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, app_id, permission)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_utap_user_permission ON user_to_app_permissions (user_id, permission);
|
||||
CREATE INDEX IF NOT EXISTS idx_utap_app_permission ON user_to_app_permissions (app_id, permission);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_to_group_permissions (
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
group_id integer NOT NULL REFERENCES "group" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
permission varchar(255) NOT NULL,
|
||||
extra jsonb,
|
||||
PRIMARY KEY (user_id, group_id, permission)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_to_group_permissions_group_id ON user_to_group_permissions (group_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_to_user_permissions (
|
||||
issuer_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
holder_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
permission varchar(255) NOT NULL,
|
||||
extra jsonb,
|
||||
PRIMARY KEY (issuer_user_id, holder_user_id, permission)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_to_user_permissions_holder_user_id ON user_to_user_permissions (holder_user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dev_to_app_permissions (
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
app_id integer NOT NULL REFERENCES apps (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
permission varchar(255) NOT NULL,
|
||||
extra jsonb,
|
||||
PRIMARY KEY (user_id, app_id, permission)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_to_app_permissions_app_id ON dev_to_app_permissions (app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_to_app_permissions_permission ON dev_to_app_permissions (permission);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS access_token_permissions (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
token_uid varchar(40) NOT NULL,
|
||||
authorizer_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
authorizer_app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
permission varchar(255) NOT NULL,
|
||||
extra jsonb,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_access_token_permissions_token_uid ON access_token_permissions (token_uid);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS old_app_names (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
app_uid varchar(40) NOT NULL REFERENCES apps (uid) ON DELETE CASCADE,
|
||||
name varchar(255) NOT NULL,
|
||||
"timestamp" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_old_app_names_app_uid_name UNIQUE (app_uid, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_old_app_names_app_uid ON old_app_names (app_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_old_app_names_name ON old_app_names (name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jct_user_group (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
group_id integer NOT NULL REFERENCES "group" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
extra jsonb,
|
||||
metadata jsonb,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_jct_user_group_user_id ON jct_user_group (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_jct_user_group_group_id ON jct_user_group (group_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
uid varchar(40) UNIQUE,
|
||||
value jsonb NOT NULL,
|
||||
acknowledged bigint,
|
||||
shown bigint,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_user_id ON notification (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS share (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
uid varchar(40) UNIQUE,
|
||||
issuer_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
recipient_email varchar(255) NOT NULL,
|
||||
data jsonb,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_share_issuer_user_id ON share (issuer_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_share_recipient_email ON share (recipient_email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
message text,
|
||||
ts timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_user_id ON feedback (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS thread (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
uid varchar(40) NOT NULL UNIQUE,
|
||||
parent_uid varchar(40) REFERENCES thread (uid) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
owner_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
"schema" text,
|
||||
text text NOT NULL,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_thread_parent_uid ON thread (parent_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_thread_owner_user_id ON thread (owner_user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_comments (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
uid varchar(40) NOT NULL UNIQUE,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
metadata jsonb,
|
||||
text text NOT NULL,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_comments_user_id ON user_comments (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_comments_uid ON user_comments (uid);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_fsentry_comments (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_comment_id integer NOT NULL REFERENCES user_comments (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
fsentry_id integer NOT NULL REFERENCES fsentries (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_fsentry_comments_user_comment_id ON user_fsentry_comments (user_comment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_fsentry_comments_fsentry_id ON user_fsentry_comments (fsentry_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_fsentry_version_comments (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_comment_id integer NOT NULL REFERENCES user_comments (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
fsentry_version_id integer NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_fsentry_version_comments_user_comment_id ON user_fsentry_version_comments (user_comment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_fsentry_version_comments_version_id ON user_fsentry_version_comments (fsentry_version_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_group_comments (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_comment_id integer NOT NULL REFERENCES user_comments (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
group_id integer NOT NULL REFERENCES "group" (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_group_comments_user_comment_id ON user_group_comments (user_comment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_group_comments_group_id ON user_group_comments (group_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_user_comments (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_comment_id integer NOT NULL REFERENCES user_comments (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
commented_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_user_comments_user_comment_id ON user_user_comments (user_comment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_user_comments_commented_user_id ON user_user_comments (commented_user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_oidc_providers (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
provider varchar(64) NOT NULL,
|
||||
provider_sub varchar(255) NOT NULL,
|
||||
refresh_token text,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_oidc_providers_user_id ON user_oidc_providers (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_oidc_providers_provider ON user_oidc_providers (provider);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_oidc_providers_provider_sub_unique ON user_oidc_providers (provider, provider_sub);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_update_audit (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
app_id_keep integer NOT NULL,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
old_name varchar(50),
|
||||
new_name varchar(50),
|
||||
reason varchar(255)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_update_audit_app_id ON app_update_audit (app_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_update_audit (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
user_id_keep integer NOT NULL,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
old_email varchar(256),
|
||||
new_email varchar(256),
|
||||
old_username varchar(50),
|
||||
new_username varchar(50),
|
||||
reason varchar(255)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_update_audit_user_id ON user_update_audit (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS storage_audit (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
user_id_keep integer NOT NULL,
|
||||
is_subtract boolean NOT NULL DEFAULT FALSE,
|
||||
amount bigint NOT NULL,
|
||||
field_a varchar(16),
|
||||
field_b varchar(16),
|
||||
reason varchar(255),
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_storage_audit_user_id ON storage_audit (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_user_to_app_permissions (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
user_id_keep integer NOT NULL,
|
||||
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
app_id_keep integer NOT NULL,
|
||||
permission varchar(255) NOT NULL,
|
||||
extra jsonb,
|
||||
action varchar(16),
|
||||
reason varchar(255),
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user_to_app_permissions_user_id ON audit_user_to_app_permissions (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user_to_app_permissions_app_id ON audit_user_to_app_permissions (app_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_dev_to_app_permissions (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
user_id_keep integer NOT NULL,
|
||||
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
app_id_keep integer NOT NULL,
|
||||
permission varchar(255) NOT NULL,
|
||||
extra jsonb,
|
||||
action varchar(16),
|
||||
reason varchar(255),
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_dev_to_app_permissions_user_id ON audit_dev_to_app_permissions (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_dev_to_app_permissions_app_id ON audit_dev_to_app_permissions (app_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_user_to_group_permissions (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
user_id_keep integer NOT NULL,
|
||||
group_id integer REFERENCES "group" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
group_id_keep integer NOT NULL,
|
||||
permission varchar(255) NOT NULL,
|
||||
extra jsonb,
|
||||
action varchar(255),
|
||||
reason varchar(255),
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user_to_group_permissions_user_id ON audit_user_to_group_permissions (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user_to_group_permissions_group_id ON audit_user_to_group_permissions (group_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_user_to_user_permissions (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
issuer_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
issuer_user_id_keep integer NOT NULL,
|
||||
holder_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
holder_user_id_keep integer NOT NULL,
|
||||
permission varchar(255) NOT NULL,
|
||||
extra jsonb,
|
||||
action varchar(16),
|
||||
reason varchar(255),
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user_to_user_permissions_issuer_user_id ON audit_user_to_user_permissions (issuer_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user_to_user_permissions_holder_user_id ON audit_user_to_user_permissions (holder_user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_usage (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
service_name varchar(64),
|
||||
model_name varchar(128),
|
||||
price_modifier varchar(40),
|
||||
cost integer,
|
||||
value_uint_1 integer,
|
||||
value_uint_2 integer,
|
||||
value_uint_3 integer,
|
||||
value_uint_4 integer,
|
||||
value_uint_5 integer,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_app_id ON ai_usage (app_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_service_name ON ai_usage (service_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_model_name ON ai_usage (model_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_price_modifier ON ai_usage (price_modifier);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_created_at ON ai_usage (created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_user_timestamp ON ai_usage (user_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS general_analytics (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
uid varchar(40) NOT NULL,
|
||||
trace_id varchar(40),
|
||||
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
user_id_keep integer,
|
||||
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
app_id_keep integer,
|
||||
server_id varchar(40),
|
||||
actor_type varchar(40),
|
||||
tags jsonb,
|
||||
fields jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_general_analytics_user_id ON general_analytics (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_general_analytics_app_id ON general_analytics (app_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS monthly_usage_counts (
|
||||
year integer NOT NULL,
|
||||
month integer NOT NULL,
|
||||
service_type varchar(40) NOT NULL,
|
||||
service_name varchar(40) NOT NULL,
|
||||
actor_key varchar(255) NOT NULL,
|
||||
pricing_category jsonb NOT NULL,
|
||||
pricing_category_hash bytea NOT NULL,
|
||||
"count" integer DEFAULT 0,
|
||||
value_uint_1 integer,
|
||||
value_uint_2 integer,
|
||||
value_uint_3 integer,
|
||||
PRIMARY KEY (year, month, service_type, service_name, actor_key, pricing_category_hash)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS service_usage_monthly (
|
||||
"key" varchar(255) NOT NULL,
|
||||
year integer NOT NULL,
|
||||
month integer NOT NULL,
|
||||
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
"count" integer NOT NULL,
|
||||
extra jsonb,
|
||||
PRIMARY KEY ("key", year, month)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_usage_monthly_user_id ON service_usage_monthly (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_service_usage_monthly_app_id ON service_usage_monthly (app_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS per_user_credit (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL UNIQUE REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
amount bigint NOT NULL,
|
||||
last_updated_at bigint NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO "user" (id, uuid, username, metadata)
|
||||
VALUES (1, '5d4adce0-a381-4982-9c02-6e2540026238', 'system', '{}'::jsonb)
|
||||
ON CONFLICT (uuid) DO UPDATE SET username = EXCLUDED.username;
|
||||
|
||||
SELECT setval(pg_get_serial_sequence('"user"', 'id'), COALESCE((SELECT MAX(id) FROM "user"), 1), true);
|
||||
|
||||
INSERT INTO "group" (uid, owner_user_id, extra, metadata)
|
||||
VALUES
|
||||
('26bfb1fb-421f-45bc-9aa4-d81ea569e7a5', 1, '{"critical": true, "type": "default", "name": "system"}'::jsonb, '{"title": "System", "color": "#000000"}'::jsonb),
|
||||
('ca342a5e-b13d-4dee-9048-58b11a57cc55', 1, '{"critical": true, "type": "default", "name": "admin"}'::jsonb, '{"title": "Admin", "color": "#a83232"}'::jsonb),
|
||||
('78b1b1dd-c959-44d2-b02c-8735671f9997', 1, '{"critical": true, "type": "default", "name": "user"}'::jsonb, '{"title": "User", "color": "#3254a8"}'::jsonb),
|
||||
('b7220104-7905-4985-b996-649fdcdb3c8f', 1, '{"critical": true, "type": "default", "name": "temp"}'::jsonb, '{"title": "Temp", "color": "#888888"}'::jsonb),
|
||||
('3c2dfff7-d22a-41aa-a193-59a61dac4b64', 1, '{"type": "default", "name": "moderator"}'::jsonb, '{"title": "Moderator", "color": "#a432a8"}'::jsonb),
|
||||
('5e8f251d-3382-4b0d-932c-7bb82f48652f', 1, '{"type": "default", "name": "developer"}'::jsonb, '{"title": "Developer", "color": "#32a852"}'::jsonb)
|
||||
ON CONFLICT (uid) DO UPDATE SET
|
||||
owner_user_id = EXCLUDED.owner_user_id,
|
||||
extra = EXCLUDED.extra,
|
||||
metadata = EXCLUDED.metadata;
|
||||
|
||||
INSERT INTO apps (
|
||||
uid, owner_user_id, icon, name, title, description, index_url,
|
||||
godmode, maximize_on_start, background,
|
||||
approved_for_listing, approved_for_opening_items,
|
||||
approved_for_incentive_program, tags, "timestamp"
|
||||
)
|
||||
VALUES
|
||||
('app-3920851d-bda8-479b-9407-8517293c7d44', 1, NULL, 'pdf', 'PDF', '', 'https://pdf.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
|
||||
('app-5584fbf7-ed69-41fc-99cd-85da21b1ef51', 1, NULL, 'camera', 'Camera', 'Camera in the browser.', 'https://online-camera.com', FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, NULL, '2020-01-01 00:00:00'),
|
||||
('app-11edfba2-1ed3-4e22-8573-47e88fb87d70', 1, NULL, 'player', 'Player', 'A free video player app in the browser.', 'https://simple-player.puter.com', FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, NULL, '2020-01-01 00:00:00'),
|
||||
('app-7bdca1a4-6373-4c98-ad97-03ff2d608ca1', 1, NULL, 'recorder', 'Recorder', 'Online voice recorder in the browser with cloud storage.', 'https://voice-recorder.com', FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, NULL, '2020-01-01 00:00:00'),
|
||||
('app-d7e9471f-e441-4d72-a5ab-75e96573b76b', 1, NULL, 'music-player', 'Music Player', 'A free music player app in the browser.', 'https://player.puter.com', FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, NULL, '2026-05-10 00:00:00'),
|
||||
('app-e3ac5486-da8c-42ad-8377-8728086e0980', 1, NULL, 'git', 'Git', 'Puter Git client', 'https://builtins.namespaces.puter.com/git', FALSE, FALSE, TRUE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
|
||||
('app-0b37f054-07d4-4627-8765-11bd23e889d4', 1, NULL, 'dev-center', 'Dev Center', 'This is the app that makes apps', 'https://builtins.namespaces.puter.com/dev-center', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, NULL, '2020-01-01 00:00:00'),
|
||||
('app-fbbdb72b-ad08-4cb4-86a1-de0f27cf2e1e', 1, NULL, 'puter-linux', 'Puter Linux', 'Linux emulator for Puter', 'https://builtins.namespaces.puter.com/emulator', TRUE, FALSE, FALSE, TRUE, TRUE, FALSE, NULL, '2020-01-01 00:00:00'),
|
||||
('app-838dfbc4-bf8b-48c2-b47b-c4adc77fab58', 1, NULL, 'editor', 'Editor', 'Text editor', 'https://online-notepad.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, 'productivity', '2020-01-01 00:00:00'),
|
||||
('app-58282b08-990a-4906-95f7-fa37ff92452b', 1, NULL, 'draw', 'Draw', 'Image editor', 'https://draw.puter.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, 'graphics', '2020-01-01 00:00:00'),
|
||||
('app-0bef044f-918f-4cbf-a0c0-b4a17ee81085', 1, NULL, 'about', 'About', 'About Puter', 'https://about.puter.com', TRUE, FALSE, FALSE, FALSE, TRUE, FALSE, NULL, '2020-01-01 00:00:00'),
|
||||
('app-a2ae72a4-1ba3-4a29-b5c0-6de1be5cf178', 1, NULL, 'app-center', 'App Center', 'Discover apps for Puter', 'https://app-center.puter.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, NULL, '2020-01-01 00:00:00'),
|
||||
('app-93005ce0-80d1-50d9-9b1e-9c453c375d56', 1, NULL, 'markus', 'Markus', 'Markdown editor', 'https://markus.puter.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, 'productivity', '2020-01-01 00:00:00'),
|
||||
('app-6f79ef7b-52b7-4b31-91c6-fc07b62e9396', 1, NULL, 'code', 'Code', 'Code editor', 'https://code.puter.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, 'productivity', '2020-01-01 00:00:00'),
|
||||
('app-862fc09e-c7b8-4c30-b5b4-47b3cc5f5232', 1, NULL, 'memos', 'Memos', 'Notes and memos', 'https://memos.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
|
||||
('app-aeb8c03e-1144-4c57-bff5-3a1a7c17f9f0', 1, NULL, 'word-processor', 'Word Processor', 'Write documents', 'https://word-processor.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
|
||||
('app-c10a1999-22f5-4644-9960-9aaac0d4934e', 1, NULL, 'spreadsheet', 'Spreadsheet', 'Work with spreadsheets', 'https://spreadsheet.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
|
||||
('app-e9c1d58d-3d8d-4f0a-a85c-e383ef63bc29', 1, NULL, 'presentation', 'Presentation', 'Create presentations', 'https://presentation.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
|
||||
('app-fb7d9e42-8207-4fa0-b680-f0239327097f', 1, NULL, 'pdf-editor', 'PDF Editor', 'Edit PDF documents', 'https://pdf-editor.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00')
|
||||
ON CONFLICT (uid) DO UPDATE SET
|
||||
owner_user_id = EXCLUDED.owner_user_id,
|
||||
name = EXCLUDED.name,
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
index_url = EXCLUDED.index_url,
|
||||
godmode = EXCLUDED.godmode,
|
||||
maximize_on_start = EXCLUDED.maximize_on_start,
|
||||
background = EXCLUDED.background,
|
||||
approved_for_listing = EXCLUDED.approved_for_listing,
|
||||
approved_for_opening_items = EXCLUDED.approved_for_opening_items,
|
||||
approved_for_incentive_program = EXCLUDED.approved_for_incentive_program,
|
||||
tags = EXCLUDED.tags;
|
||||
|
||||
INSERT INTO user_to_group_permissions (user_id, group_id, permission, extra)
|
||||
SELECT u.id, g.id, 'driver', '{}'::jsonb
|
||||
FROM "user" u, "group" g
|
||||
WHERE u.username = 'system'
|
||||
AND g.uid = 'ca342a5e-b13d-4dee-9048-58b11a57cc55'
|
||||
ON CONFLICT (user_id, group_id, permission) DO UPDATE SET extra = EXCLUDED.extra;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- SMS phone verification columns. Mirrors SQLite migration 0058. `phone` is the
|
||||
-- E.164 number collected during verification (indexed like `email`);
|
||||
-- `requires_phone_verification` gates account use for low-reputation signups
|
||||
-- (not indexed, mirroring `requires_email_confirmation`).
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS phone varchar(20);
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_phone_verification boolean NOT NULL DEFAULT FALSE;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_phone ON "user" (phone);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Credit-card verification column. Mirrors SQLite migration 0059.
|
||||
-- `requires_card_verification` gates account use for low-reputation signups;
|
||||
-- the card itself never touches our DB, so this is the only column (not
|
||||
-- indexed, mirroring `requires_phone_verification`).
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_card_verification boolean NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Card fingerprint column. Mirrors SQLite migration 0060. `card_fingerprint`
|
||||
-- is the Stripe card fingerprint (stable per card number) recorded when a user
|
||||
-- clears card verification — the card sibling of `phone`, indexed like it so
|
||||
-- admin tooling can find the accounts that verified with a given card. The card
|
||||
-- itself never touches our DB, only Stripe's fingerprint for it.
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS card_fingerprint varchar(128);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_card_fingerprint ON "user" (card_fingerprint);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Suspended-at column. Mirrors SQLite migration 0061. `suspended_at` is when
|
||||
-- the account was suspended, as unix seconds (NULL while not suspended) — the
|
||||
-- timestamp sibling of the boolean `suspended` flag, indexed so the
|
||||
-- signup-abuse harness can count an IP's recently-suspended accounts.
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS suspended_at bigint;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_suspended_at ON "user" (suspended_at);
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Admin-managed blocklist of app origins. Mirrors SQLite migration 0062 /
|
||||
-- MySQL mysql_mig_17 (which Postgres was originally missed for). An app whose
|
||||
-- `index_url` host (or a request origin) matches an entry is denied access to
|
||||
-- Puter resources. `include_subdomains = 1` also blocks every subdomain of
|
||||
-- `domain`. Enforced in AuthService via AppOriginBlocklistService.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blocked_app_origins (
|
||||
id SERIAL PRIMARY KEY,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
include_subdomains SMALLINT NOT NULL DEFAULT 0,
|
||||
reason TEXT DEFAULT NULL,
|
||||
created_by VARCHAR(255) DEFAULT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_blocked_app_origins_domain
|
||||
ON blocked_app_origins (domain);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Suspension reason column. Mirrors SQLite migration 0063. Why an account was
|
||||
-- suspended (NULL while not suspended) — companion to the boolean `suspended`
|
||||
-- flag and `suspended_at` timestamp. Constrained at the application layer to a
|
||||
-- fixed set of reasons (see extensions/admin suspension_reasons.js).
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS suspended_reason text;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Append-only log of admin moderation actions. Mirrors SQLite migration 0064 /
|
||||
-- MySQL mysql_mig_19. Used to measure the abuse system's false-positive rate:
|
||||
-- `unsuspend` (admin unblock) and `admin_create_user` are the false-positive
|
||||
-- signals; `suspend` gives the denominator. Preserves history the `user`
|
||||
-- suspension columns lose on unsuspend. `created_at` is unix seconds.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS abuse_moderation_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
action VARCHAR(32) NOT NULL,
|
||||
target_user_id BIGINT DEFAULT NULL,
|
||||
target_username VARCHAR(255) DEFAULT NULL,
|
||||
admin_username VARCHAR(255) DEFAULT NULL,
|
||||
reason TEXT DEFAULT NULL,
|
||||
source VARCHAR(64) DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_created_at
|
||||
ON abuse_moderation_events (created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_action
|
||||
ON abuse_moderation_events (action);
|
||||
@@ -19,22 +19,22 @@
|
||||
|
||||
INSERT INTO `apps` (
|
||||
`id`, `uid`, `owner_user_id`, `icon`, `name`, `title`, `description`, `godmode`, `maximize_on_start`, `index_url`, `approved_for_listing`, `approved_for_opening_items`, `approved_for_incentive_program`, `timestamp`, `last_review`, `tags`, `app_owner`
|
||||
) VALUES (14,'app-7870be61-8dff-4a99-af64-e9ae6811e367',60950,
|
||||
) VALUES (14,'app-7870be61-8dff-4a99-af64-e9ae6811e367',1,
|
||||
'data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjIiIGJhc2VQcm9maWxlPSJ0aW55LXBzIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCA0OCIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4Ij4KCTx0aXRsZT5hcHAtaWNvbi12aWV3ZXItc3ZnPC90aXRsZT4KCTxkZWZzPgoJCTxsaW5lYXJHcmFkaWVudCBpZD0iZ3JkMSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiICB4MT0iNDciIHkxPSIzOS41MTQiIHgyPSIxIiB5Mj0iOC40ODYiPgoJCQk8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiMwMzYzYWQiICAvPgoJCQk8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiM1Njg0ZjUiICAvPgoJCTwvbGluZWFyR3JhZGllbnQ+Cgk8L2RlZnM+Cgk8c3R5bGU+CgkJdHNwYW4geyB3aGl0ZS1zcGFjZTpwcmUgfQoJCS5zaHAwIHsgZmlsbDogdXJsKCNncmQxKSB9IAoJCS5zaHAxIHsgZmlsbDogI2ZmZDc2NCB9IAoJCS5zaHAyIHsgZmlsbDogI2NiZWFmYiB9IAoJPC9zdHlsZT4KCTxnIGlkPSJMYXllciI+CgkJPHBhdGggaWQ9IlNoYXBlIDEiIGNsYXNzPSJzaHAwIiBkPSJNMSAxTDQ3IDFMNDcgNDdMMSA0N0wxIDFaIiAvPgoJCTxwYXRoIGlkPSJMYXllciIgY2xhc3M9InNocDEiIGQ9Ik0xOCAxOEMxNS43OSAxOCAxNCAxNi4yMSAxNCAxNEMxNCAxMS43OSAxNS43OSAxMCAxOCAxMEMyMC4yMSAxMCAyMiAxMS43OSAyMiAxNEMyMiAxNi4yMSAyMC4yMSAxOCAxOCAxOFoiIC8+CgkJPHBhdGggaWQ9IkxheWVyIiBjbGFzcz0ic2hwMiIgZD0iTTM5Ljg2IDM2LjUxQzM5LjgyIDM2LjU4IDM5Ljc3IDM2LjY1IDM5LjcgMzYuNzFDMzkuNjQgMzYuNzcgMzkuNTcgMzYuODIgMzkuNSAzNi44N0MzOS40MiAzNi45MSAzOS4zNCAzNi45NCAzOS4yNiAzNi45N0MzOS4xNyAzNi45OSAzOS4wOSAzNyAzOSAzN0w5IDM3QzguODIgMzcgOC42NCAzNi45NSA4LjQ5IDM2Ljg2QzguMzMgMzYuNzYgOC4yIDM2LjYzIDguMTIgMzYuNDdDOC4wMyAzNi4zMSA3Ljk5IDM2LjEzIDggMzUuOTVDOC4wMSAzNS43NyA4LjA3IDM1LjYgOC4xNyAzNS40NEwxNC4xNyAyNi40NUMxNC4yNCAyNi4zNCAxNC4zMyAyNi4yNCAxNC40NCAyNi4xN0MxNC41NSAyNi4xIDE0LjY4IDI2LjA0IDE0LjggMjYuMDJDMTQuOTMgMjUuOTkgMTUuMDcgMjUuOTkgMTUuMTkgMjYuMDJDMTUuMzIgMjYuMDQgMTUuNDUgMjYuMSAxNS41NSAyNi4xN0MxNS41NyAyNi4xOCAxNS41OCAyNi4xOSAxNS42IDI2LjJDMTUuNjEgMjYuMjEgMTUuNjIgMjYuMjIgMTUuNjMgMjYuMjNDMTUuNjUgMjYuMjQgMTUuNjYgMjYuMjUgMTUuNjcgMjYuMjZDMTUuNjggMjYuMjcgMTUuNyAyNi4yOCAxNS43MSAyNi4yOUwyMC44NiAzMS40NUwyOS4xOCAxOS40M0MyOS4yMyAxOS4zNiAyOS4yOCAxOS4zIDI5LjM1IDE5LjI0QzI5LjQxIDE5LjE5IDI5LjQ4IDE5LjE0IDI5LjU2IDE5LjFDMjkuNjMgMTkuMDYgMjkuNzEgMTkuMDQgMjkuNzkgMTkuMDJDMjkuODggMTkgMjkuOTYgMTkgMzAuMDUgMTlDMzAuMTMgMTkgMzAuMjEgMTkuMDIgMzAuMjkgMTkuMDRDMzAuMzggMTkuMDcgMzAuNDUgMTkuMSAzMC41MiAxOS4xNUMzMC42IDE5LjE5IDMwLjY2IDE5LjI1IDMwLjcyIDE5LjMxQzMwLjc4IDE5LjM3IDMwLjgzIDE5LjQ0IDMwLjg3IDE5LjUxTDM5Ljg3IDM1LjUxQzM5LjkxIDM1LjU5IDM5Ljk1IDM1LjY3IDM5Ljk3IDM1Ljc1QzM5Ljk5IDM1Ljg0IDQwIDM1LjkyIDQwIDM2LjAxQzQwIDM2LjEgMzkuOTkgMzYuMTggMzkuOTYgMzYuMjdDMzkuOTQgMzYuMzUgMzkuOTEgMzYuNDMgMzkuODYgMzYuNTFaIiAvPgoJPC9nPgo8L3N2Zz4=',
|
||||
'viewer','Viewer','',0,1,'https://viewer.puter.com/index.html',1,0,0,'2022-08-16 01:40:02',NULL,NULL,NULL);
|
||||
|
||||
INSERT INTO `apps` (`id`, `uid`, `owner_user_id`, `icon`, `name`, `title`, `description`, `godmode`, `maximize_on_start`, `index_url`, `approved_for_listing`, `approved_for_opening_items`, `approved_for_incentive_program`, `timestamp`, `last_review`, `tags`, `app_owner`) VALUES (6,'app-3920851d-bda8-479b-9407-8517293c7d44',60950,
|
||||
INSERT INTO `apps` (`id`, `uid`, `owner_user_id`, `icon`, `name`, `title`, `description`, `godmode`, `maximize_on_start`, `index_url`, `approved_for_listing`, `approved_for_opening_items`, `approved_for_incentive_program`, `timestamp`, `last_review`, `tags`, `app_owner`) VALUES (6,'app-3920851d-bda8-479b-9407-8517293c7d44',1,
|
||||
'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iaXNvLTg4NTktMSI/Pg0KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE4LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPg0KPCFET0NUWVBFIHN2ZyBQVUJMSUMgIi0vL1czQy8vRFREIFNWRyAxLjEvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkIj4NCjxzdmcgdmVyc2lvbj0iMS4xIiBpZD0iQ2FwYV8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiIHk9IjBweCINCgkgdmlld0JveD0iMCAwIDU2IDU2IiBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCA1NiA1NjsiIHhtbDpzcGFjZT0icHJlc2VydmUiPg0KPGc+DQoJPHBhdGggc3R5bGU9ImZpbGw6I0U5RTlFMDsiIGQ9Ik0zNi45ODUsMEg3Ljk2M0M3LjE1NSwwLDYuNSwwLjY1NSw2LjUsMS45MjZWNTVjMCwwLjM0NSwwLjY1NSwxLDEuNDYzLDFoNDAuMDc0DQoJCWMwLjgwOCwwLDEuNDYzLTAuNjU1LDEuNDYzLTFWMTIuOTc4YzAtMC42OTYtMC4wOTMtMC45Mi0wLjI1Ny0xLjA4NUwzNy42MDcsMC4yNTdDMzcuNDQyLDAuMDkzLDM3LjIxOCwwLDM2Ljk4NSwweiIvPg0KCTxwb2x5Z29uIHN0eWxlPSJmaWxsOiNEOUQ3Q0E7IiBwb2ludHM9IjM3LjUsMC4xNTEgMzcuNSwxMiA0OS4zNDksMTIgCSIvPg0KCTxwYXRoIHN0eWxlPSJmaWxsOiNDQzRCNEM7IiBkPSJNMTkuNTE0LDMzLjMyNEwxOS41MTQsMzMuMzI0Yy0wLjM0OCwwLTAuNjgyLTAuMTEzLTAuOTY3LTAuMzI2DQoJCWMtMS4wNDEtMC43ODEtMS4xODEtMS42NS0xLjExNS0yLjI0MmMwLjE4Mi0xLjYyOCwyLjE5NS0zLjMzMiw1Ljk4NS01LjA2OGMxLjUwNC0zLjI5NiwyLjkzNS03LjM1NywzLjc4OC0xMC43NQ0KCQljLTAuOTk4LTIuMTcyLTEuOTY4LTQuOTktMS4yNjEtNi42NDNjMC4yNDgtMC41NzksMC41NTctMS4wMjMsMS4xMzQtMS4yMTVjMC4yMjgtMC4wNzYsMC44MDQtMC4xNzIsMS4wMTYtMC4xNzINCgkJYzAuNTA0LDAsMC45NDcsMC42NDksMS4yNjEsMS4wNDljMC4yOTUsMC4zNzYsMC45NjQsMS4xNzMtMC4zNzMsNi44MDJjMS4zNDgsMi43ODQsMy4yNTgsNS42Miw1LjA4OCw3LjU2Mg0KCQljMS4zMTEtMC4yMzcsMi40MzktMC4zNTgsMy4zNTgtMC4zNThjMS41NjYsMCwyLjUxNSwwLjM2NSwyLjkwMiwxLjExN2MwLjMyLDAuNjIyLDAuMTg5LDEuMzQ5LTAuMzksMi4xNg0KCQljLTAuNTU3LDAuNzc5LTEuMzI1LDEuMTkxLTIuMjIsMS4xOTFjLTEuMjE2LDAtMi42MzItMC43NjgtNC4yMTEtMi4yODVjLTIuODM3LDAuNTkzLTYuMTUsMS42NTEtOC44MjgsMi44MjINCgkJYy0wLjgzNiwxLjc3NC0xLjYzNywzLjIwMy0yLjM4Myw0LjI1MUMyMS4yNzMsMzIuNjU0LDIwLjM4OSwzMy4zMjQsMTkuNTE0LDMzLjMyNHogTTIyLjE3NiwyOC4xOTgNCgkJYy0yLjEzNywxLjIwMS0zLjAwOCwyLjE4OC0zLjA3MSwyLjc0NGMtMC4wMSwwLjA5Mi0wLjAzNywwLjMzNCwwLjQzMSwwLjY5MkMxOS42ODUsMzEuNTg3LDIwLjU1NSwzMS4xOSwyMi4xNzYsMjguMTk4eg0KCQkgTTM1LjgxMywyMy43NTZjMC44MTUsMC42MjcsMS4wMTQsMC45NDQsMS41NDcsMC45NDRjMC4yMzQsMCwwLjkwMS0wLjAxLDEuMjEtMC40NDFjMC4xNDktMC4yMDksMC4yMDctMC4zNDMsMC4yMy0wLjQxNQ0KCQljLTAuMTIzLTAuMDY1LTAuMjg2LTAuMTk3LTEuMTc1LTAuMTk3QzM3LjEyLDIzLjY0OCwzNi40ODUsMjMuNjcsMzUuODEzLDIzLjc1NnogTTI4LjM0MywxNy4xNzQNCgkJYy0wLjcxNSwyLjQ3NC0xLjY1OSw1LjE0NS0yLjY3NCw3LjU2NGMyLjA5LTAuODExLDQuMzYyLTEuNTE5LDYuNDk2LTIuMDJDMzAuODE1LDIxLjE1LDI5LjQ2NiwxOS4xOTIsMjguMzQzLDE3LjE3NHoNCgkJIE0yNy43MzYsOC43MTJjLTAuMDk4LDAuMDMzLTEuMzMsMS43NTcsMC4wOTYsMy4yMTZDMjguNzgxLDkuODEzLDI3Ljc3OSw4LjY5OCwyNy43MzYsOC43MTJ6Ii8+DQoJPHBhdGggc3R5bGU9ImZpbGw6I0NDNEI0QzsiIGQ9Ik00OC4wMzcsNTZINy45NjNDNy4xNTUsNTYsNi41LDU1LjM0NSw2LjUsNTQuNTM3VjM5aDQzdjE1LjUzN0M0OS41LDU1LjM0NSw0OC44NDUsNTYsNDguMDM3LDU2eiIvPg0KCTxnPg0KCQk8cGF0aCBzdHlsZT0iZmlsbDojRkZGRkZGOyIgZD0iTTE3LjM4NSw1M2gtMS42NDFWNDIuOTI0aDIuODk4YzAuNDI4LDAsMC44NTIsMC4wNjgsMS4yNzEsMC4yMDUNCgkJCWMwLjQxOSwwLjEzNywwLjc5NSwwLjM0MiwxLjEyOCwwLjYxNWMwLjMzMywwLjI3MywwLjYwMiwwLjYwNCwwLjgwNywwLjk5MXMwLjMwOCwwLjgyMiwwLjMwOCwxLjMwNg0KCQkJYzAsMC41MTEtMC4wODcsMC45NzMtMC4yNiwxLjM4OGMtMC4xNzMsMC40MTUtMC40MTUsMC43NjQtMC43MjUsMS4wNDZjLTAuMzEsMC4yODItMC42ODQsMC41MDEtMS4xMjEsMC42NTYNCgkJCXMtMC45MjEsMC4yMzItMS40NDksMC4yMzJoLTEuMjE3VjUzeiBNMTcuMzg1LDQ0LjE2OHYzLjk5MmgxLjUwNGMwLjIsMCwwLjM5OC0wLjAzNCwwLjU5NS0wLjEwMw0KCQkJYzAuMTk2LTAuMDY4LDAuMzc2LTAuMTgsMC41NC0wLjMzNWMwLjE2NC0wLjE1NSwwLjI5Ni0wLjM3MSwwLjM5Ni0wLjY0OWMwLjEtMC4yNzgsMC4xNS0wLjYyMiwwLjE1LTEuMDMyDQoJCQljMC0wLjE2NC0wLjAyMy0wLjM1NC0wLjA2OC0wLjU2N2MtMC4wNDYtMC4yMTQtMC4xMzktMC40MTktMC4yOC0wLjYxNWMtMC4xNDItMC4xOTYtMC4zNC0wLjM2LTAuNTk1LTAuNDkyDQoJCQljLTAuMjU1LTAuMTMyLTAuNTkzLTAuMTk4LTEuMDEyLTAuMTk4SDE3LjM4NXoiLz4NCgkJPHBhdGggc3R5bGU9ImZpbGw6I0ZGRkZGRjsiIGQ9Ik0zMi4yMTksNDcuNjgyYzAsMC44MjktMC4wODksMS41MzgtMC4yNjcsMi4xMjZzLTAuNDAzLDEuMDgtMC42NzcsMS40NzdzLTAuNTgxLDAuNzA5LTAuOTIzLDAuOTM3DQoJCQlzLTAuNjcyLDAuMzk4LTAuOTkxLDAuNTEzYy0wLjMxOSwwLjExNC0wLjYxMSwwLjE4Ny0wLjg3NSwwLjIxOUMyOC4yMjIsNTIuOTg0LDI4LjAyNiw1MywyNy44OTgsNTNoLTMuODE0VjQyLjkyNGgzLjAzNQ0KCQkJYzAuODQ4LDAsMS41OTMsMC4xMzUsMi4yMzUsMC40MDNzMS4xNzYsMC42MjcsMS42LDEuMDczczAuNzQsMC45NTUsMC45NSwxLjUyNEMzMi4xMTQsNDYuNDk0LDMyLjIxOSw0Ny4wOCwzMi4yMTksNDcuNjgyeg0KCQkJIE0yNy4zNTIsNTEuNzk3YzEuMTEyLDAsMS45MTQtMC4zNTUsMi40MDYtMS4wNjZzMC43MzgtMS43NDEsMC43MzgtMy4wOWMwLTAuNDE5LTAuMDUtMC44MzQtMC4xNS0xLjI0NA0KCQkJYy0wLjEwMS0wLjQxLTAuMjk0LTAuNzgxLTAuNTgxLTEuMTE0cy0wLjY3Ny0wLjYwMi0xLjE2OS0wLjgwN3MtMS4xMy0wLjMwOC0xLjkxNC0wLjMwOGgtMC45NTd2Ny42MjlIMjcuMzUyeiIvPg0KCQk8cGF0aCBzdHlsZT0iZmlsbDojRkZGRkZGOyIgZD0iTTM2LjI2Niw0NC4xNjh2My4xNzJoNC4yMTF2MS4xMjFoLTQuMjExVjUzaC0xLjY2OFY0Mi45MjRINDAuOXYxLjI0NEgzNi4yNjZ6Ii8+DQoJPC9nPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NLine truncated
|
||||
'pdf','PDF','',0,1,'https://pdf.puter.com/index.html',1,0,0,'2022-08-16 01:28:47',NULL,'productivity',NULL);
|
||||
|
||||
INSERT INTO `apps` (`id`, `uid`, `owner_user_id`, `icon`, `name`, `title`, `description`, `godmode`, `maximize_on_start`, `index_url`, `approved_for_listing`, `approved_for_opening_items`, `approved_for_incentive_program`, `timestamp`, `last_review`, `tags`, `app_owner`) VALUES (9,'app-5584fbf7-ed69-41fc-99cd-85da21b1ef51',60950,
|
||||
INSERT INTO `apps` (`id`, `uid`, `owner_user_id`, `icon`, `name`, `title`, `description`, `godmode`, `maximize_on_start`, `index_url`, `approved_for_listing`, `approved_for_opening_items`, `approved_for_incentive_program`, `timestamp`, `last_review`, `tags`, `app_owner`) VALUES (9,'app-5584fbf7-ed69-41fc-99cd-85da21b1ef51',1,
|
||||
'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyB2aWV3Qm94PSIwIDAgNTEyIDUxMiIgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPGRlZnM+CiAgICA8bGluZWFyR3JhZGllbnQgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIHgxPSIyNTYiIHkxPSIwIiB4Mj0iMjU2IiB5Mj0iNTEyIiBpZD0iZ3JhZGllbnQtMCI+CiAgICAgIDxzdG9wIG9mZnNldD0iMCIgc3R5bGU9InN0b3AtY29sb3I6IHJnYigwLCAxMiwgMTA4KTsiLz4KICAgICAgPHN0b3Agb2Zmc2V0PSIxIiBzdHlsZT0ic3RvcC1jb2xvcjogcmdiKDE2LCAwLCAxNDkpOyIvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICA8L2RlZnM+CiAgPHJlY3Qgc3R5bGU9InBhaW50LW9yZGVyOiBmaWxsOyBmaWxsLXJ1bGU6IG5vbnplcm87IGZpbGw6IHVybCgnI2dyYWRpZW50LTAnKTsiIHg9IjAiIHk9IjAiIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiByeD0iNzAiIHJ5PSI3MCIvPgogIDxjaXJjbGUgY3g9IjE3OC4zMzciIGN5PSIyNTguODc2IiBmaWxsPSIjYzBkYWRjIiByPSIyOSIgc3R5bGU9IiIgdHJhbnNmb3JtPSJtYXRyaXgoNi4xMDExMTEsIDAsIDAsIDYuMTI2OTY2LCAtODMzLjU4ODg2NywgLTEzMzAuODY4MDQyKSIvPgogIDxjaXJjbGUgY3g9IjE3OC4zMzciIGN5PSIyNTguODc2IiBmaWxsPSIjNGQ2ZmM0IiByPSIyMyIgc3R5bGU9IiIgdHJhbnNmb3JtPSJtYXRyaXgoNi4xMDExMTEsIDAsIDAsIDYuMTI2OTY2LCAtODMzLjU4ODg2NywgLTEzMzAuODY4MDQyKSIvPgogIDxjaXJjbGUgY3g9IjE3OC4zMzciIGN5PSIyNTguODc2IiBmaWxsPSIjM2Q1ZmEzIiByPSIxOCIgc3R5bGU9IiIgdHJhbnNmb3JtPSJtYXRyaXgoNi4xMDExMTEsIDAsIDAsIDYuMTI2OTY2LCAtODMzLjU4ODg2NywgLTEzMzAuODY4MDQyKSIvPgogIDxwYXRoIGQ9Ik0gMjExLjAyNSAxODguNjU2IEMgMjYyLjE0NiAxNTUuMDA2IDMzMC4zNzQgMTg5LjU1IDMzMy44MzQgMjUwLjgzOCBDIDMzNy4yOTMgMzEyLjEyNyAyNzMuMzkgMzU0LjE4OSAyMTguODA5IDMyNi41NTUgQyAxNzYuNDc0IDMwNS4xMjMgMTYyLjE1NSAyNTEuNDUxIDE4OC4xNDYgMjExLjYzMiBMIDIxMS4wMjUgMTg4LjY1NiBaIiBmaWxsPSIjMmY0Yjc3IiBzdHlsZT0iIi8+CiAgPGcgZmlsbD0iI2ZmZiIgdHJhbnNmb3JtPSJtYXRyaXgoNi4xMDExMTEsIDAsIDAsIDYuMTI2OTY2LCA3MS40MzIxOSwgNzEuNDQ5NjIzKSIgc3R5bGU9IiI+CiAgICA8Y2lyY2xlIGN4PSIyNCIgY3k9IjI0IiByPSI1Ii8+CiAgICA8Y2lyY2xlIGN4PSIzMi41IiBjeT0iMzIuNSIgcj0iMi41Ii8+CiAgPC9nPgo8L3N2Zz4=',
|
||||
'camera','Camera','Camera in the browser.',0,0,'https://camera.puter.com/index.html',1,0,0,'2022-08-16 01:32:36',NULL,NULL,NULL);
|
||||
|
||||
INSERT INTO `apps` (`id`, `uid`, `owner_user_id`, `icon`, `name`, `title`, `description`, `godmode`, `maximize_on_start`, `index_url`, `approved_for_listing`, `approved_for_opening_items`, `approved_for_incentive_program`, `timestamp`, `last_review`, `tags`, `app_owner`) VALUES (5,'app-11edfba2-1ed3-4e22-8573-47e88fb87d70',60950,
|
||||
INSERT INTO `apps` (`id`, `uid`, `owner_user_id`, `icon`, `name`, `title`, `description`, `godmode`, `maximize_on_start`, `index_url`, `approved_for_listing`, `approved_for_opening_items`, `approved_for_incentive_program`, `timestamp`, `last_review`, `tags`, `app_owner`) VALUES (5,'app-11edfba2-1ed3-4e22-8573-47e88fb87d70',1,
|
||||
'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iaXNvLTg4NTktMSI/Pg0KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE5LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPg0KPHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiIHk9IjBweCINCgkgdmlld0JveD0iMCAwIDUxMi4wMDEgNTEyLjAwMSIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgNTEyLjAwMSA1MTIuMDAxOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+DQo8cGF0aCBzdHlsZT0iZmlsbDojNTE1MDRFOyIgZD0iTTQ5MC42NjUsNDMuNTU3SDIxLjMzM0M5LjU1Miw0My41NTcsMCw1My4xMDgsMCw2NC44OXYzODIuMjJjMCwxMS43ODIsOS41NTIsMjEuMzM0LDIxLjMzMywyMS4zMzQNCgloNDY5LjMzMmMxMS43ODMsMCwyMS4zMzUtOS41NTIsMjEuMzM1LTIxLjMzNFY2NC44OUM1MTIsNTMuMTA4LDUwMi40NDgsNDMuNTU3LDQ5MC42NjUsNDMuNTU3eiBNOTkuMDMsNDI3LjA1MUg1Ni4yNjd2LTM4LjA2OQ0KCUg5OS4wM1Y0MjcuMDUxeiBNOTkuMDMsMTIzLjAxOUg1Ni4yNjd2LTM4LjA3SDk5LjAzVjEyMy4wMTl6IE0xODguMjA2LDQyNy4wNTFoLTQyLjc2M3YtMzguMDY5aDQyLjc2M1Y0MjcuMDUxeiBNMTg4LjIwNiwxMjMuMDE5DQoJaC00Mi43NjN2LTM4LjA3aDQyLjc2M1YxMjMuMDE5eiBNMjc3LjM4Miw0MjcuMDUxaC00Mi43NjR2LTM4LjA2OWg0Mi43NjRWNDI3LjA1MXogTTI3Ny4zODIsMTIzLjAxOWgtNDIuNzY0di0zOC4wN2g0Mi43NjRWMTIzLjAxOQ0KCXogTTM2Ni41NTcsNDI3LjA1MWgtNDIuNzYzdi0zOC4wNjloNDIuNzYzVjQyNy4wNTF6IE0zNjYuNTU3LDEyMy4wMTloLTQyLjc2M3YtMzguMDdoNDIuNzYzVjEyMy4wMTl6IE00NTUuNzMzLDQyNy4wNTFINDEyLjk3DQoJdi0zOC4wNjloNDIuNzY0djM4LjA2OUg0NTUuNzMzeiBNNDU1LjczMywxMjMuMDE5SDQxMi45N3YtMzguMDdoNDIuNzY0djM4LjA3SDQ1NS43MzN6Ii8+DQo8cGF0aCBzdHlsZT0iZmlsbDojNkI2OTY4OyIgZD0iTTQ5MC42NjUsNDMuNTU3SDEzMy44MWMtMTYuMzQzLDM4Ljg3Ny0yNS4zODEsODEuNTgtMjUuMzgxLDEyNi4zOTYNCgljMCwxMzMuMTkyLDc5Ljc4MiwyNDcuNzM0LDE5NC4xNTUsMjk4LjQ5aDE4OC4wODJjMTEuNzgzLDAsMjEuMzM1LTkuNTUyLDIxLjMzNS0yMS4zMzRWNjQuODkNCglDNTEyLDUzLjEwOCw1MDIuNDQ4LDQzLjU1Nyw0OTAuNjY1LDQzLjU1N3ogTTE4OC4yMDYsMTIzLjAxOWgtNDIuNzYzdi0zOC4wN2g0Mi43NjNWMTIzLjAxOXogTTI3Ny4zODIsNDI3LjA1MWgtNDIuNzY0di0zOC4wNjkNCgloNDIuNzY0VjQyNy4wNTF6IE0yNzcuMzgyLDEyMy4wMTloLTQyLjc2NHYtMzguMDdoNDIuNzY0VjEyMy4wMTl6IE0zNjYuNTU3LDQyNy4wNTFoLTQyLjc2M3YtMzguMDY5aDQyLjc2M1Y0MjcuMDUxeg0KCSBNMzY2LjU1NywxMjMuMDE5aC00Mi43NjN2LTM4LjA3aDQyLjc2M1YxMjMuMDE5eiBNNDU1LjczMyw0MjcuMDUxSDQxMi45N3YtMzguMDY5aDQyLjc2NHYzOC4wNjlINDU1LjczM3ogTTQ1NS43MzMsMTIzLjAxOUg0MTIuOTcNCgl2LTM4LjA3aDQyLjc2NHYzOC4wN0g0NTUuNzMzeiIvPg0KPHBhdGggc3R5bGU9ImZpbGw6Izg4RENFNTsiIGQ9Ik0zMTguNjEyLDI0My42NTdsLTExMi44OC01Ni40NGMtOS4xOTEtNC41OTUtMTkuOTc0LDIuMTMtMTkuOTc0LDEyLjM0NlYzMTIuNDQNCgljMCwxMC4yNjcsMTAuODM3LDE2LjkyNywxOS45NzQsMTIuMzQ1bDExMi44OC01Ni40MzljNC42NzQtMi4zMzgsNy42MjgtNy4xMTcsNy42MjgtMTIuMzQ1DQoJQzMyNi4yNCwyNTAuNzc0LDMyMy4yODYsMjQ1Ljk5NSwzMTguNjEyLDI0My42NTd6Ii8+DQo8cGF0aCBzdHlsZT0iZmlsbDojNzRDNEM0OyIgZD0iTTIxMS41MTUsMTk5LjU2MmMwLTIuOTY4LDAuOTU3LTUuODAyLDIuNjUyLTguMTI4bC04LjQzNS00LjIxOA0KCWMtOS4xOTEtNC41OTUtMTkuOTc0LDIuMTMtMTkuOTc0LDEyLjM0NlYzMTIuNDRjMCwxMC4yNjcsMTAuODM3LDE2LjkyNywxOS45NzQsMTIuMzQ1bDguNDMzLTQuMjE3DQoJQzIxMC41MDgsMzE1LjU0NywyMTEuNTE1LDMyMS45NjksMjExLjUxNSwxOTkuNTYyeiIvPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPC9zdmc+DQo=',
|
||||
'player','Player','A free video player app in the browser.',0,0,'https://player.puter.com/index.html',1,0,0,'2022-08-16 01:27:30',NULL,NULL,NULL);
|
||||
|
||||
INSERT INTO `apps` (`id`, `uid`, `owner_user_id`, `icon`, `name`, `title`, `description`, `godmode`, `maximize_on_start`, `index_url`, `approved_for_listing`, `approved_for_opening_items`, `approved_for_incentive_program`, `timestamp`, `last_review`, `tags`, `app_owner`) VALUES (562,'app-7bdca1a4-6373-4c98-ad97-03ff2d608ca1',60950,
|
||||
INSERT INTO `apps` (`id`, `uid`, `owner_user_id`, `icon`, `name`, `title`, `description`, `godmode`, `maximize_on_start`, `index_url`, `approved_for_listing`, `approved_for_opening_items`, `approved_for_incentive_program`, `timestamp`, `last_review`, `tags`, `app_owner`) VALUES (562,'app-7bdca1a4-6373-4c98-ad97-03ff2d608ca1',1,
|
||||
'data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIj48ZGVmcz48aW1hZ2UgIHdpZHRoPSIzNjEiIGhlaWdodD0iMzYxIiBpZD0iaW1nMSIgaHJlZj0iZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFXa0FBQUZwQVFNQUFBQmt0VXNOQUFBQUFYTlNSMElCMmNrc2Z3QUFBQU5RVEZSRi8vLy9wOFFieUFBQUFDZEpSRUZVZUp6dHdRRU5BQUFBd3FEM1QyMFBCeFFBQUFBQUFBQUFBQUFBQUFBQUFBQUFCd1pDUndBQlJ3bDNjZ0FBQUFCSlJVNUVya0pnZ2c9PSIvPjxsaW5lYXJHcmFkaWVudCBpZD0iUCIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiLz48bGluZWFyR3JhZGllbnQgaWQ9ImcxIiB4MT0iMjMiIHkxPSI0ODkiIHgyPSI0ODkiIHkyPSIyMyIgaHJlZj0iI1AiPjxzdG9wIHN0b3AtY29sb3I9IiNmY2M2MGUiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNlOTJlMjkiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48c3R5bGU+LmF7ZmlsbDp1cmwoI2cxKX08L3N0eWxlPjx1c2UgIGhyZWY9IiNpbWcxIiB4PSI3NSIgeT0iNzYiLz48cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsYXNzPSJhIiBkPSJtNTEyIDc4LjR2MzU1LjJjMCA0My4yLTM1LjIgNzguNC03OC40IDc4LjRoLTM1NS4yYy00My4yIDAtNzguNC0zNS4yLTc4LjQtNzguNHYtMzU1LjJjMC00My4yIDM1LjItNzguNCA3OC40LTc4LjRoMzU1LjJjNDMuMiAwIDc4LjQgMzUuMiA3OC40IDc4LjR6bS0zMjQuMyAxNzkuNWMwIDM0LjIgMjcuOSA2MiA2MiA2MmgxMi42YzM0LjEgMCA2Mi0yNy44IDYyLTYydi0xMDEuOWMwLTM0LjItMjcuOS02Mi02Mi02MmgtMTIuNmMtMzQuMSAwLTYyIDI3LjgtNjIgNjJ6bTI0IDB2LTEwMS45YzAtMjEgMTcuMS0zOCAzOC0zOGgxMi42YzIwLjkgMCAzOCAxNyAzOCAzOHYxMDEuOWMwIDIxLTE3LjEgMzgtMzggMzhoLTEyLjZjLTIwLjkgMC0zOC0xNy0zOC0zOHptMTY1LjQtNi4zYzAtNi42LTUuMy0xMi0xMi0xMi02LjYgMC0xMiA1LjQtMTIgMTIgMCA1My42LTQzLjUgOTcuMi05Ny4xIDk3LjItNTMuNiAwLTk3LjEtNDMuNi05Ny4xLTk3LjIgMC02LjYtNS40LTExLjktMTItMTEuOS02LjcgMC0xMiA1LjMtMTIgMTEuOSAwIDYyLjggNDcuOSAxMTQuNSAxMDkuMSAxMjAuNnYzMy44YzAgNi42IDUuNCAxMiAxMiAxMiA2LjYgMCAxMi01LjQgMTItMTJ2LTMzLjhjNjEuMi02LjEgMTA5LjEtNTcuOCAxMDkuMS0xMjAuNnoiLz48L3N2Zz4=',
|
||||
'recorder','Recorder','Online voice recorder in the browser with cloud storage. Take voice memos by recording through your mic directly in your web browser on any device.',0,0,'https://recorder.puter.com/index.html',1,0,0,'2022-10-21 03:36:06',NULL,NULL,NULL);
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Replace UNIQUE(name) on old_app_names with UNIQUE(app_uid, name).
|
||||
-- The original constraint blocked the same name from cycling through
|
||||
-- multiple apps over time and made it impossible for AppStore to use
|
||||
-- ON CONFLICT(app_uid, name) DO UPDATE to refresh the timestamp when
|
||||
-- an app re-records its previous name. SQLite has no ALTER TABLE
|
||||
-- DROP CONSTRAINT, so we recreate the table.
|
||||
|
||||
CREATE TABLE `old_app_names_new` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`app_uid` char(40) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`timestamp` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (`app_uid`, `name`),
|
||||
FOREIGN KEY (`app_uid`) REFERENCES `apps`(`uid`) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO `old_app_names_new` (`id`, `app_uid`, `name`, `timestamp`)
|
||||
SELECT `id`, `app_uid`, `name`, `timestamp` FROM `old_app_names`;
|
||||
|
||||
DROP TABLE `old_app_names`;
|
||||
|
||||
ALTER TABLE `old_app_names_new` RENAME TO `old_app_names`;
|
||||
|
||||
CREATE INDEX `idx_old_app_names_name` ON `old_app_names` (`name`);
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Refresh PDF and Player to point at hosted icons and updated index_urls.
|
||||
-- The Player app moves to simple-player.puter.com so the player.puter.com
|
||||
-- hostname can be reused by the new Music Player entry inserted below.
|
||||
|
||||
UPDATE `apps`
|
||||
SET `index_url` = 'https://pdf.puter.com',
|
||||
`icon` = 'data:image/png;base64,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 truncated
|
||||
WHERE `uid` = 'app-3920851d-bda8-479b-9407-8517293c7d44';
|
||||
|
||||
UPDATE `apps`
|
||||
SET `index_url` = 'https://simple-player.puter.com',
|
||||
`icon` = 'data:image/png;base64,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 truncated
|
||||
WHERE `uid` = 'app-11edfba2-1ed3-4e22-8573-47e88fb87d70';
|
||||
|
||||
INSERT OR IGNORE INTO `apps` (
|
||||
`uid`, `owner_user_id`, `icon`, `name`, `title`, `description`,
|
||||
`godmode`, `maximize_on_start`, `index_url`,
|
||||
`approved_for_listing`, `approved_for_opening_items`, `approved_for_incentive_program`,
|
||||
`timestamp`, `last_review`, `tags`, `app_owner`
|
||||
) VALUES (
|
||||
'app-d7e9471f-e441-4d72-a5ab-75e96573b76b',
|
||||
1,
|
||||
'data:image/png;base64,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 truncated
|
||||
'music-player', 'Music Player', 'A free music player app in the browser.',
|
||||
0, 0, 'https://player.puter.com',
|
||||
1, 0, 0,
|
||||
'2026-05-10 00:00:00', NULL, NULL, NULL
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
ALTER TABLE `subdomains` ADD COLUMN `preamble_version` varchar(64) DEFAULT NULL;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Extend `sessions` so a single row can represent any token kind
|
||||
-- (web/app/access_token/asset), carry display metadata for the
|
||||
-- manage-sessions UI, and be soft-revoked.
|
||||
|
||||
ALTER TABLE `sessions` ADD COLUMN `kind` TEXT NOT NULL DEFAULT 'web'
|
||||
CHECK (`kind` IN ('web', 'app', 'access_token', 'asset'));
|
||||
ALTER TABLE `sessions` ADD COLUMN `label` TEXT;
|
||||
ALTER TABLE `sessions` ADD COLUMN `parent_session_id` TEXT;
|
||||
ALTER TABLE `sessions` ADD COLUMN `last_ip` TEXT;
|
||||
ALTER TABLE `sessions` ADD COLUMN `last_user_agent` TEXT;
|
||||
ALTER TABLE `sessions` ADD COLUMN `revoked_at` INTEGER;
|
||||
ALTER TABLE `sessions` ADD COLUMN `expires_at` INTEGER;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_user_revoked`
|
||||
ON `sessions` (`user_id`, `revoked_at`);
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_parent`
|
||||
ON `sessions` (`parent_session_id`);
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Composite-key lookups + audit columns.
|
||||
-- - `app_uid` : binds `kind='app'` rows to their app authorization
|
||||
-- target. (user_id, app_uid) is the idempotency key.
|
||||
-- - `legacy_token_uid` : keys lazy-backfilled rows to the v1 token_uid that
|
||||
-- originally minted them.
|
||||
-- - `created_via` : audit sentinel (e.g. 'legacy_backfill').
|
||||
-- - `auth_id` : stable per-user identity that survives re-login;
|
||||
-- lets manage-sessions group by identity.
|
||||
|
||||
ALTER TABLE `sessions` ADD COLUMN `app_uid` TEXT;
|
||||
ALTER TABLE `sessions` ADD COLUMN `legacy_token_uid` TEXT;
|
||||
ALTER TABLE `sessions` ADD COLUMN `created_via` TEXT;
|
||||
ALTER TABLE `sessions` ADD COLUMN `auth_id` TEXT;
|
||||
|
||||
-- Partial unique indexes keep "at most one active row per key" without
|
||||
-- breaking the soft-revoke pattern (revoked rows stay for audit).
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_app_active`
|
||||
ON `sessions` (`user_id`, `app_uid`)
|
||||
WHERE `kind` = 'app' AND `revoked_at` IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_legacy_token_active`
|
||||
ON `sessions` (`legacy_token_uid`)
|
||||
WHERE `legacy_token_uid` IS NOT NULL AND `revoked_at` IS NULL;
|
||||
|
||||
-- Supports manage-sessions list queries grouped by kind.
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_kind_user`
|
||||
ON `sessions` (`kind`, `user_id`);
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Let `kind='access_token'` rows be reverse-looked-up
|
||||
-- from the `token_uid` claim that lives only in `access_token_permissions`.
|
||||
-- Required so `POST /auth/revoke-access-token` with a raw token_uid input
|
||||
-- (no JWT) can find and soft-revoke the session row, matching the JWT
|
||||
-- input path's coverage. Without it, raw-uuid revoke would only drop the
|
||||
-- permissions row, leaving the session-row kill switch un-flipped.
|
||||
|
||||
ALTER TABLE `sessions` ADD COLUMN `access_token_uid` TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_access_token_uid`
|
||||
ON `sessions` (`access_token_uid`)
|
||||
WHERE `access_token_uid` IS NOT NULL;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Worker session uniqueness. One active kind='worker' row per
|
||||
-- (user_id, app_uid, worker_name).
|
||||
--
|
||||
-- SQLite UNIQUE indexes treat NULLs as distinct (per the SQL standard),
|
||||
-- so user-scoped workers (where `app_uid` is NULL) would otherwise be
|
||||
-- allowed to duplicate. IFNULL collapses NULL `app_uid` to empty
|
||||
-- string in the index expression so two user-scoped workers with the
|
||||
-- same worker_name correctly conflict. MySQL handles the same case
|
||||
-- via the IFNULL in mig_11's generated column.
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_worker_active`
|
||||
ON `sessions` (`user_id`, IFNULL(`app_uid`, ''), json_extract(`meta`, '$.worker_name'))
|
||||
WHERE `kind` = 'worker' AND `revoked_at` IS NULL;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Enforce case-insensitive uniqueness on user.username.
|
||||
--
|
||||
-- SQLite defaults varchar columns to BINARY collation, so without this
|
||||
-- index `Admin` and `admin` can coexist as separate rows even though the
|
||||
-- reserved-name check and adminOnly gate both treat usernames as
|
||||
-- case-insensitive. Prod MySQL already uses ascii_general_ci on this
|
||||
-- column; this brings self-hosted SQLite to the same invariant.
|
||||
--
|
||||
-- If this CREATE fails because the DB already contains case-collision
|
||||
-- duplicates, resolve them manually before re-running the migration —
|
||||
-- there is no safe automatic merge of two user accounts.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_username_nocase
|
||||
ON user(username COLLATE NOCASE)
|
||||
WHERE username IS NOT NULL;
|
||||
@@ -0,0 +1,90 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Add 'worker' to the `kind` CHECK constraint on `sessions`. Mirrors the
|
||||
-- MySQL ENUM extension in mysql_mig_12.
|
||||
--
|
||||
-- SQLite cannot ALTER a CHECK constraint in place, so we follow the
|
||||
-- standard 12-step rebuild: create `sessions_new` with the corrected
|
||||
-- constraint, copy rows, drop the old table, rename, then recreate every
|
||||
-- index that lived on the original (indexes are auto-dropped with their
|
||||
-- table).
|
||||
|
||||
CREATE TABLE `sessions_new` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" INTEGER NOT NULL,
|
||||
"uuid" TEXT NOT NULL,
|
||||
"meta" JSON DEFAULT NULL,
|
||||
"created_at" INTEGER DEFAULT 0,
|
||||
"last_activity" INTEGER DEFAULT 0,
|
||||
"kind" TEXT NOT NULL DEFAULT 'web'
|
||||
CHECK (`kind` IN ('web', 'app', 'access_token', 'asset', 'worker')),
|
||||
"label" TEXT,
|
||||
"parent_session_id" TEXT,
|
||||
"last_ip" TEXT,
|
||||
"last_user_agent" TEXT,
|
||||
"revoked_at" INTEGER,
|
||||
"expires_at" INTEGER,
|
||||
"app_uid" TEXT,
|
||||
"legacy_token_uid" TEXT,
|
||||
"created_via" TEXT,
|
||||
"auth_id" TEXT,
|
||||
"access_token_uid" TEXT,
|
||||
FOREIGN KEY("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO `sessions_new` (
|
||||
`id`, `user_id`, `uuid`, `meta`, `created_at`, `last_activity`,
|
||||
`kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`,
|
||||
`revoked_at`, `expires_at`, `app_uid`, `legacy_token_uid`,
|
||||
`created_via`, `auth_id`, `access_token_uid`
|
||||
)
|
||||
SELECT
|
||||
`id`, `user_id`, `uuid`, `meta`, `created_at`, `last_activity`,
|
||||
`kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`,
|
||||
`revoked_at`, `expires_at`, `app_uid`, `legacy_token_uid`,
|
||||
`created_via`, `auth_id`, `access_token_uid`
|
||||
FROM `sessions`;
|
||||
|
||||
DROP TABLE `sessions`;
|
||||
|
||||
ALTER TABLE `sessions_new` RENAME TO `sessions`;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_user_revoked`
|
||||
ON `sessions` (`user_id`, `revoked_at`);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_parent`
|
||||
ON `sessions` (`parent_session_id`);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_app_active`
|
||||
ON `sessions` (`user_id`, `app_uid`)
|
||||
WHERE `kind` = 'app' AND `revoked_at` IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_legacy_token_active`
|
||||
ON `sessions` (`legacy_token_uid`)
|
||||
WHERE `legacy_token_uid` IS NOT NULL AND `revoked_at` IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_kind_user`
|
||||
ON `sessions` (`kind`, `user_id`);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_access_token_uid`
|
||||
ON `sessions` (`access_token_uid`)
|
||||
WHERE `access_token_uid` IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_worker_active`
|
||||
ON `sessions` (`user_id`, IFNULL(`app_uid`, ''), json_extract(`meta`, '$.worker_name'))
|
||||
WHERE `kind` = 'worker' AND `revoked_at` IS NULL;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Abuse v2 reputation score (0–100+) recorded at signup. Mirrors the
|
||||
-- `reputation smallint DEFAULT 100` column already present in the mysql and
|
||||
-- postgres baseline migrations.
|
||||
ALTER TABLE `user` ADD COLUMN `reputation` SMALLINT DEFAULT 100;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- SMS phone verification. `phone` holds the E.164 number collected during
|
||||
-- verification (indexed like `email`). `requires_phone_verification` gates
|
||||
-- account use until verified — the abuse v2 harness sets it for low-reputation
|
||||
-- signups instead of blocking them. (Not indexed, mirroring
|
||||
-- `requires_email_confirmation`.)
|
||||
ALTER TABLE `user` ADD COLUMN `phone` varchar(20) DEFAULT NULL;
|
||||
ALTER TABLE `user` ADD COLUMN `requires_phone_verification` tinyint(1) NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_phone ON `user` (`phone`);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Credit-card verification ($0 authorization handled by a payments
|
||||
-- extension). `requires_card_verification` gates account use until verified —
|
||||
-- the abuse v2 harness sets it for low-reputation signups instead of blocking
|
||||
-- them. The card itself never touches our DB, so this is the only column.
|
||||
-- (Not indexed, mirroring `requires_phone_verification`.)
|
||||
ALTER TABLE `user` ADD COLUMN `requires_card_verification` tinyint(1) NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Card fingerprint column. `card_fingerprint` is the Stripe card fingerprint
|
||||
-- (stable per card number) recorded when a user clears card verification — the
|
||||
-- card sibling of `phone`. Indexed like `phone` so admin tooling can find the
|
||||
-- accounts that verified with a given card. The card itself never touches our
|
||||
-- DB, only Stripe's fingerprint for it. Server-only (not on the whoami
|
||||
-- allowlist), so it never reaches clients.
|
||||
ALTER TABLE `user` ADD COLUMN `card_fingerprint` varchar(128) DEFAULT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_card_fingerprint ON `user` (`card_fingerprint`);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- When the account was suspended, as unix seconds; NULL while not suspended.
|
||||
-- The timestamp sibling of the existing boolean `suspended` flag, stamped by
|
||||
-- every suspension site. Indexed so the signup-abuse harness can count an IP's
|
||||
-- recently-suspended accounts (see extensions/abuse/v2 suspendedAccountsByIp).
|
||||
ALTER TABLE `user` ADD COLUMN `suspended_at` INTEGER DEFAULT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_suspended_at ON `user` (`suspended_at`);
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Admin-managed blocklist of app origins. An app whose `index_url` host (or
|
||||
-- a request origin) matches an entry is denied access to Puter resources:
|
||||
-- it cannot obtain an app token and already-issued app tokens are rejected
|
||||
-- on each request. `include_subdomains = 1` also blocks every subdomain of
|
||||
-- `domain`. Enforced in AuthService via AppOriginBlocklistService.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `blocked_app_origins` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"domain" TEXT NOT NULL,
|
||||
"include_subdomains" INTEGER NOT NULL DEFAULT 0,
|
||||
"reason" TEXT DEFAULT NULL,
|
||||
"created_by" TEXT DEFAULT NULL,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_blocked_app_origins_domain`
|
||||
ON `blocked_app_origins` (`domain`);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Why an account was suspended, NULL while not suspended. Companion to the
|
||||
-- boolean `suspended` flag and `suspended_at` timestamp. Constrained at the
|
||||
-- application layer to a fixed set of reasons (see extensions/admin
|
||||
-- suspension_reasons.js) — kept as free TEXT here so adding a reason later
|
||||
-- needs no migration.
|
||||
ALTER TABLE `user` ADD COLUMN `suspended_reason` TEXT DEFAULT NULL;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Append-only log of admin moderation actions, used to measure the abuse
|
||||
-- system's false-positive rate: `unsuspend` (admin unblock) and
|
||||
-- `admin_create_user` (an account minted by an admin, typically to recover a
|
||||
-- legitimately-blocked signup) are the false-positive signals; `suspend` gives
|
||||
-- the denominator. Unlike the `suspended`/`suspended_at`/`suspended_reason`
|
||||
-- columns on `user` — which are cleared on unsuspend and so keep no history —
|
||||
-- this table preserves every event. `created_at` is unix seconds (matching
|
||||
-- `user.suspended_at`), stamped by the recorder in
|
||||
-- extensions/admin/moderation_events.js. Written best-effort at the admin
|
||||
-- suspend/unsuspend/create-user sites; surfaced on the /admin/abuse dashboard.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `abuse_moderation_events` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"action" TEXT NOT NULL, -- 'suspend' | 'unsuspend' | 'admin_create_user'
|
||||
"target_user_id" INTEGER DEFAULT NULL,
|
||||
"target_username" TEXT DEFAULT NULL,
|
||||
"admin_username" TEXT DEFAULT NULL, -- the acting admin (req.actor.user.username)
|
||||
"reason" TEXT DEFAULT NULL,
|
||||
"source" TEXT DEFAULT NULL, -- 'user_page' | 'email_hostname_blacklist' | 'create_user'
|
||||
"created_at" INTEGER NOT NULL -- unix seconds
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_created_at
|
||||
ON `abuse_moderation_events` (`created_at`);
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_action
|
||||
ON `abuse_moderation_events` (`action`);
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { preparePostgresSql } from './preparePostgresSql.js';
|
||||
|
||||
describe('preparePostgresSql', () => {
|
||||
it('converts placeholders and backtick identifiers outside SQL literals', () => {
|
||||
const prepared = preparePostgresSql(
|
||||
'SELECT `user`.`id` FROM `user` WHERE `email` = ? AND `username` = ?',
|
||||
);
|
||||
|
||||
expect(prepared).toEqual({
|
||||
text: 'SELECT "user"."id" FROM "user" WHERE "email" = $1 AND "username" = $2',
|
||||
parameterCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves question marks inside strings and comments untouched', () => {
|
||||
const prepared = preparePostgresSql(`
|
||||
SELECT '?' AS literal, ?
|
||||
-- ? in a comment
|
||||
FROM \`apps\`
|
||||
WHERE \`description\` = 'is this ok?'
|
||||
/* and ? in a block comment */
|
||||
AND \`name\` = ?
|
||||
`);
|
||||
|
||||
expect(prepared.text).toContain("SELECT '?' AS literal, $1");
|
||||
expect(prepared.text).toContain('-- ? in a comment');
|
||||
expect(prepared.text).toContain('"description" = \'is this ok?\'');
|
||||
expect(prepared.text).toContain('/* and ? in a block comment */');
|
||||
expect(prepared.text).toContain('"name" = $2');
|
||||
expect(prepared.parameterCount).toBe(2);
|
||||
});
|
||||
|
||||
it('escapes double quotes inside converted identifiers', () => {
|
||||
const prepared = preparePostgresSql(
|
||||
'SELECT `odd"name` FROM `odd``table` WHERE `id` = ?',
|
||||
);
|
||||
|
||||
expect(prepared).toEqual({
|
||||
text: 'SELECT "odd""name" FROM "odd`table" WHERE "id" = $1',
|
||||
parameterCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not scan placeholders inside dollar-quoted strings', () => {
|
||||
const prepared = preparePostgresSql(
|
||||
"SELECT $$?$$, $tag$`not_ident` ?$tag$, `id` FROM `user` WHERE `id` = ?",
|
||||
);
|
||||
|
||||
expect(prepared).toEqual({
|
||||
text: 'SELECT $$?$$, $tag$`not_ident` ?$tag$, "id" FROM "user" WHERE "id" = $1',
|
||||
parameterCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat Postgres JSON operators as comments', () => {
|
||||
const prepared = preparePostgresSql(
|
||||
"SELECT * FROM `sessions` WHERE `user_id` = ? AND `meta` #>> ARRAY['worker_name'] = ? AND `expires_at` > ?",
|
||||
);
|
||||
|
||||
expect(prepared).toEqual({
|
||||
text: 'SELECT * FROM "sessions" WHERE "user_id" = $1 AND "meta" #>> ARRAY[\'worker_name\'] = $2 AND "expires_at" > $3',
|
||||
parameterCount: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface PreparedPostgresSql {
|
||||
text: string;
|
||||
parameterCount: number;
|
||||
}
|
||||
|
||||
type ScannerState =
|
||||
| { kind: 'normal' }
|
||||
| { kind: 'singleQuote' }
|
||||
| { kind: 'doubleQuote' }
|
||||
| { kind: 'backtickIdentifier' }
|
||||
| { kind: 'lineComment' }
|
||||
| { kind: 'blockComment' }
|
||||
| { kind: 'dollarQuote'; tag: string };
|
||||
|
||||
const isLineCommentStart = (sql: string, index: number): boolean => {
|
||||
if (sql[index] !== '-' || sql[index + 1] !== '-') return false;
|
||||
const after = sql[index + 2];
|
||||
return after === undefined || /\s/u.test(after);
|
||||
};
|
||||
|
||||
const readDollarQuoteTag = (sql: string, index: number): string | null => {
|
||||
const match = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/u.exec(sql.slice(index));
|
||||
return match?.[0] ?? null;
|
||||
};
|
||||
|
||||
export const preparePostgresSql = (sql: string): PreparedPostgresSql => {
|
||||
let state: ScannerState = { kind: 'normal' };
|
||||
let parameterCount = 0;
|
||||
let out = '';
|
||||
|
||||
for (let i = 0; i < sql.length; i++) {
|
||||
const char = sql[i]!;
|
||||
const next = sql[i + 1];
|
||||
|
||||
switch (state.kind) {
|
||||
case 'normal': {
|
||||
const dollarTag =
|
||||
char === '$' ? readDollarQuoteTag(sql, i) : null;
|
||||
if (dollarTag) {
|
||||
out += dollarTag;
|
||||
i += dollarTag.length - 1;
|
||||
state = { kind: 'dollarQuote', tag: dollarTag };
|
||||
break;
|
||||
}
|
||||
if (char === "'") {
|
||||
out += char;
|
||||
state = { kind: 'singleQuote' };
|
||||
break;
|
||||
}
|
||||
if (char === '"') {
|
||||
out += char;
|
||||
state = { kind: 'doubleQuote' };
|
||||
break;
|
||||
}
|
||||
if (char === '`') {
|
||||
out += '"';
|
||||
state = { kind: 'backtickIdentifier' };
|
||||
break;
|
||||
}
|
||||
if (isLineCommentStart(sql, i)) {
|
||||
out += char;
|
||||
if (char === '-') {
|
||||
out += next ?? '';
|
||||
i += 1;
|
||||
}
|
||||
state = { kind: 'lineComment' };
|
||||
break;
|
||||
}
|
||||
if (char === '/' && next === '*') {
|
||||
out += '/*';
|
||||
i += 1;
|
||||
state = { kind: 'blockComment' };
|
||||
break;
|
||||
}
|
||||
if (char === '?') {
|
||||
parameterCount += 1;
|
||||
out += `$${parameterCount}`;
|
||||
break;
|
||||
}
|
||||
out += char;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'singleQuote':
|
||||
out += char;
|
||||
if (char === "'" && next === "'") {
|
||||
out += next;
|
||||
i += 1;
|
||||
} else if (char === '\\' && next !== undefined) {
|
||||
out += next;
|
||||
i += 1;
|
||||
} else if (char === "'") {
|
||||
state = { kind: 'normal' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'doubleQuote':
|
||||
out += char;
|
||||
if (char === '"' && next === '"') {
|
||||
out += next;
|
||||
i += 1;
|
||||
} else if (char === '"') {
|
||||
state = { kind: 'normal' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'backtickIdentifier':
|
||||
if (char === '`' && next === '`') {
|
||||
out += '`';
|
||||
i += 1;
|
||||
} else if (char === '`') {
|
||||
out += '"';
|
||||
state = { kind: 'normal' };
|
||||
} else if (char === '"') {
|
||||
out += '""';
|
||||
} else {
|
||||
out += char;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'lineComment':
|
||||
out += char;
|
||||
if (char === '\n') {
|
||||
state = { kind: 'normal' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'blockComment':
|
||||
out += char;
|
||||
if (char === '*' && next === '/') {
|
||||
out += '/';
|
||||
i += 1;
|
||||
state = { kind: 'normal' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'dollarQuote':
|
||||
if (sql.startsWith(state.tag, i)) {
|
||||
out += state.tag;
|
||||
i += state.tag.length - 1;
|
||||
state = { kind: 'normal' };
|
||||
} else {
|
||||
out += char;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { text: out, parameterCount };
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
type ScannerState =
|
||||
| { kind: 'normal' }
|
||||
| { kind: 'singleQuote' }
|
||||
| { kind: 'doubleQuote' }
|
||||
| { kind: 'lineComment' }
|
||||
| { kind: 'blockComment' }
|
||||
| { kind: 'dollarQuote'; tag: string };
|
||||
|
||||
const readDollarQuoteTag = (sql: string, index: number): string | null => {
|
||||
const match = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/u.exec(sql.slice(index));
|
||||
return match?.[0] ?? null;
|
||||
};
|
||||
|
||||
export const splitPostgresStatements = (sql: string): string[] => {
|
||||
const statements: string[] = [];
|
||||
let state: ScannerState = { kind: 'normal' };
|
||||
let start = 0;
|
||||
|
||||
for (let i = 0; i < sql.length; i++) {
|
||||
const char = sql[i]!;
|
||||
const next = sql[i + 1];
|
||||
|
||||
switch (state.kind) {
|
||||
case 'normal': {
|
||||
const dollarTag =
|
||||
char === '$' ? readDollarQuoteTag(sql, i) : null;
|
||||
if (dollarTag) {
|
||||
i += dollarTag.length - 1;
|
||||
state = { kind: 'dollarQuote', tag: dollarTag };
|
||||
break;
|
||||
}
|
||||
if (char === "'") {
|
||||
state = { kind: 'singleQuote' };
|
||||
break;
|
||||
}
|
||||
if (char === '"') {
|
||||
state = { kind: 'doubleQuote' };
|
||||
break;
|
||||
}
|
||||
if (char === '-' && next === '-') {
|
||||
i += 1;
|
||||
state = { kind: 'lineComment' };
|
||||
break;
|
||||
}
|
||||
if (char === '/' && next === '*') {
|
||||
i += 1;
|
||||
state = { kind: 'blockComment' };
|
||||
break;
|
||||
}
|
||||
if (char === ';') {
|
||||
const statement = sql.slice(start, i).trim();
|
||||
if (statement !== '') statements.push(statement);
|
||||
start = i + 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'singleQuote':
|
||||
if (char === "'" && next === "'") {
|
||||
i += 1;
|
||||
} else if (char === '\\' && next !== undefined) {
|
||||
i += 1;
|
||||
} else if (char === "'") {
|
||||
state = { kind: 'normal' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'doubleQuote':
|
||||
if (char === '"' && next === '"') {
|
||||
i += 1;
|
||||
} else if (char === '"') {
|
||||
state = { kind: 'normal' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'lineComment':
|
||||
if (char === '\n') {
|
||||
state = { kind: 'normal' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'blockComment':
|
||||
if (char === '*' && next === '/') {
|
||||
i += 1;
|
||||
state = { kind: 'normal' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'dollarQuote':
|
||||
if (sql.startsWith(state.tag, i)) {
|
||||
i += state.tag.length - 1;
|
||||
state = { kind: 'normal' };
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const finalStatement = sql.slice(start).trim();
|
||||
if (finalStatement !== '') statements.push(finalStatement);
|
||||
return statements;
|
||||
};
|
||||
@@ -36,33 +36,44 @@ import {
|
||||
UpdateCommand,
|
||||
} from '@aws-sdk/lib-dynamodb';
|
||||
import { NodeHttpHandler } from '@smithy/node-http-handler';
|
||||
//@ts-expect-error - no types available for dynalite
|
||||
import dynalite from 'dynalite';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import { Agent as httpsAgent } from 'node:https';
|
||||
import { PuterClient } from '../types';
|
||||
import type { IConfig, IDynamoConfig } from '../../types';
|
||||
import { HttpError } from '../../core/http';
|
||||
import type { IConfig, IDynamoConfig } from '../../types';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { PuterClient } from '../types';
|
||||
|
||||
const LOCAL_DYNAMO_PATH_KEY = ':memory:';
|
||||
const LOCAL_DYNAMO_MEMORY_PREFIX = ':memory:';
|
||||
const localDynaliteEndpointPromises = new Map<string, Promise<string>>();
|
||||
const MAX_BATCH_WRITE_ITEMS = 25;
|
||||
const MAX_BATCH_WRITE_RETRIES = 8;
|
||||
const BATCH_WRITE_RETRY_BASE_MS = 25;
|
||||
|
||||
const getDynalitePathKey = (path?: string) => {
|
||||
if (path === ':memory:') return LOCAL_DYNAMO_PATH_KEY;
|
||||
// In-memory mode gives each DDBClient its own unique key, which keys
|
||||
// into a fresh dynalite server. This is what test parallelism needs:
|
||||
// two clients in the same Node process don't share state. Persistent
|
||||
// paths still share by `path`, so prod-like reuse keeps working.
|
||||
const getDynalitePathKey = (path?: string, inMemory?: boolean) => {
|
||||
if (inMemory) return `${LOCAL_DYNAMO_MEMORY_PREFIX}#${randomUUID()}`;
|
||||
if (path === ':memory:')
|
||||
return `${LOCAL_DYNAMO_MEMORY_PREFIX}#${randomUUID()}`;
|
||||
return path || './volatile/runtime/puter-ddb';
|
||||
};
|
||||
|
||||
const isMemoryPathKey = (pathKey: string) =>
|
||||
pathKey.startsWith(LOCAL_DYNAMO_MEMORY_PREFIX);
|
||||
|
||||
const getOrCreateLocalDynaliteEndpoint = async (pathKey: string) => {
|
||||
let endpointPromise = localDynaliteEndpointPromises.get(pathKey);
|
||||
if (endpointPromise) return endpointPromise;
|
||||
|
||||
endpointPromise = (async () => {
|
||||
const dynaliteOptions =
|
||||
pathKey === LOCAL_DYNAMO_PATH_KEY
|
||||
? { createTableMs: 0 }
|
||||
: { createTableMs: 0, path: pathKey };
|
||||
const dynaliteOptions = isMemoryPathKey(pathKey)
|
||||
? { createTableMs: 0 }
|
||||
: { createTableMs: 0, path: pathKey };
|
||||
|
||||
const dynaliteInstance = dynalite(dynaliteOptions);
|
||||
const dynaliteServer = dynaliteInstance.listen(0, '127.0.0.1');
|
||||
@@ -107,10 +118,18 @@ export class DDBClient extends PuterClient {
|
||||
#documentClient: DynamoDBDocumentClient | null = null;
|
||||
#localInitPromise: Promise<void> | null = null;
|
||||
#ddbConfig: IDynamoConfig;
|
||||
// Resolved once at construction. In-memory mode generates a unique
|
||||
// key per instance so parallel clients don't share state, but
|
||||
// `recreateClient()` reuses this same key and so reuses the server.
|
||||
#localPathKey: string;
|
||||
|
||||
constructor(config: IConfig) {
|
||||
super(config);
|
||||
this.#ddbConfig = config.dynamo ?? {};
|
||||
this.#localPathKey = getDynalitePathKey(
|
||||
this.#ddbConfig.path,
|
||||
this.#ddbConfig.inMemory,
|
||||
);
|
||||
|
||||
if (this.#ddbConfig.aws) {
|
||||
this.#bindAwsClient();
|
||||
@@ -133,6 +152,7 @@ export class DDBClient extends PuterClient {
|
||||
await this.#localInitPromise;
|
||||
}
|
||||
|
||||
@Span('ddb.get', (table: string) => ({ 'db.table': table }))
|
||||
async get<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
key: T,
|
||||
@@ -149,6 +169,7 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.put', (table: string) => ({ 'db.table': table }))
|
||||
async put<T extends Record<string, unknown>>(table: string, item: T) {
|
||||
const command = new PutCommand({
|
||||
TableName: table,
|
||||
@@ -160,6 +181,9 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.batchGet', (params: unknown[]) => ({
|
||||
'db.batch_size': params.length,
|
||||
}))
|
||||
async batchGet(
|
||||
params: { table: string; items: Record<string, unknown> }[],
|
||||
consistentRead = false,
|
||||
@@ -194,6 +218,9 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.batchPut', (params: unknown[]) => ({
|
||||
'db.batch_size': params.length,
|
||||
}))
|
||||
async batchPut(params: { table: string; item: Record<string, unknown> }[]) {
|
||||
const consumedCapacityByTable = new Map<string, number>();
|
||||
if (params.length === 0) {
|
||||
@@ -300,6 +327,7 @@ export class DDBClient extends PuterClient {
|
||||
};
|
||||
}
|
||||
|
||||
@Span('ddb.del', (table: string) => ({ 'db.table': table }))
|
||||
async del<T extends Record<string, unknown>>(table: string, key: T) {
|
||||
const command = new DeleteCommand({
|
||||
TableName: table,
|
||||
@@ -311,6 +339,7 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.query', (table: string) => ({ 'db.table': table }))
|
||||
async query<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
keys: T,
|
||||
@@ -365,6 +394,7 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.update', (table: string) => ({ 'db.table': table }))
|
||||
async update<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
key: T,
|
||||
@@ -388,13 +418,8 @@ export class DDBClient extends PuterClient {
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
});
|
||||
|
||||
try {
|
||||
const client = await this.#getDocumentClient();
|
||||
return await client.send(command);
|
||||
} catch (error) {
|
||||
console.error('DDB Update Error', error);
|
||||
throw error;
|
||||
}
|
||||
const client = await this.#getDocumentClient();
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
async createTableIfNotExists(
|
||||
@@ -480,8 +505,9 @@ export class DDBClient extends PuterClient {
|
||||
}
|
||||
|
||||
async #bindLocalClient() {
|
||||
const pathKey = getDynalitePathKey(this.#ddbConfig.path);
|
||||
const endpoint = await getOrCreateLocalDynaliteEndpoint(pathKey);
|
||||
const endpoint = await getOrCreateLocalDynaliteEndpoint(
|
||||
this.#localPathKey,
|
||||
);
|
||||
|
||||
const ddbClient = new DynamoDBClient({
|
||||
credentials: {
|
||||
|
||||
@@ -24,12 +24,20 @@ import type { IConfig } from '../../types';
|
||||
import { PuterClient } from '../types';
|
||||
import { EMAIL_TEMPLATES, type EmailTemplateName } from './templates';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
/** Attachment shape passed through to the underlying transport. */
|
||||
export interface EmailAttachment {
|
||||
filename: string;
|
||||
content: Buffer | string;
|
||||
contentType?: string;
|
||||
encoding?: string;
|
||||
}
|
||||
|
||||
// nodemailer doesn't ship TS types, so declare the subset we use.
|
||||
interface NodemailerTransport {
|
||||
sendMail: (options: SendMailOptions) => Promise<unknown>;
|
||||
close?: () => void;
|
||||
/** Subset of the transport's send result callers may care about. */
|
||||
export interface SentMessageInfo {
|
||||
messageId?: string;
|
||||
accepted?: string[];
|
||||
rejected?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SendMailOptions {
|
||||
@@ -41,6 +49,7 @@ export interface SendMailOptions {
|
||||
html?: string;
|
||||
text?: string;
|
||||
replyTo?: string;
|
||||
attachments?: EmailAttachment[];
|
||||
}
|
||||
|
||||
export type EmailValidator = (email: string) => Promise<boolean> | boolean;
|
||||
@@ -50,7 +59,7 @@ interface CompiledTemplate {
|
||||
html: ReturnType<typeof template>;
|
||||
}
|
||||
|
||||
// ── Clean-email rules ────────────────────────────────────────────────
|
||||
// -- Clean-email rules ------------------------------------------------
|
||||
|
||||
type CleanRule = (parts: { local: string; domain: string }) => {
|
||||
local: string;
|
||||
@@ -90,7 +99,7 @@ const DOMAIN_ALIASES: Record<string, string> = {
|
||||
'googlemail.com': 'gmail.com',
|
||||
};
|
||||
|
||||
// ── EmailClient ──────────────────────────────────────────────────────
|
||||
// -- EmailClient ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Unified email client. Handles:
|
||||
@@ -100,7 +109,8 @@ const DOMAIN_ALIASES: Record<string, string> = {
|
||||
* - Policy + extensible validation (via `validate`)
|
||||
*/
|
||||
export class EmailClient extends PuterClient {
|
||||
private transport: NodemailerTransport | null = null;
|
||||
private transport: ReturnType<typeof nodemailer.createTransport> | null =
|
||||
null;
|
||||
private compiledTemplates: Partial<
|
||||
Record<EmailTemplateName, CompiledTemplate>
|
||||
> = {};
|
||||
@@ -112,7 +122,7 @@ export class EmailClient extends PuterClient {
|
||||
this.compileTemplates();
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────
|
||||
// -- Lifecycle ----------------------------------------------------
|
||||
|
||||
override onServerStart(): void {
|
||||
const emailConf = this.config.email;
|
||||
@@ -123,8 +133,7 @@ export class EmailClient extends PuterClient {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.transport = nodemailer.createTransport(emailConf as any);
|
||||
this.transport = nodemailer.createTransport(emailConf);
|
||||
console.log('[email] transport configured');
|
||||
}
|
||||
|
||||
@@ -133,7 +142,7 @@ export class EmailClient extends PuterClient {
|
||||
this.transport = null;
|
||||
}
|
||||
|
||||
// ── Public API: sending ──────────────────────────────────────────
|
||||
// -- Public API: sending ------------------------------------------
|
||||
|
||||
/**
|
||||
* Render a template and send it to `to`.
|
||||
@@ -159,22 +168,31 @@ export class EmailClient extends PuterClient {
|
||||
/**
|
||||
* Raw send — bypasses the template system. Useful for one-off
|
||||
* admin emails that don't warrant a named template.
|
||||
*
|
||||
* Returns the transport's send result, or `null` when no transport
|
||||
* is configured (the send is a no-op in that case — callers that
|
||||
* must not silently drop mail should check `isConfigured` first).
|
||||
*/
|
||||
async sendRaw(options: SendMailOptions): Promise<void> {
|
||||
async sendRaw(options: SendMailOptions) {
|
||||
if (!this.transport) {
|
||||
console.warn(
|
||||
'[email] attempted to send email without transport. If you need to send email, configure an SMTP transport in your config file (see docs for details). Email content:',
|
||||
options,
|
||||
);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
await this.transport.sendMail({
|
||||
from: options.from ?? this.defaultFrom(),
|
||||
return await this.transport.sendMail({
|
||||
...options,
|
||||
from: options.from ?? this.defaultFrom(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Public API: clean / validate ─────────────────────────────────
|
||||
/** Whether an SMTP transport is configured (sends are no-ops otherwise). */
|
||||
get isConfigured(): boolean {
|
||||
return this.transport !== null;
|
||||
}
|
||||
|
||||
// -- Public API: clean / validate ---------------------------------
|
||||
|
||||
/**
|
||||
* Normalize an email to its canonical form for dedup comparisons.
|
||||
@@ -241,7 +259,7 @@ export class EmailClient extends PuterClient {
|
||||
this.validators.push(fn);
|
||||
}
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────
|
||||
// -- Internals ----------------------------------------------------
|
||||
|
||||
private defaultFrom(): string {
|
||||
return this.config.email?.from ?? '"Puter" [email protected]';
|
||||
|
||||
@@ -69,7 +69,7 @@ The Puter Team
|
||||
html: `
|
||||
<p>Hi{{#if owner_username}} {{owner_username}}{{/if}},</p>
|
||||
<p>
|
||||
Please update <a href="https://apps.puter.com/app/{{app_name}}">{{app_title}}</a>.
|
||||
Please update <a href="https://puter.com/app/{{app_name}}">{{app_title}}</a>.
|
||||
</p>
|
||||
<p><strong>Requested updates:</strong></p>
|
||||
<blockquote>{{nl2br message}}</blockquote>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { PuterServer } from '../../server.ts';
|
||||
import { setupTestServer } from '../../testUtil.ts';
|
||||
import type { EventClient } from './EventClient.js';
|
||||
|
||||
describe('EventClient', () => {
|
||||
let server: PuterServer;
|
||||
let target: EventClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
target = server.clients.event as unknown as EventClient;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
// Each test gets a fresh key so listeners registered by earlier tests
|
||||
// never collide with later ones (the client has no public way to
|
||||
// unsubscribe).
|
||||
let key: string;
|
||||
beforeEach(() => {
|
||||
key = `test.${Math.random().toString(36).slice(2)}`;
|
||||
});
|
||||
|
||||
describe('on / emit', () => {
|
||||
it('invokes a listener registered for the exact key', () => {
|
||||
const listener = vi.fn();
|
||||
target.on(key, listener);
|
||||
target.emit(key, { hello: 'world' }, { source: 'test' });
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
key,
|
||||
{ hello: 'world' },
|
||||
{ source: 'test' },
|
||||
);
|
||||
});
|
||||
|
||||
it('does not invoke listeners registered under a different key', () => {
|
||||
const other = vi.fn();
|
||||
target.on(`${key}.other`, other);
|
||||
target.emit(key, {}, {});
|
||||
expect(other).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invokes every listener registered for the same key', () => {
|
||||
const a = vi.fn();
|
||||
const b = vi.fn();
|
||||
target.on(key, a);
|
||||
target.on(key, b);
|
||||
target.emit(key, {}, {});
|
||||
expect(a).toHaveBeenCalledTimes(1);
|
||||
expect(b).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing when emitting a key with no listeners', () => {
|
||||
// Just asserts that no exception is thrown.
|
||||
expect(() =>
|
||||
target.emit(`${key}.nobody-home`, {}, {}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('continues firing later listeners after one throws', () => {
|
||||
const errSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const bad = vi.fn(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const good = vi.fn();
|
||||
target.on(key, bad);
|
||||
target.on(key, good);
|
||||
target.emit(key, {}, {});
|
||||
expect(bad).toHaveBeenCalledTimes(1);
|
||||
expect(good).toHaveBeenCalledTimes(1);
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('wildcard subscriptions', () => {
|
||||
it('matches every dot-extended descendant of a wildcard prefix', () => {
|
||||
const listener = vi.fn();
|
||||
target.on(`${key}.*`, listener);
|
||||
target.emit(`${key}.foo`, { v: 1 }, {});
|
||||
target.emit(`${key}.foo.bar`, { v: 2 }, {});
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
expect(listener).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
`${key}.foo`,
|
||||
{ v: 1 },
|
||||
{},
|
||||
);
|
||||
expect(listener).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
`${key}.foo.bar`,
|
||||
{ v: 2 },
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('fires both wildcard and exact-key subscribers for one emit', () => {
|
||||
const wild = vi.fn();
|
||||
const exact = vi.fn();
|
||||
target.on(`${key}.*`, wild);
|
||||
target.on(`${key}.thing`, exact);
|
||||
target.emit(`${key}.thing`, {}, {});
|
||||
expect(wild).toHaveBeenCalledTimes(1);
|
||||
expect(exact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not fire a wildcard listener for the prefix itself', () => {
|
||||
const wild = vi.fn();
|
||||
target.on(`${key}.*`, wild);
|
||||
target.emit(key, {}, {});
|
||||
expect(wild).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fires wildcards at every nesting level for a deep emit', () => {
|
||||
const top = vi.fn();
|
||||
const mid = vi.fn();
|
||||
target.on(`${key}.*`, top);
|
||||
target.on(`${key}.a.*`, mid);
|
||||
target.emit(`${key}.a.b.c`, {}, {});
|
||||
expect(top).toHaveBeenCalledTimes(1);
|
||||
expect(mid).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitAndWait', () => {
|
||||
it('awaits async listeners before resolving', async () => {
|
||||
let resolved = false;
|
||||
target.on(key, async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
resolved = true;
|
||||
});
|
||||
await target.emitAndWait(key, {}, {});
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it('runs listeners sequentially so later ones see earlier mutations', async () => {
|
||||
target.on(key, (_k, data) => {
|
||||
(data as { steps: string[] }).steps.push('first');
|
||||
});
|
||||
target.on(key, async (_k, data) => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
(data as { steps: string[] }).steps.push('second');
|
||||
});
|
||||
const data = { steps: [] as string[] };
|
||||
await target.emitAndWait(key, data, {});
|
||||
expect(data.steps).toEqual(['first', 'second']);
|
||||
});
|
||||
|
||||
it('continues the chain when a listener throws', async () => {
|
||||
const errSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const after = vi.fn();
|
||||
target.on(key, () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
target.on(key, after);
|
||||
await target.emitAndWait(key, {}, {});
|
||||
expect(after).toHaveBeenCalledTimes(1);
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('awaits wildcard listeners too', async () => {
|
||||
let resolved = false;
|
||||
target.on(`${key}.*`, async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
resolved = true;
|
||||
});
|
||||
await target.emitAndWait(`${key}.child`, {}, {});
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle hooks', () => {
|
||||
it('onServerStart emits a serverStart event', () => {
|
||||
const listener = vi.fn();
|
||||
target.on('serverStart', listener);
|
||||
target.onServerStart();
|
||||
expect(listener).toHaveBeenCalledWith('serverStart', {}, {});
|
||||
});
|
||||
|
||||
it('onServerPrepareShutdown emits a serverPrepareShutdown event', () => {
|
||||
const listener = vi.fn();
|
||||
target.on('serverPrepareShutdown', listener);
|
||||
target.onServerPrepareShutdown();
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
'serverPrepareShutdown',
|
||||
{},
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('onServerShutdown emits a serverShutdown event', () => {
|
||||
const listener = vi.fn();
|
||||
target.on('serverShutdown', listener);
|
||||
target.onServerShutdown();
|
||||
expect(listener).toHaveBeenCalledWith('serverShutdown', {}, {});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,10 +17,19 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { PuterClient } from './types';
|
||||
import { extensionStore } from '../../extensions';
|
||||
import { withSpan } from '../../util/span.js';
|
||||
import { PuterClient } from '../types';
|
||||
import {
|
||||
EventListener,
|
||||
EventMap,
|
||||
EventMetadata,
|
||||
ListenKey,
|
||||
MatchingEvents,
|
||||
} from './types';
|
||||
|
||||
export class EventClient extends PuterClient {
|
||||
#eventListeners = {};
|
||||
#eventListeners: Partial<Record<ListenKey, EventListener[]>> = {};
|
||||
|
||||
onServerStart() {
|
||||
this.emit('serverStart', {}, {});
|
||||
@@ -49,18 +58,23 @@ export class EventClient extends PuterClient {
|
||||
* under their literal `<prefix>.*` string. No regex, no per-emit
|
||||
* scan of every listener.
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {unknown} data
|
||||
* @param {object} meta
|
||||
*/
|
||||
emit(key, data, meta) {
|
||||
emit<T extends keyof EventMap>(
|
||||
key: T,
|
||||
data: EventMap[T],
|
||||
meta: EventMetadata,
|
||||
) {
|
||||
const parts = key.split('.');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const matchKey =
|
||||
const matchKey = (
|
||||
i === parts.length - 1
|
||||
? key
|
||||
: `${parts.slice(0, i + 1).join('.')}.*`;
|
||||
const listeners = this.#eventListeners[matchKey];
|
||||
: `${parts.slice(0, i + 1).join('.')}.*`
|
||||
) as ListenKey;
|
||||
const extensionListeners = extensionStore.events[matchKey];
|
||||
const listeners = (this.#eventListeners[matchKey] || []).concat(
|
||||
extensionListeners || [],
|
||||
);
|
||||
if (!listeners) continue;
|
||||
for (const listener of listeners) {
|
||||
this.#emitEvent(listener, key, data, meta);
|
||||
@@ -80,29 +94,40 @@ export class EventClient extends PuterClient {
|
||||
* Listeners run sequentially in the order they're registered so an
|
||||
* earlier handler's mutation is visible to later ones. A listener that
|
||||
* throws is logged (same as `emit`) and the chain continues.
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {unknown} data
|
||||
* @param {object} meta
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async emitAndWait(key, data, meta) {
|
||||
const parts = key.split('.');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const matchKey =
|
||||
i === parts.length - 1
|
||||
? key
|
||||
: `${parts.slice(0, i + 1).join('.')}.*`;
|
||||
const listeners = this.#eventListeners[matchKey];
|
||||
if (!listeners) continue;
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
await listener(key, data, meta);
|
||||
} catch (e) {
|
||||
console.error('Error in event listener for event', key, e);
|
||||
async emitAndWait<T extends keyof EventMap>(
|
||||
key: T,
|
||||
data: EventMap[T],
|
||||
meta: EventMetadata,
|
||||
) {
|
||||
// Spanned because callers block on listeners — this is where time
|
||||
// spent in extension hooks (e.g. `ip.validate`) gets attributed.
|
||||
return withSpan('event.emitAndWait', { 'event.key': key }, async () => {
|
||||
const parts = key.split('.');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const matchKey = (
|
||||
i === parts.length - 1
|
||||
? key
|
||||
: `${parts.slice(0, i + 1).join('.')}.*`
|
||||
) as ListenKey;
|
||||
const extensionListeners = extensionStore.events[matchKey];
|
||||
const listeners = (this.#eventListeners[matchKey] || []).concat(
|
||||
extensionListeners || [],
|
||||
);
|
||||
if (!listeners) continue;
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
await listener(key, data, meta);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'Error in event listener for event',
|
||||
key,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,17 +142,38 @@ export class EventClient extends PuterClient {
|
||||
* to `emit()` — wildcard subscribers can branch on the triggering
|
||||
* event name.
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {(key: string, data: unknown, meta: object) => void} callback
|
||||
*/
|
||||
on(key, callback) {
|
||||
if (!this.#eventListeners[key]) {
|
||||
this.#eventListeners[key] = [];
|
||||
}
|
||||
this.#eventListeners[key].push(callback);
|
||||
on<P extends ListenKey>(
|
||||
key: P,
|
||||
callback: (
|
||||
key: MatchingEvents<P>,
|
||||
data: EventMap[MatchingEvents<P>],
|
||||
meta: EventMetadata,
|
||||
) => Promise<void> | void,
|
||||
) {
|
||||
const listeners: EventListener[] =
|
||||
this.#eventListeners[key] ?? (this.#eventListeners[key] = []);
|
||||
listeners.push(callback as EventListener);
|
||||
}
|
||||
|
||||
async #emitEvent(listener, key, data, meta) {
|
||||
off<P extends ListenKey>(
|
||||
key: P,
|
||||
callback: (
|
||||
key: MatchingEvents<P>,
|
||||
data: EventMap[MatchingEvents<P>],
|
||||
meta: EventMetadata,
|
||||
) => Promise<void> | void,
|
||||
) {
|
||||
const listeners = this.#eventListeners[key];
|
||||
if (!listeners) return;
|
||||
const idx = listeners.indexOf(callback as EventListener);
|
||||
if (idx !== -1) listeners.splice(idx, 1);
|
||||
}
|
||||
async #emitEvent<T extends keyof EventMap>(
|
||||
listener: EventListener,
|
||||
key: T,
|
||||
data: EventMap[T],
|
||||
meta: EventMetadata,
|
||||
) {
|
||||
try {
|
||||
await listener(key, data, meta);
|
||||
} catch (e) {
|
||||
@@ -0,0 +1,495 @@
|
||||
/**
|
||||
* Shape of every payload emitted on the event bus, keyed by event name.
|
||||
*
|
||||
* Domain objects (User, Actor, FSEntry, Socket, ...) are typed as `unknown`
|
||||
* here on purpose — pulling their real types in would couple this module to
|
||||
* most of the backend and risk import cycles. Refine at the listener if
|
||||
* you need narrowed access.
|
||||
*
|
||||
* Conventions:
|
||||
* - `*.validate` events carry an `allow` flag listeners flip to reject;
|
||||
* they tend to grow listener-specific fields, so they accept extras via
|
||||
* an index signature.
|
||||
* - `outer.gui.*` events ride the `{ user_id_list, response }` envelope
|
||||
* the SocketService fans out to user-scoped channels.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Request as ExpressRequest,
|
||||
Response as ExpressResponse,
|
||||
} from 'express';
|
||||
import { Actor } from '../../core';
|
||||
import { FSEntry } from '../../stores/fs/FSEntry';
|
||||
|
||||
// GUI write events spread an entry plus per-event metadata into `response`.
|
||||
// The exact field set varies by emit site (FSController / LegacyFSController /
|
||||
// WebDAVController each project a slightly different shape) so the envelope
|
||||
// stays loose; listeners narrow on whichever fields they actually consume.
|
||||
type GuiEvent<R = Record<string, unknown>> = {
|
||||
user_id_list: number[];
|
||||
response: R;
|
||||
};
|
||||
|
||||
export type EventMap = {
|
||||
// ---- Server lifecycle ----
|
||||
serverStart: Record<string, never>;
|
||||
serverPrepareShutdown: Record<string, never>;
|
||||
serverShutdown: Record<string, never>;
|
||||
|
||||
// ---- AI ----
|
||||
'ai.prompt.validate': {
|
||||
username: string;
|
||||
intended_service: string;
|
||||
parameters: unknown;
|
||||
allow?: boolean;
|
||||
abuse?: unknown;
|
||||
custom?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'ai.prompt.complete': {
|
||||
username: string;
|
||||
completionId: string;
|
||||
intended_service: string;
|
||||
parameters: unknown;
|
||||
result: unknown;
|
||||
model_used: string;
|
||||
service_used: string;
|
||||
};
|
||||
'ai.prompt.cost-calculated': {
|
||||
completionId: string;
|
||||
username: string;
|
||||
usage: unknown;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
input_ucents: number;
|
||||
output_ucents: number;
|
||||
total_ucents: number;
|
||||
costs_currency: string;
|
||||
model_used: string;
|
||||
service_used: string;
|
||||
intended_service: string;
|
||||
model_details: {
|
||||
id: string;
|
||||
provider: string;
|
||||
input_cost_key: string;
|
||||
output_cost_key: string;
|
||||
costs: unknown;
|
||||
costs_currency: string;
|
||||
};
|
||||
};
|
||||
'ai.log.image': {
|
||||
actor: unknown;
|
||||
completionId: string;
|
||||
parameters: unknown;
|
||||
intended_service: string;
|
||||
model_used: string;
|
||||
service_used: string;
|
||||
};
|
||||
|
||||
// ---- Apps ----
|
||||
'app.changed': {
|
||||
app_uid: string;
|
||||
action: string;
|
||||
app?: unknown;
|
||||
old_app?: unknown;
|
||||
};
|
||||
'app.new-icon': { app_uid: string; data_url: string };
|
||||
'app.opened': { app_uid: string; user_id: number; ts: number };
|
||||
'app.rename': {
|
||||
app_uid: string;
|
||||
old_name: string;
|
||||
new_name: string;
|
||||
app: unknown;
|
||||
};
|
||||
'app.from-origin': { origin: string };
|
||||
'app.privateAccess.check': {
|
||||
appUid: string;
|
||||
userUid: string;
|
||||
requestHost: string;
|
||||
requestPath: string;
|
||||
actor?: Actor;
|
||||
result: {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
redirectUrl?: string;
|
||||
checkedBy?: string;
|
||||
};
|
||||
};
|
||||
'app.privateAccess.resolveLaunch': {
|
||||
app: unknown;
|
||||
actor: unknown;
|
||||
result: { allowed: boolean; reason?: string; error?: string };
|
||||
};
|
||||
// Emitted after an app token is granted (get-user-app-token). The app-side
|
||||
// analogue of `site.htmlServed` — the external-app abuse scanner
|
||||
// (extensions/subdomainAbuse/appScan.ts) listens to screenshot + classify
|
||||
// external `index_url`s that Puter never serves itself.
|
||||
'puter.app.authenticated': {
|
||||
app_uid?: string;
|
||||
user_id?: number | null;
|
||||
app?: {
|
||||
id?: number;
|
||||
uid?: string;
|
||||
index_url?: string | null;
|
||||
owner_user_id?: number | null;
|
||||
name?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
// ---- Auth / signup ----
|
||||
'puter.signup.validate': {
|
||||
allow: boolean;
|
||||
email?: string;
|
||||
ip?: string | null;
|
||||
source?: 'oidc';
|
||||
req?: unknown;
|
||||
data?: unknown;
|
||||
abuse?: unknown;
|
||||
trail?: Array<string>;
|
||||
/**
|
||||
* Set by the abuse harness for flagged signups — the id under which the
|
||||
* decision trail is persisted to KV (`abuse:trail:<id>`), shared back on
|
||||
* the request for log / support correlation.
|
||||
*/
|
||||
trail_id?: string;
|
||||
/** Device signal forwarded verbatim from the signup request body. */
|
||||
fingerprint?: string | null;
|
||||
/** Set by the abuse harness — require SMS phone verification post-signup. */
|
||||
requires_phone_verification?: boolean;
|
||||
/** Set by the abuse harness — require card verification post-signup. */
|
||||
requires_card_verification?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'puter.signup.success': {
|
||||
user_id: number;
|
||||
user_uuid: string;
|
||||
email: string;
|
||||
username: string;
|
||||
ip?: string | null;
|
||||
fingerprint?: string | null;
|
||||
/** True when the created account is a temp user (no email/password). */
|
||||
is_temp?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'email.validate': {
|
||||
email: string;
|
||||
allow: boolean;
|
||||
message: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'user.save_account': {
|
||||
user_id: number;
|
||||
old_username?: string;
|
||||
new_username?: string;
|
||||
email?: string;
|
||||
};
|
||||
'user.email-confirmed': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
email: string;
|
||||
};
|
||||
// Phone-reuse cap is pure mechanism here — the abuse extension answers
|
||||
// (emitted via `emitAndWait`) by counting how many OTHER accounts have
|
||||
// already verified this number and flipping `allowed` to false when the
|
||||
// cross-account limit is hit. No extension listening → `allowed` stays
|
||||
// true and the send proceeds.
|
||||
'puter.phone-verification.check': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
phone: string;
|
||||
// Client-supplied device fingerprint for this request, or null. Lets
|
||||
// the abuse extension cap sends per device (across accounts) so a
|
||||
// device farm can't dodge the per-account cap with fresh signups.
|
||||
device_fingerprint: string | null;
|
||||
allowed: boolean;
|
||||
reason: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
// Fire-and-forget signal that a code was actually sent — the abuse
|
||||
// extension bumps its per-number / per-account / per-device send-velocity
|
||||
// counters off this. No-op with no extension listening.
|
||||
'puter.phone-verification.sent': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
phone: string;
|
||||
device_fingerprint: string | null;
|
||||
};
|
||||
'user.phone-verified': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
phone: string;
|
||||
};
|
||||
// Card verification is pure mechanism here — a payments extension fills
|
||||
// these in (emitted via `emitAndWait`). `enabled` stays null when no
|
||||
// extension is installed; the extension always sets it (true/false) so
|
||||
// the endpoints can distinguish "disabled" from "not installed".
|
||||
'puter.card-verification.setup': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
ip?: string | null;
|
||||
// Client-supplied device fingerprint for this request, or null. Lets
|
||||
// the abuse extension cap card-verification setups per device (across
|
||||
// accounts) before any Stripe SetupIntent is created.
|
||||
device_fingerprint: string | null;
|
||||
enabled: boolean | null;
|
||||
// Set false by the extension to refuse this setup (e.g. the per-device
|
||||
// setup-velocity cap); `reason` carries the opaque code. Stays true
|
||||
// when allowed or with no extension listening.
|
||||
allowed: boolean;
|
||||
reason: string | null;
|
||||
client_secret: string | null;
|
||||
publishable_key: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'puter.card-verification.confirm': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
setup_intent_id: string;
|
||||
enabled: boolean | null;
|
||||
verified: boolean;
|
||||
reason: string | null;
|
||||
fingerprint: string | null;
|
||||
funding: string | null;
|
||||
country: string | null;
|
||||
customer_id: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'user.card-verified': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
fingerprint: string | null;
|
||||
funding: string | null;
|
||||
country: string | null;
|
||||
customer_id: string | null;
|
||||
};
|
||||
'user.username-changed': {
|
||||
user_id: number;
|
||||
old_username: string;
|
||||
new_username: string;
|
||||
};
|
||||
'user.email-changed': { user_id: number; new_email: string };
|
||||
// Fired after an account is torn down (self-serve, admin, or temp-user
|
||||
// logout cleanup). Listeners purge external state tied to the account —
|
||||
// e.g. the marketplace extension cancels the user's Stripe subscriptions.
|
||||
// The row is already gone by emit time, so identifiers ride the payload.
|
||||
'user.delete': {
|
||||
user_id: number;
|
||||
user_uuid?: string;
|
||||
stripe_customer_id?: string | null;
|
||||
};
|
||||
|
||||
// ---- Filesystem ----
|
||||
'fs.copy.node': {
|
||||
source: unknown;
|
||||
copy: unknown;
|
||||
sourceObjectKey: string;
|
||||
copyObjectKey: string;
|
||||
};
|
||||
'fs.move.node': { node: FSEntry; fromPath: string; toPath: string };
|
||||
'fs.remove.node': { node: FSEntry; entry: FSEntry; target: FSEntry };
|
||||
'fs.write.file': { node: FSEntry; entry: FSEntry; target: FSEntry };
|
||||
'fs.storage.upload-progress': {
|
||||
upload_tracker: unknown;
|
||||
context: unknown;
|
||||
meta: {
|
||||
user_id: number;
|
||||
userId: number;
|
||||
item_uid: string;
|
||||
item_path: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
'storage.quota.bonus': { userId: number; extra: number };
|
||||
|
||||
// ---- Outer / GUI broadcast ----
|
||||
'outer.cacheUpdate': {
|
||||
cacheKey: string[];
|
||||
data?: unknown;
|
||||
ttlSeconds?: number;
|
||||
};
|
||||
'outer.fs.write-hash': { hash: string; uuid: string };
|
||||
'outer.gui.item.added': GuiEvent;
|
||||
'outer.gui.item.updated': GuiEvent;
|
||||
'outer.gui.item.moved': GuiEvent;
|
||||
'outer.gui.item.pending': GuiEvent;
|
||||
'outer.gui.item.removed': GuiEvent;
|
||||
'outer.gui.notif.ack': GuiEvent<{ uid: string }>;
|
||||
'outer.gui.notif.persisted': GuiEvent<{ uid: string }>;
|
||||
'outer.gui.notif.message': GuiEvent<{ uid: string; notification: unknown }>;
|
||||
'outer.gui.notif.unreads': GuiEvent<{
|
||||
unreads: { uid: string; notification: unknown }[];
|
||||
}>;
|
||||
|
||||
// ---- Subdomains ----
|
||||
'subdomain.delete': { subdomain: string };
|
||||
'subdomain.update': { subdomain: string };
|
||||
'site.htmlServed': {
|
||||
subdomain: string;
|
||||
entry: unknown;
|
||||
host: string;
|
||||
requestPath: string;
|
||||
requestUrl?: string;
|
||||
requestHash?: string;
|
||||
mime: string;
|
||||
};
|
||||
|
||||
// ---- Thumbnails ----
|
||||
'thumbnail.read': {
|
||||
uri: string;
|
||||
size?: string;
|
||||
thumbnail?: string | null;
|
||||
};
|
||||
'thumbnail.created': { url: string };
|
||||
'thumbnail.upload.prepare': {
|
||||
items: { index: number; item_uid: string }[];
|
||||
uploadUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
};
|
||||
|
||||
// ---- Web sockets ----
|
||||
'web.socket.connected': { socket: unknown; user: unknown };
|
||||
'web.socket.user-connected': { socket: unknown; user: unknown };
|
||||
|
||||
// ---- Extension hooks / misc ----
|
||||
'puter.gui.addons': {
|
||||
prependHeadContent?: string[];
|
||||
prependBodyContent?: string[];
|
||||
};
|
||||
'whoami.details': {
|
||||
user: unknown;
|
||||
details: {
|
||||
uuid?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
app_name?: string;
|
||||
};
|
||||
isUser: boolean;
|
||||
};
|
||||
'wisp.get-policy': {
|
||||
app: unknown;
|
||||
actor: unknown;
|
||||
allow: boolean;
|
||||
policy?: unknown;
|
||||
};
|
||||
'ip.validate': { allow: boolean; ip: string };
|
||||
} & {
|
||||
// SocketService re-emits each fanout-eligible event under
|
||||
// `sent-to-user.<wireName>` so per-user channels can subscribe by wire name.
|
||||
[K in `sent-to-user.${string}`]: { user_id: number; response: unknown };
|
||||
} & {
|
||||
// Generic per-driver-method lifecycle, emitted by DriverController for
|
||||
// EVERY driver call under a key scoped to the interface + method:
|
||||
// `driver.<iface>.<method>.before|after|error|reject`. Wildcard
|
||||
// subscribers can listen to `driver.*` for everything, `driver.<iface>.*`
|
||||
// for one interface, or the exact key for one method. The `.before` phase
|
||||
// is emitted via `emitAndWait`, so a listener may set `allow = false`
|
||||
// (with an optional `rejectReason`) to veto the call before it runs — a
|
||||
// vetoed call emits `.reject` instead of running.
|
||||
[K in `driver.${string}`]: DriverMethodLifecycleEvent;
|
||||
} & {
|
||||
// Generic per-route-endpoint lifecycle, emitted by the route materializer
|
||||
// for EVERY non-middleware route under a key scoped to the HTTP method +
|
||||
// normalized path: `route.<method>.<path>.before|after|error|reject`. Same
|
||||
// wildcard + veto semantics as the driver lifecycle above.
|
||||
[K in `route.${string}`]: RouteLifecycleEvent;
|
||||
} & {
|
||||
[K in `pubsub.login.${string}`]: { authtoken: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Phase of a request/method lifecycle. `reject` is emitted when a `before`
|
||||
* listener vetoes the call (sets `allow = false`); the call never runs and no
|
||||
* `after`/`error` follows.
|
||||
*/
|
||||
export type LifecyclePhase = 'before' | 'after' | 'error' | 'reject';
|
||||
|
||||
/**
|
||||
* Payload for `driver.<iface>.<method>.<phase>` events.
|
||||
*
|
||||
* One shape across all phases; read `phase` (or the key suffix) to
|
||||
* branch. `allow`/`rejectReason` are only meaningful on the `before` phase
|
||||
* (emitted via `emitAndWait`).
|
||||
*/
|
||||
export type DriverMethodLifecycleEvent = {
|
||||
phase: LifecyclePhase;
|
||||
iface: string;
|
||||
method: string;
|
||||
/** Resolved concrete driver name. */
|
||||
driver: string;
|
||||
/** Full actor object, if the request is authenticated. */
|
||||
actor?: Actor;
|
||||
/** Stable actor id (see `actorUid`), if the request is authenticated. */
|
||||
actorUid?: string;
|
||||
/** Call arguments. Present on every phase. */
|
||||
args?: unknown;
|
||||
/** Return value. Present on `after`. */
|
||||
result?: unknown;
|
||||
/** Thrown error. Present on `error`. */
|
||||
error?: unknown;
|
||||
/** Wall-clock duration of the invocation. Present on `after`/`error`. */
|
||||
durationMs?: number;
|
||||
/** Veto channel for `before`: set `false` to block the call. */
|
||||
allow?: boolean;
|
||||
/** Optional human-readable reason surfaced to the caller when vetoed. */
|
||||
rejectReason?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Payload for `route.<method>.<path>.<phase>` events. Mirrors
|
||||
* {@link DriverMethodLifecycleEvent} for HTTP endpoints.
|
||||
*/
|
||||
export type RouteLifecycleEvent = {
|
||||
phase: LifecyclePhase;
|
||||
/** HTTP method, lowercased (`get`, `post`, ...). */
|
||||
method: string;
|
||||
/** Full route path including the controller prefix. */
|
||||
path: string;
|
||||
/**
|
||||
* The live express request/response for this call. Present on every phase.
|
||||
* On `before` a listener can read the parsed body/headers or write its own
|
||||
* response; on terminal phases they're useful for logging. These are real
|
||||
* in-process objects — never serialize or forward them across nodes.
|
||||
*/
|
||||
req: ExpressRequest;
|
||||
res: ExpressResponse;
|
||||
/** Full actor object, if the request is authenticated. */
|
||||
actor?: Actor;
|
||||
/** Stable actor id (see `actorUid`), if the request is authenticated. */
|
||||
actorUid?: string;
|
||||
/** Response status code. Present on `after`/`error`. */
|
||||
statusCode?: number;
|
||||
/** Wall-clock duration from `before` to terminal phase. */
|
||||
durationMs?: number;
|
||||
/** Set when the request did not complete normally (abort / >=500). */
|
||||
error?: unknown;
|
||||
/** Veto channel for `before`: set `false` to block the request. */
|
||||
allow?: boolean;
|
||||
/** Optional human-readable reason surfaced to the caller when vetoed. */
|
||||
rejectReason?: string;
|
||||
};
|
||||
|
||||
export type EventKey = keyof EventMap & string;
|
||||
|
||||
// "a.b.c" -> "a.*" | "a.b.*"
|
||||
// Generates a wildcard for every non-final dot-separated prefix of K.
|
||||
export type WildcardPrefixes<K extends string> =
|
||||
K extends `${infer Head}.${infer Tail}`
|
||||
?
|
||||
| `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
: never;
|
||||
|
||||
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
|
||||
|
||||
export type MatchingEvents<P extends ListenKey> = P extends `${infer Prefix}.*`
|
||||
? Extract<EventKey, `${Prefix}.${string}`>
|
||||
: P & EventKey;
|
||||
|
||||
export type EventMetadata = { from_outside?: boolean };
|
||||
export type EventListener<K extends EventKey = EventKey> = (
|
||||
key: K,
|
||||
data: EventMap[K],
|
||||
meta: EventMetadata,
|
||||
) => Promise<void> | void;
|
||||
@@ -20,10 +20,11 @@
|
||||
import { AlarmClient } from './alarm/AlarmClient';
|
||||
import { DatabaseClientFactory } from './database';
|
||||
import { EmailClient } from './email/EmailClient';
|
||||
import { EventClient } from './EventClient';
|
||||
import { EventClient } from './event/EventClient';
|
||||
import { DDBClient } from './dynamodb/DDBClient';
|
||||
import { RedisClient } from './redis/RedisClient';
|
||||
import { S3Client } from './s3/S3Client';
|
||||
import { PreludeClient } from './prelude/PreludeClient';
|
||||
import type { IPuterClientRegistry } from './types';
|
||||
|
||||
export const puterClients = {
|
||||
@@ -34,4 +35,5 @@ export const puterClients = {
|
||||
dynamo: DDBClient,
|
||||
redis: RedisClient,
|
||||
s3: S3Client,
|
||||
prelude: PreludeClient,
|
||||
} satisfies IPuterClientRegistry;
|
||||
Loaded 100 of 694 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user