mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f1e5674d8 | ||
|
|
8db4fb4f5b | ||
|
|
e7c89b0639 | ||
|
|
83d64ac685 | ||
|
|
5e34258da5 | ||
|
|
51eba174b3 | ||
|
|
f533eb84ae | ||
|
|
3dfc70cab7 | ||
|
|
ebf5086e1c | ||
|
|
59e28bed23 | ||
|
|
73ebe3f47a | ||
|
|
c981a51f21 | ||
|
|
4ef5e6c110 | ||
|
|
794469845d | ||
|
|
9ca77be5a9 | ||
|
|
007d5391a1 | ||
|
|
c0f4fe93b1 | ||
|
|
d57f638eda | ||
|
|
26abd4e4a5 | ||
|
|
3defae3cfc | ||
|
|
205f19a167 | ||
|
|
2cfc1461e4 | ||
|
|
3fa23bb809 | ||
|
|
e59f6285ba | ||
|
|
c3abec8663 | ||
|
|
04406373f4 | ||
|
|
91aff909e1 | ||
|
|
a242105540 | ||
|
|
47510897ed | ||
|
|
d0da8c5b1e | ||
|
|
303ee6c2c7 | ||
|
|
37c1678897 | ||
|
|
7b8b3752e4 | ||
|
|
521812be02 | ||
|
|
bb38e8c86f | ||
|
|
74d89b64fd | ||
|
|
31e5b64a0f | ||
|
|
e96cf77982 | ||
|
|
22d52cf544 | ||
|
|
86bb94d3d9 | ||
|
|
a4f1e665df | ||
|
|
abd0d809d5 | ||
|
|
82bd7a7360 | ||
|
|
b32ae7dcfc | ||
|
|
d5e6d5ea07 | ||
|
|
f46d187d51 | ||
|
|
0fcee62884 | ||
|
|
384524a027 | ||
|
|
42b204f46e | ||
|
|
91c8b2385b | ||
|
|
05e2e7428a | ||
|
|
f387a7aa68 | ||
|
|
e6bda950f0 | ||
|
|
4f31388dad | ||
|
|
376a047ecc | ||
|
|
fe7b0ce535 | ||
|
|
e85c6917f7 | ||
|
|
989399a857 | ||
|
|
143948ed2c | ||
|
|
e48dc0cbf3 | ||
|
|
a67cdac380 | ||
|
|
c6da95beb0 | ||
|
|
ed7b8f828a | ||
|
|
29c6f78b7c | ||
|
|
c542e4675b | ||
|
|
d3b46a6b86 | ||
|
|
fd8b8288d3 | ||
|
|
fc62ea2ee1 | ||
|
|
d1b11c793c | ||
|
|
d6b7c8ff52 | ||
|
|
4448103ef0 | ||
|
|
79e496e79d | ||
|
|
2e0bf26cac | ||
|
|
b126cf9c5a | ||
|
|
ec4d3b31cf | ||
|
|
8862420594 | ||
|
|
3b4f3f6d06 | ||
|
|
3d1357455f | ||
|
|
f8d8003fea | ||
|
|
23c8f73bcb | ||
|
|
b9efac2363 | ||
|
|
ad12da2942 | ||
|
|
9aa3989e9d | ||
|
|
cd37cf147f | ||
|
|
f60d94ef3f | ||
|
|
8bce62e880 | ||
|
|
abd27c0192 | ||
|
|
6a31b2af9e | ||
|
|
3ebb753eb1 | ||
|
|
ee4d55027d | ||
|
|
7eb9ace733 | ||
|
|
14ae6a69ee | ||
|
|
3b00cd2d14 | ||
|
|
785304e807 | ||
|
|
8e31d9149a | ||
|
|
9539c7b553 | ||
|
|
27c4873042 | ||
|
|
0b1a4283f5 | ||
|
|
1cc091206c | ||
|
|
09887747e0 | ||
|
|
f80d4ae136 | ||
|
|
42e0c28d0a | ||
|
|
d888377138 | ||
|
|
8a53d14540 | ||
|
|
12e6bc3d71 | ||
|
|
359d68e51f | ||
|
|
020e61065f | ||
|
|
480d9d032b | ||
|
|
528eb192b5 | ||
|
|
ca52b5236c | ||
|
|
153f581011 | ||
|
|
1fc2eae1ae | ||
|
|
dcb61bc4fd | ||
|
|
b445680f10 | ||
|
|
95ee796157 | ||
|
|
3d3678a555 | ||
|
|
a9ee3d1ea6 | ||
|
|
ee3f928b0e | ||
|
|
176c8848f5 | ||
|
|
beaf4a9a7c | ||
|
|
d36da7522b | ||
|
|
e6cfe8c492 | ||
|
|
eef8258539 | ||
|
|
3105378fdc | ||
|
|
695a26b272 | ||
|
|
cab70254b1 | ||
|
|
db1a95260a | ||
|
|
e041fb6b9b | ||
|
|
5721bdeb1d | ||
|
|
132cae9f6a | ||
|
|
4a9e5a2440 | ||
|
|
600064dcee | ||
|
|
66ee50317c | ||
|
|
8f0a317a97 | ||
|
|
8c816060a3 | ||
|
|
0131ebfe9b | ||
|
|
ca48477db9 | ||
|
|
2cb7516141 | ||
|
|
0c5c1be777 | ||
|
|
8ecaa77a0e | ||
|
|
07a11240ea | ||
|
|
7758aea78c | ||
|
|
83de67cca9 | ||
|
|
9f69483593 | ||
|
|
606f5a27a3 | ||
|
|
58ed2f485f | ||
|
|
d73933b8a5 | ||
|
|
4c3c684cb4 | ||
|
|
438a4584de | ||
|
|
b3fda186e7 | ||
|
|
bc0f4dc279 | ||
|
|
17f5454265 | ||
|
|
5ddff2b3d6 | ||
|
|
52f0176854 | ||
|
|
300fb115a1 | ||
|
|
6debd7df76 | ||
|
|
70404579f2 | ||
|
|
7462a8eda9 | ||
|
|
718de8a9c6 | ||
|
|
8abdcf4f7f | ||
|
|
476f7e075d | ||
|
|
1bc8cb964e | ||
|
|
2690f4934c | ||
|
|
d07ddeaaf5 | ||
|
|
1261976088 | ||
|
|
2abd5533ac | ||
|
|
922d203e18 | ||
|
|
027e9a71f3 | ||
|
|
259cb69fde | ||
|
|
081941a6f3 | ||
|
|
e2a5ab8cdc | ||
|
|
07e889f135 | ||
|
|
ab7a5f325a | ||
|
|
b4b464554b | ||
|
|
4de1d1fb39 | ||
|
|
4e9b03bf5c | ||
|
|
a63172e9ae | ||
|
|
29d805d793 | ||
|
|
059fae3d15 | ||
|
|
18ada4764a | ||
|
|
72203152d9 | ||
|
|
cec8382d4d | ||
|
|
3f335939b3 | ||
|
|
4e821c128c | ||
|
|
c313a381b6 | ||
|
|
76e0d2ec93 | ||
|
|
24fb679dd3 | ||
|
|
eb9f03e984 | ||
|
|
9292771554 | ||
|
|
f4974581d0 | ||
|
|
b39bb771d9 | ||
|
|
766379ae9f | ||
|
|
0be3bc55c2 | ||
|
|
d1484af754 | ||
|
|
dfd7925872 | ||
|
|
e50d210423 | ||
|
|
51ce868d1e | ||
|
|
461cb7d0a9 | ||
|
|
bb97660ad7 | ||
|
|
3e3d02bafe | ||
|
|
b2fef45fd6 | ||
|
|
728966bfb1 | ||
|
|
3f7ee34706 | ||
|
|
ff333d05e8 | ||
|
|
194ca7c789 | ||
|
|
0b85203edb | ||
|
|
23c33b2e06 | ||
|
|
1b6334c02c | ||
|
|
0593844acf | ||
|
|
67d048148c | ||
|
|
05cbd33211 | ||
|
|
5c8f02384c | ||
|
|
5d20e054ac | ||
|
|
3bd3955e0d | ||
|
|
79c856386f | ||
|
|
59f73528cb | ||
|
|
27e858d760 | ||
|
|
0bd6458441 | ||
|
|
45aff36f0c | ||
|
|
a1f7577acb | ||
|
|
af6547724d | ||
|
|
e9922aea26 | ||
|
|
b136c56cb5 | ||
|
|
aadcda073d | ||
|
|
9da03555ae | ||
|
|
f428797a79 | ||
|
|
ad3d15e7e2 | ||
|
|
07bf0be9c7 | ||
|
|
d116f49669 | ||
|
|
a5f267c2d4 | ||
|
|
2fdd67c72e | ||
|
|
92dfe4019e | ||
|
|
61a6820767 | ||
|
|
38b131e5e8 | ||
|
|
230241d6d2 | ||
|
|
577693bf84 | ||
|
|
27dd0f8ce6 | ||
|
|
50d3794283 | ||
|
|
7fdbc0247f | ||
|
|
3bf5ba3525 | ||
|
|
018196f8b4 | ||
|
|
c3e2717e8e | ||
|
|
15527eac4c | ||
|
|
4a23c296f0 | ||
|
|
790db87e13 | ||
|
|
b901040c14 | ||
|
|
ed7e0b9cc4 | ||
|
|
883072d144 | ||
|
|
8d90418c9e | ||
|
|
de74b0c488 | ||
|
|
967b974fe9 | ||
|
|
93885dfc54 | ||
|
|
ab077f047b | ||
|
|
63f857ce3c | ||
|
|
480d6bc3b5 | ||
|
|
1efe086565 | ||
|
|
06bdc26efd | ||
|
|
c2a975b3f7 | ||
|
|
b8b0650990 | ||
|
|
bafe8af71b | ||
|
|
187e4c8a2f | ||
|
|
f2c9737bd8 | ||
|
|
5fbdc7cac5 | ||
|
|
3d2bdfceef | ||
|
|
2083ef47c0 | ||
|
|
9940beb4bb | ||
|
|
fa36f2e3cf | ||
|
|
efe6ef09bd | ||
|
|
367d20a55c | ||
|
|
e886e86cb5 | ||
|
|
106978e714 | ||
|
|
fb74789de1 | ||
|
|
a983969f32 | ||
|
|
b19b312e30 |
No files matched your search
@@ -16,3 +16,8 @@ MARIADB_PASSWORD=replace-with-strong-password
|
||||
S3_ACCESS_KEY=puter
|
||||
S3_SECRET_KEY=replace-with-strong-secret
|
||||
S3_BUCKET=puter-local
|
||||
|
||||
# ── Inbound SMTP (compose profile `smtp`) ------------------------------
|
||||
# Host port mapped to the receiver's 2525. 25 is what an MX record means;
|
||||
# many hosting providers block it.
|
||||
SMTP_PORT=25
|
||||
+56
-45
@@ -1,61 +1,72 @@
|
||||
# Default owner, including backend and SDK areas without a specialist below.
|
||||
|
||||
# The last matching rule takes precedence.
|
||||
* @Salazareo
|
||||
|
||||
- @Salazareo
|
||||
|
||||
# GUI
|
||||
|
||||
/src/gui/ @jelveh @Salazareo
|
||||
|
||||
# Documentation, developer site, and CLI
|
||||
/doc/ @reynaldichernando
|
||||
/src/docs/ @reynaldichernando
|
||||
/src/dev-center/ @reynaldichernando
|
||||
/src/cli/ @reynaldichernando
|
||||
|
||||
/doc/ @reynaldichernando @Salazareo
|
||||
/src/docs/ @reynaldichernando @Salazareo
|
||||
/src/dev-center/ @reynaldichernando @Salazareo
|
||||
/src/cli/ @reynaldichernando @Salazareo
|
||||
|
||||
# Backend AI
|
||||
|
||||
/src/backend/controllers/puterai/ @404oops @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/drivers/ai-\*/ @404oops @ProgrammerIn-wonderland @Salazareo
|
||||
|
||||
# Backend: AI, hosting, workers, networking, and WebDAV
|
||||
/src/backend/controllers/puterai/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/hosting/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/webdav/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/wisp/ @ProgrammerIn-wonderland
|
||||
/src/backend/drivers/ai-*/ @ProgrammerIn-wonderland
|
||||
/src/backend/drivers/util/ @ProgrammerIn-wonderland
|
||||
/src/backend/drivers/workers/ @ProgrammerIn-wonderland
|
||||
/src/backend/services/localworker/ @ProgrammerIn-wonderland
|
||||
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland
|
||||
/extensions/workerSandbox* @ProgrammerIn-wonderland
|
||||
/src/worker/ @ProgrammerIn-wonderland
|
||||
/src/worker-types/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/peer/ @ProgrammerIn-wonderland
|
||||
|
||||
/src/backend/controllers/hosting/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/controllers/webdav/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/controllers/wisp/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/drivers/util/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/drivers/workers/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/services/localworker/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland @Salazareo
|
||||
/extensions/workerSandbox\* @ProgrammerIn-wonderland @Salazareo
|
||||
/src/worker/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/worker-types/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/controllers/peer/ @ProgrammerIn-wonderland @Salazareo
|
||||
|
||||
# Backend: sharing, permissions, and teams
|
||||
/src/backend/controllers/share/ @jfcastro92
|
||||
/src/backend/controllers/team/ @jfcastro92
|
||||
/src/backend/services/share/ @jfcastro92
|
||||
/src/backend/services/permission/ @jfcastro92
|
||||
/src/backend/services/acl/ @jfcastro92
|
||||
/src/backend/services/team/ @jfcastro92
|
||||
/src/backend/services/apps/AppPermissionService* @jfcastro92
|
||||
/src/backend/stores/share/ @jfcastro92
|
||||
/src/backend/stores/permission/ @jfcastro92
|
||||
/src/backend/stores/group/ @jfcastro92
|
||||
/src/backend/stores/team/ @jfcastro92
|
||||
|
||||
/src/backend/controllers/share/ @jfcastro92 @Salazareo
|
||||
/src/backend/controllers/team/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/share/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/permission/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/acl/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/team/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/apps/AppPermissionService\* @jfcastro92 @Salazareo
|
||||
/src/backend/stores/share/ @jfcastro92 @Salazareo
|
||||
/src/backend/stores/permission/ @jfcastro92 @Salazareo
|
||||
/src/backend/stores/group/ @jfcastro92 @Salazareo
|
||||
/src/backend/stores/team/ @jfcastro92 @Salazareo
|
||||
|
||||
# SDK: AI, hosting, workers, and networking
|
||||
/src/puter-js/src/modules/ai/ @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/hosting/ @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/Workers* @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/networking/ @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/lib/networkUtils* @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/ai.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/Peer* @ProgrammerIn-wonderland
|
||||
|
||||
/src/puter-js/src/modules/ai/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/modules/hosting/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/modules/Workers* @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/modules/networking/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/lib/networkUtils* @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/tests/api/suites/ai.suite.ts @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/modules/Peer\* @ProgrammerIn-wonderland @Salazareo
|
||||
|
||||
# SDK: sharing and permissions
|
||||
|
||||
/src/puter-js/src/modules/perms/ @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/share* @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/unshare* @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/getShares* @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/listShared* @jfcastro92
|
||||
/src/puter-js/tests/api/suites/perms.suite.ts @jfcastro92
|
||||
/src/puter-js/tests/api/suites/sharing.suite.ts @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/share* @jfcastro92 @Salazareo
|
||||
/src/puter-js/src/modules/FileSystem/operations/unshare* @jfcastro92 @Salazareo
|
||||
/src/puter-js/src/modules/FileSystem/operations/getShares* @jfcastro92 @Salazareo
|
||||
/src/puter-js/src/modules/FileSystem/operations/listShared* @jfcastro92 @Salazareo
|
||||
/src/puter-js/tests/api/suites/perms.suite.ts @jfcastro92 @Salazareo
|
||||
/src/puter-js/tests/api/suites/sharing.suite.ts @jfcastro92 @Salazareo
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dorny/paths-filter@v3
|
||||
- uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
git reset --hard main
|
||||
- name: Create/Update PR
|
||||
if: steps.find-pr.outputs.number == ''
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: Update release branch
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
puterjs: ${{ steps.filter.outputs.puterjs }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dorny/paths-filter@v3
|
||||
- uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
|
||||
@@ -72,3 +72,6 @@ servers.json
|
||||
config.*.json
|
||||
|
||||
volatile/
|
||||
|
||||
# Local build override
|
||||
docker-compose.override.yml
|
||||
@@ -47,6 +47,8 @@ Before opening a PR, scan the diff for:
|
||||
|
||||
When in doubt, return less. Auth-, permission-, or data-export-related changes deserve an explicit callout in the PR description.
|
||||
|
||||
Commit messages and PR descriptions never credit anyone or mention a bug report. For security fixes, state what the change does mechanically ("scoped tokens do xyz now"), not the vulnerability or how it could be abused.
|
||||
|
||||
### 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.
|
||||
@@ -58,6 +60,36 @@ When in doubt, return less. Auth-, permission-, or data-export-related changes d
|
||||
|
||||
---
|
||||
|
||||
## Local development configuration
|
||||
|
||||
To test a local Docker build, create `docker-compose.override.yml` in the repository
|
||||
root (or merge into the existing file):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
puter:
|
||||
pull_policy: never
|
||||
build:
|
||||
context: .
|
||||
```
|
||||
|
||||
Compose merges it automatically. Run `docker compose up -d --build` to build and
|
||||
start the stack, and repeat after source changes. Keep local build settings in
|
||||
this gitignored override instead of editing `docker-compose.yml`, so they stay
|
||||
out of pull requests and do not conflict when pulling updates. See
|
||||
[Self-Hosting Puter](doc/self-hosting.md#building-from-source-instead-of-pulling)
|
||||
for the required runtime configuration.
|
||||
|
||||
For local API testing, agents may decide whether to set `"unlimitedMetering": true`
|
||||
when budget checks block unrelated tests; no additional approval is needed.
|
||||
Merge it into the ignored `config.json` for `npm start` or
|
||||
`puter/config/config.json` for Docker, then restart Puter. It applies to all
|
||||
accounts on that local instance, including guests, and usage is still recorded.
|
||||
Keep normal settings when testing budget or subscription enforcement, and leave
|
||||
shared defaults unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
A layered stack with explicit dependency injection: each layer depends only on the layers beneath it, receives them through its constructor, and `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires the whole thing together. [doc/architecture.md](doc/architecture.md) is the full reference.
|
||||
|
||||
+10
-3
@@ -21,12 +21,19 @@ Out-of-scope:
|
||||
|
||||
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.
|
||||
- **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 out of scope. This explicitly includes **missing `Content-Security-Policy: sandbox` on any endpoint that serves a file inline** — `/token-read`, `/file`, `/fs/read`, WebDAV, and any MIME type the `applyInlineContentSecurity` allow-list does not cover (`text/xsl`, `image/svg+xml`, `application/xhtml+xml`, …). Script execution on the API origin has nothing to reach: there is no session cookie, no `localStorage`, and no ambient credential there. WebDAV additionally has to return an accurate MIME type for real WebDAV clients. The one exception we _do_ evaluate on its merits is anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host — if your report does not name a specific credential that is actually reachable from the API origin, it is this non-issue.
|
||||
- **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.
|
||||
- **App metadata or app user-count "leaks".** This information is currently public by design. This covers **private apps** too: private apps are an unreleased, admin-only beta for paid listings in the Puter App Center. Their `index_url` and other metadata are not secret, the flag is not user-settable, and no production app relies on it. Reports that a private app's metadata is reachable through a sibling route (taskbar items, `/get-launch-apps`, `index_url` drift on the hosting gate, etc.) are normalization work, not vulnerabilities.
|
||||
- **Worker hot-reload driven by a share recipient.** Sharing a worker's source file with write access is how you let a collaborator change that worker's code and have it deploy live. The redeploy running as the file owner, with the owner's worker credentials, is the intended behavior of that grant — the owner chose to hand out write access to executable code. If you do not want that, do not share the source file with write access.
|
||||
- **Share-mode downgrades that do not cascade.** Demoting a delegate does not retroactively revoke the grants that delegate already issued downstream. Shares are independent grants once issued, not a live-evaluated chain. Revoking the downstream grant is a separate, supported action.
|
||||
- **Concurrency / TOCTOU overspend of credits or storage quota.** Firing concurrent requests to exceed an AI credit hold or a storage quota by some margin is known and accepted; the per-account concurrency and rate-limit gates are the control we rely on, and the residual overage is bounded and billed. Report these only if you can demonstrate _unbounded_ overspend.
|
||||
- **Session and credential lifecycle around 2FA enrollment.** Enrolling in 2FA is not a credential-revocation event: existing authenticated sessions stay valid, and outstanding password-reset links stay valid. Likewise, account-management actions behind the `/user-protected/*` gate are password-gated by design, not TOTP-gated. A recovery code that stops being accepted fails _closed_ — it denies access rather than granting it, so it is a bug report, not a vulnerability.
|
||||
- **Group permissions** (`/auth/revoke-user-group` and siblings). The group-permission surface is unfinished and not exposed anywhere in the product. Findings there are not reachable by any user.
|
||||
- **Correctness bugs with no security boundary crossed.** For example, an API that reports success while silently leaving data behind. We want these reported — please open a GitHub issue — but they are not bounty-eligible.
|
||||
- **Missing Subresource Integrity (SRI) on external scripts/styles.** Accepted as hardening, not as a vulnerability, absent a demonstrated compromise of the CDN in question.
|
||||
- **General "token in a URL" / token-lifetime / token-storage designs** — signed directory URLs exposing children, a write signature implying read, app tokens outliving a web session, or the session token being held in `localStorage` on `puter.com`. These are current intended behaviors. Shorter-lived tokens with a refresh flow are on the roadmap; their absence is not a vulnerability on its own. Show a reproducible attack that does not already presuppose script execution on `puter.com`.
|
||||
- **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.
|
||||
|
||||
@@ -79,6 +79,8 @@ ENV PUTER_CONFIG_PATH=/etc/puter/config.json
|
||||
ENV NODE_OPTIONS=--enable-source-maps
|
||||
|
||||
EXPOSE 4100
|
||||
# The inbound SMTP receiver, when run from this image with the `smtp` profile.
|
||||
EXPOSE 2525
|
||||
|
||||
USER node
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
·
|
||||
<a href="https://apps.puter.com/">App Store</a>
|
||||
·
|
||||
<a href="https://builder.puter.com/">AI Builder</a>
|
||||
·
|
||||
<a href="https://developer.puter.com" target="_blank">Developers</a>
|
||||
·
|
||||
<a href="https://twitter.com/HeyPuter">X</a>
|
||||
@@ -46,6 +48,8 @@ npm start
|
||||
```
|
||||
**→** This should launch Puter at http://puter.localhost:4100
|
||||
|
||||
To run this checkout with Docker, follow [Building from source](./doc/self-hosting.md#building-from-source-instead-of-pulling). Create a local `docker-compose.override.yml` to select the local build; keeping these settings out of `docker-compose.yml` avoids conflicts when pulling updates and keeps local configuration out of pull requests.
|
||||
|
||||
|
||||
<br/>
|
||||
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"disable_user_signup": false,
|
||||
"teams_enabled": false,
|
||||
"max_teams_per_user": 1,
|
||||
"max_seats_per_team": 50,
|
||||
"max_seats_per_team_free": 4,
|
||||
"max_seats_per_team_paid": 40,
|
||||
"strict_email_verification_required": false,
|
||||
"gui_assets_root": "./src/gui",
|
||||
"puterjs_root": "./src/puter-js/dist",
|
||||
|
||||
+62
-6
@@ -32,6 +32,10 @@
|
||||
"port": 4100,
|
||||
// Externally-visible port (set this when behind a reverse proxy on 80/443).
|
||||
"pub_port": 4100,
|
||||
// Idle keep-alive timeout in ms. Must stay above the idle timeout of any
|
||||
// proxy in front, or the proxy reuses connections this server has closed
|
||||
// and its clients see 502s.
|
||||
"keep_alive_timeout": 620000,
|
||||
"protocol": "http",
|
||||
"domain": "puter.localhost",
|
||||
// Fully-qualified externally-visible URL. Computed from protocol/domain/
|
||||
@@ -251,6 +255,38 @@
|
||||
}
|
||||
},
|
||||
|
||||
// ── Inbound mail (separate process) ─────────────────────────────────
|
||||
// Mailboxes addressed at one of your own domains. `secret` gates the
|
||||
// ingress endpoint that files a message into the recipient's ~/.mail.
|
||||
//
|
||||
// `localServer` additionally runs an SMTP listener that accepts mail from
|
||||
// other mail servers and hands each message to that endpoint. It runs as
|
||||
// its own process - `npm run start:smtp`, or the `smtp` compose profile -
|
||||
// and stores nothing itself. Without it, nothing delivers to the endpoint
|
||||
// unless you point something else at it.
|
||||
//
|
||||
// The listener is unauthenticated by nature: anything on the internet may
|
||||
// hand it a message for an address in `localDomains`. It performs no spam,
|
||||
// SPF, DKIM or DMARC checks. Read the self-hosting guide before exposing
|
||||
// it.
|
||||
// "userEmail": {
|
||||
// "secret": "a long random string",
|
||||
// "localServer": true,
|
||||
// "localDomains": ["example.com"],
|
||||
// // Container-internal by default; publish 25 onto it from outside.
|
||||
// "localPort": 2525,
|
||||
// // Defaults to <api_base_url>/email/ingress. The endpoint is served
|
||||
// // on the `api` subdomain, so when this points somewhere internal,
|
||||
// // `localIngressHost` has to name that virtual host.
|
||||
// "localIngressUrl": "http://puter:4100/email/ingress",
|
||||
// "localIngressHost": "api.example.com",
|
||||
// "localHostname": "mx.example.com",
|
||||
// "localMaxRecipients": 50,
|
||||
// // Concurrent connections. Each message is held in memory while it
|
||||
// // is delivered, so this also bounds memory use.
|
||||
// "localMaxClients": 20
|
||||
// },
|
||||
|
||||
// ── Sharing ─────────────────────────────────────────────────────────
|
||||
// Email a recipient who already has an account about a new share. On
|
||||
// unless set to false; they opt out via the unsubscribe link, or by
|
||||
@@ -285,12 +321,26 @@
|
||||
// deleting yours frees the slot. Default 1.
|
||||
"max_teams_per_user": 1,
|
||||
//
|
||||
// Seats one team may provision. Each seat is a real Puter account
|
||||
// taking a name from the global username pool, so this is what bounds a
|
||||
// team's blast radius until per-seat billing lands. Lowering it below
|
||||
// a team's current seat count blocks new provisioning and disables
|
||||
// nobody. Default 50.
|
||||
"max_seats_per_team": 50,
|
||||
// Seats one team may provision. Each seat is a real Puter account taking a
|
||||
// name from the global username pool, so this is what bounds a team's blast
|
||||
// radius. The owner's plan decides which of the two applies; defaults are 4
|
||||
// and 40. Lowering either below a team's current seat count blocks new
|
||||
// provisioning and disables nobody. Deployments upgrading from the old flat
|
||||
// default of 50: without a billing extension every owner reads as free, so
|
||||
// set `max_seats_per_team` to keep the old behavior.
|
||||
"max_seats_per_team_free": 4,
|
||||
"max_seats_per_team_paid": 40,
|
||||
//
|
||||
// One flat cap for every team, whatever the owner pays. Setting it
|
||||
// overrides both of the above, so a deployment that does not sell seats can
|
||||
// ignore plans entirely. Unset by default — leave it that way to keep the
|
||||
// free cap meaningful.
|
||||
// "max_seats_per_team": 50,
|
||||
//
|
||||
// Staged rollout: only these email domains may create a team, and the tab
|
||||
// renders for them even with `gui_params.teams_ui` off. Members of an
|
||||
// existing team always pass. Unset means everyone, gated by `teams_ui`.
|
||||
// "teams_allowed_email_domains": ["puter.com"],
|
||||
|
||||
// ── Notifications ───────────────────────────────────────────────────
|
||||
// How long a notification is kept, in days from creation. Acknowledged or
|
||||
@@ -524,4 +574,10 @@
|
||||
// what this deployment's normal traffic costs, so it has to be measured
|
||||
// rather than guessed, and a stale number here alarms on healthy growth.
|
||||
// "maxGlobalUsagePerMinute": 200000000
|
||||
|
||||
// How many full months past its own a monthly usage record is kept before
|
||||
// it expires. Defaults to 3; set to 0 to keep every month's records
|
||||
// forever. Changing it, including to 0, applies to records first written
|
||||
// after the change; records already stamped keep their expiry.
|
||||
// "meteringRetentionMonths": 3
|
||||
}
|
||||
@@ -59,6 +59,16 @@ We use [`Context`](../src/backend/core/context.ts) — backed by `AsyncLocalStor
|
||||
|
||||
Prefer explicit arguments. Reach for `Context` only when the value is truly request-scoped and would otherwise need to thread through many layers.
|
||||
|
||||
## Actors
|
||||
|
||||
An [`Actor`](../src/backend/core/actor.ts) is who a request is acting as. It has two app-shaped fields and they are not interchangeable: `app` is the app the actor carries *directly* (an app-under-user token), while `effectiveApp` is the app it ultimately acts as — its own, or the one that issued its access token. An access-token actor has no `app` of its own, so a gate that reads `app` answers "no app" for it and falls open.
|
||||
|
||||
`makeActor` resolves `effectiveApp` once at construction, and `assertResolvedActor` at the request edge rejects any actor that skipped it — so reading `effectiveApp` needs no fallback. Reach for `app` only where the direct app genuinely is the question, and say why in a comment.
|
||||
|
||||
- `actor.effectiveApp` — which app is acting. This is the default; nearly every scoping rule, namespace key and attribution wants it.
|
||||
- `isAppActor(actor)` — a direct app actor specifically. Use it where a token must *not* be treated as its issuer, e.g. the permission scanners that read an app's own grant rows.
|
||||
- `isPlainUserActor(actor)` — the account acting through nothing at all; `isAccountContext(actor)` for the wider "the account's own reach", which also admits a full-access token.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -56,7 +56,7 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
|
||||
- **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), `requireSubscription`, `requireReputation`, and `noUserSession`. The `/drivers/call` surface enforces them.
|
||||
- **A surface only paying accounts should reach** declares `requireSubscription` — the route option on a controller endpoint, the per-method `@Driver({ requireSubscription })` block on a driver (`/drivers/call` is one shared route, so a driver's requirement can't live in route options). `true` accepts any plan that isn't free, so a plan registered by an extension counts without core naming it; an array of policy ids (`['business', 'pro']`) accepts only those; `false` is "no requirement", the same as leaving it out. An empty array is a boot error rather than a silent no-op — it reads as subscribers-only while admitting everyone. Off unless asked for, and answered from the metering service's cached per-actor subscription, so it costs a map lookup. Distinct from `requireCredits`: this asks which plan the account is on, not whether it has budget left. Deployments with no paid plans (self-hosted installs) turn the whole thing off with `meteringEnforcement.subscriptions: false`.
|
||||
- **A surface only a trusted-enough account should reach** declares `requireReputation` — the route option on a controller endpoint, the per-method `@Driver({ requireReputation })` block on a driver. The value names a tier; what that tier takes is `reputationGate.tiers` in config, so the score a surface is worth is a deployment call and can be retuned without editing the surface. A tier the running config doesn't define is inert and everyone passes — an install that doesn't score its accounts must not start turning traffic away on a score it never computed — and `reputationGate.enabled: false` stops every declared gate at once. Opt-in, implies `requireAuth`, and denies with a bare 403 `reputation_required` that names neither the score nor the tier. Nothing in the tree declares one yet: the mechanism ships ahead of the enrollment.
|
||||
- **An account must have verified a specific factor** — `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. These are opt-in and require the factor to have actually been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for.
|
||||
- **An account must have verified a specific factor** — `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. These are opt-in and require the factor to have actually been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for. `requireAnyVerified: ['phone', 'card']` is the OR of the last two: any listed factor verified at any point passes, as does a paid plan where `card` is listed (a paying account has a card on file already); otherwise only the factors the deployment can verify are asked for (an SMS provider configured, a card gate an extension reports on), and with none verifiable the gate is inert rather than locking the route on a self-hosted install. It denies with the first verifiable factor's code plus `factors`, the verifiable ones in the route's order, so a client can lead with one flow and offer the other. `verifiedFactorGate.enabled: false` in config stops every declared gate at once, for an SMS-provider outage.
|
||||
- **An endpoint that spends metered resources** on the caller's behalf — moving file content, making object-store requests, anything else the account is billed for — also declares `requireCredits: true`, which turns an account with nothing left of its budget away with a 402 before the handler runs. Endpoints that only describe or delete things deliberately don't: an account that has run out still has to be able to see what it has, clear it, and reach its billing pages. Drivers have no route options to declare this on, so they call `assertActorHasCredits` themselves ([src/backend/services/metering/enforcement.ts](../src/backend/services/metering/enforcement.ts)) — see `KVStoreDriver`, which does it once for every method.
|
||||
|
||||
### 3. puter.js
|
||||
|
||||
+147
-1
@@ -35,6 +35,12 @@ Optional services (compose profile `ai`, opt-in):
|
||||
| `puter-ollama` | `ollama/ollama` | Local LLM provider (CPU; GPU passthrough opt-in) |
|
||||
| `puter-ollama-init` | `ollama/ollama` | One-shot — pulls the default model (`tinyllama`) on first boot |
|
||||
|
||||
Optional services (compose profile `smtp`, opt-in):
|
||||
|
||||
| Container | Image | Role |
|
||||
| ------------ | ------------------------ | -------------------------------------------------------- |
|
||||
| `puter-smtp` | `ghcr.io/heyputer/puter` | Receives mail over SMTP and files it into user mailboxes |
|
||||
|
||||
State lives under `./puter/data/<service>/`.
|
||||
|
||||
---
|
||||
@@ -144,6 +150,16 @@ Why these knobs:
|
||||
- `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.
|
||||
- `s3.s3Config.forcePathStyle: true` — RustFS / MinIO / fauxqs need path-style URLs (`<endpoint>/<bucket>`). Real AWS S3 wants virtual-hosted (`<bucket>.<endpoint>`) — drop this flag (or set `false`) when you swap to real S3.
|
||||
- `s3.s3Config.publicEndpoint` — `endpoint` (`http://s3:9000`) only resolves inside the docker network; presigned upload/download URLs handed to the browser need a host-reachable URL. Caddy routes the `s3.<domain>` subdomain to RustFS internally and preserves the Host header end-to-end (required for S3 signature validation), so the browser hits the same port/protocol as the rest of the app — no separate published port, no mixed-content surprises when you turn on TLS. Switch to `https://s3.<your-domain>` once you enable TLS in Step 3. Real AWS S3 doesn't need this — its endpoint is already public; drop the field entirely.
|
||||
- **Bucket CORS** — the browser uploads file bytes straight to RustFS with a cross-origin `PUT` to the presigned URL (Dev Center deploy, `puter.fs.upload`). That only works if the bucket answers the `OPTIONS` preflight with `Access-Control-Allow-*` headers. The `s3-init` container applies those rules on every boot (`put-bucket-cors`, origins `*` — the signed URL is the credential, and the SDK sends no cookies). If deploys fail with `No 'Access-Control-Allow-Origin' header is present`, re-run it: `docker compose run --rm s3-init`. Verify with:
|
||||
|
||||
```bash
|
||||
curl -sk -X OPTIONS "https://s3.<domain>/<bucket>/test" \
|
||||
-H "Origin: https://<domain>" \
|
||||
-H "Access-Control-Request-Method: PUT" \
|
||||
-H "Access-Control-Request-Headers: content-type" -D - -o /dev/null | grep -i access-control
|
||||
```
|
||||
|
||||
You should see `access-control-allow-origin`, `allow-methods: GET, HEAD, PUT, POST, DELETE`, `allow-headers: *`, and `max-age: 3600`.
|
||||
- `trust_proxy: 1` — Caddy terminates TLS and forwards `X-Forwarded-For`. Without this, `req.ip` is the docker-network address of the Caddy container instead of the real client IP, which breaks rate limiting and IP-based audit logs. `1` = one trusted hop (Caddy). Bump to `2` if you put Cloudflare in front of Caddy; never set `true` (it trusts every hop and makes XFF forgeable).
|
||||
|
||||
> If you ever change `MARIADB_PASSWORD` after first boot, `.env` alone won't update MariaDB — its credentials are baked into `./puter/data/mariadb/` on first init. Either rotate the password inside MariaDB by hand or `docker compose down && rm -rf ./puter/data/mariadb` to start fresh.
|
||||
@@ -408,6 +424,12 @@ Every account then resolves to an unlimited policy. Usage is still recorded, so
|
||||
the dashboard still shows what is being consumed; nothing is ever refused for
|
||||
lack of budget.
|
||||
|
||||
For API testing, you can also set `"unlimitedMetering": true` in your
|
||||
ignored runtime config: `puter/config/config.json` for Docker, or the repository's
|
||||
`config.json` for `npm start`. Merge it into the existing config and restart Puter.
|
||||
This includes guest accounts and applies to the whole local instance. Keep normal
|
||||
metering settings when testing budget or subscription enforcement.
|
||||
|
||||
To keep the budgets but stop them blocking anything — recording only:
|
||||
|
||||
```json
|
||||
@@ -418,6 +440,17 @@ Calls driven by a deployed worker are exempt from enforcement by default,
|
||||
because a worker has no prompt to show and nobody watching it fail. Set
|
||||
`"meteringEnforcement": { "workers": true }` to include them.
|
||||
|
||||
Monthly usage records expire on their own once they're old enough to be of no
|
||||
further use — `meteringRetentionMonths` full months past the month they
|
||||
belong to, 3 by default. Set it to `0` to keep every month's records forever:
|
||||
|
||||
```json
|
||||
"meteringRetentionMonths": 6
|
||||
```
|
||||
|
||||
Changing it, including to `0`, applies to records first written after the
|
||||
change; records already stamped keep their expiry.
|
||||
|
||||
### Captcha on signup / login
|
||||
|
||||
Built-in proof-of-work captcha — no external service needed.
|
||||
@@ -486,14 +519,127 @@ Without `--profile ai`, the `ollama` containers stay down and Puter (with `enabl
|
||||
|
||||
For GPU acceleration (NVIDIA), uncomment the `deploy:` block under the `ollama` service in [docker-compose.yml](../docker-compose.yml). Requires `nvidia-container-toolkit` on the host.
|
||||
|
||||
## Optional: receiving mail over SMTP
|
||||
|
||||
Puter can give each account a mailbox at a domain you control. Two pieces are
|
||||
involved, and they are configured together under `userEmail`:
|
||||
|
||||
- an **ingress endpoint** (`POST /email/ingress`) that files a message into the
|
||||
recipient's `~/.mail`. It exists whenever `userEmail.secret` is set.
|
||||
- the **`puter-smtp` receiver**, which accepts mail from other mail servers and
|
||||
hands each message to that endpoint. It runs as its own process, behind the
|
||||
`smtp` compose profile.
|
||||
|
||||
> **Read this before you expose it.** An inbound mail server is unauthenticated
|
||||
> by nature: anyone on the internet may hand it a message for any address in
|
||||
> `localDomains`. Puter performs **no** spam filtering and **no** SPF, DKIM or
|
||||
> DMARC checks — whatever is delivered is stored in the recipient's mailbox and
|
||||
> counts against their storage. If that is not acceptable, put a filtering relay
|
||||
> in front of it and point `localDomains` at that instead.
|
||||
|
||||
1. Configure it in `puter/config/config.json`:
|
||||
|
||||
```json
|
||||
"userEmail": {
|
||||
"secret": "a long random string",
|
||||
"localServer": true,
|
||||
"localDomains": ["example.com"]
|
||||
}
|
||||
```
|
||||
|
||||
`secret` is shared between the two halves; generate it with
|
||||
`openssl rand -hex 32`. `localDomains` lists the domains this server accepts
|
||||
mail for — anything else is refused at `RCPT TO`, which is what stops it
|
||||
being used as an open relay. It has no default, and the receiver refuses to
|
||||
start without it.
|
||||
|
||||
The receiver posts to `<api_base_url>/email/ingress` unless you set
|
||||
`localIngressUrl`. The endpoint is served on the `api` subdomain, so if you
|
||||
point it at an internal address to skip the reverse proxy, name that virtual
|
||||
host too or the request will 404:
|
||||
|
||||
```json
|
||||
"localIngressUrl": "http://puter:4100/email/ingress",
|
||||
"localIngressHost": "api.example.com"
|
||||
```
|
||||
|
||||
`localIngressHost` defaults to the host of `api_base_url`, which is usually
|
||||
what you want.
|
||||
|
||||
2. Bring it up with the `smtp` profile:
|
||||
|
||||
```bash
|
||||
docker compose --profile smtp up -d
|
||||
docker compose logs -f puter-smtp
|
||||
```
|
||||
|
||||
3. Point DNS at it:
|
||||
|
||||
```dns
|
||||
mx.example.com. A 203.0.113.10
|
||||
example.com. MX 10 mx.example.com.
|
||||
```
|
||||
|
||||
Also set a `PTR` record for the address that resolves back to
|
||||
`mx.example.com` — many senders refuse mail from a server without one.
|
||||
|
||||
Inbound TCP port 25 must reach the host. **Most cloud providers block port
|
||||
25 by default and residential ISPs block it permanently**; if you cannot get
|
||||
it unblocked, this will not work no matter how it is configured. The
|
||||
container itself binds 2525 (it runs unprivileged) and compose publishes
|
||||
`${SMTP_PORT:-25}` onto it, so you can move the host port if you need to.
|
||||
|
||||
A message is held in memory while it is being delivered, so peak memory is
|
||||
bounded by `localMaxClients` (default 20) times the 25 MiB message ceiling.
|
||||
Lower `localMaxClients` on a small host. There is no queue and no retry
|
||||
schedule — if delivery fails, the sending server is told to try again later and
|
||||
retrying is its job.
|
||||
|
||||
Mail is stored as `message/rfc822` objects under `~/.mail/objects/<date>/` and
|
||||
read back through `puter.email` in the SDK.
|
||||
|
||||
Every setting is documented in
|
||||
[config.template.jsonc](../config.template.jsonc). Two deliberate limits, both
|
||||
in the name of keeping the self-hosted path simple: it does not offer STARTTLS,
|
||||
so mail arrives in the clear, and it applies no per-sender rate limiting. Put a
|
||||
filtering relay in front if either matters to you.
|
||||
|
||||
Note this is a *receiving* server only: it never sends, and it must not be used
|
||||
for authenticated submission. Outbound transactional mail is the separate
|
||||
`email` block above.
|
||||
|
||||
## Building from source instead of pulling
|
||||
|
||||
If you want to test local Dockerfile changes against the full stack, uncomment the `build:` block in [docker-compose.yml](../docker-compose.yml) under the `puter` service, change `pull_policy: always` → `pull_policy: never`, then:
|
||||
To run a local build against the full stack, use a source checkout and complete
|
||||
the configuration steps above. Create `docker-compose.override.yml` in the
|
||||
repository root, next to [docker-compose.yml](../docker-compose.yml):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
puter:
|
||||
pull_policy: never
|
||||
build:
|
||||
context: .
|
||||
```
|
||||
|
||||
If that file already exists, merge these settings into its `puter` service.
|
||||
Compose loads and merges the override automatically. `build.context: .` selects
|
||||
this checkout, and `pull_policy: never` prevents pulling the published Puter image.
|
||||
|
||||
Use this override for local build settings instead of editing `docker-compose.yml`.
|
||||
The override is ignored by Git, keeping local configuration out of pull requests
|
||||
and avoiding conflicts in the shared Compose file when pulling updates.
|
||||
|
||||
From the repository root, build and start the stack:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Run the same command after changing or pulling source code to rebuild the image.
|
||||
To return to the published image, remove the local build settings (or the override
|
||||
file if those are its only settings), then run `docker compose up -d`.
|
||||
|
||||
---
|
||||
|
||||
## Managing running backend
|
||||
|
||||
+52
-11
@@ -139,8 +139,10 @@ services:
|
||||
start_period: 5s
|
||||
|
||||
s3-init:
|
||||
# One-shot container that creates the `puter-local` bucket on first
|
||||
# boot. Exits 0 once the bucket exists; stays exited 0 thereafter.
|
||||
# One-shot container that creates the `puter-local` bucket and
|
||||
# applies its CORS rules on every boot (put-bucket-cors is
|
||||
# idempotent). Without CORS, browser uploads to presigned URLs
|
||||
# fail the preflight check.
|
||||
image: amazon/aws-cli:latest
|
||||
container_name: puter-s3-init
|
||||
depends_on:
|
||||
@@ -163,6 +165,26 @@ services:
|
||||
echo "creating bucket $$bucket"
|
||||
aws --endpoint-url "$$endpoint" s3 mb "s3://$$bucket"
|
||||
fi
|
||||
# Browser uploads (Dev Center deploy, puter.fs.upload) PUT to
|
||||
# presigned URLs cross-origin, so the bucket must answer the
|
||||
# OPTIONS preflight. Origins are open because the presigned URL
|
||||
# itself is the credential and the SDK sends no cookies.
|
||||
cat > /tmp/cors.json <<'EOF'
|
||||
{
|
||||
"CORSRules": [
|
||||
{
|
||||
"AllowedOrigins": ["*"],
|
||||
"AllowedMethods": ["GET", "HEAD", "PUT", "POST", "DELETE"],
|
||||
"AllowedHeaders": ["*"],
|
||||
"ExposeHeaders": ["ETag", "x-amz-request-id"],
|
||||
"MaxAgeSeconds": 3600
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
aws --endpoint-url "$$endpoint" s3api put-bucket-cors \
|
||||
--bucket "$$bucket" --cors-configuration file:///tmp/cors.json
|
||||
echo "bucket CORS configured"
|
||||
restart: "no"
|
||||
|
||||
# ── Optional: local LLM ───────────────────────────────────────────
|
||||
@@ -230,15 +252,8 @@ services:
|
||||
puter:
|
||||
image: ghcr.io/heyputer/puter:main
|
||||
pull_policy: always
|
||||
# Uncomment to build from this directory instead of pulling the published
|
||||
# image. Also flip pull_policy to `never` so compose doesn't overwrite
|
||||
# your local build by re-pulling :latest.
|
||||
# build:
|
||||
# context: .
|
||||
# # buildx-only: cross-compile to both archs in a single push
|
||||
# # platforms:
|
||||
# # - linux/amd64
|
||||
# # - linux/arm64
|
||||
# For local builds, create a docker-compose.override.yml;
|
||||
# see doc/self-hosting.md#building-from-source-instead-of-pulling.
|
||||
container_name: puter
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -271,6 +286,32 @@ services:
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# Inbound mail. Behind the `smtp` profile, so it only starts when asked for:
|
||||
# docker compose --profile smtp up -d
|
||||
# Accepts SMTP from other mail servers and hands each message to the ingress
|
||||
# endpoint, which files it into the recipient's mailbox. Needs `userEmail`
|
||||
# configured — see doc/self-hosting.md#optional-receiving-mail-over-smtp.
|
||||
puter-smtp:
|
||||
profiles: ["smtp"]
|
||||
image: ghcr.io/heyputer/puter:main
|
||||
pull_policy: always
|
||||
container_name: puter-smtp
|
||||
restart: unless-stopped
|
||||
command: ["node", "-r", "./dist/src/backend/telemetry.js", "./dist/src/backend/smtp/index.js"]
|
||||
depends_on:
|
||||
puter:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
# The container runs unprivileged and binds 2525; 25 is what an MX record
|
||||
# means, and many providers block it.
|
||||
- "${SMTP_PORT:-25}:2525"
|
||||
environment:
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
volumes:
|
||||
# The same config.json the backend reads; only `userEmail` applies here.
|
||||
- ./puter/config:/etc/puter:z
|
||||
|
||||
caddy:
|
||||
image: caddy:2.11-alpine
|
||||
container_name: puter-caddy
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { makeActor, type Actor } from '../src/backend/core/actor.ts';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||||
@@ -88,6 +89,80 @@ describe('metering extension — handleMeteringUsage', () => {
|
||||
(captured.body as Record<string, unknown>).allowanceInfo,
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
describe('usage split across apps', () => {
|
||||
const seedUsage = async () => {
|
||||
const user = await seedUser();
|
||||
const owner = { uuid: user.uuid, id: user.id as number };
|
||||
const metering = server.services.metering;
|
||||
await metering.incrementUsage(
|
||||
makeActor({ user: owner, app: { uid: 'app-mine' } }),
|
||||
'kv:read',
|
||||
1,
|
||||
10,
|
||||
);
|
||||
await metering.incrementUsage(
|
||||
makeActor({ user: owner, app: { uid: 'app-other' } }),
|
||||
'kv:write',
|
||||
1,
|
||||
20,
|
||||
);
|
||||
await metering.incrementUsage(
|
||||
makeActor({ user: owner }),
|
||||
'kv:list',
|
||||
1,
|
||||
40,
|
||||
);
|
||||
await server.stores.meteringBuffer.flushCycle();
|
||||
return owner;
|
||||
};
|
||||
|
||||
const usageAs = async (actor: Actor) => {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext({ actor }, () =>
|
||||
handleMeteringUsage(makeReq(), res),
|
||||
);
|
||||
return captured.body as {
|
||||
usage: Record<string, unknown> & { total: number };
|
||||
appTotals: Record<string, unknown>;
|
||||
allowanceInfo: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
it('reports only the calling app’s own usage to an app actor', async () => {
|
||||
const owner = await seedUsage();
|
||||
const body = await usageAs(
|
||||
makeActor({ user: owner, app: { uid: 'app-mine' } }),
|
||||
);
|
||||
|
||||
expect(body.usage.total).toBe(10);
|
||||
expect(Object.keys(body.usage).sort()).toEqual([
|
||||
'kv:read',
|
||||
'total',
|
||||
]);
|
||||
expect(body.appTotals).toEqual({
|
||||
'app-mine': { total: 10, count: 1 },
|
||||
});
|
||||
expect(body.allowanceInfo).toMatchObject({
|
||||
remaining: expect.any(Number),
|
||||
monthUsageAllowance: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('still reports the whole account to the user', async () => {
|
||||
const owner = await seedUsage();
|
||||
const body = await usageAs(makeActor({ user: owner }));
|
||||
|
||||
expect(body.usage.total).toBe(70);
|
||||
expect(body.usage).toHaveProperty('kv:read');
|
||||
expect(body.usage).toHaveProperty('kv:write');
|
||||
expect(body.usage).toHaveProperty('kv:list');
|
||||
expect(body.appTotals).toMatchObject({
|
||||
'app-mine': { total: 10, count: 1 },
|
||||
'app-other': { total: 20, count: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('metering extension — handleMeteringUsageForApp', () => {
|
||||
@@ -132,6 +207,96 @@ describe('metering extension — handleMeteringUsageForApp', () => {
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
describe('scoped to the calling app', () => {
|
||||
const appUsageAs = async (actor: Actor, appIdOrName: string) => {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext({ actor }, () =>
|
||||
handleMeteringUsageForApp(
|
||||
makeReq({ params: { appIdOrName } }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
return captured.body as { total: number };
|
||||
};
|
||||
|
||||
const seedApps = async () => {
|
||||
const user = await seedUser();
|
||||
const owner = { uuid: user.uuid, id: user.id as number };
|
||||
const mine = `app-${uuidv4()}`;
|
||||
const other = `app-${uuidv4()}`;
|
||||
await server.services.metering.incrementUsage(
|
||||
makeActor({ user: owner, app: { uid: mine } }),
|
||||
'kv:read',
|
||||
1,
|
||||
10,
|
||||
);
|
||||
await server.services.metering.incrementUsage(
|
||||
makeActor({ user: owner, app: { uid: other } }),
|
||||
'kv:write',
|
||||
1,
|
||||
20,
|
||||
);
|
||||
await server.stores.meteringBuffer.flushCycle();
|
||||
return {
|
||||
owner,
|
||||
mine,
|
||||
other,
|
||||
asMine: makeActor({ user: owner, app: { uid: mine } }),
|
||||
};
|
||||
};
|
||||
|
||||
it('lets an app read its own usage', async () => {
|
||||
const { mine, asMine } = await seedApps();
|
||||
expect((await appUsageAs(asMine, mine)).total).toBe(10);
|
||||
});
|
||||
|
||||
it('refuses an app another app’s usage', async () => {
|
||||
const { other, asMine } = await seedApps();
|
||||
await expect(appUsageAs(asMine, other)).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses an app another app’s usage by name', async () => {
|
||||
const { owner, other, asMine } = await seedApps();
|
||||
const name = `other-${uuidv4()}`;
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)',
|
||||
[other, name, name, `https://${name}.example/`, owner.id],
|
||||
);
|
||||
await expect(appUsageAs(asMine, name)).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses an app the usage outside any app', async () => {
|
||||
const { asMine } = await seedApps();
|
||||
await expect(appUsageAs(asMine, 'os-global')).rejects.toMatchObject(
|
||||
{ statusCode: 403 },
|
||||
);
|
||||
});
|
||||
|
||||
it('still lets the user read any of their apps', async () => {
|
||||
const { owner, mine, other } = await seedApps();
|
||||
const asUser = makeActor({ user: owner });
|
||||
expect((await appUsageAs(asUser, mine)).total).toBe(10);
|
||||
expect((await appUsageAs(asUser, other)).total).toBe(20);
|
||||
});
|
||||
|
||||
it('lets the user read their usage outside any app', async () => {
|
||||
const { owner } = await seedApps();
|
||||
const asUser = makeActor({ user: owner });
|
||||
await server.services.metering.incrementUsage(
|
||||
asUser,
|
||||
'kv:read',
|
||||
1,
|
||||
5,
|
||||
);
|
||||
await server.stores.meteringBuffer.flushCycle();
|
||||
expect((await appUsageAs(asUser, 'os-global')).total).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('metering extension — handleMeteringGlobalUsage', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
servicesContainers,
|
||||
} from '@heyputer/backend/src/exports';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { GLOBAL_APP_KEY } from '@heyputer/backend/src/services/metering/consts';
|
||||
import {
|
||||
creditMultiplierFrom,
|
||||
toCredits,
|
||||
@@ -151,8 +152,9 @@ export const handleMeteringUsageForApp = async (
|
||||
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-')) {
|
||||
// If not a UUID-shaped app UID or the global sentinel, look up by name.
|
||||
// Which apps an actor may read is MeteringService's call, not this route's.
|
||||
if (!appId.startsWith('app-') && appId !== GLOBAL_APP_KEY) {
|
||||
const appRows = (await clients.db.read(
|
||||
'SELECT `uid` FROM `apps` WHERE `name` = ? LIMIT 1',
|
||||
[appId],
|
||||
|
||||
+496
-173
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
type S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import type { Request, Response } from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import {
|
||||
afterAll,
|
||||
@@ -12,8 +14,17 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import type { FSController } from '../src/backend/controllers/fs/FSController.ts';
|
||||
import type {
|
||||
ClientSignedWriteResponse,
|
||||
CompleteWriteRequest,
|
||||
SignedWriteRequest,
|
||||
} from '../src/backend/controllers/fs/requestTypes.ts';
|
||||
import type { Actor } from '../src/backend/core/actor.ts';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||||
import { generateDefaultFsentries } from '../src/backend/util/userProvisioning.ts';
|
||||
import {
|
||||
handleFsCopyNodeThumbnail,
|
||||
handleFsRemoveNodeThumbnail,
|
||||
@@ -27,9 +38,33 @@ const TINY_PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
||||
|
||||
const BUCKET = 'puter-local';
|
||||
const BUCKET_ENDPOINT = 'http://127.0.0.1:4566/puter-local/';
|
||||
|
||||
// Keys the extension will accept back: its own `thumbnails/<uuid>` namespace.
|
||||
const mintedKey = () => `thumbnails/${crypto.randomUUID()}`;
|
||||
// Keys the extension mints: `thumbnails/<entry uuid>/<uuid>`.
|
||||
const mintedKey = (entryUuid: string = crypto.randomUUID()) =>
|
||||
`thumbnails/${entryUuid}/${crypto.randomUUID()}`;
|
||||
// Minted before keys were bound to an entry.
|
||||
const legacyKey = () => `thumbnails/${crypto.randomUUID()}`;
|
||||
|
||||
const putObject = async (s3: S3Client, key: string, body: Buffer) => {
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: 'image/png',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const objectExists = async (s3: S3Client, key: string) => {
|
||||
try {
|
||||
await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const streamToBuffer = async (
|
||||
body: { transformToByteArray: () => Promise<Uint8Array> } | undefined,
|
||||
@@ -40,28 +75,42 @@ const streamToBuffer = async (
|
||||
|
||||
describe('thumbnails extension — handleThumbnailCreated', () => {
|
||||
let server: PuterServer;
|
||||
let s3: S3Client;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
s3 = server.clients.s3.get();
|
||||
});
|
||||
|
||||
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}`,
|
||||
};
|
||||
const created = async (url: unknown, uuid?: unknown) => {
|
||||
const event: Record<string, unknown> = { url, uuid };
|
||||
await handleThumbnailCreated(event, {
|
||||
s3,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: BUCKET_ENDPOINT,
|
||||
});
|
||||
return event.url;
|
||||
};
|
||||
|
||||
await handleThumbnailCreated(event, { s3, bucketName: BUCKET });
|
||||
it('uploads a valid data: URL thumbnail under a key bound to the entry', async () => {
|
||||
const entryUuid = crypto.randomUUID();
|
||||
const newUrl = await created(
|
||||
`data:image/png;base64,${TINY_PNG_BASE64}`,
|
||||
entryUuid,
|
||||
);
|
||||
|
||||
expect(typeof event.url).toBe('string');
|
||||
const newUrl = event.url as string;
|
||||
expect(newUrl.startsWith(`s3://${BUCKET}/`)).toBe(true);
|
||||
expect(typeof newUrl).toBe('string');
|
||||
expect(
|
||||
(newUrl as string).startsWith(
|
||||
`s3://${BUCKET}/thumbnails/${entryUuid}/`,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const key = newUrl.slice(`s3://${BUCKET}/`.length);
|
||||
const key = (newUrl as string).slice(`s3://${BUCKET}/`.length);
|
||||
const obj = await s3.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: key }),
|
||||
);
|
||||
@@ -73,34 +122,77 @@ describe('thumbnails extension — handleThumbnailCreated', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
const url = await created(
|
||||
`data:image/png;base64,${Buffer.from('not an image').toString('base64')}`,
|
||||
crypto.randomUUID(),
|
||||
);
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves event.url untouched when the URL is not a data: URL', async () => {
|
||||
const s3 = server.clients.s3.get();
|
||||
it('sets event.url to null for a data: URL with no entry to bind it to', async () => {
|
||||
const url = await created(`data:image/png;base64,${TINY_PNG_BASE64}`);
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
// A documented SDK input: `thumbnail` / `thumbnailGenerator` may return
|
||||
// a plain URL. It never names our storage, so it is stored as given.
|
||||
it('leaves an external image URL untouched', async () => {
|
||||
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);
|
||||
expect(await created(original, crypto.randomUUID())).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 });
|
||||
await handleThumbnailCreated(event, {
|
||||
s3,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: BUCKET_ENDPOINT,
|
||||
});
|
||||
|
||||
expect(event.url).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps an uploaded pointer minted for the entry, normalised to our bucket', async () => {
|
||||
const entryUuid = crypto.randomUUID();
|
||||
const key = mintedKey(entryUuid);
|
||||
await putObject(s3, key, Buffer.from(TINY_PNG_BASE64, 'base64'));
|
||||
|
||||
expect(
|
||||
await created(`s3://client-named-bucket/${key}`, entryUuid),
|
||||
).toBe(`s3://${BUCKET}/${key}`);
|
||||
});
|
||||
|
||||
// A thumbnail key is visible to anyone who can list the entry holding it.
|
||||
// Planting it on a file of one's own used to hand its deletion to them.
|
||||
it.each([
|
||||
['a key minted for another entry', () => mintedKey()],
|
||||
['a key that predates entry binding', legacyKey],
|
||||
])('drops a pointer naming %s', async (_label, makeKey) => {
|
||||
const key = makeKey();
|
||||
await putObject(s3, key, Buffer.from(TINY_PNG_BASE64, 'base64'));
|
||||
|
||||
expect(
|
||||
await created(`s3://${BUCKET}/${key}`, crypto.randomUUID()),
|
||||
).toBeNull();
|
||||
expect(await objectExists(s3, key)).toBe(true);
|
||||
});
|
||||
|
||||
it('drops a pointer whose object was never uploaded', async () => {
|
||||
const entryUuid = crypto.randomUUID();
|
||||
expect(
|
||||
await created(`s3://${BUCKET}/${mintedKey(entryUuid)}`, entryUuid),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('drops and removes an uploaded object over the size bound', async () => {
|
||||
const entryUuid = crypto.randomUUID();
|
||||
const key = mintedKey(entryUuid);
|
||||
await putObject(s3, key, Buffer.alloc(2 * 1024 * 1024 + 1));
|
||||
|
||||
expect(await created(`s3://${BUCKET}/${key}`, entryUuid)).toBeNull();
|
||||
expect(await objectExists(s3, key)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('thumbnails extension — handleThumbnailUploadPrepare', () => {
|
||||
@@ -148,10 +240,15 @@ describe('thumbnails extension — handleThumbnailUploadPrepare', () => {
|
||||
expect(item.thumbnailUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips items whose size exceeds the max thumbnail bytes', async () => {
|
||||
it.each([
|
||||
['exceeds the max thumbnail bytes', { size: 999_999_999 }],
|
||||
['is not declared', {}],
|
||||
['is not a whole number', { size: 10.5 }],
|
||||
])('skips items whose size %s', async (_label, sizeField) => {
|
||||
const item: Record<string, unknown> = {
|
||||
contentType: 'image/png',
|
||||
size: 999_999_999,
|
||||
item_uid: crypto.randomUUID(),
|
||||
...sizeField,
|
||||
};
|
||||
await handleThumbnailUploadPrepare(
|
||||
{ items: [item] },
|
||||
@@ -161,10 +258,26 @@ describe('thumbnails extension — handleThumbnailUploadPrepare', () => {
|
||||
expect(item.thumbnailUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('mints a presigned uploadUrl and an s3:// thumbnailUrl for valid items', async () => {
|
||||
it('skips items with no entry to bind the key to', async () => {
|
||||
const item: Record<string, unknown> = {
|
||||
contentType: 'image/png',
|
||||
size: 1024,
|
||||
item_uid: 'not-a-uuid',
|
||||
};
|
||||
await handleThumbnailUploadPrepare(
|
||||
{ items: [item] },
|
||||
{ s3Presign, bucketName: BUCKET },
|
||||
);
|
||||
expect(item.uploadUrl).toBeUndefined();
|
||||
expect(item.thumbnailUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('mints a size-bound upload URL for a key bound to the entry', async () => {
|
||||
const entryUuid = crypto.randomUUID();
|
||||
const item: Record<string, unknown> = {
|
||||
contentType: 'image/png',
|
||||
size: 1024,
|
||||
item_uid: entryUuid,
|
||||
};
|
||||
await handleThumbnailUploadPrepare(
|
||||
{ items: [item] },
|
||||
@@ -174,8 +287,15 @@ describe('thumbnails extension — handleThumbnailUploadPrepare', () => {
|
||||
expect((item.uploadUrl as string).startsWith('http')).toBe(true);
|
||||
expect(typeof item.thumbnailUrl).toBe('string');
|
||||
expect(
|
||||
(item.thumbnailUrl as string).startsWith(`s3://${BUCKET}/`),
|
||||
(item.thumbnailUrl as string).startsWith(
|
||||
`s3://${BUCKET}/thumbnails/${entryUuid}/`,
|
||||
),
|
||||
).toBe(true);
|
||||
// A signed `content-length` makes the store refuse any other body size.
|
||||
const signedHeaders = new URL(
|
||||
item.uploadUrl as string,
|
||||
).searchParams.get('X-Amz-SignedHeaders');
|
||||
expect(signedHeaders).toContain('content-length');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,34 +316,41 @@ describe('thumbnails extension — handleThumbnailRead', () => {
|
||||
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 = mintedKey();
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: key,
|
||||
Body: Buffer.from(TINY_PNG_BASE64, 'base64'),
|
||||
ContentType: 'image/png',
|
||||
}),
|
||||
);
|
||||
// Thumbnails stored before keys were bound to entries keep displaying.
|
||||
it.each([
|
||||
['an entry-bound key', () => mintedKey()],
|
||||
['a key that predates entry binding', legacyKey],
|
||||
])(
|
||||
'rewrites an s3:// thumbnail naming %s into a presigned https URL',
|
||||
async (_label, makeKey) => {
|
||||
// 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 = makeKey();
|
||||
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,
|
||||
});
|
||||
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);
|
||||
});
|
||||
expect(typeof entry.thumbnail).toBe('string');
|
||||
expect((entry.thumbnail as string).startsWith('http')).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
// `fsentries.thumbnail` is writable through the FS API, so a stored
|
||||
// pointer is attacker input. Signing one the extension didn't mint would
|
||||
@@ -245,6 +372,10 @@ describe('thumbnails extension — handleThumbnailRead', () => {
|
||||
`s3://${BUCKET}/thumbnails/../${crypto.randomUUID()}`,
|
||||
],
|
||||
['a non-uuid inside the namespace', `s3://${BUCKET}/thumbnails/etc`],
|
||||
[
|
||||
'a key nested deeper than the namespace allows',
|
||||
`s3://${BUCKET}/${mintedKey()}/${crypto.randomUUID()}`,
|
||||
],
|
||||
])('refuses to presign a pointer naming %s', async (_label, thumbnail) => {
|
||||
const entry: Record<string, unknown> = { thumbnail };
|
||||
await handleThumbnailRead(entry, {
|
||||
@@ -301,8 +432,9 @@ describe('thumbnails extension — handleThumbnailRead', () => {
|
||||
});
|
||||
|
||||
it('migrates an inline data: URL by uploading to S3 and updating the DB row', async () => {
|
||||
const entryUuid = crypto.randomUUID();
|
||||
const entry: Record<string, unknown> = {
|
||||
uuid: 'fs-entry-uuid',
|
||||
uuid: entryUuid,
|
||||
thumbnail: `data:image/png;base64,${TINY_PNG_BASE64}`,
|
||||
};
|
||||
|
||||
@@ -322,9 +454,27 @@ describe('thumbnails extension — handleThumbnailRead', () => {
|
||||
await Promise.resolve();
|
||||
expect(stubDb.write).toHaveBeenCalledWith(
|
||||
'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?',
|
||||
[expect.stringMatching(/^s3:\/\//), 'fs-entry-uuid'],
|
||||
[
|
||||
expect.stringMatching(
|
||||
new RegExp(`^s3://${BUCKET}/thumbnails/${entryUuid}/`),
|
||||
),
|
||||
entryUuid,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves an inline data: URL as is when there is no entry to bind it to', async () => {
|
||||
const thumbnail = `data:image/png;base64,${TINY_PNG_BASE64}`;
|
||||
const entry: Record<string, unknown> = { thumbnail };
|
||||
await handleThumbnailRead(entry, {
|
||||
s3,
|
||||
s3Presign,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: BUCKET_ENDPOINT,
|
||||
db: stubDb,
|
||||
});
|
||||
expect(entry.thumbnail).toBe(thumbnail);
|
||||
});
|
||||
});
|
||||
|
||||
describe('thumbnails extension — handleFsRemoveNodeThumbnail', () => {
|
||||
@@ -340,66 +490,63 @@ describe('thumbnails extension — handleFsRemoveNodeThumbnail', () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('deletes the S3 object referenced by an s3:// thumbnail URL', async () => {
|
||||
const key = mintedKey();
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: key,
|
||||
Body: Buffer.from(TINY_PNG_BASE64, 'base64'),
|
||||
ContentType: 'image/png',
|
||||
}),
|
||||
const remove = (target: { thumbnail?: string | null; uuid?: string }) =>
|
||||
handleFsRemoveNodeThumbnail(
|
||||
{ target },
|
||||
{ s3, bucketName: BUCKET, bucketEndpoint: BUCKET_ENDPOINT },
|
||||
);
|
||||
|
||||
await handleFsRemoveNodeThumbnail(
|
||||
{ target: { thumbnail: `s3://${BUCKET}/${key}` } },
|
||||
{ s3, bucketName: BUCKET },
|
||||
);
|
||||
it('deletes the object minted for the removed entry', async () => {
|
||||
const entryUuid = crypto.randomUUID();
|
||||
const key = mintedKey(entryUuid);
|
||||
await putObject(s3, key, Buffer.from(TINY_PNG_BASE64, 'base64'));
|
||||
|
||||
// GetObject should now error because the key was deleted.
|
||||
await expect(
|
||||
s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key })),
|
||||
).rejects.toThrow();
|
||||
await remove({ thumbnail: `s3://${BUCKET}/${key}`, uuid: entryUuid });
|
||||
|
||||
expect(await objectExists(s3, key)).toBe(false);
|
||||
});
|
||||
|
||||
// The destructive half of the same confused deputy: the stored pointer
|
||||
// decides which object is deleted, so a key the extension didn't mint
|
||||
// must never reach DeleteObject.
|
||||
it('does not delete an object the pointer names but we did not mint', async () => {
|
||||
const victimKey = crypto.randomUUID(); // shaped like an fs object key
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: victimKey,
|
||||
Body: Buffer.from(TINY_PNG_BASE64, 'base64'),
|
||||
ContentType: 'image/png',
|
||||
}),
|
||||
);
|
||||
// decides which object is deleted, so only a key minted for the removed
|
||||
// entry may reach DeleteObject.
|
||||
it.each([
|
||||
// Shaped like an fs object key.
|
||||
[
|
||||
'an object outside the thumbnails namespace',
|
||||
() => crypto.randomUUID(),
|
||||
],
|
||||
// Visible to anyone who can list the entry it belongs to.
|
||||
["another entry's thumbnail", () => mintedKey()],
|
||||
// Can't be told apart from a planted or shared pointer.
|
||||
['a thumbnail that predates entry binding', legacyKey],
|
||||
])('does not delete %s', async (_label, makeKey) => {
|
||||
const victimKey = makeKey();
|
||||
await putObject(s3, victimKey, Buffer.from(TINY_PNG_BASE64, 'base64'));
|
||||
|
||||
await handleFsRemoveNodeThumbnail(
|
||||
{ target: { thumbnail: `s3://${BUCKET}/${victimKey}` } },
|
||||
{ s3, bucketName: BUCKET },
|
||||
);
|
||||
await remove({
|
||||
thumbnail: `s3://${BUCKET}/${victimKey}`,
|
||||
uuid: crypto.randomUUID(),
|
||||
});
|
||||
|
||||
const survivor = await s3.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: victimKey }),
|
||||
);
|
||||
expect(survivor.ContentType).toBe('image/png');
|
||||
expect(await objectExists(s3, victimKey)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not delete through an external URL that names our key', async () => {
|
||||
const entryUuid = crypto.randomUUID();
|
||||
const key = mintedKey(entryUuid);
|
||||
await putObject(s3, key, Buffer.from(TINY_PNG_BASE64, 'base64'));
|
||||
|
||||
await remove({
|
||||
thumbnail: `https://cdn.example.com/${BUCKET}/${key}`,
|
||||
uuid: entryUuid,
|
||||
});
|
||||
|
||||
expect(await objectExists(s3, key)).toBe(true);
|
||||
});
|
||||
|
||||
it('is a no-op when the target has no thumbnail', async () => {
|
||||
// Should not throw or attempt a delete.
|
||||
await handleFsRemoveNodeThumbnail(
|
||||
{ target: {} },
|
||||
{ s3, bucketName: BUCKET },
|
||||
);
|
||||
});
|
||||
|
||||
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, bucketName: BUCKET },
|
||||
);
|
||||
await remove({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -416,64 +563,58 @@ describe('thumbnails extension — handleFsCopyNodeThumbnail', () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('duplicates the shared thumbnail object and repoints the copied row', async () => {
|
||||
const sourceKey = mintedKey();
|
||||
const body = Buffer.from(TINY_PNG_BASE64, 'base64');
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: sourceKey,
|
||||
Body: body,
|
||||
ContentType: 'image/png',
|
||||
}),
|
||||
);
|
||||
|
||||
const copyUuid = crypto.randomUUID();
|
||||
const copyNode = async (thumbnail: string | null, copyUuid: string) => {
|
||||
const db = { write: vi.fn().mockResolvedValue(undefined) };
|
||||
await handleFsCopyNodeThumbnail(
|
||||
{
|
||||
copy: {
|
||||
thumbnail: `s3://${BUCKET}/${sourceKey}`,
|
||||
uuid: copyUuid,
|
||||
},
|
||||
},
|
||||
{ s3, bucketName: BUCKET, db },
|
||||
{ copy: { thumbnail, uuid: copyUuid } },
|
||||
{ s3, bucketName: BUCKET, bucketEndpoint: BUCKET_ENDPOINT, db },
|
||||
);
|
||||
return db;
|
||||
};
|
||||
|
||||
// The copied row was repointed at a fresh object...
|
||||
expect(db.write).toHaveBeenCalledTimes(1);
|
||||
const [, params] = db.write.mock.calls[0] as [string, [string, string]];
|
||||
const [newPointer, updatedUuid] = params;
|
||||
expect(updatedUuid).toBe(copyUuid);
|
||||
expect(newPointer.startsWith(`s3://${BUCKET}/thumbnails/`)).toBe(true);
|
||||
expect(newPointer).not.toBe(`s3://${BUCKET}/${sourceKey}`);
|
||||
it.each([
|
||||
['an entry-bound key', () => mintedKey()],
|
||||
['a key that predates entry binding', legacyKey],
|
||||
])(
|
||||
'duplicates a thumbnail naming %s under a key bound to the copy',
|
||||
async (_label, makeKey) => {
|
||||
const sourceKey = makeKey();
|
||||
const body = Buffer.from(TINY_PNG_BASE64, 'base64');
|
||||
await putObject(s3, sourceKey, body);
|
||||
|
||||
// ...whose content matches, while the source object survives — so
|
||||
// deleting either entry can no longer break the other's thumbnail.
|
||||
const newKey = newPointer.slice(`s3://${BUCKET}/`.length);
|
||||
const duplicated = await s3.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: newKey }),
|
||||
);
|
||||
expect(
|
||||
(await streamToBuffer(duplicated.Body as never)).equals(body),
|
||||
).toBe(true);
|
||||
const original = await s3.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: sourceKey }),
|
||||
);
|
||||
expect(original.ContentType).toBe('image/png');
|
||||
});
|
||||
const copyUuid = crypto.randomUUID();
|
||||
const db = await copyNode(`s3://${BUCKET}/${sourceKey}`, copyUuid);
|
||||
|
||||
// The copied row was repointed at a fresh object of its own...
|
||||
expect(db.write).toHaveBeenCalledTimes(1);
|
||||
const [, params] = db.write.mock.calls[0] as [
|
||||
string,
|
||||
[string, string],
|
||||
];
|
||||
const [newPointer, updatedUuid] = params;
|
||||
expect(updatedUuid).toBe(copyUuid);
|
||||
expect(
|
||||
newPointer.startsWith(`s3://${BUCKET}/thumbnails/${copyUuid}/`),
|
||||
).toBe(true);
|
||||
|
||||
// ...whose content matches, while the source object survives — so
|
||||
// deleting either entry can no longer break the other's thumbnail.
|
||||
const newKey = newPointer.slice(`s3://${BUCKET}/`.length);
|
||||
const duplicated = await s3.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: newKey }),
|
||||
);
|
||||
expect(
|
||||
(await streamToBuffer(duplicated.Body as never)).equals(body),
|
||||
).toBe(true);
|
||||
expect(await objectExists(s3, sourceKey)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('drops the pointer when the shared object is already gone', async () => {
|
||||
const copyUuid = crypto.randomUUID();
|
||||
const db = { write: vi.fn().mockResolvedValue(undefined) };
|
||||
await handleFsCopyNodeThumbnail(
|
||||
{
|
||||
copy: {
|
||||
thumbnail: `s3://${BUCKET}/${mintedKey()}`, // never uploaded
|
||||
uuid: copyUuid,
|
||||
},
|
||||
},
|
||||
{ s3, bucketName: BUCKET, db },
|
||||
const db = await copyNode(
|
||||
`s3://${BUCKET}/${mintedKey()}`, // never uploaded
|
||||
copyUuid,
|
||||
);
|
||||
|
||||
expect(db.write).toHaveBeenCalledTimes(1);
|
||||
@@ -482,27 +623,209 @@ describe('thumbnails extension — handleFsCopyNodeThumbnail', () => {
|
||||
expect(params).toEqual([copyUuid]);
|
||||
});
|
||||
|
||||
// Only reachable for objects uploaded before upload URLs were size-bound;
|
||||
// copying one would multiply bytes no allowance counts.
|
||||
it('drops the pointer instead of duplicating an object over the size bound', async () => {
|
||||
const sourceKey = legacyKey();
|
||||
await putObject(s3, sourceKey, Buffer.alloc(2 * 1024 * 1024 + 1));
|
||||
|
||||
const copyUuid = crypto.randomUUID();
|
||||
const db = await copyNode(`s3://${BUCKET}/${sourceKey}`, copyUuid);
|
||||
|
||||
expect(db.write).toHaveBeenCalledTimes(1);
|
||||
const [sql, params] = db.write.mock.calls[0] as [string, [string]];
|
||||
expect(sql).toContain('NULL');
|
||||
expect(params).toEqual([copyUuid]);
|
||||
});
|
||||
|
||||
it('does not duplicate an object the pointer names but we did not mint', async () => {
|
||||
const foreignKey = crypto.randomUUID(); // shaped like an fs object key
|
||||
const db = { write: vi.fn().mockResolvedValue(undefined) };
|
||||
await handleFsCopyNodeThumbnail(
|
||||
{
|
||||
copy: {
|
||||
thumbnail: `s3://${BUCKET}/${foreignKey}`,
|
||||
uuid: crypto.randomUUID(),
|
||||
},
|
||||
},
|
||||
{ s3, bucketName: BUCKET, db },
|
||||
const db = await copyNode(
|
||||
`s3://${BUCKET}/${foreignKey}`,
|
||||
crypto.randomUUID(),
|
||||
);
|
||||
expect(db.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves an external image URL to ride along as is', async () => {
|
||||
const sourceKey = legacyKey();
|
||||
await putObject(s3, sourceKey, Buffer.from(TINY_PNG_BASE64, 'base64'));
|
||||
const db = await copyNode(
|
||||
`https://cdn.example.com/${BUCKET}/${sourceKey}`,
|
||||
crypto.randomUUID(),
|
||||
);
|
||||
expect(db.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op when the copy has no thumbnail', async () => {
|
||||
const db = { write: vi.fn().mockResolvedValue(undefined) };
|
||||
await handleFsCopyNodeThumbnail(
|
||||
{ copy: { thumbnail: null, uuid: crypto.randomUUID() } },
|
||||
{ s3, bucketName: BUCKET, db },
|
||||
);
|
||||
const db = await copyNode(null, crypto.randomUUID());
|
||||
expect(db.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// The flow `puter.fs.upload()` runs — start, PUT to the signed URLs, complete
|
||||
// with the returned pointer — through the real controller and the listeners
|
||||
// this extension registers.
|
||||
describe('thumbnails extension — signed batch upload through /fs', () => {
|
||||
let server: PuterServer;
|
||||
let controller: FSController;
|
||||
const png = Buffer.from(TINY_PNG_BASE64, 'base64');
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
controller = server.controllers.fs as unknown as FSController;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const makeActor = async (): Promise<{ actor: Actor; username: string }> => {
|
||||
const username = `thumb-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const user = await server.stores.user.create({
|
||||
username,
|
||||
uuid: crypto.randomUUID(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
await generateDefaultFsentries(
|
||||
server.clients.db,
|
||||
server.stores.user,
|
||||
user,
|
||||
);
|
||||
return {
|
||||
username,
|
||||
actor: {
|
||||
user: {
|
||||
id: user.id,
|
||||
uuid: user.uuid,
|
||||
username,
|
||||
email: user.email ?? null,
|
||||
email_confirmed: true,
|
||||
} as Actor['user'],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const call = async <B>(
|
||||
actor: Actor,
|
||||
handler: (req: Request, res: Response) => Promise<void>,
|
||||
body: B,
|
||||
): Promise<unknown> => {
|
||||
let captured: unknown;
|
||||
const res = {
|
||||
json: (value: unknown) => {
|
||||
captured = value;
|
||||
return res;
|
||||
},
|
||||
status: () => res,
|
||||
setHeader: () => res,
|
||||
};
|
||||
const req = {
|
||||
body,
|
||||
query: {},
|
||||
headers: { 'content-type': 'application/json' },
|
||||
actor,
|
||||
user: { id: actor.user!.id, username: actor.user!.username },
|
||||
};
|
||||
await runWithContext({ actor }, () =>
|
||||
handler.call(
|
||||
controller,
|
||||
req as unknown as Request,
|
||||
res as unknown as Response,
|
||||
),
|
||||
);
|
||||
return captured;
|
||||
};
|
||||
|
||||
const upload = async (
|
||||
actor: Actor,
|
||||
path: string,
|
||||
thumbnailData: (
|
||||
started: ClientSignedWriteResponse,
|
||||
) => string | undefined,
|
||||
) => {
|
||||
const [started] = (await call<SignedWriteRequest[]>(
|
||||
actor,
|
||||
controller.startBatchWrites,
|
||||
[
|
||||
{
|
||||
fileMetadata: { path, size: 4 },
|
||||
thumbnailMetadata: {
|
||||
contentType: 'image/png',
|
||||
size: png.length,
|
||||
},
|
||||
},
|
||||
],
|
||||
)) as ClientSignedWriteResponse[];
|
||||
await fetch(started!.url!, { method: 'PUT', body: 'abcd' });
|
||||
if (started!.thumbnailUploadUrl) {
|
||||
await fetch(started!.thumbnailUploadUrl, {
|
||||
method: 'PUT',
|
||||
body: png,
|
||||
headers: { 'content-type': 'image/png' },
|
||||
});
|
||||
}
|
||||
const thumbnail = thumbnailData(started!);
|
||||
await call<CompleteWriteRequest[]>(
|
||||
actor,
|
||||
controller.completeBatchWrites,
|
||||
[
|
||||
{
|
||||
uploadId: started!.sessionId,
|
||||
...(thumbnail === undefined
|
||||
? {}
|
||||
: { thumbnailData: thumbnail }),
|
||||
},
|
||||
],
|
||||
);
|
||||
return {
|
||||
started: started!,
|
||||
entry: (await server.stores.fsEntry.getEntryByPath(path))!,
|
||||
};
|
||||
};
|
||||
|
||||
it('stores the pointer minted for the uploaded entry', async () => {
|
||||
const { actor, username } = await makeActor();
|
||||
const { started, entry } = await upload(
|
||||
actor,
|
||||
`/${username}/Documents/photo.png`,
|
||||
(s) => s.thumbnailUrl,
|
||||
);
|
||||
|
||||
expect(started.thumbnailUrl).toMatch(
|
||||
new RegExp(`^s3://${BUCKET}/thumbnails/${entry.uuid}/`),
|
||||
);
|
||||
expect(entry.thumbnail).toBe(started.thumbnailUrl);
|
||||
});
|
||||
|
||||
it("drops a pointer minted for another user's entry", async () => {
|
||||
const victim = await makeActor();
|
||||
const { entry: victimEntry } = await upload(
|
||||
victim.actor,
|
||||
`/${victim.username}/Documents/photo.png`,
|
||||
(s) => s.thumbnailUrl,
|
||||
);
|
||||
|
||||
const attacker = await makeActor();
|
||||
const { entry } = await upload(
|
||||
attacker.actor,
|
||||
`/${attacker.username}/Documents/decoy.png`,
|
||||
() => victimEntry.thumbnail!,
|
||||
);
|
||||
|
||||
expect(entry.thumbnail).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps an external image URL as given', async () => {
|
||||
const { actor, username } = await makeActor();
|
||||
const { entry } = await upload(
|
||||
actor,
|
||||
`/${username}/Documents/linked.png`,
|
||||
() => 'https://example.com/thumb.png',
|
||||
);
|
||||
expect(entry.thumbnail).toBe('https://example.com/thumb.png');
|
||||
});
|
||||
});
|
||||
+144
-51
@@ -2,11 +2,13 @@ import {
|
||||
CopyObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { isMissingObjectError } from '@heyputer/backend/src/stores/fs/S3ObjectStore';
|
||||
import crypto from 'node:crypto';
|
||||
import sharp from 'sharp';
|
||||
const clients = extension.import('client');
|
||||
@@ -22,8 +24,13 @@ const THUMBNAIL_KEY_PREFIX = 'thumbnails/';
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
const mintThumbnailKey = (): string =>
|
||||
`${THUMBNAIL_KEY_PREFIX}${crypto.randomUUID()}`;
|
||||
const isUuid = (value: unknown): value is string =>
|
||||
typeof value === 'string' && UUID_PATTERN.test(value);
|
||||
|
||||
// `thumbnails/<entry uuid>/<random uuid>`: the entry segment binds the object
|
||||
// to the entry it was minted for, which is what writes and deletes check.
|
||||
const mintThumbnailKey = (entryUuid: string): string =>
|
||||
`${THUMBNAIL_KEY_PREFIX}${entryUuid}/${crypto.randomUUID()}`;
|
||||
|
||||
/**
|
||||
* Extract the object key from a stored thumbnail pointer, or null when the
|
||||
@@ -31,12 +38,13 @@ const mintThumbnailKey = (): string =>
|
||||
*
|
||||
* `fsentries.thumbnail` is writable through the FS API, so neither half of the
|
||||
* stored string is trusted: the bucket is discarded (callers always pass their
|
||||
* own) and the key must sit under {@link THUMBNAIL_KEY_PREFIX} with a random
|
||||
* uuid. Honouring an arbitrary key would lend this extension's storage
|
||||
* credentials to whatever object the caller named — in the shared-bucket layout
|
||||
* that is every user's file, since an fs object's key is its fsentry uuid.
|
||||
* Legacy bare-uuid thumbnails fail the check and are treated as absent; they
|
||||
* are indistinguishable from a planted pointer, so there is nothing safer to do
|
||||
* own) and the key must sit under {@link THUMBNAIL_KEY_PREFIX} as either
|
||||
* `<uuid>` (minted before keys were bound to entries) or `<entry uuid>/<uuid>`.
|
||||
* Honouring an arbitrary key would lend this extension's storage credentials to
|
||||
* whatever object the caller named — in the shared-bucket layout that is every
|
||||
* user's file, since an fs object's key is its fsentry uuid. Bare-uuid keys
|
||||
* from before the prefix fail the check and are treated as absent; they are
|
||||
* indistinguishable from a planted pointer, so there is nothing safer to do
|
||||
* with them than stop signing them.
|
||||
*/
|
||||
const resolveThumbnailKey = (pointer: string): string | null => {
|
||||
@@ -58,10 +66,26 @@ const resolveThumbnailKey = (pointer: string): string | null => {
|
||||
key = segments.join('/');
|
||||
}
|
||||
if (!key.startsWith(THUMBNAIL_KEY_PREFIX)) return null;
|
||||
const id = key.slice(THUMBNAIL_KEY_PREFIX.length);
|
||||
return UUID_PATTERN.test(id) ? key : null;
|
||||
const segments = key.slice(THUMBNAIL_KEY_PREFIX.length).split('/');
|
||||
return segments.length <= 2 && segments.every(isUuid) ? key : null;
|
||||
};
|
||||
|
||||
// The entry a resolved key was minted for. Null for an unbound key, whose
|
||||
// holder can't be told apart from a row that copied or planted the pointer.
|
||||
const thumbnailKeyOwner = (key: string): string | null => {
|
||||
const segments = key.slice(THUMBNAIL_KEY_PREFIX.length).split('/');
|
||||
return segments.length === 2 ? segments[0] : null;
|
||||
};
|
||||
|
||||
// Whether a stored thumbnail names an object in our storage. Anything else is
|
||||
// an external image URL the client supplied: shown as given, never copied or
|
||||
// deleted.
|
||||
const isStoragePointer = (pointer: string, bucketEndpoint: string): boolean =>
|
||||
pointer.startsWith('s3://') ||
|
||||
// Legacy format — remove after full migration
|
||||
(pointer.startsWith('https') &&
|
||||
pointer.includes(new URL(bucketEndpoint).hostname));
|
||||
|
||||
// S3 client + bucket config — lazily resolved after boot from config.
|
||||
let s3Client: S3Client | null = null;
|
||||
let s3PresignClient: S3Client | null = null;
|
||||
@@ -144,16 +168,73 @@ async function decodeAndValidateThumbnail(
|
||||
return { mimeType, data };
|
||||
}
|
||||
|
||||
// A storage pointer a client hands back after a presigned upload is kept only
|
||||
// if it names a key minted for this entry and an object within the size bound.
|
||||
async function vetUploadedThumbnail(
|
||||
pointer: string,
|
||||
entryUuid: unknown,
|
||||
deps: { s3: S3Client; bucketName: string },
|
||||
): Promise<string | null> {
|
||||
const key = resolveThumbnailKey(pointer);
|
||||
if (!key || thumbnailKeyOwner(key) !== entryUuid) return null;
|
||||
|
||||
let size: number | undefined;
|
||||
try {
|
||||
const head = await deps.s3.send(
|
||||
new HeadObjectCommand({ Bucket: deps.bucketName, Key: key }),
|
||||
);
|
||||
size = head.ContentLength;
|
||||
} catch (err) {
|
||||
if (!isMissingObjectError(err)) {
|
||||
console.warn(
|
||||
'[thumbnails] failed to check uploaded thumbnail',
|
||||
err,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof size !== 'number') return null;
|
||||
if (size > MAX_THUMBNAIL_BYTES) {
|
||||
// Only reachable on a store that doesn't enforce the signed length.
|
||||
// The key is this entry's own, so removing it is safe.
|
||||
try {
|
||||
await deps.s3.send(
|
||||
new DeleteObjectCommand({ Bucket: deps.bucketName, Key: key }),
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[thumbnails] failed to remove oversized thumbnail',
|
||||
err,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return `s3://${deps.bucketName}/${key}`;
|
||||
}
|
||||
|
||||
// -- thumbnail.created -----------------------------------------------
|
||||
// Intercept data-URL thumbnails before they hit the DB: upload to S3
|
||||
// and replace the URL with an s3:// pointer.
|
||||
// and replace the URL with an s3:// pointer bound to the entry.
|
||||
|
||||
export async function handleThumbnailCreated(
|
||||
event: Record<string, unknown>,
|
||||
deps: { s3: S3Client; bucketName: string },
|
||||
deps: { s3: S3Client; bucketName: string; bucketEndpoint: string },
|
||||
): Promise<void> {
|
||||
const url = event.url;
|
||||
if (typeof url !== 'string' || !url.startsWith('data:')) return;
|
||||
if (typeof url !== 'string') return;
|
||||
|
||||
if (!url.startsWith('data:')) {
|
||||
if (isStoragePointer(url, deps.bucketEndpoint)) {
|
||||
event.url = await vetUploadedThumbnail(url, event.uuid, deps);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const entryUuid = event.uuid;
|
||||
if (!isUuid(entryUuid)) {
|
||||
event.url = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const decoded = await decodeAndValidateThumbnail(url);
|
||||
if (!decoded) {
|
||||
@@ -161,7 +242,7 @@ export async function handleThumbnailCreated(
|
||||
return;
|
||||
}
|
||||
|
||||
const key = mintThumbnailKey();
|
||||
const key = mintThumbnailKey(entryUuid);
|
||||
event.url = `s3://${deps.bucketName}/${key}`;
|
||||
|
||||
await deps.s3.send(
|
||||
@@ -190,21 +271,24 @@ export const handleThumbnailUploadPrepare = async (
|
||||
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 size = item.size;
|
||||
if (
|
||||
typeof size !== 'number' ||
|
||||
!Number.isInteger(size) ||
|
||||
size < 0 ||
|
||||
size > MAX_THUMBNAIL_BYTES
|
||||
)
|
||||
continue;
|
||||
if (!isUuid(item.item_uid)) continue;
|
||||
|
||||
const key = mintThumbnailKey();
|
||||
const key = mintThumbnailKey(item.item_uid);
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: deps.bucketName,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
// Signing covers `content-length`, so a body of any other size
|
||||
// fails the signature instead of landing.
|
||||
ContentLength: size,
|
||||
});
|
||||
item.uploadUrl = await getSignedUrl(presignClient, command, {
|
||||
expiresIn: 900,
|
||||
@@ -227,12 +311,7 @@ export const handleThumbnailRead = async (
|
||||
if (typeof thumb !== 'string' || !thumb) return;
|
||||
const presignClient = deps.s3Presign;
|
||||
|
||||
if (
|
||||
thumb.startsWith('s3://') ||
|
||||
// Legacy format — remove after full migration
|
||||
(thumb.startsWith('https') &&
|
||||
thumb.includes(new URL(deps.bucketEndpoint).hostname))
|
||||
) {
|
||||
if (isStoragePointer(thumb, deps.bucketEndpoint)) {
|
||||
const key = resolveThumbnailKey(thumb);
|
||||
if (!key) {
|
||||
// Not a pointer we minted — refuse to sign it rather than hand
|
||||
@@ -247,7 +326,9 @@ export const handleThumbnailRead = async (
|
||||
);
|
||||
} else if (thumb.startsWith('data')) {
|
||||
// Inline data-URL migration: upload to S3 and update the DB entry.
|
||||
const key = mintThumbnailKey();
|
||||
const uuid = entry.uuid ?? entry.uid;
|
||||
if (!isUuid(uuid)) return;
|
||||
const key = mintThumbnailKey(uuid);
|
||||
const { mimeType, data } = base64ParseDataUrl(thumb);
|
||||
const newUrl = `s3://${deps.bucketName}/${key}`;
|
||||
|
||||
@@ -261,7 +342,6 @@ export const handleThumbnailRead = async (
|
||||
);
|
||||
|
||||
// Best-effort async DB update
|
||||
const uuid = entry.uuid ?? entry.uid;
|
||||
if (uuid) {
|
||||
deps.db
|
||||
.write(
|
||||
@@ -286,24 +366,36 @@ export const handleFsCopyNodeThumbnail = async (
|
||||
deps: {
|
||||
s3: S3Client;
|
||||
bucketName: string;
|
||||
bucketEndpoint: string;
|
||||
db: { write: (sql: string, params: unknown[]) => Promise<unknown> };
|
||||
},
|
||||
): Promise<void> => {
|
||||
const copy = payload.copy;
|
||||
const thumbnailUrl = copy?.thumbnail;
|
||||
if (!copy || !copy.uuid || typeof thumbnailUrl !== 'string') return;
|
||||
if (!copy || !isUuid(copy.uuid) || typeof thumbnailUrl !== 'string') return;
|
||||
if (!isStoragePointer(thumbnailUrl, deps.bucketEndpoint)) return;
|
||||
|
||||
// Same trust rule as the read and remove paths: only touch objects this
|
||||
// extension minted.
|
||||
const sourceKey = resolveThumbnailKey(thumbnailUrl);
|
||||
if (!sourceKey) return;
|
||||
|
||||
// The copied row points at the SAME S3 object as its source, and
|
||||
// fs.remove.node deletes the pointed-to object — so the first removal
|
||||
// among the sharers (an overwrite, a trash purge) would break every
|
||||
// other sharer's thumbnail. Give the copy an object of its own.
|
||||
const newKey = mintThumbnailKey();
|
||||
// The copied row points at the SAME object as its source. Give the copy
|
||||
// an object of its own, bound to it, so removing either entry leaves the
|
||||
// other's thumbnail alone.
|
||||
const newKey = mintThumbnailKey(copy.uuid);
|
||||
try {
|
||||
// Nothing larger than an upload may produce gets duplicated; such an
|
||||
// object came in through an unbounded upload URL.
|
||||
const head = await deps.s3.send(
|
||||
new HeadObjectCommand({ Bucket: deps.bucketName, Key: sourceKey }),
|
||||
);
|
||||
if (
|
||||
typeof head.ContentLength !== 'number' ||
|
||||
head.ContentLength > MAX_THUMBNAIL_BYTES
|
||||
) {
|
||||
throw new Error('thumbnail exceeds the size bound');
|
||||
}
|
||||
await deps.s3.send(
|
||||
new CopyObjectCommand({
|
||||
Bucket: deps.bucketName,
|
||||
@@ -312,10 +404,8 @@ export const handleFsCopyNodeThumbnail = async (
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
// The shared object is already gone (e.g. a sharer was removed
|
||||
// before this fix existed) — the pointer is dead either way, so
|
||||
// drop it rather than leave the row advertising a thumbnail it
|
||||
// doesn't have.
|
||||
// The shared object is gone or oversized — drop the pointer rather
|
||||
// than leave the row advertising a thumbnail it doesn't have.
|
||||
await deps.db.write(
|
||||
'UPDATE `fsentries` SET `thumbnail` = NULL WHERE `uuid` = ?',
|
||||
[copy.uuid],
|
||||
@@ -331,18 +421,18 @@ export const handleFsCopyNodeThumbnail = async (
|
||||
};
|
||||
|
||||
export const handleFsRemoveNodeThumbnail = async (
|
||||
payload: { target: { thumbnail?: string | null } },
|
||||
deps: { s3: S3Client; bucketName: string },
|
||||
payload: { target: { thumbnail?: string | null; uuid?: string } },
|
||||
deps: { s3: S3Client; bucketName: string; bucketEndpoint: string },
|
||||
): Promise<void> => {
|
||||
const thumbnailUrl = payload.target.thumbnail;
|
||||
if (!thumbnailUrl) return;
|
||||
const { thumbnail: thumbnailUrl, uuid } = payload.target;
|
||||
if (!thumbnailUrl || !isStoragePointer(thumbnailUrl, deps.bucketEndpoint))
|
||||
return;
|
||||
|
||||
// Same trust rule as the read path, and load-bearing for the same reason:
|
||||
// the pointer decides which object gets deleted, so a key we didn't mint
|
||||
// would let the owner of one file destroy an object belonging to someone
|
||||
// else just by naming it here.
|
||||
// The pointer decides which object gets deleted, and a key is visible to
|
||||
// anyone who can list the entry holding it. Only a key minted for this
|
||||
// entry is deleted; an unbound one may be shared or planted, so it stays.
|
||||
const key = resolveThumbnailKey(thumbnailUrl);
|
||||
if (!key) return;
|
||||
if (!key || thumbnailKeyOwner(key) !== uuid) return;
|
||||
|
||||
await deps.s3.send(
|
||||
new DeleteObjectCommand({ Bucket: deps.bucketName, Key: key }),
|
||||
@@ -355,6 +445,7 @@ extension.on(
|
||||
await handleThumbnailCreated(event, {
|
||||
s3: getClient(),
|
||||
bucketName: thumbnailBucketName,
|
||||
bucketEndpoint: extensionBucketEndpoint,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -397,6 +488,7 @@ extension.on('fs.copy.node', async (_key, payload) => {
|
||||
{
|
||||
s3: getClient(),
|
||||
bucketName: thumbnailBucketName,
|
||||
bucketEndpoint: extensionBucketEndpoint,
|
||||
db: clients.db,
|
||||
},
|
||||
);
|
||||
@@ -409,5 +501,6 @@ extension.on('fs.remove.node', async (_key, payload) => {
|
||||
await handleFsRemoveNodeThumbnail(payload, {
|
||||
s3: getClient(),
|
||||
bucketName: thumbnailBucketName,
|
||||
bucketEndpoint: extensionBucketEndpoint,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { Request, Response } from 'express';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const SECRET = 'ingress-secret';
|
||||
|
||||
// Only `extension` is stubbed; the secret comparison and the routing are the
|
||||
// real code paths, and the fake service/store layer is the boundary.
|
||||
const state = vi.hoisted(() => ({
|
||||
config: { userEmail: { secret: 'ingress-secret' } } as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
routes: [] as Array<{
|
||||
path: string;
|
||||
handler: (req: unknown, res: unknown) => unknown;
|
||||
}>,
|
||||
users: new Map<string, Record<string, unknown>>(),
|
||||
writes: [] as Array<{
|
||||
userId: number;
|
||||
path: string;
|
||||
homeRegion?: string;
|
||||
content?: unknown;
|
||||
}>,
|
||||
feedback: [] as Array<{ to: string; from: string | null; raw: Buffer }>,
|
||||
takeFeedback: true,
|
||||
}));
|
||||
|
||||
vi.mock('@heyputer/backend/src/extensions', () => ({
|
||||
extension: {
|
||||
get config() {
|
||||
return state.config;
|
||||
},
|
||||
post(
|
||||
path: string,
|
||||
_opts: unknown,
|
||||
handler: (req: unknown, res: unknown) => unknown,
|
||||
) {
|
||||
state.routes.push({ path, handler });
|
||||
},
|
||||
import(layer: string) {
|
||||
if (layer === 'client') {
|
||||
return {
|
||||
event: {
|
||||
async emitAndWait(
|
||||
key: string,
|
||||
data: {
|
||||
to: string;
|
||||
from: string | null;
|
||||
raw: Buffer;
|
||||
handled: boolean;
|
||||
},
|
||||
) {
|
||||
if (key !== 'email.ingress.feedback') return;
|
||||
state.feedback.push(data);
|
||||
if (state.takeFeedback) data.handled = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (layer === 'store') {
|
||||
return {
|
||||
user: {
|
||||
getByUsername: async (username: string) =>
|
||||
state.users.get(username) ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
fs: {
|
||||
async write(
|
||||
userId: number,
|
||||
{
|
||||
fileMetadata,
|
||||
fileContent,
|
||||
}: {
|
||||
fileMetadata: { path: string };
|
||||
fileContent: unknown;
|
||||
},
|
||||
_uploadTracker?: unknown,
|
||||
_storageAllowanceMax?: number,
|
||||
homeRegion?: string,
|
||||
) {
|
||||
state.writes.push({
|
||||
userId,
|
||||
path: fileMetadata.path,
|
||||
homeRegion,
|
||||
content: fileContent,
|
||||
});
|
||||
return { fsEntry: { path: fileMetadata.path } };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import './index.js';
|
||||
|
||||
const permanent = (id: number, username: string) => ({
|
||||
id,
|
||||
username,
|
||||
email: `${username}@example.com`,
|
||||
password: '$2b$10$hash',
|
||||
});
|
||||
/** No password and no email is what makes an account temporary. */
|
||||
const temp = (id: number, username: string) => ({
|
||||
id,
|
||||
username,
|
||||
email: null,
|
||||
password: null,
|
||||
});
|
||||
|
||||
const ingress = async (
|
||||
to: string,
|
||||
over: { secret?: string; contentLength?: string | null } = {},
|
||||
) => {
|
||||
const route = state.routes.find((r) => r.path === '/email/ingress');
|
||||
if (!route) throw new Error('route not registered');
|
||||
|
||||
const body = Buffer.from('From: [email protected]\r\n\r\nhello\r\n');
|
||||
const req = Readable.from([body], { objectMode: false }) as unknown as {
|
||||
headers: Record<string, string>;
|
||||
query: Record<string, string>;
|
||||
};
|
||||
// `contentLength: null` sends no length header at all, which is the one
|
||||
// framing the HTTP parser does not bound for us.
|
||||
req.headers =
|
||||
over.contentLength === null
|
||||
? {}
|
||||
: {
|
||||
'content-length': over.contentLength ?? String(body.length),
|
||||
};
|
||||
req.query = {
|
||||
SECRET: over.secret ?? SECRET,
|
||||
to,
|
||||
from: '[email protected]',
|
||||
subject: 'hi',
|
||||
};
|
||||
|
||||
let status = 200;
|
||||
let ended = false;
|
||||
// `end` takes (chunk, encoding) and no callback, the way the compression
|
||||
// middleware leaves it, and emits `finish` — which is what the refusal
|
||||
// paths hang their drain/destroy on. A stub that accepts a callback
|
||||
// instead hides the bug that took the 413 path to production as a 500.
|
||||
const emitter = new EventEmitter();
|
||||
const res = {
|
||||
status(code: number) {
|
||||
status = code;
|
||||
return res;
|
||||
},
|
||||
end(chunk?: unknown, encoding?: BufferEncoding) {
|
||||
if (chunk) Buffer.byteLength(chunk as string, encoding);
|
||||
ended = true;
|
||||
emitter.emit('finish');
|
||||
},
|
||||
once(event: string, listener: () => void) {
|
||||
emitter.once(event, listener);
|
||||
return res;
|
||||
},
|
||||
} as unknown as Response;
|
||||
|
||||
await route.handler(req as unknown as Request, res);
|
||||
return {
|
||||
status,
|
||||
ended,
|
||||
drained: (req as unknown as Readable).readableFlowing,
|
||||
destroyed: (req as unknown as Readable).destroyed,
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state.users.clear();
|
||||
state.writes.length = 0;
|
||||
state.feedback.length = 0;
|
||||
state.takeFeedback = true;
|
||||
});
|
||||
|
||||
describe('temporary accounts cannot receive', () => {
|
||||
test('mail for a temp account is accepted and dropped', async () => {
|
||||
state.users.set('tempy', temp(1, 'tempy'));
|
||||
|
||||
const { status, ended, drained } = await ingress('[email protected]');
|
||||
// Accepted, so the relay neither retries nor bounces to the sender.
|
||||
expect(status).toBe(200);
|
||||
expect(ended).toBe(true);
|
||||
// The body is read off the wire rather than left unconsumed.
|
||||
expect(drained).toBe(true);
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test('mail for a permanent account still lands in the mailbox', async () => {
|
||||
state.users.set('bob', permanent(2, 'bob'));
|
||||
|
||||
const { status } = await ingress('[email protected]');
|
||||
expect(status).toBe(200);
|
||||
expect(state.writes).toHaveLength(1);
|
||||
expect(state.writes[0].userId).toBe(2);
|
||||
expect(state.writes[0].path).toMatch(/^\/bob\/\.mail\/objects\//);
|
||||
});
|
||||
|
||||
test('an unknown address is still rejected outright', async () => {
|
||||
const { status } = await ingress('[email protected]');
|
||||
expect(status).toBe(404);
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inbound mail is placed in the recipient home region', () => {
|
||||
/** A permanent account with explicit placement columns. */
|
||||
const placed = (
|
||||
id: number,
|
||||
username: string,
|
||||
over: { home?: string | null; signup_server?: string | null },
|
||||
) => ({
|
||||
...permanent(id, username),
|
||||
home: null,
|
||||
signup_server: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
test("uses the recipient's own home region when they have one", async () => {
|
||||
state.users.set('bob', placed(2, 'bob', { home: 'frankfurt' }));
|
||||
|
||||
await ingress('[email protected]');
|
||||
expect(state.writes[0].homeRegion).toBe('frankfurt');
|
||||
});
|
||||
|
||||
test('falls back to the server that served their signup', async () => {
|
||||
// Every account that predates the `home` column is this case.
|
||||
state.users.set(
|
||||
'bob',
|
||||
placed(2, 'bob', { home: null, signup_server: 'london' }),
|
||||
);
|
||||
|
||||
await ingress('[email protected]');
|
||||
expect(state.writes[0].homeRegion).toBe('london');
|
||||
});
|
||||
|
||||
test('falls back to the primary region when neither is set', async () => {
|
||||
state.users.set('bob', placed(2, 'bob', {}));
|
||||
|
||||
await ingress('[email protected]');
|
||||
expect(state.writes[0].homeRegion).toBe('oregon');
|
||||
});
|
||||
|
||||
test('home wins over signup_server when both are set', async () => {
|
||||
state.users.set(
|
||||
'bob',
|
||||
placed(2, 'bob', { home: 'sydney', signup_server: 'london' }),
|
||||
);
|
||||
|
||||
await ingress('[email protected]');
|
||||
expect(state.writes[0].homeRegion).toBe('sydney');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refusals dispose of the body nobody read', () => {
|
||||
test('an unknown recipient drains it and keeps the connection', async () => {
|
||||
// The secret checked out and the length is under the cap, so the body
|
||||
// was ours to read: discard it rather than resetting a usable socket.
|
||||
const { status, drained, destroyed } =
|
||||
await ingress('[email protected]');
|
||||
expect(status).toBe(404);
|
||||
expect(drained).toBe(true);
|
||||
expect(destroyed).toBe(false);
|
||||
});
|
||||
|
||||
test('a bad secret closes rather than reading the upload', async () => {
|
||||
state.users.set('bob', permanent(2, 'bob'));
|
||||
|
||||
const { status, destroyed } = await ingress('[email protected]', {
|
||||
secret: 'not-the-secret',
|
||||
});
|
||||
expect(status).toBe(403);
|
||||
// Nothing authenticated this caller; their bytes are not worth reading.
|
||||
expect(destroyed).toBe(true);
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test('no content-length is refused with 411', async () => {
|
||||
state.users.set('bob', permanent(2, 'bob'));
|
||||
|
||||
const { status, destroyed } = await ingress('[email protected]', {
|
||||
contentLength: null,
|
||||
});
|
||||
expect(status).toBe(411);
|
||||
expect(destroyed).toBe(true);
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test('a declared length over the cap is refused with 413', async () => {
|
||||
state.users.set('bob', permanent(2, 'bob'));
|
||||
|
||||
const { status, destroyed } = await ingress('[email protected]', {
|
||||
contentLength: String(25 * 1024 * 1024 + 1),
|
||||
});
|
||||
expect(status).toBe(413);
|
||||
expect(destroyed).toBe(true);
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feedback addresses', () => {
|
||||
test('are handed to a listener and reach no mailbox', async () => {
|
||||
const { status, ended } = await ingress('[email protected]');
|
||||
expect(status).toBe(200);
|
||||
expect(ended).toBe(true);
|
||||
expect(state.feedback).toHaveLength(1);
|
||||
expect(state.feedback[0].to).toBe('[email protected]');
|
||||
expect(state.feedback[0].from).toBe('[email protected]');
|
||||
expect(state.feedback[0].raw.toString()).toContain('hello');
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test('match the local part case-insensitively', async () => {
|
||||
await ingress('[email protected]');
|
||||
expect(state.feedback).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('fall through to normal routing when nobody takes them', async () => {
|
||||
state.takeFeedback = false;
|
||||
state.users.set('abuse', permanent(3, 'abuse'));
|
||||
|
||||
const { status } = await ingress('[email protected]');
|
||||
expect(status).toBe(200);
|
||||
expect(state.feedback).toHaveLength(1);
|
||||
// The body was already read, so the buffered copy is what is stored.
|
||||
expect(state.writes).toHaveLength(1);
|
||||
expect(Buffer.isBuffer(state.writes[0].content)).toBe(true);
|
||||
});
|
||||
|
||||
test('an unclaimed one with no such account is still a 404', async () => {
|
||||
state.takeFeedback = false;
|
||||
const { status } = await ingress('[email protected]');
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
|
||||
test('only count on a Puter mail domain', async () => {
|
||||
state.users.set('abuse', permanent(3, 'abuse'));
|
||||
await ingress('[email protected]');
|
||||
expect(state.feedback).toEqual([]);
|
||||
});
|
||||
|
||||
test('are refused over the feedback size cap without being read', async () => {
|
||||
const { status, destroyed } = await ingress('[email protected]', {
|
||||
contentLength: String(5 * 1024 * 1024 + 1),
|
||||
});
|
||||
expect(status).toBe(413);
|
||||
expect(destroyed).toBe(true);
|
||||
expect(state.feedback).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import type { Request } from 'express';
|
||||
import {
|
||||
MAX_MESSAGE_BYTES,
|
||||
isPuterEmailAddress,
|
||||
isTempUser,
|
||||
puterEmailUsername,
|
||||
storeInboxMessage,
|
||||
} from '@heyputer/backend/src/services/email/mailbox.js';
|
||||
|
||||
// copied form peer secrets checker
|
||||
const COMPARE_KEY = randomBytes(32);
|
||||
const secretsEqual = (a: string, b: string): boolean =>
|
||||
timingSafeEqual(
|
||||
createHmac('sha256', COMPARE_KEY).update(a).digest(),
|
||||
createHmac('sha256', COMPARE_KEY).update(b).digest(),
|
||||
);
|
||||
|
||||
const USER_EMAIL_CONFIG = (extension.config as Record<string, unknown>)
|
||||
.userEmail as { secret?: string; feedbackAddresses?: unknown } | undefined;
|
||||
|
||||
const INGRESS_SECRET = USER_EMAIL_CONFIG?.secret;
|
||||
|
||||
/**
|
||||
* Local parts that take complaint feedback rather than naming a mailbox. The
|
||||
* same names are reserved at signup, so no account can be handed them.
|
||||
*/
|
||||
const DEFAULT_FEEDBACK_ADDRESSES = ['fbl', 'abuse', 'postmaster'];
|
||||
const FEEDBACK_ADDRESSES = new Set(
|
||||
(Array.isArray(USER_EMAIL_CONFIG?.feedbackAddresses)
|
||||
? USER_EMAIL_CONFIG.feedbackAddresses
|
||||
: DEFAULT_FEEDBACK_ADDRESSES
|
||||
).map((local) => String(local).toLowerCase()),
|
||||
);
|
||||
|
||||
/**
|
||||
* Feedback is handed over whole, so it is read into memory; a complaint report
|
||||
* quotes one message and has no business near the mailbox ceiling.
|
||||
*/
|
||||
const MAX_FEEDBACK_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
const isFeedbackAddress = (to: string): boolean =>
|
||||
isPuterEmailAddress(to) &&
|
||||
FEEDBACK_ADDRESSES.has(puterEmailUsername(to).toLowerCase());
|
||||
|
||||
const readBody = async (req: Request): Promise<Buffer> => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer);
|
||||
return Buffer.concat(chunks);
|
||||
};
|
||||
|
||||
if (!INGRESS_SECRET) {
|
||||
console.warn(
|
||||
'[useremailrx] config.userEmail.secret unset - email ingress disabled',
|
||||
);
|
||||
} else {
|
||||
extension.post(
|
||||
'/email/ingress',
|
||||
{
|
||||
subdomain: 'api',
|
||||
},
|
||||
async (req, res) => {
|
||||
// Answer first, then deal with the body nobody read. Touching the
|
||||
// request before the response has flushed takes the response down
|
||||
// with the connection and the caller sees a reset with no status,
|
||||
// so this hangs off `finish` — and it has to be the event rather
|
||||
// than `res.end(cb)`, because the compression middleware replaces
|
||||
// `res.end` with an `(chunk, encoding)` signature that has no
|
||||
// callback parameter, so a callback lands in `chunk` and dies in
|
||||
// `Buffer.byteLength`.
|
||||
//
|
||||
// `drain` discards the rest of a body we were entitled to read and
|
||||
// leaves the connection reusable. `close` is for bodies we do not
|
||||
// want to read at all: an unauthenticated caller's, or one whose
|
||||
// declared length is missing or over the cap.
|
||||
const refuse = (status: number, how: 'drain' | 'close') => {
|
||||
res.once('finish', () =>
|
||||
how === 'drain' ? req.resume() : req.destroy(),
|
||||
);
|
||||
res.status(status).end();
|
||||
};
|
||||
|
||||
const presented = req.query.SECRET;
|
||||
if (
|
||||
typeof presented !== 'string' ||
|
||||
!secretsEqual(presented, INGRESS_SECRET)
|
||||
) {
|
||||
// Nothing has authenticated this caller, so refuse to spend
|
||||
// bandwidth reading whatever they were uploading.
|
||||
refuse(403, 'close');
|
||||
return;
|
||||
}
|
||||
|
||||
const size = Number(req.headers['content-length']);
|
||||
if (!Number.isInteger(size) || size <= 0) {
|
||||
refuse(411, 'close');
|
||||
return;
|
||||
}
|
||||
if (size > MAX_MESSAGE_BYTES) {
|
||||
refuse(413, 'close');
|
||||
return;
|
||||
}
|
||||
|
||||
const stores = extension.import('store');
|
||||
const services = extension.import('service');
|
||||
|
||||
const to = String(req.query.to ?? '');
|
||||
let content: Buffer | Request = req;
|
||||
if (isFeedbackAddress(to)) {
|
||||
if (size > MAX_FEEDBACK_BYTES) {
|
||||
refuse(413, 'close');
|
||||
return;
|
||||
}
|
||||
const raw = await readBody(req);
|
||||
const event = {
|
||||
to,
|
||||
from:
|
||||
typeof req.query.from === 'string'
|
||||
? req.query.from
|
||||
: null,
|
||||
raw,
|
||||
handled: false,
|
||||
};
|
||||
await extension
|
||||
.import('client')
|
||||
.event.emitAndWait('email.ingress.feedback', event, {});
|
||||
if (event.handled) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
// Nobody took it: route it like any other message.
|
||||
content = raw;
|
||||
}
|
||||
|
||||
const user = await stores.user.getByUsername(
|
||||
puterEmailUsername(to),
|
||||
);
|
||||
if (!user) {
|
||||
refuse(404, 'drain');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTempUser(user)) {
|
||||
if (content === req) req.resume();
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
await storeInboxMessage(services.fs, user, {
|
||||
subject: req.query.subject,
|
||||
content,
|
||||
size: Buffer.isBuffer(content) ? content.length : size,
|
||||
});
|
||||
|
||||
const { SECRET: _secret, ..._loggableQuery } = req.query;
|
||||
// console.log('got email: ', puterUser, loggableQuery, size);
|
||||
res.end();
|
||||
},
|
||||
);
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "emailrx",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "emailrx",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "emailrx",
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"description": "Internal service which handles routing puter emails to users"
|
||||
}
|
||||
+182
-7
@@ -8,6 +8,7 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { makeActor, type Actor } from '../src/backend/core/actor.ts';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { configContainer } from '../src/backend/exports.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
@@ -48,6 +49,7 @@ beforeAll(async () => {
|
||||
create_shortcut: true,
|
||||
payment_bypass: true,
|
||||
},
|
||||
teams_enabled: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
@@ -66,6 +68,81 @@ const seedUser = async () => {
|
||||
};
|
||||
|
||||
describe('whoami extension — handleWhoami', () => {
|
||||
// The sidebar label needs this at boot, which is why it rides whoami
|
||||
// rather than a call of its own.
|
||||
describe('the team an account belongs to', () => {
|
||||
// A seat is created, never adopted, so it must have no password.
|
||||
const seedSeat = async () => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
return server.stores.user.create({
|
||||
username: `wseat_${slug}`,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: null,
|
||||
});
|
||||
};
|
||||
|
||||
const seatOf = async (teamName: string) => {
|
||||
const owner = await seedUser();
|
||||
const seat = await seedSeat();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id as number,
|
||||
name: teamName,
|
||||
handle: `wt-${Math.random().toString(36).slice(2, 9)}`,
|
||||
});
|
||||
await server.stores.team.addMember(team.uid, seat.id as number, {
|
||||
orgOwned: true,
|
||||
});
|
||||
return { seat, team };
|
||||
};
|
||||
|
||||
it('names the team for a seat', async () => {
|
||||
const { seat, team } = await seatOf('Acme Corp');
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: seat.uuid, id: seat.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
expect((captured.body as { team?: unknown }).team).toEqual({
|
||||
uid: team.uid,
|
||||
name: 'Acme Corp',
|
||||
});
|
||||
});
|
||||
|
||||
it('says nothing for an account that is not a seat', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
expect(captured.body).not.toHaveProperty('team');
|
||||
});
|
||||
|
||||
it('withholds it from an app actor', async () => {
|
||||
// Same class as the phone number: a seat's employer is not an
|
||||
// app's business.
|
||||
const { seat } = await seatOf('Acme Corp');
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{
|
||||
actor: makeActor({
|
||||
user: { uuid: seat.uuid, id: seat.id as number },
|
||||
app: { uid: 'app-1' },
|
||||
}),
|
||||
},
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
expect(captured.body).not.toHaveProperty('team');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 401 when no actor is on the context', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
@@ -186,10 +263,10 @@ describe('whoami extension — handleWhoami', () => {
|
||||
|
||||
await runWithContext(
|
||||
{
|
||||
actor: {
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
@@ -208,6 +285,104 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body.directories).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('fields added by whoami.details listeners', () => {
|
||||
const addAccountDetails = (
|
||||
_key: unknown,
|
||||
event: { details: Record<string, unknown> },
|
||||
) => {
|
||||
Object.assign(event.details, {
|
||||
subscribed: true,
|
||||
paid_storage: 1024,
|
||||
subscription: { tier: 'pro', status: 'trialing' },
|
||||
accountEligible: true,
|
||||
});
|
||||
};
|
||||
|
||||
const whoamiWithListener = async (
|
||||
actor: Actor,
|
||||
listener: typeof addAccountDetails = addAccountDetails,
|
||||
) => {
|
||||
const { res, captured } = makeRes();
|
||||
server.clients.event.on('whoami.details', listener);
|
||||
try {
|
||||
await runWithContext({ actor }, () =>
|
||||
handleWhoami(makeReq(), res),
|
||||
);
|
||||
} finally {
|
||||
server.clients.event.off('whoami.details', listener);
|
||||
}
|
||||
return captured.body as Record<string, unknown>;
|
||||
};
|
||||
|
||||
it('reach an app actor only as the documented subscribed / paid_storage', async () => {
|
||||
const user = await seedUser();
|
||||
const body = await whoamiWithListener(
|
||||
makeActor({
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(body.subscribed).toBe(true);
|
||||
expect(body.paid_storage).toBe(1024);
|
||||
expect(body).not.toHaveProperty('subscription');
|
||||
expect(body).not.toHaveProperty('accountEligible');
|
||||
// Fields the handler built itself still ship.
|
||||
expect(body.username).toBe(user.username);
|
||||
expect(body.app_name).toBe('app-test-actor');
|
||||
});
|
||||
|
||||
it('are withheld from an app reached through an access token it issued', async () => {
|
||||
const user = await seedUser();
|
||||
const owner = { uuid: user.uuid, id: user.id as number };
|
||||
const body = await whoamiWithListener(
|
||||
makeActor({
|
||||
user: owner,
|
||||
accessToken: {
|
||||
uid: 'tok-1',
|
||||
issuer: makeActor({
|
||||
user: owner,
|
||||
app: { uid: 'app-issuer' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(body.subscribed).toBe(true);
|
||||
expect(body).not.toHaveProperty('subscription');
|
||||
expect(body).not.toHaveProperty('accountEligible');
|
||||
});
|
||||
|
||||
it('all reach a user actor', async () => {
|
||||
const user = await seedUser();
|
||||
const body = await whoamiWithListener(
|
||||
makeActor({ user: { uuid: user.uuid, id: user.id as number } }),
|
||||
);
|
||||
|
||||
expect(body).toMatchObject({
|
||||
subscribed: true,
|
||||
paid_storage: 1024,
|
||||
subscription: { tier: 'pro', status: 'trialing' },
|
||||
accountEligible: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('cannot add taskbar_items for an app actor, which core omits', async () => {
|
||||
const user = await seedUser();
|
||||
const body = await whoamiWithListener(
|
||||
makeActor({
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
}),
|
||||
(_key, event) => {
|
||||
event.details.taskbar_items = [{ name: 'injected' }];
|
||||
},
|
||||
);
|
||||
|
||||
expect(body).not.toHaveProperty('taskbar_items');
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts tmp_password from metadata for user actors', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.updateMetadata(user.id as number, {
|
||||
@@ -238,10 +413,10 @@ describe('whoami extension — handleWhoami', () => {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{
|
||||
actor: {
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
@@ -269,11 +444,11 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(stored?.card_fingerprint).toBe('fp_ABC123');
|
||||
|
||||
for (const actor of [
|
||||
{ user: { uuid: user.uuid, id: user.id as number } },
|
||||
{
|
||||
makeActor({ user: { uuid: user.uuid, id: user.id as number } }),
|
||||
makeActor({
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
}),
|
||||
]) {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext({ actor }, () =>
|
||||
|
||||
+64
-32
@@ -1,6 +1,9 @@
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { isCardFallbackEligible } from '@heyputer/backend/src/util/cardFallback.js';
|
||||
import {
|
||||
cardFallbackDepsFrom,
|
||||
isCardFallbackEligible,
|
||||
} from '@heyputer/backend/src/util/cardFallback.js';
|
||||
import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js';
|
||||
import { getTaskbarItems } from '@heyputer/backend/src/util/taskbarItems.js';
|
||||
import type { Request, Response } from 'express';
|
||||
@@ -68,6 +71,13 @@ const SENSITIVE_KEYS: ReadonlySet<string> = new Set([
|
||||
'audit_metadata',
|
||||
]);
|
||||
|
||||
// What an app actor may receive from `whoami.details` listeners. Anything
|
||||
// else a listener adds describes the account, and only ships to user actors.
|
||||
const APP_VISIBLE_LISTENER_KEYS: ReadonlySet<string> = new Set([
|
||||
'subscribed',
|
||||
'paid_storage',
|
||||
]);
|
||||
|
||||
// Depth-limited, cycle-safe walk deleting every SENSITIVE_KEYS entry it finds
|
||||
// at any level (`metadata` and `taskbar_items` are both nested structures).
|
||||
const scrubSensitive = (
|
||||
@@ -107,7 +117,7 @@ export const handleWhoami = async (
|
||||
return;
|
||||
}
|
||||
|
||||
const isUser = !actor.app;
|
||||
const isUser = !actor.effectiveApp;
|
||||
const user = await stores.user.getById(actor.user.id);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
@@ -156,6 +166,8 @@ export const handleWhoami = async (
|
||||
// every app actor. Only the verification flag ships.
|
||||
requires_phone_verification: user.requires_phone_verification,
|
||||
requires_card_verification: user.requires_card_verification,
|
||||
// A seat reaches nothing until it replaces its admin's password.
|
||||
requires_password_change: user.requires_password_change,
|
||||
// The SMS-to-card escape hatch: true once this user is out of SMS send
|
||||
// attempts and may verify a card instead. It has to ship from here
|
||||
// because /send-confirm-phone can no longer say so — by the time the
|
||||
@@ -168,19 +180,7 @@ export const handleWhoami = async (
|
||||
extension.config,
|
||||
user,
|
||||
async (key) => (await stores.kv.get({ key })).res,
|
||||
{
|
||||
smsConfigured: () =>
|
||||
Boolean(clients.prelude?.isConfigured()),
|
||||
probeCardVerification: async () => {
|
||||
const status = { enabled: null as boolean | null };
|
||||
await clients.event?.emitAndWait(
|
||||
'puter.card-verification.status',
|
||||
status,
|
||||
{},
|
||||
);
|
||||
return status.enabled;
|
||||
},
|
||||
},
|
||||
cardFallbackDepsFrom(clients),
|
||||
)
|
||||
: false,
|
||||
desktop_bg_url: user.desktop_bg_url,
|
||||
@@ -193,19 +193,6 @@ export const handleWhoami = async (
|
||||
// this endpoint is polled, and a mint is a write.
|
||||
referral_code: user.referral_code,
|
||||
oidc_only: oidcOnly,
|
||||
taskbar_items: isUser
|
||||
? await getTaskbarItems(
|
||||
user,
|
||||
{
|
||||
clients,
|
||||
stores,
|
||||
services,
|
||||
apiBaseUrl: String(extension.config.api_base_url ?? ''),
|
||||
config: extension.config,
|
||||
},
|
||||
{ iconSize, noIcons },
|
||||
)
|
||||
: undefined,
|
||||
otp: !!user.otp_enabled,
|
||||
feature_flags,
|
||||
created_ts: toUnixSeconds(user.timestamp),
|
||||
@@ -234,6 +221,21 @@ export const handleWhoami = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Taskbar items — only sent to user actors
|
||||
if (isUser) {
|
||||
details.taskbar_items = await getTaskbarItems(
|
||||
user,
|
||||
{
|
||||
clients,
|
||||
stores,
|
||||
services,
|
||||
apiBaseUrl: String(extension.config.api_base_url ?? ''),
|
||||
config: extension.config,
|
||||
},
|
||||
{ iconSize, noIcons },
|
||||
);
|
||||
}
|
||||
|
||||
// Directories — only sent to user actors
|
||||
if (isUser) {
|
||||
const directories: Record<string, unknown> = {};
|
||||
@@ -251,6 +253,23 @@ export const handleWhoami = async (
|
||||
details.directories = directories;
|
||||
}
|
||||
|
||||
// The team an account belongs to, when it is one a team pays for. User
|
||||
// actors only, and only where teams are on.
|
||||
if (isUser && extension.config.teams_enabled === true) {
|
||||
try {
|
||||
const seat = await stores.team.getOrgSeat(user.id);
|
||||
if (seat) {
|
||||
details.team = {
|
||||
uid: seat.team_uid,
|
||||
name: seat.team_name ?? null,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// Never fail whoami over this; the account still works without it.
|
||||
console.warn('[whoami] team lookup failed:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// Last activity
|
||||
const lastActivityTs = toUnixSeconds(user.last_activity_ts);
|
||||
if (lastActivityTs !== undefined) {
|
||||
@@ -277,10 +296,17 @@ export const handleWhoami = async (
|
||||
delete details.referral_code;
|
||||
}
|
||||
|
||||
if (actor.app) {
|
||||
details.app_name = actor.app.uid;
|
||||
const app = actor.effectiveApp;
|
||||
if (app) {
|
||||
details.app_name = app.uid;
|
||||
}
|
||||
|
||||
// A key core left undefined isn't built, so a listener can't fill it for an app.
|
||||
const builtKeys = isUser
|
||||
? null
|
||||
: new Set(
|
||||
Object.keys(details).filter((key) => details[key] !== undefined),
|
||||
);
|
||||
try {
|
||||
await clients.event.emitAndWait(
|
||||
'whoami.details',
|
||||
@@ -290,10 +316,16 @@ export const handleWhoami = async (
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
if (builtKeys) {
|
||||
for (const key of Object.keys(details)) {
|
||||
if (!builtKeys.has(key) && !APP_VISIBLE_LISTENER_KEYS.has(key)) {
|
||||
delete details[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const subscription = details.subscription as
|
||||
| { offering?: Record<string, unknown> }
|
||||
| undefined;
|
||||
{ offering?: Record<string, unknown> } | undefined;
|
||||
if (subscription?.offering) {
|
||||
delete subscription.offering.group;
|
||||
delete subscription.offering.benefits;
|
||||
|
||||
Generated
+185
-699
File diff suppressed because it is too large.
Load diff
+3
-2
@@ -50,6 +50,7 @@
|
||||
"build:workerLib:coverage": "cd src/puter-js && npm run build:coverage && cd ../worker && npm run build",
|
||||
"start:gui": "nodemon --exec \"node dev-server.js\" ",
|
||||
"start": "node ./tools/start.mjs",
|
||||
"start:smtp": "node ./tools/start.mjs --smtp",
|
||||
"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",
|
||||
@@ -76,7 +77,7 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.1028.0",
|
||||
"dedent": "^1.5.3",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"libphonenumber-js": "1.13.12",
|
||||
"libphonenumber-js": "1.13.14",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
@@ -96,6 +97,6 @@
|
||||
"workerd": true
|
||||
},
|
||||
"overrides": {
|
||||
"undici": "^7.29.0"
|
||||
"undici": "^7.30.0"
|
||||
}
|
||||
}
|
||||
@@ -17,24 +17,9 @@
|
||||
* 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.
|
||||
*/
|
||||
// Optional analytics client, registered by an extension. Only the surface the
|
||||
// app-stats path consumes is declared; extend it rather than widening to `any`.
|
||||
|
||||
export interface ClickhouseQueryResult {
|
||||
json<T = Record<string, unknown>>(): Promise<T[]>;
|
||||
}
|
||||
|
||||
@@ -184,7 +184,8 @@ describe('compareMigrationFilenames', () => {
|
||||
//
|
||||
// `read()` normally goes to the replica batcher; when the replica side is
|
||||
// degraded (batcher load-shed or a transient connection error) and a real
|
||||
// replica is configured, the read retries once on the primary batcher.
|
||||
// replica is configured, the read retries once on the primary read batcher.
|
||||
// The write batcher is left unset, so a failover that reached it would throw.
|
||||
|
||||
type Batcher = { execute: ReturnType<typeof vi.fn> };
|
||||
|
||||
@@ -200,7 +201,7 @@ const makeClient = (opts: {
|
||||
// inject the batchers directly. Configuration enum: SINGLE=0, REPLICA=1.
|
||||
Object.assign(client as unknown as Record<string, unknown>, {
|
||||
dbReplica: opts.replica,
|
||||
db: opts.primary,
|
||||
dbPrimaryRead: opts.primary,
|
||||
configuration: opts.multiNode === false ? 0 : 1,
|
||||
});
|
||||
return client;
|
||||
@@ -284,6 +285,10 @@ const startClient = async (
|
||||
// must answer with one row-set per statement plus one for the SELECT 1.
|
||||
const rowSets = (...sets: unknown[][]) => [[...sets, [{ 1: 1 }]], undefined];
|
||||
|
||||
// Writes go out one statement at a time, so the driver answers with just the
|
||||
// result header.
|
||||
const writeHeader = (header: Record<string, unknown>) => [header, undefined];
|
||||
|
||||
beforeEach(() => {
|
||||
createdPools.length = 0;
|
||||
createPoolMock.mockClear();
|
||||
@@ -303,6 +308,9 @@ describe('MySQLDatabaseClient — pool construction', () => {
|
||||
expect(createdPools[0].poolConfig).toEqual({
|
||||
maxPreparedStatements: 900,
|
||||
connectionLimit: 30,
|
||||
maxIdle: 29,
|
||||
idleTimeout: 60_000,
|
||||
gracefulEnd: true,
|
||||
enableKeepAlive: true,
|
||||
host: 'db.internal',
|
||||
port: 3307,
|
||||
@@ -313,6 +321,32 @@ describe('MySQLDatabaseClient — pool construction', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// mysql2 skips its idle reaper unless maxIdle < connectionLimit, and an
|
||||
// unreaped connection is closed by the server under the pool's feet.
|
||||
it('keeps the idle reaper on when a pool sets its own connection limit', async () => {
|
||||
await startClient({
|
||||
replica: { host: 'replica.internal', connectionLimit: 10 },
|
||||
});
|
||||
|
||||
expect(createdPools[1].poolConfig).toMatchObject({
|
||||
connectionLimit: 10,
|
||||
maxIdle: 9,
|
||||
idleTimeout: 60_000,
|
||||
});
|
||||
});
|
||||
|
||||
// mysql2 reads 0 as unlimited; a maxIdle of -1 would crash its reaper.
|
||||
it('leaves maxIdle to mysql2 for an unlimited pool', async () => {
|
||||
await startClient({
|
||||
replica: { host: 'replica.internal', connectionLimit: 0 },
|
||||
});
|
||||
|
||||
expect(createdPools[1].poolConfig).toMatchObject({
|
||||
connectionLimit: 0,
|
||||
});
|
||||
expect(createdPools[1].poolConfig).not.toHaveProperty('maxIdle');
|
||||
});
|
||||
|
||||
it('falls back to loopback defaults when the endpoint is unspecified', async () => {
|
||||
await startClient();
|
||||
|
||||
@@ -401,10 +435,30 @@ describe('MySQLDatabaseClient — query interface', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends a write on its own, outside a transaction', async () => {
|
||||
const client = await startClient();
|
||||
const pool = createdPools[0];
|
||||
pool.respond = () => writeHeader({ affectedRows: 1 });
|
||||
|
||||
await client.write('UPDATE `user` SET `username` = ? WHERE `id` = ?', [
|
||||
'ada',
|
||||
1,
|
||||
]);
|
||||
|
||||
expect(pool.calls).toEqual([
|
||||
{
|
||||
sql: 'UPDATE `user` SET `username` = ? WHERE `id` = ?',
|
||||
values: ['ada', 1],
|
||||
},
|
||||
]);
|
||||
expect(pool.connections[0].beginTransaction).not.toHaveBeenCalled();
|
||||
expect(pool.connections[0].commit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps the mysql result header onto the shared write result', async () => {
|
||||
const client = await startClient();
|
||||
createdPools[0].respond = () =>
|
||||
rowSets({ insertId: 42, affectedRows: 2 } as unknown as unknown[]);
|
||||
writeHeader({ insertId: 42, affectedRows: 2 });
|
||||
|
||||
await expect(
|
||||
client.write('UPDATE `user` SET `username` = ? WHERE `id` = ?', [
|
||||
@@ -420,7 +474,7 @@ describe('MySQLDatabaseClient — query interface', () => {
|
||||
|
||||
it('reports no rows affected when the header omits the counters', async () => {
|
||||
const client = await startClient();
|
||||
createdPools[0].respond = () => rowSets({} as unknown as unknown[]);
|
||||
createdPools[0].respond = () => writeHeader({});
|
||||
|
||||
await expect(client.write('DELETE FROM `user`')).resolves.toEqual({
|
||||
insertId: 0,
|
||||
@@ -432,17 +486,14 @@ describe('MySQLDatabaseClient — query interface', () => {
|
||||
it('builds an INSERT with quoted identifiers and positional params', async () => {
|
||||
const client = await startClient();
|
||||
const pool = createdPools[0];
|
||||
pool.respond = () =>
|
||||
rowSets({ insertId: 7, affectedRows: 1 } as unknown as unknown[]);
|
||||
pool.respond = () => writeHeader({ insertId: 7, affectedRows: 1 });
|
||||
|
||||
await expect(
|
||||
client.insert('user', { username: 'ada', email: null }),
|
||||
).resolves.toMatchObject({ insertId: 7 });
|
||||
|
||||
expect(pool.calls[0]).toEqual({
|
||||
sql:
|
||||
'INSERT INTO `user` (`username`, `email`) VALUES (?, ?); ' +
|
||||
'SELECT 1',
|
||||
sql: 'INSERT INTO `user` (`username`, `email`) VALUES (?, ?)',
|
||||
values: ['ada', null],
|
||||
});
|
||||
});
|
||||
@@ -693,31 +744,19 @@ describe('MySQLDatabaseClient — pool lifecycle', () => {
|
||||
expect(createdPools[1].ended).toBe(true);
|
||||
});
|
||||
|
||||
it('closes the pools once the drain window elapses', async () => {
|
||||
it('leaves the pools open through prepare-shutdown; only onServerShutdown closes them', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = await startClient();
|
||||
await client.onServerPrepareShutdown();
|
||||
expect(createdPools[0].ended).toBe(false);
|
||||
|
||||
// No timer closes the pools on its own — the layers above are
|
||||
// still draining through them for as long as the process drains.
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(createdPools[0].ended).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('cancels the drain timer when shutdown arrives first', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = await startClient();
|
||||
await client.onServerPrepareShutdown();
|
||||
// A second prepare is a no-op — only one drain timer may exist.
|
||||
await client.onServerPrepareShutdown();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
expect(createdPools[0].ended).toBe(false);
|
||||
|
||||
await client.onServerShutdown();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(createdPools[0].ended).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve as resolvePath } from 'path';
|
||||
import { metrics } from '@opentelemetry/api';
|
||||
import { createPool, type Pool } from 'mysql2';
|
||||
import { createPool, ExecuteValues, type Pool } from 'mysql2';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
import { SQLBatcher } from './SQLBatcher.js';
|
||||
@@ -30,6 +30,8 @@ import { compareMigrationFilenames } from './migrationFilenames.js';
|
||||
import type { IConfig } from '../../types';
|
||||
|
||||
const DEFAULT_SELECT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_CONNECTION_LIMIT = 30;
|
||||
const IDLE_TIMEOUT_MS = 60_000;
|
||||
|
||||
const replicaFailoverCounter = metrics
|
||||
.getMeter('puter-backend')
|
||||
@@ -54,9 +56,10 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
private replicaPool!: Pool;
|
||||
private db!: SQLBatcher;
|
||||
private dbReplica!: SQLBatcher;
|
||||
/** Primary pool, SELECT-only: same rows as `db`, coalesced like the replica. */
|
||||
private dbPrimaryRead!: SQLBatcher;
|
||||
private configuration = Configuration.SINGLE;
|
||||
private shutdownStarted = false;
|
||||
private shutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(config: IConfig) {
|
||||
super(config);
|
||||
@@ -79,6 +82,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
console.log('[mysql] connected to primary');
|
||||
|
||||
this.db = this.createPrimaryBatcher(this.primaryPool);
|
||||
this.dbPrimaryRead = this.createPrimaryReadBatcher(this.primaryPool);
|
||||
|
||||
if (dbConf.replica) {
|
||||
this.replicaPool = this.createPool(dbConf.replica);
|
||||
@@ -98,29 +102,12 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
if (this.shutdownStarted) return;
|
||||
this.shutdownStarted = true;
|
||||
|
||||
// Allow in-flight queries to drain before closing pools
|
||||
const drainMs = 60_000;
|
||||
console.log(
|
||||
`[mysql] draining in-flight queries (${drainMs}ms) before closing pools`,
|
||||
);
|
||||
|
||||
this.shutdownTimer = setTimeout(() => {
|
||||
this.shutdownTimer = null;
|
||||
this.closeCurrentPools('drain').catch((e) =>
|
||||
console.error('[mysql] error closing pools after drain', e),
|
||||
);
|
||||
}, drainMs);
|
||||
|
||||
if (typeof this.shutdownTimer.unref === 'function') {
|
||||
this.shutdownTimer.unref();
|
||||
}
|
||||
// Blocks reinitPrimary/reinitReplica from here on. Pools stay open;
|
||||
// onServerShutdown closes them after the layers above have drained.
|
||||
console.log('[mysql] entering drain mode');
|
||||
}
|
||||
|
||||
override async onServerShutdown(): Promise<void> {
|
||||
if (this.shutdownTimer) {
|
||||
clearTimeout(this.shutdownTimer);
|
||||
this.shutdownTimer = null;
|
||||
}
|
||||
await this.closeCurrentPools('shutdown');
|
||||
}
|
||||
|
||||
@@ -151,7 +138,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
throw error;
|
||||
}
|
||||
replicaFailoverCounter.add(1);
|
||||
result = await this.db.execute(query, params);
|
||||
result = await this.dbPrimaryRead.execute(query, params);
|
||||
}
|
||||
if (!result) return [];
|
||||
return (result[0] as Record<string, unknown>[]) ?? [];
|
||||
@@ -162,7 +149,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const result = await this.db.execute(query, params);
|
||||
const result = await this.dbPrimaryRead.execute(query, params);
|
||||
if (!result) return [];
|
||||
return (result[0] as Record<string, unknown>[]) ?? [];
|
||||
}
|
||||
@@ -201,7 +188,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
await conn.beginTransaction();
|
||||
try {
|
||||
for (const { statement, values } of entries) {
|
||||
await conn.execute(statement, values);
|
||||
await conn.execute(statement, values as ExecuteValues);
|
||||
}
|
||||
await conn.commit();
|
||||
} catch (err) {
|
||||
@@ -224,7 +211,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
// Run both reads in parallel — prefer replica when it returns rows,
|
||||
// otherwise fall back to primary to handle replication lag.
|
||||
const primaryPromise = this.db.execute(query, params);
|
||||
const primaryPromise = this.dbPrimaryRead.execute(query, params);
|
||||
try {
|
||||
const replicaResult = await this.dbReplica.execute(query, params);
|
||||
if (
|
||||
@@ -315,9 +302,19 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private createPool(poolConfig: PoolConfig): Pool {
|
||||
const connectionLimit =
|
||||
poolConfig.connectionLimit ?? DEFAULT_CONNECTION_LIMIT;
|
||||
const pool = createPool({
|
||||
maxPreparedStatements: 900,
|
||||
connectionLimit: 30,
|
||||
connectionLimit,
|
||||
// mysql2 only reaps idle connections when maxIdle < connectionLimit.
|
||||
// Unreaped, they sit until the server's wait_timeout closes them,
|
||||
// and the next statement sent on one fails. 0 means unlimited,
|
||||
// and a negative maxIdle would crash the reaper.
|
||||
...(connectionLimit > 1 && { maxIdle: connectionLimit - 1 }),
|
||||
idleTimeout: IDLE_TIMEOUT_MS,
|
||||
// Reaped connections say goodbye instead of dropping the socket.
|
||||
gracefulEnd: true,
|
||||
enableKeepAlive: true,
|
||||
...poolConfig,
|
||||
multipleStatements: true,
|
||||
@@ -345,11 +342,26 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* One write per flush. Coalesced writes share a transaction, so unrelated
|
||||
* callers hold each other's row locks until COMMIT and deadlock; a lone
|
||||
* statement needs no BEGIN/COMMIT round trips.
|
||||
*/
|
||||
private createPrimaryBatcher(pool: Pool): SQLBatcher {
|
||||
return new SQLBatcher(pool, {
|
||||
maxBatchSize: 1,
|
||||
poolLabel: 'primary',
|
||||
acquireTimeoutMs: this.config.database?.acquireTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
/** Reads that must see the primary, still coalesced. */
|
||||
private createPrimaryReadBatcher(pool: Pool): SQLBatcher {
|
||||
return new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 30,
|
||||
maxBatchSize: 5,
|
||||
poolLabel: 'primary',
|
||||
readOnly: true,
|
||||
acquireTimeoutMs: this.config.database?.acquireTimeoutMs,
|
||||
});
|
||||
}
|
||||
@@ -378,6 +390,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
database: dbConf.database ?? 'puter',
|
||||
});
|
||||
this.db = this.createPrimaryBatcher(this.primaryPool);
|
||||
this.dbPrimaryRead = this.createPrimaryReadBatcher(this.primaryPool);
|
||||
|
||||
if (this.configuration === Configuration.SINGLE) {
|
||||
this.replicaPool = this.primaryPool;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { FieldDef, QueryResult } from 'pg';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import { DatabaseClientFactory } from './index.js';
|
||||
import {
|
||||
@@ -357,4 +357,33 @@ describe('PostgresDatabaseClient', () => {
|
||||
]);
|
||||
expect(conn.released).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the pool open through prepare-shutdown; only onServerShutdown closes it', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const pool = new RecordingPool();
|
||||
let ended = false;
|
||||
pool.end = async () => {
|
||||
ended = true;
|
||||
};
|
||||
const client = new PostgresDatabaseClient(
|
||||
postgresConfig(),
|
||||
() => pool,
|
||||
);
|
||||
await client.onServerStart();
|
||||
|
||||
await client.onServerPrepareShutdown();
|
||||
expect(ended).toBe(false);
|
||||
|
||||
// No timer closes the pool on its own — the layers above are
|
||||
// still draining through it for as long as the process drains.
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(ended).toBe(false);
|
||||
|
||||
await client.onServerShutdown();
|
||||
expect(ended).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -138,7 +138,6 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
private replicaPool!: PostgresPool;
|
||||
private configuration = Configuration.SINGLE;
|
||||
private shutdownStarted = false;
|
||||
private shutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
config: IConfig,
|
||||
@@ -172,28 +171,12 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
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();
|
||||
}
|
||||
// Pools stay open; onServerShutdown closes them after the layers above
|
||||
// have drained through them.
|
||||
console.log('[postgres] entering drain mode');
|
||||
}
|
||||
|
||||
override async onServerShutdown(): Promise<void> {
|
||||
if (this.shutdownTimer) {
|
||||
clearTimeout(this.shutdownTimer);
|
||||
this.shutdownTimer = null;
|
||||
}
|
||||
await this.closeCurrentPools();
|
||||
}
|
||||
|
||||
|
||||
@@ -258,16 +258,11 @@ export class SQLBatcher {
|
||||
}
|
||||
if (batch.length === 0) return;
|
||||
|
||||
const query = `${batch.map((b) => b.sql.replace(/;+\s*$/, '')).join(';')}; SELECT 1`; // SELECT 1 forces mysql2 to return array
|
||||
const values = batch.map((b) => b.values ?? []).flat();
|
||||
|
||||
let connection;
|
||||
try {
|
||||
connection = await this.#acquireConnection();
|
||||
} catch (error) {
|
||||
this.#consecutiveFailures++;
|
||||
this.#lastFailureAt = Date.now();
|
||||
flushFailureCounter.add(1, this.#metricAttrs);
|
||||
this.#recordFlushFailure();
|
||||
console.warn(
|
||||
'SQLBatcher could not acquire connection for flush:',
|
||||
error,
|
||||
@@ -278,17 +273,30 @@ export class SQLBatcher {
|
||||
return;
|
||||
}
|
||||
|
||||
if (batch.length === 1 && !this.readOnly) {
|
||||
await this.#flushLoneWrite(batch[0], connection);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = `${batch.map((b) => b.sql.replace(/;+\s*$/, '')).join(';')}; SELECT 1`; // SELECT 1 forces mysql2 to return array
|
||||
const values = batch.map((b) => b.values ?? []).flat();
|
||||
|
||||
// Run the coalesced multi-statement inside an explicit transaction so
|
||||
// a single bad statement (e.g. a duplicate-key INSERT) rolls back the
|
||||
// whole batch atomically, leaving us free to re-run each item
|
||||
// individually below. Without this, MySQL would commit every
|
||||
// statement up to the failure point and a per-item retry would
|
||||
// misreport already-committed inserts as duplicate-key failures.
|
||||
//
|
||||
// A read-only batch has nothing to roll back, and the wrapper is not
|
||||
// free: BEGIN and COMMIT are round trips of their own, which triples
|
||||
// the cost of a read against a pool in another region.
|
||||
const wrapInTransaction = !this.readOnly;
|
||||
let batchSucceeded = false;
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
if (wrapInTransaction) await connection.beginTransaction();
|
||||
const [results, fields] = await connection.query(query, values);
|
||||
await connection.commit();
|
||||
if (wrapInTransaction) await connection.commit();
|
||||
batchSucceeded = true;
|
||||
this.#consecutiveFailures = 0;
|
||||
for (let i = 0; i < batch.length; i++) {
|
||||
@@ -296,10 +304,12 @@ export class SQLBatcher {
|
||||
b.resolve([results[i], fields?.[i]]);
|
||||
}
|
||||
} catch (batchError) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (rollbackError) {
|
||||
console.warn('SQLBatcher rollback failed:', rollbackError);
|
||||
if (wrapInTransaction) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (rollbackError) {
|
||||
console.warn('SQLBatcher rollback failed:', rollbackError);
|
||||
}
|
||||
}
|
||||
console.warn(
|
||||
'SQLBatcher batch failed; retrying items individually:',
|
||||
@@ -311,10 +321,11 @@ export class SQLBatcher {
|
||||
|
||||
if (batchSucceeded) return;
|
||||
|
||||
// Per-item fallback. The transaction was rolled back so no statement
|
||||
// committed; re-running each item independently produces clean
|
||||
// success/failure outcomes for each caller. Concurrency is capped to
|
||||
// avoid briefly saturating the pool when a large batch fails.
|
||||
// Per-item fallback. Nothing from the batch is live — it was rolled
|
||||
// back, or carried only SELECTs — so re-running each item
|
||||
// independently produces clean success/failure outcomes for each
|
||||
// caller. Concurrency is capped to avoid briefly saturating the pool
|
||||
// when a large batch fails.
|
||||
flushFailureCounter.add(1, this.#metricAttrs);
|
||||
fallbackInvocationsCounter.add(1, this.#metricAttrs);
|
||||
|
||||
@@ -325,7 +336,7 @@ export class SQLBatcher {
|
||||
async () => {
|
||||
while (cursor < batch.length) {
|
||||
const i = cursor++;
|
||||
settled[i] = await this.#runFallbackItem(batch[i]);
|
||||
settled[i] = await this.#runItem(batch[i]);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -359,7 +370,34 @@ export class SQLBatcher {
|
||||
}
|
||||
}
|
||||
|
||||
// Run one fallback item, retrying transient failures with backoff.
|
||||
// A single statement is atomic under autocommit, so it skips BEGIN/COMMIT
|
||||
// and their round trips. With no transaction to roll back, a failure is
|
||||
// not re-run wholesale; only the per-item retry policy applies.
|
||||
async #flushLoneWrite(item, connection) {
|
||||
const r = await this.#runItem(item, connection);
|
||||
if (r.ok) {
|
||||
this.#consecutiveFailures = 0;
|
||||
item.resolve(r.value);
|
||||
return;
|
||||
}
|
||||
// An error the server answered with (duplicate key, bad SQL) means it
|
||||
// is up; only failing to reach it counts toward the breaker.
|
||||
if (isRetriableError(r.error)) {
|
||||
this.#recordFlushFailure();
|
||||
} else {
|
||||
this.#consecutiveFailures = 0;
|
||||
flushFailureCounter.add(1, this.#metricAttrs);
|
||||
}
|
||||
item.reject(r.error);
|
||||
}
|
||||
|
||||
#recordFlushFailure() {
|
||||
this.#consecutiveFailures++;
|
||||
this.#lastFailureAt = Date.now();
|
||||
flushFailureCounter.add(1, this.#metricAttrs);
|
||||
}
|
||||
|
||||
// Run one item on its own, retrying transient failures with backoff.
|
||||
// A read-only batcher may retry anything transient; a batcher that
|
||||
// carries writes only retries failures where the statement provably
|
||||
// did not apply — either it never reached the server, or the server
|
||||
@@ -370,33 +408,37 @@ export class SQLBatcher {
|
||||
// than surfacing: an item is a single statement, so a deadlock victim has
|
||||
// been fully undone, and the caller sees an unhandled 500 for what the
|
||||
// database is telling us to just run again.
|
||||
async #runFallbackItem(b) {
|
||||
async #runItem(b, connection) {
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
let connection;
|
||||
try {
|
||||
connection = await this.#acquireConnection();
|
||||
} catch (error) {
|
||||
return { ok: false, error };
|
||||
if (!connection) {
|
||||
try {
|
||||
connection = await this.#acquireConnection();
|
||||
} catch (error) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
}
|
||||
let error;
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
value: await connection.query(b.sql, b.values ?? []),
|
||||
};
|
||||
} catch (error) {
|
||||
const canRetry = this.readOnly
|
||||
? isRetriableError(error) || isRolledBackError(error)
|
||||
: isNeverSentError(error) || isRolledBackError(error);
|
||||
if (!canRetry || attempt >= ITEM_RETRY_ATTEMPTS) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
attempt++;
|
||||
fallbackItemRetriesCounter.add(1, this.#metricAttrs);
|
||||
await sleep(RETRY_BASE_BACKOFF_MS * attempt);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
} finally {
|
||||
connection.release();
|
||||
connection = undefined;
|
||||
}
|
||||
const canRetry = this.readOnly
|
||||
? isRetriableError(error) || isRolledBackError(error)
|
||||
: isNeverSentError(error) || isRolledBackError(error);
|
||||
if (!canRetry || attempt >= ITEM_RETRY_ATTEMPTS) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
attempt++;
|
||||
fallbackItemRetriesCounter.add(1, this.#metricAttrs);
|
||||
await sleep(RETRY_BASE_BACKOFF_MS * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,9 +86,48 @@ describe('SQLBatcher', () => {
|
||||
expect(conn.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drops the oldest item with reason queueOverflow at the high-water mark', async () => {
|
||||
it('skips the transaction wrapper on a readOnly batcher', async () => {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
batcher.query('SELECT a', []),
|
||||
batcher.query('SELECT b', []),
|
||||
]);
|
||||
expect(a[0]).toEqual([{ n: 0 }]);
|
||||
expect(b[0]).toEqual([{ n: 1 }]);
|
||||
// BEGIN and COMMIT are round trips; a SELECT-only batch has nothing to
|
||||
// roll back, so it must not pay for them.
|
||||
expect(conn.beginTransaction).not.toHaveBeenCalled();
|
||||
expect(conn.commit).not.toHaveBeenCalled();
|
||||
expect(conn.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not roll back a failed readOnly batch', async () => {
|
||||
const conn = makeConnection((sql: string) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ER_LOCK_DEADLOCK');
|
||||
return [[{ n: 0 }], undefined];
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
batcher.query('SELECT a', []),
|
||||
batcher.query('SELECT b', []),
|
||||
]);
|
||||
expect(conn.rollback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops the oldest item with reason queueOverflow at the high-water mark', async () => {
|
||||
const conn = makeConnection(() => [[{ n: 0 }], undefined]);
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
maxQueueSize: 1,
|
||||
@@ -238,6 +277,65 @@ describe('SQLBatcher', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('sends a lone write as-is, outside a transaction', async () => {
|
||||
const conn = makeConnection((sql) => {
|
||||
if (isBatchQuery(sql)) throw new Error('unexpected batch query');
|
||||
return [{ affectedRows: 1 }, undefined];
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, { maxBatchSize: 1 });
|
||||
|
||||
const result = await batcher.query('UPDATE t SET x = ?', [1]);
|
||||
expect(result[0]).toEqual({ affectedRows: 1 });
|
||||
expect(conn.query).toHaveBeenCalledTimes(1);
|
||||
expect(conn.query).toHaveBeenCalledWith('UPDATE t SET x = ?', [1]);
|
||||
expect(conn.beginTransaction).not.toHaveBeenCalled();
|
||||
expect(conn.commit).not.toHaveBeenCalled();
|
||||
expect(conn.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('runs a lone write the server rejects once, without tripping the breaker', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const conn = makeConnection((sql) => {
|
||||
if (sql === 'INSERT dup') throw makeError('ER_DUP_ENTRY');
|
||||
return [{ affectedRows: 1 }, undefined];
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxBatchSize: 1,
|
||||
failureThreshold: 1,
|
||||
cooldownMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(batcher.query('INSERT dup', [])).rejects.toMatchObject({
|
||||
code: 'ER_DUP_ENTRY',
|
||||
});
|
||||
expect(conn.query).toHaveBeenCalledTimes(1);
|
||||
expect(conn.rollback).not.toHaveBeenCalled();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
// The server answered, so it is up: the next write goes through.
|
||||
await expect(batcher.query('INSERT fine', [])).resolves.toBeTruthy();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('retries a lone write on a connection the server closed for inactivity', async () => {
|
||||
let attempts = 0;
|
||||
const conn = makeConnection(() => {
|
||||
attempts++;
|
||||
if (attempts === 1) {
|
||||
throw makeError('ER_CLIENT_INTERACTION_TIMEOUT');
|
||||
}
|
||||
return [{ affectedRows: 1 }, undefined];
|
||||
});
|
||||
const { pool, getConnection } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, { maxBatchSize: 1 });
|
||||
|
||||
const result = await batcher.query('INSERT x', []);
|
||||
expect(result[0]).toEqual({ affectedRows: 1 });
|
||||
expect(attempts).toBe(2);
|
||||
expect(getConnection).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('never retries deterministic row-level errors and does not escalate the breaker', async () => {
|
||||
let fallbackAttempts = 0;
|
||||
const conn = makeConnection((sql) => {
|
||||
|
||||
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
|
||||
|
||||
/** Highest schema version the migration table can reach. */
|
||||
const CURRENT_SCHEMA_VERSION = 79;
|
||||
const CURRENT_SCHEMA_VERSION = 86;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
@@ -276,6 +276,68 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
]);
|
||||
});
|
||||
|
||||
it('deletes the subdomain rows an app owns when the app is deleted', async () => {
|
||||
const [user] = (await client.read(
|
||||
'SELECT MIN(`id`) AS id FROM `user`',
|
||||
)) as { id: number }[];
|
||||
await client.write(
|
||||
'INSERT INTO `apps` (`uid`, `owner_user_id`, `name`, `title`, `index_url`) ' +
|
||||
'VALUES (?, ?, ?, ?, ?)',
|
||||
[
|
||||
'app-cascade-test',
|
||||
user.id,
|
||||
'cascade-test',
|
||||
'cascade-test',
|
||||
'https://cascade.test/',
|
||||
],
|
||||
);
|
||||
const [app] = (await client.read(
|
||||
'SELECT `id` FROM `apps` WHERE `uid` = ?',
|
||||
['app-cascade-test'],
|
||||
)) as { id: number }[];
|
||||
const insertSubdomain = (
|
||||
uuid: string,
|
||||
name: string,
|
||||
appOwner: number | null,
|
||||
) =>
|
||||
client.write(
|
||||
'INSERT INTO `subdomains` (`uuid`, `subdomain`, `user_id`, `app_owner`) ' +
|
||||
'VALUES (?, ?, ?, ?)',
|
||||
[uuid, name, user.id, appOwner],
|
||||
);
|
||||
await insertSubdomain('sd-cascade-owned', 'cascade-owned', app.id);
|
||||
await insertSubdomain('sd-cascade-unowned', 'cascade-unowned', null);
|
||||
|
||||
await client.write('DELETE FROM `apps` WHERE `id` = ?', [app.id]);
|
||||
|
||||
await expect(
|
||||
client.read(
|
||||
'SELECT `subdomain` FROM `subdomains` WHERE `subdomain` LIKE ? ORDER BY `id`',
|
||||
['cascade-%'],
|
||||
),
|
||||
).resolves.toEqual([{ subdomain: 'cascade-unowned' }]);
|
||||
});
|
||||
|
||||
it('keeps every subdomains column through the 0086 rebuild', async () => {
|
||||
const columns = (await client.read(
|
||||
"SELECT `name` FROM pragma_table_info('subdomains') ORDER BY `cid`",
|
||||
)) as { name: string }[];
|
||||
expect(columns.map((c) => c.name)).toEqual([
|
||||
'id',
|
||||
'uuid',
|
||||
'subdomain',
|
||||
'user_id',
|
||||
'root_dir_id',
|
||||
'associated_app_id',
|
||||
'ts',
|
||||
'app_owner',
|
||||
'protected',
|
||||
'domain',
|
||||
'database_id',
|
||||
'preamble_version',
|
||||
]);
|
||||
});
|
||||
|
||||
it('constrains team shares that the user-holder index cannot', async () => {
|
||||
const [user] = (await client.read(
|
||||
'SELECT MIN(`id`) AS id FROM `user`',
|
||||
|
||||
@@ -113,6 +113,13 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[76, ['0081_event-subscriptions-indexes.sql']],
|
||||
[77, ['0082_temp-password-expiry.sql']],
|
||||
[78, ['0083_team-directory.sql']],
|
||||
[79, ['0084_share-anyone-with-link.sql']],
|
||||
[80, ['0085_event-subscriptions-include-value.sql']],
|
||||
[81, ['0086_subdomains-app-owner-cascade.sql']],
|
||||
[82, ['0087_feedback-attachments.sql']],
|
||||
[83, ['0088_apps-index-url.sql']],
|
||||
[84, ['0089_team-require-2fa.sql']],
|
||||
[85, ['0090_user-app-permission-index.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-- 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/>.
|
||||
|
||||
-- See sqlite/0084_share-anyone-with-link.sql for the column rationale.
|
||||
-- No per-file applied-state tracking, so the column goes through _puter_add_col
|
||||
-- and the index through a guarded procedure, as mysql_mig_34 does.
|
||||
|
||||
CALL _puter_add_col('share', 'anyone', '`anyone` tinyint(1) DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_share_anyone_index;
|
||||
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_share_anyone_index()
|
||||
BEGIN
|
||||
-- Every other row leaves `anyone` NULL, so this binds only link shares.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND INDEX_NAME = 'idx_share_anyone'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD UNIQUE INDEX `idx_share_anyone` (`fsentry_id`, `anyone`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_share_anyone_index();
|
||||
DROP PROCEDURE IF EXISTS _puter_add_share_anyone_index;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 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/>.
|
||||
|
||||
-- See sqlite/0085_event-subscriptions-include-value.sql for the column
|
||||
-- rationale. No per-file applied-state tracking, so it goes through
|
||||
-- _puter_add_col as mysql_mig_39 does.
|
||||
|
||||
CALL _puter_add_col('event_subscriptions', 'include_value', '`include_value` tinyint(1) NOT NULL DEFAULT 0');
|
||||
@@ -0,0 +1,75 @@
|
||||
-- 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 0086: `subdomains.app_owner` cascades on app
|
||||
-- delete instead of nulling. The existing constraint is found by column
|
||||
-- rather than by name, since an older database need not carry the name
|
||||
-- mysql_mig_1 declares. Guarded procedure as mysql_mig_34: a replay sees
|
||||
-- DELETE_RULE already CASCADE and does nothing.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_subdomains_app_owner_cascade;
|
||||
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_subdomains_app_owner_cascade()
|
||||
BEGIN
|
||||
DECLARE fk_name VARCHAR(64) DEFAULT NULL;
|
||||
|
||||
-- A subquery, not SELECT ... INTO: no matching row yields NULL quietly.
|
||||
SET fk_name = (
|
||||
SELECT rc.CONSTRAINT_NAME
|
||||
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
|
||||
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
||||
ON kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA
|
||||
AND kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
|
||||
AND kcu.TABLE_NAME = rc.TABLE_NAME
|
||||
WHERE rc.CONSTRAINT_SCHEMA = DATABASE()
|
||||
AND rc.TABLE_NAME = 'subdomains'
|
||||
AND rc.REFERENCED_TABLE_NAME = 'apps'
|
||||
AND kcu.COLUMN_NAME = 'app_owner'
|
||||
AND rc.DELETE_RULE <> 'CASCADE'
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
IF fk_name IS NOT NULL THEN
|
||||
SET @s := CONCAT('ALTER TABLE `subdomains` DROP FOREIGN KEY `', fk_name, '`');
|
||||
PREPARE stmt FROM @s;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
|
||||
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
||||
ON kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA
|
||||
AND kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
|
||||
AND kcu.TABLE_NAME = rc.TABLE_NAME
|
||||
WHERE rc.CONSTRAINT_SCHEMA = DATABASE()
|
||||
AND rc.TABLE_NAME = 'subdomains'
|
||||
AND rc.REFERENCED_TABLE_NAME = 'apps'
|
||||
AND kcu.COLUMN_NAME = 'app_owner'
|
||||
AND rc.DELETE_RULE = 'CASCADE'
|
||||
) THEN
|
||||
ALTER TABLE `subdomains` ADD CONSTRAINT `fk_subdomains_app_owner`
|
||||
FOREIGN KEY (`app_owner`) REFERENCES `apps` (`id`)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_subdomains_app_owner_cascade();
|
||||
DROP PROCEDURE IF EXISTS _puter_subdomains_app_owner_cascade;
|
||||
@@ -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/>.
|
||||
|
||||
-- Mirrors SQLite migration 0087: Contact Us attachment metadata, a JSON array
|
||||
-- of `{name, type, size}` (NULL when none). Guarded by a throwaway procedure,
|
||||
-- as in mysql_mig_21.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_feedback_attachments;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_feedback_attachments()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'feedback'
|
||||
AND COLUMN_NAME = 'attachments'
|
||||
) THEN
|
||||
ALTER TABLE `feedback` ADD COLUMN `attachments` text DEFAULT NULL;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_feedback_attachments();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_feedback_attachments;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- 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 0088: index `apps.index_url` for origin lookups.
|
||||
-- TEXT needs a prefix; 191 characters keeps the key under 767 bytes in
|
||||
-- utf8mb4 whatever the table's row format, and URLs diverge well before it.
|
||||
--
|
||||
-- Idempotent: the guarded procedure, as mig_36. The 1061 handler covers two
|
||||
-- nodes booting at once, where the loser finds the index already built.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_apps_index_url_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_apps_index_url_index()
|
||||
BEGIN
|
||||
DECLARE CONTINUE HANDLER FOR 1061 BEGIN END;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'apps'
|
||||
AND INDEX_NAME = 'idx_apps_index_url'
|
||||
) THEN
|
||||
ALTER TABLE `apps`
|
||||
ADD INDEX `idx_apps_index_url` (`index_url`(191)),
|
||||
ALGORITHM=INPLACE, LOCK=NONE;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_apps_index_url_index();
|
||||
DROP PROCEDURE IF EXISTS _puter_add_apps_index_url_index;
|
||||
@@ -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/>.
|
||||
|
||||
-- Mirrors SQLite migration 0089: a team may require 2FA of the accounts it
|
||||
-- provisioned. Added through `_puter_add_col` as mysql_mig_32 does, so a
|
||||
-- replay finds the column and does nothing.
|
||||
CALL _puter_add_col('group', 'require_2fa', '`require_2fa` tinyint(1) NOT NULL DEFAULT 0');
|
||||
@@ -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/>.
|
||||
|
||||
-- Mirrors SQLite migration 0090. Guarded like mysql_mig_22, because there is
|
||||
-- no per-file applied-state tracking and a replay must do nothing.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_app_permission_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_user_app_permission_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user_to_app_permissions'
|
||||
AND INDEX_NAME = 'idx_user_to_app_permissions_permission'
|
||||
) THEN
|
||||
ALTER TABLE `user_to_app_permissions`
|
||||
ADD INDEX `idx_user_to_app_permissions_permission` (`permission`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_user_app_permission_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_app_permission_index;
|
||||
@@ -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/>.
|
||||
|
||||
-- See sqlite/0084_share-anyone-with-link.sql for the column rationale.
|
||||
-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
|
||||
|
||||
ALTER TABLE share ADD COLUMN IF NOT EXISTS anyone smallint;
|
||||
|
||||
-- Default NULLS DISTINCT, matching the other engines, so this binds only link shares.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_share_anyone ON share (fsentry_id, anyone);
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 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/>.
|
||||
|
||||
-- See sqlite/0085_event-subscriptions-include-value.sql for the column
|
||||
-- rationale. Idempotent via IF NOT EXISTS; there is no per-file applied-state
|
||||
-- tracking.
|
||||
|
||||
ALTER TABLE event_subscriptions ADD COLUMN IF NOT EXISTS include_value smallint NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,59 @@
|
||||
-- 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 0086: `subdomains.app_owner` cascades on app
|
||||
-- delete instead of nulling. The existing constraint is found by column, not
|
||||
-- by name. Idempotent: there is no per-file applied-state tracking, and a
|
||||
-- replay sees the cascade already in place and does nothing.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
fk_name text;
|
||||
BEGIN
|
||||
SELECT c.conname INTO fk_name
|
||||
FROM pg_constraint c
|
||||
JOIN pg_attribute a
|
||||
ON a.attrelid = c.conrelid
|
||||
AND a.attnum = ANY (c.conkey)
|
||||
WHERE c.conrelid = 'subdomains'::regclass
|
||||
AND c.contype = 'f'
|
||||
AND c.confrelid = 'apps'::regclass
|
||||
AND a.attname = 'app_owner'
|
||||
AND c.confdeltype <> 'c'
|
||||
LIMIT 1;
|
||||
|
||||
IF fk_name IS NOT NULL THEN
|
||||
EXECUTE format('ALTER TABLE subdomains DROP CONSTRAINT %I', fk_name);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint c
|
||||
JOIN pg_attribute a
|
||||
ON a.attrelid = c.conrelid
|
||||
AND a.attnum = ANY (c.conkey)
|
||||
WHERE c.conrelid = 'subdomains'::regclass
|
||||
AND c.contype = 'f'
|
||||
AND c.confrelid = 'apps'::regclass
|
||||
AND a.attname = 'app_owner'
|
||||
AND c.confdeltype = 'c'
|
||||
) THEN
|
||||
ALTER TABLE subdomains ADD CONSTRAINT subdomains_app_owner_fkey
|
||||
FOREIGN KEY (app_owner) REFERENCES apps (id)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -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/>.
|
||||
|
||||
-- Mirrors SQLite migration 0087: Contact Us attachment metadata, a JSON array
|
||||
-- of `{name, type, size}` (NULL when none).
|
||||
|
||||
ALTER TABLE feedback ADD COLUMN IF NOT EXISTS attachments text DEFAULT NULL;
|
||||
@@ -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/>.
|
||||
|
||||
-- Mirrors SQLite migration 0088: index `apps.index_url` for origin lookups.
|
||||
-- Hash rather than btree: `index_url` allows 3000 characters, past btree's
|
||||
-- ~2.7 KB row limit, and every lookup is equality (`=` / `IN`).
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_index_url ON apps USING hash (index_url);
|
||||
@@ -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/>.
|
||||
|
||||
-- Mirrors SQLite migration 0089: a team may require 2FA of the accounts it
|
||||
-- provisioned. Idempotent -- there is no per-file applied-state tracking, so
|
||||
-- a replay must find the column already present and do nothing.
|
||||
ALTER TABLE "group" ADD COLUMN IF NOT EXISTS require_2fa smallint NOT NULL DEFAULT 0;
|
||||
@@ -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/>.
|
||||
|
||||
-- Mirrors SQLite migration 0090, as postgres_mig_11 did for user_to_user.
|
||||
-- text_pattern_ops is what makes the left-anchored LIKE a range scan under a
|
||||
-- non-C collation -- the plain index dev_to_app already has does not.
|
||||
CREATE INDEX IF NOT EXISTS idx_user_to_app_permissions_permission
|
||||
ON user_to_app_permissions (permission text_pattern_ops);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_to_app_permissions_permission_pattern
|
||||
ON dev_to_app_permissions (permission text_pattern_ops);
|
||||
@@ -0,0 +1,27 @@
|
||||
-- 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/>.
|
||||
|
||||
-- "Anyone with the link": a share row with no holder of any kind — not a
|
||||
-- person, not a team, not an invite. `anyone` is 1 on such a row and NULL on
|
||||
-- every other, so the unique index binds only these: one per node, whoever
|
||||
-- last set it. No permission row stands behind it; ACLService reads the share
|
||||
-- row itself, and honours it only while the owner's plan covers link sharing.
|
||||
|
||||
ALTER TABLE `share` ADD COLUMN `anyone` INTEGER DEFAULT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_share_anyone`
|
||||
ON `share` (`fsentry_id`, `anyone`);
|
||||
+23
@@ -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/>.
|
||||
|
||||
-- A key-value subscription may ask for the key's new value to ride along with
|
||||
-- each delivery. Off by default: a delivery that carries nothing but the key is
|
||||
-- what every existing row was made under.
|
||||
|
||||
ALTER TABLE `event_subscriptions`
|
||||
ADD COLUMN `include_value` INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- 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/>.
|
||||
|
||||
-- A deleted app takes the subdomain rows it owns (its hosted sites and its
|
||||
-- workers) with it. `SET NULL` left them behind as unowned rows, and an
|
||||
-- unowned worker row is redeployed with the account's own credential.
|
||||
-- SQLite cannot alter a constraint, so the table is rebuilt as 0043 does.
|
||||
|
||||
PRAGMA foreign_keys = OFF;
|
||||
|
||||
CREATE TABLE `subdomains_new` (
|
||||
`id` INTEGER PRIMARY KEY,
|
||||
`uuid` varchar(40) DEFAULT NULL,
|
||||
`subdomain` varchar(64) NOT NULL,
|
||||
`user_id` int(10) NOT NULL,
|
||||
`root_dir_id` int(10) DEFAULT NULL,
|
||||
`associated_app_id` int(10) DEFAULT NULL,
|
||||
`ts` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`app_owner` int(10) DEFAULT NULL,
|
||||
`protected` tinyint(1) DEFAULT '0',
|
||||
`domain` varchar(256) DEFAULT NULL,
|
||||
`database_id` varchar(40) DEFAULT NULL,
|
||||
`preamble_version` varchar(64) DEFAULT NULL,
|
||||
FOREIGN KEY (`app_owner`) REFERENCES `apps` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO `subdomains_new`
|
||||
(`id`, `uuid`, `subdomain`, `user_id`, `root_dir_id`, `associated_app_id`, `ts`,
|
||||
`app_owner`, `protected`, `domain`, `database_id`, `preamble_version`)
|
||||
SELECT
|
||||
`id`, `uuid`, `subdomain`, `user_id`, `root_dir_id`, `associated_app_id`, `ts`,
|
||||
`app_owner`, `protected`, `domain`, `database_id`, `preamble_version`
|
||||
FROM `subdomains`;
|
||||
|
||||
DROP TABLE `subdomains`;
|
||||
ALTER TABLE `subdomains_new` RENAME TO `subdomains`;
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
@@ -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/>.
|
||||
|
||||
-- Contact Us attachment metadata: a JSON array of `{name, type, size}`, NULL
|
||||
-- when none. The files themselves go out with the support email.
|
||||
|
||||
ALTER TABLE `feedback` ADD COLUMN "attachments" TEXT DEFAULT NULL;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Resolving an origin to its app looks `apps` up by `index_url` on every
|
||||
-- origin-based app token request (`AuthService#findCanonicalAppUidForOrigin`),
|
||||
-- plus `AppStore.existsByIndexUrl`. With no index each lookup scans the table.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_apps_index_url` ON `apps` (`index_url`);
|
||||
@@ -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/>.
|
||||
|
||||
-- Whether every account this team provisioned must hold 2FA before it can
|
||||
-- use Puter. Off until the owner turns it on, and the owner must hold 2FA
|
||||
-- themselves first, or enabling it would lock out the account that did so.
|
||||
ALTER TABLE `group` ADD COLUMN `require_2fa` INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -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/>.
|
||||
|
||||
-- Withdrawing an app's cross-app data grants looks them up by permission text,
|
||||
-- and no existing index on either table leads with `permission`. Same fix as
|
||||
-- `idx_user_to_user_permissions_permission` in 0067.
|
||||
CREATE INDEX IF NOT EXISTS `idx_user_to_app_permissions_permission`
|
||||
ON `user_to_app_permissions` (`permission`);
|
||||
|
||||
-- The sweep reads both tables; MySQL and Postgres already index this one.
|
||||
CREATE INDEX IF NOT EXISTS `idx_dev_to_app_permissions_permission`
|
||||
ON `dev_to_app_permissions` (`permission`);
|
||||
@@ -32,6 +32,7 @@ const RETRIABLE_ERROR_CODES = new Set([
|
||||
'ENETUNREACH',
|
||||
'EAI_AGAIN',
|
||||
POOL_ACQUIRE_TIMEOUT,
|
||||
'ER_CLIENT_INTERACTION_TIMEOUT',
|
||||
]);
|
||||
|
||||
const RETRIABLE_ERROR_MESSAGES = [
|
||||
@@ -52,6 +53,9 @@ const NEVER_SENT_ERROR_CODES = new Set([
|
||||
'ENETUNREACH',
|
||||
'EAI_AGAIN',
|
||||
POOL_ACQUIRE_TIMEOUT,
|
||||
// The server closed the pooled connection for inactivity before the
|
||||
// statement arrived, so nothing ran.
|
||||
'ER_CLIENT_INTERACTION_TIMEOUT',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -142,6 +142,42 @@ describe('DDBClient — item operations', () => {
|
||||
expect(result.Item).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes only while its condition holds', async () => {
|
||||
await client.put(TABLE, { pk: 'u6', sk: 'guarded', v: 1 });
|
||||
|
||||
await expect(
|
||||
client.del(
|
||||
TABLE,
|
||||
{ pk: 'u6', sk: 'guarded' },
|
||||
{
|
||||
condition: {
|
||||
expression: '#v = :wrong',
|
||||
names: { '#v': 'v' },
|
||||
values: { ':wrong': 2 },
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ name: 'ConditionalCheckFailedException' });
|
||||
expect(
|
||||
(await client.get(TABLE, { pk: 'u6', sk: 'guarded' })).Item,
|
||||
).toBeDefined();
|
||||
|
||||
await client.del(
|
||||
TABLE,
|
||||
{ pk: 'u6', sk: 'guarded' },
|
||||
{
|
||||
condition: {
|
||||
expression: '#v = :right',
|
||||
names: { '#v': 'v' },
|
||||
values: { ':right': 1 },
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(
|
||||
(await client.get(TABLE, { pk: 'u6', sk: 'guarded' })).Item,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fetches keys from several tables in one batch', async () => {
|
||||
await client.put(TABLE, { pk: 'b1', sk: 'a', v: 1 });
|
||||
await client.put(TABLE, { pk: 'b1', sk: 'b', v: 2 });
|
||||
@@ -364,6 +400,59 @@ describe('DDBClient — expired item sweep', () => {
|
||||
expect((await client.get(table, { pk: 'fresh' })).Item).toBeDefined();
|
||||
expect((await client.get(table, { pk: 'eternal' })).Item).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps an item whose ttl is 0, which never expires', async () => {
|
||||
const client = new DDBClient(localConfig());
|
||||
const table = 'ttl-zero-items';
|
||||
const schema = {
|
||||
TableName: table,
|
||||
KeySchema: [{ AttributeName: 'pk', KeyType: 'HASH' as const }],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' as const },
|
||||
],
|
||||
ProvisionedThroughput: {
|
||||
ReadCapacityUnits: 5,
|
||||
WriteCapacityUnits: 5,
|
||||
},
|
||||
};
|
||||
|
||||
await client.createTableIfNotExists(schema, 'expireAt');
|
||||
await awaitTable(client, table, { pk: 'ready' });
|
||||
await client.put(table, { pk: 'zero', expireAt: 0 });
|
||||
|
||||
// Second call finds the table in use, so the sweep runs.
|
||||
await client.createTableIfNotExists(schema, 'expireAt');
|
||||
|
||||
expect((await client.get(table, { pk: 'zero' })).Item).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps an item whose ttl is not a number, which the sweep can never purge', async () => {
|
||||
const client = new DDBClient(localConfig());
|
||||
const table = 'ttl-nan-items';
|
||||
const schema = {
|
||||
TableName: table,
|
||||
KeySchema: [{ AttributeName: 'pk', KeyType: 'HASH' as const }],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' as const },
|
||||
],
|
||||
ProvisionedThroughput: {
|
||||
ReadCapacityUnits: 5,
|
||||
WriteCapacityUnits: 5,
|
||||
},
|
||||
};
|
||||
|
||||
await client.createTableIfNotExists(schema, 'expireAt');
|
||||
await awaitTable(client, table, { pk: 'ready' });
|
||||
const past = Math.floor(Date.now() / 1000) - 60;
|
||||
await client.put(table, { pk: 'stringTtl', expireAt: String(past) });
|
||||
|
||||
// Second call finds the table in use, so the sweep runs.
|
||||
await client.createTableIfNotExists(schema, 'expireAt');
|
||||
|
||||
expect(
|
||||
(await client.get(table, { pk: 'stringTtl' })).Item,
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DDBClient — expired item sweep on an empty table', () => {
|
||||
@@ -559,3 +648,118 @@ describe('DDBClient — unprocessed batch items', () => {
|
||||
).resolves.toEqual({ ConsumedCapacity: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// UnprocessedKeys are batchGet's analogue of UnprocessedItems above, driven
|
||||
// against the same kind of wire-format stub.
|
||||
describe('DDBClient — unprocessed batch keys', () => {
|
||||
let server: Server;
|
||||
let endpoint: string;
|
||||
let responses: unknown[];
|
||||
let requestCount = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
const next = responses.shift() ?? {};
|
||||
requestCount += 1;
|
||||
res.writeHead(200, {
|
||||
'content-type': 'application/x-amz-json-1.0',
|
||||
});
|
||||
res.end(JSON.stringify(next));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve),
|
||||
);
|
||||
endpoint = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
const stubClient = () =>
|
||||
new DDBClient({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
dynamo: {
|
||||
aws: { access_key: 'a', secret_key: 'b', region: 'us-west-2' },
|
||||
endpoint,
|
||||
},
|
||||
} as unknown as IConfig);
|
||||
|
||||
const unprocessed = (pk: string) => ({
|
||||
UnprocessedKeys: {
|
||||
[TABLE]: { Keys: [{ pk: { S: pk } }] },
|
||||
},
|
||||
ConsumedCapacity: [{ TableName: TABLE, CapacityUnits: 1 }],
|
||||
});
|
||||
|
||||
it('retries leftover keys and merges responses and capacity across attempts', async () => {
|
||||
requestCount = 0;
|
||||
responses = [
|
||||
unprocessed('retry-me'),
|
||||
{
|
||||
Responses: {
|
||||
[TABLE]: [{ pk: { S: 'retry-me' }, v: { N: '1' } }],
|
||||
},
|
||||
UnprocessedKeys: {},
|
||||
ConsumedCapacity: [{ TableName: TABLE, CapacityUnits: 2 }],
|
||||
},
|
||||
];
|
||||
|
||||
const result = await stubClient().batchGet([
|
||||
{ table: TABLE, items: { pk: 'retry-me' } },
|
||||
]);
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
expect(result.Responses?.[TABLE]).toEqual([{ pk: 'retry-me', v: 1 }]);
|
||||
expect(result.ConsumedCapacity).toEqual([
|
||||
{ TableName: TABLE, CapacityUnits: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives up with a bad-request error when keys never drain', async () => {
|
||||
requestCount = 0;
|
||||
responses = Array.from({ length: 12 }, () => unprocessed('stuck'));
|
||||
|
||||
await expect(
|
||||
stubClient().batchGet([{ table: TABLE, items: { pk: 'stuck' } }]),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
message: 'Failed to batch get all items from DynamoDB',
|
||||
});
|
||||
// One initial attempt plus the full retry budget.
|
||||
expect(requestCount).toBe(9);
|
||||
}, 30_000);
|
||||
|
||||
it('tolerates a response that reports no items or capacity at all', async () => {
|
||||
requestCount = 0;
|
||||
responses = [{ Responses: {}, UnprocessedKeys: {} }];
|
||||
|
||||
await expect(
|
||||
stubClient().batchGet([{ table: TABLE, items: { pk: 'quiet' } }]),
|
||||
).resolves.toEqual({ Responses: {}, ConsumedCapacity: [] });
|
||||
expect(requestCount).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores capacity entries with no table name', async () => {
|
||||
requestCount = 0;
|
||||
responses = [
|
||||
{
|
||||
Responses: {},
|
||||
UnprocessedKeys: {},
|
||||
ConsumedCapacity: [{ CapacityUnits: 7 }],
|
||||
},
|
||||
];
|
||||
|
||||
await expect(
|
||||
stubClient().batchGet([{ table: TABLE, items: { pk: 'anon' } }]),
|
||||
).resolves.toEqual({ Responses: {}, ConsumedCapacity: [] });
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,8 @@ 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 MAX_BATCH_GET_RETRIES = 8;
|
||||
const BATCH_GET_RETRY_BASE_MS = 25;
|
||||
|
||||
// In-memory mode gives each DDBClient its own unique key, which keys
|
||||
// into a fresh dynalite server. This is what test parallelism needs:
|
||||
@@ -234,6 +236,9 @@ export class DDBClient extends PuterClient {
|
||||
);
|
||||
}
|
||||
|
||||
// UnprocessedKeys retried with capped exponential backoff, same posture as
|
||||
// #batchWrite's UnprocessedItems: a batch this size can be throttled or
|
||||
// partially served, and the caller shouldn't have to know that.
|
||||
@Span('ddb.batchGet', (params: unknown[]) => ({
|
||||
'db.batch_size': params.length,
|
||||
}))
|
||||
@@ -250,25 +255,84 @@ export class DDBClient extends PuterClient {
|
||||
{} as Record<string, Record<string, unknown>[]>,
|
||||
);
|
||||
|
||||
const requestItems: BatchGetCommandInput['RequestItems'] =
|
||||
Object.entries(allRequestItemsPerTable).reduce(
|
||||
(acc, [table, keyList]) => {
|
||||
acc[table] = {
|
||||
Keys: keyList,
|
||||
ConsistentRead: consistentRead,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as NonNullable<BatchGetCommandInput['RequestItems']>,
|
||||
);
|
||||
|
||||
const command = new BatchGetCommand({
|
||||
RequestItems: requestItems,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
});
|
||||
let requestItems: BatchGetCommandInput['RequestItems'] = Object.entries(
|
||||
allRequestItemsPerTable,
|
||||
).reduce(
|
||||
(acc, [table, keyList]) => {
|
||||
acc[table] = {
|
||||
Keys: keyList,
|
||||
ConsistentRead: consistentRead,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as NonNullable<BatchGetCommandInput['RequestItems']>,
|
||||
);
|
||||
|
||||
const client = await this.#getDocumentClient();
|
||||
return client.send(command);
|
||||
const responsesByTable = new Map<string, Record<string, unknown>[]>();
|
||||
const consumedCapacityByTable = new Map<string, number>();
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_BATCH_GET_RETRIES; attempt++) {
|
||||
if (Object.keys(requestItems).length === 0) break;
|
||||
|
||||
const response = await client.send(
|
||||
new BatchGetCommand({
|
||||
RequestItems: requestItems,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [table, items] of Object.entries(
|
||||
response.Responses ?? {},
|
||||
)) {
|
||||
const existing = responsesByTable.get(table) ?? [];
|
||||
existing.push(...items);
|
||||
responsesByTable.set(table, existing);
|
||||
}
|
||||
for (const entry of response.ConsumedCapacity ?? []) {
|
||||
if (!entry.TableName) continue;
|
||||
consumedCapacityByTable.set(
|
||||
entry.TableName,
|
||||
(consumedCapacityByTable.get(entry.TableName) ?? 0) +
|
||||
Number(entry.CapacityUnits ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
const unprocessedKeys = response.UnprocessedKeys ?? {};
|
||||
if (Object.keys(unprocessedKeys).length === 0) {
|
||||
requestItems = {};
|
||||
break;
|
||||
}
|
||||
|
||||
requestItems = unprocessedKeys as NonNullable<
|
||||
BatchGetCommandInput['RequestItems']
|
||||
>;
|
||||
if (attempt < MAX_BATCH_GET_RETRIES) {
|
||||
const delayMs = Math.min(
|
||||
1000,
|
||||
BATCH_GET_RETRY_BASE_MS * 2 ** attempt,
|
||||
);
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(requestItems).length > 0) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Failed to batch get all items from DynamoDB',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
Responses: Object.fromEntries(responsesByTable),
|
||||
ConsumedCapacity: Array.from(consumedCapacityByTable.entries()).map(
|
||||
([TableName, CapacityUnits]) => ({
|
||||
TableName,
|
||||
CapacityUnits,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Span('ddb.batchPut', (params: unknown[]) => ({
|
||||
@@ -417,7 +481,15 @@ export class DDBClient extends PuterClient {
|
||||
async del<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
key: T,
|
||||
opts?: { returnOld?: boolean },
|
||||
opts?: {
|
||||
returnOld?: boolean;
|
||||
/** Refused (`ConditionalCheckFailedException`) unless it holds. */
|
||||
condition?: {
|
||||
expression: string;
|
||||
names?: Record<string, string>;
|
||||
values?: Record<string, unknown>;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const command = new DeleteCommand({
|
||||
TableName: table,
|
||||
@@ -426,6 +498,20 @@ export class DDBClient extends PuterClient {
|
||||
// ALL_OLD makes the delete an atomic claim: exactly one caller
|
||||
// gets the attributes back.
|
||||
...(opts?.returnOld ? { ReturnValues: 'ALL_OLD' as const } : {}),
|
||||
...(opts?.condition
|
||||
? {
|
||||
ConditionExpression: opts.condition.expression,
|
||||
...(opts.condition.names
|
||||
? { ExpressionAttributeNames: opts.condition.names }
|
||||
: {}),
|
||||
...(opts.condition.values
|
||||
? {
|
||||
ExpressionAttributeValues:
|
||||
opts.condition.values,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const client = await this.#getDocumentClient();
|
||||
@@ -697,14 +783,15 @@ export class DDBClient extends PuterClient {
|
||||
const scan = await client.send(
|
||||
new ScanCommand({
|
||||
TableName: table,
|
||||
FilterExpression: '#ttl < :now',
|
||||
// 0 means no expiry, so it must never sweep.
|
||||
FilterExpression: '#ttl < :now AND #ttl <> :none',
|
||||
ExpressionAttributeNames: {
|
||||
'#ttl': ttlAttribute,
|
||||
...Object.fromEntries(
|
||||
keyNames.map((key) => [`#k_${key}`, key]),
|
||||
),
|
||||
},
|
||||
ExpressionAttributeValues: { ':now': now },
|
||||
ExpressionAttributeValues: { ':now': now, ':none': 0 },
|
||||
ProjectionExpression: keyNames
|
||||
.map((key) => `#k_${key}`)
|
||||
.join(', '),
|
||||
@@ -715,8 +802,7 @@ export class DDBClient extends PuterClient {
|
||||
);
|
||||
|
||||
lastEvaluatedKey = scan.LastEvaluatedKey as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
const items = scan.Items;
|
||||
if (!items || items.length === 0) continue;
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ export interface EmailAttachment {
|
||||
path?: string;
|
||||
contentType?: string;
|
||||
encoding?: string;
|
||||
/** Content-ID of an inline part the html body references as `cid:`. */
|
||||
cid?: string;
|
||||
contentDisposition?: 'attachment' | 'inline';
|
||||
}
|
||||
|
||||
/** Subset of the transport's send result callers may care about. */
|
||||
|
||||
@@ -184,7 +184,7 @@ const SHARE_LIST_ROW = `
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width: 100%;">
|
||||
{{#each shares}}
|
||||
<tr>
|
||||
<td class="ink rule" style="padding: 12px 0;{{#unless @first}} border-top: 1px solid ${RULE};{{/unless}} font-family: ${FONT}; font-size: 16px; line-height: 24px; color: ${INK};"><strong style="font-weight: 600;">{{this.sender}}</strong> shared ${SHARE_ITEM_LIST}</td>
|
||||
<td class="ink rule" style="padding: 12px 0;{{#unless @first}} border-top: 1px solid ${RULE};{{/unless}} font-family: ${FONT}; font-size: 16px; line-height: 24px; color: ${INK};"><strong style="font-weight: 600;">{{this.sender}}</strong> shared ${SHARE_ITEM_LIST}{{#if this.via}} via {{this.via}}{{/if}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
@@ -351,9 +351,11 @@ [email protected] immediately.
|
||||
subject: 'Your {{team_name}} account on Puter',
|
||||
html: `
|
||||
<p>Hi there,</p>
|
||||
<p>{{team_name}} has created a Puter account for you: <b>{{username}}</b>.
|
||||
They will send you a temporary password separately; you will be asked to
|
||||
choose your own the first time you sign in.</p>
|
||||
<p>{{team_name}} has created a Puter account for you.</p>
|
||||
<p>Username: <b>{{username}}</b><br>
|
||||
Temporary password: <b>{{temporary_password}}</b></p>
|
||||
<p>You will be asked to choose your own password the first time you sign in.
|
||||
This temporary one stops working then, and it expires on its own if unused.</p>
|
||||
<p>What this means:</p>
|
||||
<ul>
|
||||
<li>This account belongs to {{team_name}}. They pay for it and can close it.</li>
|
||||
@@ -402,6 +404,22 @@ will be asked to choose your own the next time you sign in.</p>
|
||||
<p>If you did not expect this, the record of who reset it and when is in your
|
||||
team activity, along with every sign-in to your account.</p>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
team_2fa_reset: {
|
||||
subject:
|
||||
'Two-factor authentication was reset on your {{team_name}} account',
|
||||
html: `
|
||||
<p>Hi there,</p>
|
||||
<p>An administrator of {{team_name}} reset two-factor authentication on your
|
||||
Puter account <b>{{username}}</b>, so you can set it up again from a device you
|
||||
still have. You have been signed out everywhere.</p>
|
||||
<p>Your password is unchanged. If your team requires two-factor authentication,
|
||||
you will be asked to enrol the next time you sign in.</p>
|
||||
<p>If you did not expect this, the record of who reset it and when is in your
|
||||
team activity, along with every sign-in to your account.</p>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
@@ -456,7 +474,7 @@ immediately</p>
|
||||
|
||||
Shared with you on Puter:
|
||||
{{#each shares}}
|
||||
- {{this.sender}} shared {{this.what}}
|
||||
- {{this.sender}} shared {{this.what}}{{#if this.via}} via {{this.via}}{{/if}}
|
||||
{{#each this.items}}{{#if this.link}} {{this.name}}: {{this.link}}
|
||||
{{/if}}{{/each}}{{/each}}
|
||||
|
||||
@@ -494,7 +512,7 @@ immediately</p>
|
||||
|
||||
Shared with you on Puter:
|
||||
{{#each shares}}
|
||||
- {{this.sender}} shared {{this.what}}
|
||||
- {{this.sender}} shared {{this.what}}{{#if this.via}} via {{this.via}}{{/if}}
|
||||
{{/each}}
|
||||
|
||||
There's no Puter account for {{email}} yet. Create one with this
|
||||
|
||||
@@ -54,19 +54,9 @@ export type TeamBillingEvent = TeamBillingContext & {
|
||||
};
|
||||
|
||||
/**
|
||||
* Extension-augmentable half of {@link EventMap}. Extensions that emit their own
|
||||
* events declare the payload here by declaration merging, so both the emitter
|
||||
* and every listener are typed against the same shape:
|
||||
*
|
||||
* declare module '@heyputer/backend/clients/event/types' {
|
||||
* interface IExtensionEventMap {
|
||||
* 'my.thing.happened': { thingId: string };
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Deliberately member-less and index-signature-free: an index signature here
|
||||
* would widen `keyof EventMap` to `string` and silently disable key checking on
|
||||
* every `emit` in the tree.
|
||||
* Extension-augmentable half of {@link EventMap}, declaration-merged like
|
||||
* `IExtensionClientInstances`. No index signature: it would widen `keyof
|
||||
* EventMap` to `string` and disable key checking on every `emit`.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface IExtensionEventMap {}
|
||||
@@ -237,14 +227,57 @@ export type EventMap = {
|
||||
fingerprint?: string | null;
|
||||
/** True when the created account is a temp user (no email/password). */
|
||||
is_temp?: boolean;
|
||||
/** The bonus code `puter.signup-bonus.validate` accepted, if any. */
|
||||
bonus_code?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
// Signup bonus codes are pure mechanism here: an extension decides what a
|
||||
// code is worth and fills these in (both emitted via `emitAndWait`).
|
||||
'puter.signup-bonus.check': {
|
||||
code: string;
|
||||
ip: string | null;
|
||||
fingerprint: string | null;
|
||||
valid: boolean;
|
||||
/** Opaque to core; forwarded to the client when `valid` is false. */
|
||||
reason: string | null;
|
||||
display: { title: string; description: string } | null;
|
||||
/** Verification the code will require after signup. */
|
||||
requirements: { phone: boolean; card: boolean } | null;
|
||||
};
|
||||
/**
|
||||
* Emitted once a signup has passed `puter.signup.validate`, so listeners
|
||||
* see the harness's verdict. A listener sets `accepted` to honor the code,
|
||||
* and may raise (never lower) the verification requirements.
|
||||
*/
|
||||
'puter.signup-bonus.validate': {
|
||||
code: string;
|
||||
email?: string;
|
||||
clean_email?: string;
|
||||
ip?: string | null;
|
||||
fingerprint?: string | null;
|
||||
reputation?: number | null;
|
||||
source?: 'oidc';
|
||||
requires_phone_verification: boolean;
|
||||
requires_card_verification: boolean;
|
||||
accepted: boolean;
|
||||
};
|
||||
'email.validate': {
|
||||
email: string;
|
||||
allow: boolean;
|
||||
message: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
// Inbound mail to a feedback address (`abuse@`, `fbl@`, ...), emitted via
|
||||
// `emitAndWait`. A listener that takes it sets `handled` and it reaches no
|
||||
// mailbox; otherwise it is routed like any other message.
|
||||
'email.ingress.feedback': {
|
||||
/** The envelope recipient, as the relay presented it. */
|
||||
to: string;
|
||||
from: string | null;
|
||||
/** The whole message, exactly as received. */
|
||||
raw: Buffer;
|
||||
handled: boolean;
|
||||
};
|
||||
'user.save_account': {
|
||||
user_id: number;
|
||||
old_username?: string;
|
||||
@@ -294,6 +327,7 @@ export type EventMap = {
|
||||
'puter.card-verification.setup': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
email?: string;
|
||||
ip?: string | null;
|
||||
// Client-supplied device fingerprint for this request, or null. Lets
|
||||
// the abuse extension cap card-verification setups per device (across
|
||||
@@ -320,6 +354,7 @@ export type EventMap = {
|
||||
'puter.card-verification.confirm': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
email?: string;
|
||||
setup_intent_id: string;
|
||||
enabled: boolean | null;
|
||||
verified: boolean;
|
||||
@@ -431,6 +466,16 @@ export type EventMap = {
|
||||
userId: number;
|
||||
keys: string[];
|
||||
op: KvOp;
|
||||
/**
|
||||
* What each key holds after the change, aligned with `keys`: the
|
||||
* written value on a `set`, `null` on a `del`. Absent when the write
|
||||
* did not have it in hand, as an `expire` does not.
|
||||
*/
|
||||
values?: unknown[];
|
||||
/** Keys among `keys` private to the namespace's app. */
|
||||
noShareKeys?: string[];
|
||||
/** Events handler runs behind the write; see `Actor.handlerDepth`. */
|
||||
handlerDepth?: number;
|
||||
};
|
||||
/**
|
||||
* A whole namespace was emptied. Namespace-level on purpose: `flush`'s own
|
||||
@@ -454,6 +499,8 @@ export type EventMap = {
|
||||
appUid: string | null;
|
||||
value: Record<string, unknown>;
|
||||
createdAt: number;
|
||||
/** Events handler runs behind whatever created it. */
|
||||
handlerDepth?: number;
|
||||
};
|
||||
/**
|
||||
* One notification, addressed at whatever sockets each region holds for the
|
||||
@@ -557,6 +604,11 @@ export type EventMap = {
|
||||
generation: number;
|
||||
/** Whether the table changed; only then does a peer need to re-read it. */
|
||||
durable: boolean;
|
||||
/**
|
||||
* Session subscriptions this bump ended, so siblings stop queueing for
|
||||
* them.
|
||||
*/
|
||||
ended?: string[];
|
||||
};
|
||||
/**
|
||||
* A user's sockets moved between regions, so every node must drop what it
|
||||
@@ -607,6 +659,16 @@ export type EventMap = {
|
||||
requestHash?: string;
|
||||
mime: string;
|
||||
};
|
||||
// Asked before a hosted file is streamed. A listener that owns the site
|
||||
// sets `result.allowed = false` to withhold the entry; the visitor then
|
||||
// sees the same 404 as for a missing file.
|
||||
'site.access.check': {
|
||||
subdomain: string;
|
||||
host: string;
|
||||
requestPath: string;
|
||||
entry: { name: string; path: string };
|
||||
result: { allowed: boolean };
|
||||
};
|
||||
|
||||
// ---- Thumbnails ----
|
||||
// The listener rewrites `thumbnail` in place (an s3:// key or legacy
|
||||
@@ -618,7 +680,8 @@ export type EventMap = {
|
||||
uid?: string;
|
||||
thumbnail?: string | null;
|
||||
};
|
||||
'thumbnail.created': { url: string };
|
||||
/** `uuid` is the entry the thumbnail belongs to. */
|
||||
'thumbnail.created': { url: string; uuid: string };
|
||||
'thumbnail.upload.prepare': {
|
||||
items: { index: number; item_uid: string }[];
|
||||
uploadUrl?: string;
|
||||
@@ -666,6 +729,7 @@ export type EventMap = {
|
||||
email?: string;
|
||||
app_name?: string;
|
||||
};
|
||||
/** False for an app actor: add only `subscribed` / `paid_storage` then. */
|
||||
isUser: boolean;
|
||||
};
|
||||
'wisp.get-policy': {
|
||||
@@ -695,6 +759,11 @@ export type EventMap = {
|
||||
// normalized path: `route.<method>.<path>.before|after|error|reject`. Same
|
||||
// wildcard + veto semantics as the driver lifecycle above.
|
||||
[K in `route.${string}`]: RouteLifecycleEvent;
|
||||
} & {
|
||||
// Cost factor for recorded AI usage, keyed by driver and model:
|
||||
// `ai.cost.factor.<driverName>.<provider>:<model>`. Emitted once per model
|
||||
// per batch; the last listener to set `factor` wins.
|
||||
[K in `ai.cost.factor.${string}`]: AiCostFactorEvent;
|
||||
} & {
|
||||
[K in `pubsub.login.${string}`]: { authtoken: string };
|
||||
} & {
|
||||
@@ -715,6 +784,18 @@ export type EventMap = {
|
||||
'outer.pubsub.metering.credits-changed': { userUuid: string };
|
||||
} & IExtensionEventMap;
|
||||
|
||||
/** Payload for `ai.cost.factor.<driver>.<model>` events. */
|
||||
export type AiCostFactorEvent = {
|
||||
/** Driver doing the pricing, e.g. `ai-chat`. */
|
||||
driver: string;
|
||||
/** `<provider>:<model>` the usage is recorded under. */
|
||||
model: string;
|
||||
/** Who the usage is being charged to. */
|
||||
actor: Actor;
|
||||
/** Applied to the cost, starting at 1. Values <= 0 are ignored. */
|
||||
factor: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -793,10 +874,11 @@ export type EventKey = keyof EventMap & string;
|
||||
// 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)
|
||||
?
|
||||
| `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
: never;
|
||||
|
||||
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
|
||||
|
||||
@@ -140,13 +140,9 @@ export interface EventsInvokeCall {
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an invocation is delivered.
|
||||
*
|
||||
* `status` is what the handler answered with. A `null` status means nothing
|
||||
* ran: no transport, no script, or a dispatcher that refused us — all of which
|
||||
* are the platform's fault and so retriable. `handled` is set only when the
|
||||
* answer is provably the script's own (the handled header); a 4xx without it
|
||||
* came from something between here and the script, not a handler's refusal.
|
||||
* Delivers an invocation. `status` null means nothing ran (retriable);
|
||||
* `handled` is set only when the answer provably came from the script, so an
|
||||
* unmarked 4xx is not a handler's refusal.
|
||||
*/
|
||||
export interface EventsInvokeTransport {
|
||||
send(call: EventsInvokeCall): Promise<{
|
||||
@@ -159,14 +155,9 @@ export interface EventsInvokeTransport {
|
||||
}
|
||||
|
||||
/**
|
||||
* What the call did.
|
||||
*
|
||||
* - `settled` — the handler took the delivery (2xx).
|
||||
* - `terminal` — it refused it (a marked 4xx). Retrying sends the same body to
|
||||
* the same code, so it is not retried.
|
||||
* - `retriable` — it could not answer: 5xx, 429, a timeout, a transport failure,
|
||||
* an unmarked 4xx (nothing said it was a handler's answer), or no way to
|
||||
* reach the worker at all.
|
||||
* `settled`: handler accepted (2xx). `terminal`: handler refused (marked 4xx),
|
||||
* not retried. `retriable`: no handler answer (5xx, 429, timeout, transport
|
||||
* failure, unmarked 4xx).
|
||||
*/
|
||||
export type WorkerInvokeOutcome = 'settled' | 'terminal' | 'retriable';
|
||||
|
||||
|
||||
@@ -107,14 +107,8 @@ const buildCluster = (config: IConfig): Cluster => {
|
||||
};
|
||||
|
||||
/**
|
||||
* `RedisClient` IS the ioredis `Cluster` instance — consumers call
|
||||
* `this.clients.redis.get(...)` / `.set(...)` directly rather than going
|
||||
* through an inner `.client` field. Lifecycle methods (`onServerShutdown`) are
|
||||
* attached onto the cluster instance itself.
|
||||
*
|
||||
* Type-wise, `RedisClient` is `Cluster & WithLifecycle`; the registry- facing
|
||||
* value below is a constructor that returns that shape. Mirrors the
|
||||
* `DatabaseClientFactory` pattern.
|
||||
* The ioredis `Cluster` itself, with lifecycle methods attached; the
|
||||
* constructor below returns it.
|
||||
*/
|
||||
export type RedisClient = Cluster & WithLifecycle;
|
||||
|
||||
|
||||
@@ -21,32 +21,19 @@ import type { IConfig, WithLifecycle } from '../types';
|
||||
import type { ClickhouseClient } from './clickhouse/ClickhouseClient';
|
||||
|
||||
/**
|
||||
* Extension-augmentable client registry. Extensions add their own client
|
||||
* instance types via TypeScript declaration merging:
|
||||
*
|
||||
* declare module '@heyputer/backend/clients/types' {
|
||||
* interface IExtensionClientInstances {
|
||||
* myClient: MyClient;
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Augmentations flow into `this.clients` everywhere it's typed (PuterStore,
|
||||
* PuterService, PuterController, PuterDriver) and into the
|
||||
* `extension.import('client')` proxy.
|
||||
* Extension-augmentable client registry. Extensions add typed keys by
|
||||
* declaration-merging this interface from `@heyputer/backend/clients/types`;
|
||||
* the result is what `this.clients` and `extension.import('client')` see. The
|
||||
* same pattern applies to the store, service, driver and controller
|
||||
* registries.
|
||||
*/
|
||||
export interface IExtensionClientInstances {
|
||||
/**
|
||||
* Open index signature so reads of extension-only client keys return
|
||||
* `unknown` instead of a type error. Concrete declaration-merged keys
|
||||
* override this for that name.
|
||||
*/
|
||||
/** Unmerged extension keys read as `unknown` rather than erroring. */
|
||||
[key: string]: unknown;
|
||||
|
||||
/**
|
||||
* Optional ClickHouse analytics client. Absent by default — a production
|
||||
* deployment registers it via an extension to speed up the app-stats path
|
||||
* at scale (see {@link ClickhouseClient}). Always branch on its presence and
|
||||
* fall back to SQL.
|
||||
* Registered by an extension; absent by default, so branch on it and fall
|
||||
* back to SQL.
|
||||
*/
|
||||
clickhouse?: ClickhouseClient;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* 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 { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { IConfig } from './types';
|
||||
|
||||
// Config resolution order:
|
||||
// 1. `process.env.PUTER_CONFIG_PATH` — absolute path to a config file. Used
|
||||
// by deployments whose outer bootstrap writes a merged config to a known
|
||||
// location.
|
||||
// 2. `<PACKAGE_ROOT>/config.json` — user's runtime override (gitignored),
|
||||
// deep-merged over config.default.json so users can omit keys they
|
||||
// don't care to override (e.g. gui_assets_root, database).
|
||||
// 3. `<PACKAGE_ROOT>/config.default.json` — bundled OSS defaults.
|
||||
//
|
||||
// Post-flatten depth: compiled file is at `packages/puter/dist/src/backend/index.js`,
|
||||
// so three `..`s land at `packages/puter/`.
|
||||
const PACKAGE_ROOT = path.resolve(__dirname, '../../..');
|
||||
// Root of the running code tree. Matches PACKAGE_ROOT for a source run, but
|
||||
// points at `dist/` for a compiled run — so config-declared paths like
|
||||
// `./extensions` resolve to `dist/extensions` at runtime without the config
|
||||
// having to know about the build layout.
|
||||
const RUNTIME_ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
|
||||
const deepMerge = <T extends Record<string, unknown>>(
|
||||
base: T,
|
||||
override: Record<string, unknown>,
|
||||
): T => {
|
||||
const out: Record<string, unknown> = { ...base };
|
||||
for (const [k, v] of Object.entries(override)) {
|
||||
out[k] =
|
||||
isPlainObject(v) && isPlainObject(out[k])
|
||||
? deepMerge(out[k] as Record<string, unknown>, v)
|
||||
: v;
|
||||
}
|
||||
return out as T;
|
||||
};
|
||||
|
||||
export const loadConfig = (): IConfig => {
|
||||
const envPath = process.env.PUTER_CONFIG_PATH;
|
||||
const runtimePath = path.join(PACKAGE_ROOT, 'config.json');
|
||||
const defaultPath = path.join(PACKAGE_ROOT, 'config.default.json');
|
||||
|
||||
const defaults = existsSync(defaultPath)
|
||||
? (JSON.parse(readFileSync(defaultPath, 'utf8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
: {};
|
||||
|
||||
// Runtime override path: env wins, then config.json, else no override
|
||||
// (we still return defaults so single-file installs work).
|
||||
const overridePath =
|
||||
envPath && existsSync(envPath)
|
||||
? envPath
|
||||
: existsSync(runtimePath)
|
||||
? runtimePath
|
||||
: null;
|
||||
|
||||
console.log(`[config] defaults from ${defaultPath}`);
|
||||
if (overridePath) console.log(`[config] override from ${overridePath}`);
|
||||
|
||||
const override = overridePath
|
||||
? (JSON.parse(readFileSync(overridePath, 'utf8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
: {};
|
||||
|
||||
const config = deepMerge(defaults, override) as IConfig;
|
||||
|
||||
if (!config.version) {
|
||||
const pkgPath = path.join(PACKAGE_ROOT, 'package.json');
|
||||
if (existsSync(pkgPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as {
|
||||
version?: string;
|
||||
};
|
||||
if (pkg.version) config.version = pkg.version;
|
||||
} catch {
|
||||
// fall through — /version returns 'unknown'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Computed defaults. `origin` and `pub_port` are the externally-visible
|
||||
// URL+port — what the browser sees. Separate from `port`, which is the
|
||||
// bind port (can differ when behind a reverse proxy). Code paths that
|
||||
// build self-referential URLs (GUI bootstrap, email links, OIDC callbacks)
|
||||
// depend on `origin` having the right port baked in.
|
||||
if (config.pub_port === undefined) config.pub_port = config.port;
|
||||
const protocol = config.protocol ?? 'http';
|
||||
const domain = config.domain ?? 'localhost';
|
||||
const portSuffix =
|
||||
config.pub_port === 80 || config.pub_port === 443
|
||||
? ''
|
||||
: `:${config.pub_port}`;
|
||||
if (config.origin === undefined) {
|
||||
config.origin = `${protocol}://${domain}${portSuffix}`;
|
||||
}
|
||||
// API lives on the `api.` subdomain on the same host+port as the main
|
||||
// origin (see PuterRouter subdomain handling in server.ts). Compute it
|
||||
// from pub_port/domain so a single-port override (e.g. port: 5101) flows
|
||||
// through to the GUI bootstrap without users having to restate the URL.
|
||||
if (config.api_base_url === undefined) {
|
||||
config.api_base_url = `${protocol}://api.${domain}${portSuffix}`;
|
||||
}
|
||||
|
||||
// Resolve path-valued config fields. Two different roots:
|
||||
// - `extensions` uses RUNTIME_ROOT because extensions ship inside the
|
||||
// build output (dist/extensions) and the loader's dynamic import()
|
||||
// resolves relative paths against the *importing* module file.
|
||||
// - GUI/puter-js/builtin-apps use PACKAGE_ROOT because those assets
|
||||
// live only in the source tree (not copied into dist/) and are served
|
||||
// via express.static at runtime.
|
||||
const resolveRuntime = (p: string): string =>
|
||||
path.isAbsolute(p) ? p : path.resolve(RUNTIME_ROOT, p);
|
||||
const resolvePackage = (p: string): string =>
|
||||
path.isAbsolute(p) ? p : path.resolve(PACKAGE_ROOT, p);
|
||||
|
||||
if (Array.isArray(config.extensions)) {
|
||||
config.extensions = config.extensions.map(resolveRuntime);
|
||||
}
|
||||
if (typeof config.gui_assets_root === 'string') {
|
||||
config.gui_assets_root = resolvePackage(config.gui_assets_root);
|
||||
}
|
||||
if (typeof config.puterjs_root === 'string') {
|
||||
config.puterjs_root = resolvePackage(config.puterjs_root);
|
||||
}
|
||||
if (isPlainObject(config.builtin_apps)) {
|
||||
for (const [k, v] of Object.entries(config.builtin_apps)) {
|
||||
if (typeof v === 'string') {
|
||||
(config.builtin_apps as Record<string, string>)[k] =
|
||||
resolvePackage(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return config;
|
||||
};
|
||||
@@ -69,6 +69,11 @@ const APP_ICON_LIMIT = {
|
||||
key: 'ip',
|
||||
};
|
||||
|
||||
// Shared by `GET /apps/:name` (pipe-separated) and `POST /query/app`, which
|
||||
// both fan out one lookup per name.
|
||||
const APP_LOOKUP_MAX_ENTRIES = 200;
|
||||
const APP_LOOKUP_MAX_SELECTOR_LEN = 200;
|
||||
|
||||
export class AppController extends PuterController {
|
||||
get appStore() {
|
||||
return this.stores.app;
|
||||
@@ -186,14 +191,13 @@ export class AppController extends PuterController {
|
||||
|
||||
// POST /rao — record a recent app open. When an app-under-user
|
||||
// actor calls this, the app id is already on the token — clients
|
||||
// don't re-send it in the body. Fall back to `actor.app.uid`
|
||||
// don't re-send it in the body. Fall back to the acting app's uid
|
||||
// before 400-ing for a missing body field.
|
||||
//
|
||||
// Authorization: only two callers are trusted to report opens —
|
||||
// 1. a root user actor (plain session, no `.app` and no access
|
||||
// token), e.g. the GUI launching apps on behalf of the user;
|
||||
// 2. the app-under-user actor for the app being reported, i.e.
|
||||
// `actor.app.uid === app_uid`.
|
||||
// 2. the app-under-user actor for the app being reported.
|
||||
// Everything else — access tokens (regardless of issuer), asset
|
||||
// tokens, app actors reporting for a *different* app — is denied,
|
||||
// otherwise any authenticated party could inflate another app's
|
||||
@@ -208,11 +212,11 @@ export class AppController extends PuterController {
|
||||
async (req, res) => {
|
||||
const actor = req.actor;
|
||||
const bodyAppUid = req.body?.app_uid;
|
||||
const actorAppUid = actor?.app?.uid;
|
||||
const callerAppUid = actor?.effectiveApp?.uid;
|
||||
const app_uid =
|
||||
typeof bodyAppUid === 'string' && bodyAppUid.length > 0
|
||||
? bodyAppUid
|
||||
: actorAppUid;
|
||||
: callerAppUid;
|
||||
if (!app_uid || typeof app_uid !== 'string') {
|
||||
throw new HttpError(400, 'Missing or invalid `app_uid`', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -231,7 +235,7 @@ export class AppController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
if (isAppActor(actor) && app_uid !== actorAppUid) {
|
||||
if (isAppActor(actor) && app_uid !== callerAppUid) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'App actors can only report opens for their own app',
|
||||
@@ -267,11 +271,21 @@ export class AppController extends PuterController {
|
||||
async (req, res) => {
|
||||
const raw = req.params.name;
|
||||
const names = raw.split('|').filter(Boolean);
|
||||
if (names.length > APP_LOOKUP_MAX_ENTRIES) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`at most ${APP_LOOKUP_MAX_ENTRIES} app names per request`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
|
||||
const userUid = req.actor?.user?.uuid ?? null;
|
||||
|
||||
const results = await Promise.all(
|
||||
names.map(async (name) => {
|
||||
if (name.length > APP_LOOKUP_MAX_SELECTOR_LEN) {
|
||||
return null;
|
||||
}
|
||||
const app = await this.appStore.getByName(name);
|
||||
if (!app) return null;
|
||||
let shaped;
|
||||
@@ -328,9 +342,6 @@ export class AppController extends PuterController {
|
||||
// included for public/owned apps only, consistent with
|
||||
// marketplace semantics.
|
||||
|
||||
const QUERY_APP_MAX_ENTRIES = 200;
|
||||
const QUERY_APP_MAX_SELECTOR_LEN = 200;
|
||||
|
||||
router.post(
|
||||
'/query/app',
|
||||
{
|
||||
@@ -340,10 +351,10 @@ export class AppController extends PuterController {
|
||||
},
|
||||
async (req, res) => {
|
||||
const appList = Array.isArray(req.body) ? req.body : [];
|
||||
if (appList.length > QUERY_APP_MAX_ENTRIES) {
|
||||
if (appList.length > APP_LOOKUP_MAX_ENTRIES) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`request body must contain at most ${QUERY_APP_MAX_ENTRIES} selectors`,
|
||||
`request body must contain at most ${APP_LOOKUP_MAX_ENTRIES} selectors`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
@@ -355,7 +366,7 @@ export class AppController extends PuterController {
|
||||
if (
|
||||
typeof selector !== 'string' ||
|
||||
selector.length === 0 ||
|
||||
selector.length > QUERY_APP_MAX_SELECTOR_LEN
|
||||
selector.length > APP_LOOKUP_MAX_SELECTOR_LEN
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
@@ -411,10 +411,10 @@ describe('AppController POST /rao', () => {
|
||||
const app = await createApp(owner.actor);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
const actorWithApp: unknown = {
|
||||
const actorWithApp: unknown = makeActor({
|
||||
...owner.actor,
|
||||
app: { uid: app.uid },
|
||||
};
|
||||
});
|
||||
await withActor(owner.actor, () =>
|
||||
callRoute(
|
||||
'post',
|
||||
@@ -538,6 +538,57 @@ describe('AppController GET /apps/:name', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('throws 400 when the pipe-batch exceeds the 200-name cap', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
const names = new Array(201).fill('x').join('|');
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
callRoute(
|
||||
'get',
|
||||
'/apps/:name',
|
||||
makeReq({ params: { name: names }, actor }),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('accepts a pipe-batch at exactly the 200-name cap', async () => {
|
||||
const owner = await makeUser();
|
||||
const app = await createApp(owner.actor);
|
||||
const names = [app.name, ...new Array(199).fill('missing-app')].join(
|
||||
'|',
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(owner.actor, () =>
|
||||
callRoute(
|
||||
'get',
|
||||
'/apps/:name',
|
||||
makeReq({ params: { name: names }, actor: owner.actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(Array.isArray(captured.body)).toBe(true);
|
||||
expect(captured.body as unknown[]).toHaveLength(200);
|
||||
});
|
||||
|
||||
it('throws 404 for a single name over the per-name length cap', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
callRoute(
|
||||
'get',
|
||||
'/apps/:name',
|
||||
makeReq({ params: { name: 'x'.repeat(201) }, actor }),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('omits the stale index_url for a non-owner', async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
@@ -901,10 +952,10 @@ describe('AppController POST /rao actor gating', () => {
|
||||
const { actor } = await makeUser();
|
||||
const own = await createApp(actor);
|
||||
const other = await createApp(actor);
|
||||
const appActor = {
|
||||
const appActor = makeActor({
|
||||
...actor,
|
||||
app: { uid: own.uid as string },
|
||||
} as unknown as Actor;
|
||||
} as unknown as Actor);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
/*
|
||||
* 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 { TOTP } from 'otpauth';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
|
||||
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types';
|
||||
|
||||
/**
|
||||
* HTTP-level coverage for `/auth/revoke-own-access-token` — the route that
|
||||
* lets an app revoke a read-URL token it minted itself, without touching the
|
||||
* account-only `/auth/revoke-access-token`.
|
||||
*/
|
||||
describe('revoke-own-access-token over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const call = (
|
||||
method: string,
|
||||
path: string,
|
||||
token: string,
|
||||
body?: unknown,
|
||||
) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
const tokenReadStatus = async (fileUid: string, token: string) => {
|
||||
const url = new URL('/token-read', env.apiOrigin);
|
||||
url.searchParams.set('uid', fileUid);
|
||||
url.searchParams.set('token', token);
|
||||
return (await fetch(url)).status;
|
||||
};
|
||||
|
||||
/** A file with real content in `owner`'s home, readable by uid. */
|
||||
const makeFile = async (owner: { username: string }) => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const content = Buffer.from(`revoke-http-${crypto.randomUUID()}`);
|
||||
const path = `/${owner.username}/Documents/${crypto.randomUUID()}.txt`;
|
||||
await env.server.services.fs.write(user!.id, {
|
||||
fileMetadata: {
|
||||
path,
|
||||
size: content.byteLength,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: content,
|
||||
});
|
||||
const entry = await env.server.stores.fsEntry.getEntryByPath(path);
|
||||
return { uid: entry!.uid };
|
||||
};
|
||||
|
||||
/** An app of `owner`'s, with a token and read reach over `file`. */
|
||||
const makeApp = async (
|
||||
owner: { username: string },
|
||||
file: { uid: string },
|
||||
) => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const actor = makeActor({ user: user! });
|
||||
const app = await env.server.stores.app.create(
|
||||
{
|
||||
name: `auth-http-app-${crypto.randomUUID()}`,
|
||||
title: 'Auth app',
|
||||
index_url: `https://auth-${crypto.randomUUID()}.test/`,
|
||||
},
|
||||
{ ownerUserId: actor.user.id! },
|
||||
);
|
||||
await runWithContext({ actor }, () =>
|
||||
env.server.services.permission.grantUserAppPermission(
|
||||
actor,
|
||||
app.uid,
|
||||
`fs:${file.uid}:read`,
|
||||
),
|
||||
);
|
||||
const token = await env.server.services.auth.getUserAppToken(
|
||||
actor,
|
||||
app.uid,
|
||||
);
|
||||
return { ...app, token };
|
||||
};
|
||||
|
||||
/** A scoped (non-full-access) access token for `owner`, carrying no app. */
|
||||
const makeScopedToken = async (owner: {
|
||||
username: string;
|
||||
token: string;
|
||||
}) => {
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/create-access-token',
|
||||
owner.token,
|
||||
{ permissions: ['service:foo:ii:read'], expiresIn: '1h' },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { token: string }).token;
|
||||
};
|
||||
|
||||
/** Mints a read-URL token the way `getReadURL()` does. */
|
||||
const mintReadToken = async (callerToken: string, fileUid: string) => {
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/create-access-token',
|
||||
callerToken,
|
||||
{ permissions: [`fs:${fileUid}:read`], expiresIn: '1h' },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { token: string }).token;
|
||||
};
|
||||
|
||||
it('lets an app revoke a read token it minted itself', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const app = await makeApp(owner, file);
|
||||
const readToken = await mintReadToken(app.token, file.uid);
|
||||
|
||||
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
app.token,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
|
||||
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
|
||||
});
|
||||
|
||||
it('404s a second app of the same user, and the URL still works', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const app = await makeApp(owner, file);
|
||||
const otherApp = await makeApp(owner, file);
|
||||
const readToken = await mintReadToken(app.token, file.uid);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
otherApp.token,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.json()).toMatchObject({ code: 'not_found' });
|
||||
|
||||
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
|
||||
});
|
||||
|
||||
it('lets the account session revoke an app-minted token', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const app = await makeApp(owner, file);
|
||||
const readToken = await mintReadToken(app.token, file.uid);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.token,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
|
||||
});
|
||||
|
||||
it('404s another user entirely', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const app = await makeApp(owner, file);
|
||||
const readToken = await mintReadToken(app.token, file.uid);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
env.users.other.token,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.json()).toMatchObject({ code: 'not_found' });
|
||||
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
|
||||
});
|
||||
|
||||
it('404s an app presenting an account-issued token', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const app = await makeApp(owner, file);
|
||||
// Minted with the owner's own session, so it carries no `app_uid`.
|
||||
const accountToken = await mintReadToken(owner.token, file.uid);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
app.token,
|
||||
{ token: accountToken },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.json()).toMatchObject({ code: 'not_found' });
|
||||
expect(await tokenReadStatus(file.uid, accountToken)).toBe(200);
|
||||
});
|
||||
|
||||
it('refuses a scoped access token as caller', async () => {
|
||||
const owner = env.users.user;
|
||||
const scoped = await makeScopedToken(owner);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
scoped,
|
||||
{ token: scoped },
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ code: 'forbidden' });
|
||||
});
|
||||
|
||||
it('rejects a non-JWT string with token_invalid', async () => {
|
||||
const owner = env.users.user;
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.token,
|
||||
{ token: 'not-a-jwt' },
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toMatchObject({ code: 'token_invalid' });
|
||||
});
|
||||
|
||||
it('still requires `token` in the body', async () => {
|
||||
const owner = env.users.user;
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.token,
|
||||
{},
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toMatchObject({ code: 'bad_request' });
|
||||
});
|
||||
|
||||
/** A second personal API token for `owner`, minted the way the dashboard does. */
|
||||
const mintPersonalToken = async (owner: { token: string }) => {
|
||||
const { actor } = await env.server.services.auth.authenticate(
|
||||
owner.token,
|
||||
);
|
||||
return env.server.services.auth.createAccessToken(actor!, [
|
||||
[FULL_API_ACCESS],
|
||||
]);
|
||||
};
|
||||
|
||||
const whoamiStatus = async (token: string) =>
|
||||
(await call('GET', '/whoami', token)).status;
|
||||
|
||||
it('refuses to revoke a personal API token, even for its own account', async () => {
|
||||
const owner = env.users.user;
|
||||
const pat = await mintPersonalToken(owner);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.token,
|
||||
{ token: pat },
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ code: 'forbidden' });
|
||||
expect(await whoamiStatus(pat)).toBe(200);
|
||||
});
|
||||
|
||||
it('keeps a personal API token from revoking a sibling one', async () => {
|
||||
const owner = env.users.user;
|
||||
const sibling = await mintPersonalToken(owner);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.apiToken,
|
||||
{ token: sibling },
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ code: 'forbidden' });
|
||||
expect(await whoamiStatus(sibling)).toBe(200);
|
||||
});
|
||||
|
||||
it('lets a personal API token revoke its account`s read token', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
// Access tokens can't mint, so the account session creates the URL.
|
||||
const readToken = await mintReadToken(owner.token, file.uid);
|
||||
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.apiToken,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
|
||||
});
|
||||
|
||||
it('resolves a second revoke of the same token', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const app = await makeApp(owner, file);
|
||||
const readToken = await mintReadToken(app.token, file.uid);
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
app.token,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
|
||||
});
|
||||
|
||||
// These three run last in the file's shared `env.users.user`: each one
|
||||
// revokes a session belonging to `owner.token` / `owner.workerToken`, so
|
||||
// later tests can't rely on those credentials still being live.
|
||||
|
||||
it('a revoked access token yields 401 with no reauth_token or auth_id', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const readToken = await mintReadToken(owner.token, file.uid);
|
||||
|
||||
const revoke = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.token,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(revoke.status).toBe(200);
|
||||
|
||||
const res = await call('GET', '/whoami', readToken);
|
||||
expect(res.status).toBe(401);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.code).toBe('reauth_required');
|
||||
expect(body.reauth_token).toBeUndefined();
|
||||
expect(body.auth_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a revoked worker session gets no reauth_token even though it rides the session token type', async () => {
|
||||
const owner = env.users.user;
|
||||
const decoded = env.server.services.token.verify(
|
||||
'auth',
|
||||
owner.workerToken,
|
||||
) as { session_uid: string };
|
||||
await env.server.stores.session.removeByUuid(decoded.session_uid);
|
||||
|
||||
const res = await call('GET', '/whoami', owner.workerToken);
|
||||
expect(res.status).toBe(401);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.code).toBe('reauth_required');
|
||||
expect(body.reauth_token).toBeUndefined();
|
||||
expect(body.auth_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a revoked GUI session still gets a reauth_token', async () => {
|
||||
const owner = env.users.user;
|
||||
const decoded = env.server.services.token.verify('auth', owner.token) as {
|
||||
session_uid: string;
|
||||
};
|
||||
await env.server.stores.session.removeByUuid(decoded.session_uid);
|
||||
|
||||
const res = await call('GET', '/whoami', owner.token);
|
||||
expect(res.status).toBe(401);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.code).toBe('reauth_required');
|
||||
expect(typeof body.reauth_token).toBe('string');
|
||||
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
expect(
|
||||
env.server.services.auth.verifyReauthToken(
|
||||
body.reauth_token as string,
|
||||
).authId,
|
||||
).toBe(user!.uuid);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Enrolling a second factor: setup sits behind the user-protected gate, and
|
||||
* enable needs a live code for the secret setup issued.
|
||||
*/
|
||||
describe('2FA enrollment over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({ teams_enabled: true } as IConfig);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const PASSWORD = 'tfa-http-password';
|
||||
|
||||
const uniq = () => Math.random().toString(36).slice(2, 10);
|
||||
|
||||
/** A signed-in password account with a confirmed address. */
|
||||
const makeAccount = async () => {
|
||||
const username = `tfa_${uniq()}`;
|
||||
const { token } = await createTestUser(env.server, {
|
||||
username,
|
||||
password: PASSWORD,
|
||||
});
|
||||
const row = (await env.server.stores.user.getByUsername(username))!;
|
||||
await env.server.stores.user.update(row.id, {
|
||||
email: `${username}@test.local`,
|
||||
email_confirmed: 1,
|
||||
});
|
||||
await env.server.stores.user.invalidateById(row.id);
|
||||
return { username, token, userId: row.id };
|
||||
};
|
||||
|
||||
/** The user-protected gate takes the session cookie on the GUI origin. */
|
||||
const protectedPost = (
|
||||
path: string,
|
||||
token: string,
|
||||
body: Record<string, unknown>,
|
||||
credential: 'cookie' | 'bearer' = 'cookie',
|
||||
) =>
|
||||
fetch(new URL(path, env.origin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(credential === 'cookie'
|
||||
? { cookie: `puter_auth_token=${token}` }
|
||||
: { authorization: `Bearer ${token}` }),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const setup = (
|
||||
token: string,
|
||||
body: Record<string, unknown>,
|
||||
credential: 'cookie' | 'bearer' = 'cookie',
|
||||
) => protectedPost('/user-protected/setup-2fa', token, body, credential);
|
||||
|
||||
const configure = (
|
||||
token: string,
|
||||
action: string,
|
||||
body: Record<string, unknown> = {},
|
||||
) =>
|
||||
fetch(new URL(`/auth/configure-2fa/${action}`, env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const otpState = async (userId: number) => {
|
||||
const row = await env.server.stores.user.getByProperty('id', userId, {
|
||||
force: true,
|
||||
});
|
||||
return {
|
||||
secret: row?.otp_secret ?? null,
|
||||
enabled: Boolean(row?.otp_enabled),
|
||||
};
|
||||
};
|
||||
|
||||
const liveTotp = (username: string, secret: string) =>
|
||||
new TOTP({
|
||||
issuer: 'puter.com',
|
||||
label: username,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
secret,
|
||||
}).generate();
|
||||
|
||||
it('refuses setup to a session that has not re-proved the password', async () => {
|
||||
const account = await makeAccount();
|
||||
|
||||
const noPassword = await setup(account.token, {});
|
||||
expect(noPassword.status).toBe(403);
|
||||
expect(await noPassword.text()).toContain('password_required');
|
||||
|
||||
const wrongPassword = await setup(account.token, { password: 'nope' });
|
||||
expect(wrongPassword.status).toBe(400);
|
||||
expect(await wrongPassword.text()).toContain('password_mismatch');
|
||||
|
||||
// Right password, but a bearer token is not the session cookie.
|
||||
const bearer = await setup(
|
||||
account.token,
|
||||
{ password: PASSWORD },
|
||||
'bearer',
|
||||
);
|
||||
expect(bearer.status).toBe(401);
|
||||
|
||||
// The old ungated path no longer hands out a secret.
|
||||
expect((await configure(account.token, 'setup')).status).toBe(400);
|
||||
|
||||
expect((await otpState(account.userId)).secret).toBeNull();
|
||||
});
|
||||
|
||||
it('enrolls after the password, and enables only on a live code', async () => {
|
||||
const account = await makeAccount();
|
||||
|
||||
const res = await setup(account.token, { password: PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
const { secret, codes } = (await res.json()) as {
|
||||
secret: string;
|
||||
codes: string[];
|
||||
};
|
||||
expect(codes).toHaveLength(10);
|
||||
expect((await otpState(account.userId)).secret).toBe(secret);
|
||||
|
||||
expect((await configure(account.token, 'enable')).status).toBe(400);
|
||||
const foreign = otpCreateSecret(account.username).secret;
|
||||
const wrong = await configure(account.token, 'enable', {
|
||||
code: liveTotp(account.username, foreign),
|
||||
});
|
||||
expect(wrong.status).toBe(400);
|
||||
expect(await wrong.text()).toContain('code_mismatch');
|
||||
expect((await otpState(account.userId)).enabled).toBe(false);
|
||||
|
||||
const enabled = await configure(account.token, 'enable', {
|
||||
code: liveTotp(account.username, secret),
|
||||
});
|
||||
expect(enabled.status).toBe(200);
|
||||
expect((await otpState(account.userId)).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('lets a seat its team holds for 2FA enroll through the same gate', async () => {
|
||||
const owner = await makeAccount();
|
||||
// The rule can only be turned on by an owner who has 2FA.
|
||||
await env.server.stores.user.update(owner.userId, { otp_enabled: 1 });
|
||||
await env.server.stores.user.invalidateById(owner.userId);
|
||||
const team = await env.server.services.team.createTeam(owner.userId, {
|
||||
name: 'Acme',
|
||||
});
|
||||
const seatName = `seat_${uniq()}`;
|
||||
const { userId, temporaryPassword } =
|
||||
await env.server.services.team.provisionAccount(
|
||||
team.uid,
|
||||
owner.userId,
|
||||
{ username: seatName },
|
||||
);
|
||||
await env.server.services.team.updateTeam(team.uid, owner.userId, {
|
||||
require2fa: true,
|
||||
});
|
||||
const { token } = await env.server.services.auth.createSessionToken(
|
||||
(await env.server.stores.user.getById(userId))!,
|
||||
);
|
||||
|
||||
// First sign-in replaces the temporary password.
|
||||
const changed = await protectedPost(
|
||||
'/user-protected/change-password',
|
||||
token,
|
||||
{ password: temporaryPassword, new_pass: 'seat-own-password' },
|
||||
);
|
||||
expect(changed.status).toBe(200);
|
||||
|
||||
const held = await fetch(new URL('/teams', env.apiOrigin), {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(held.status).toBe(403);
|
||||
expect(await held.text()).toContain('two_factor_required');
|
||||
|
||||
const res = await setup(token, { password: 'seat-own-password' });
|
||||
expect(res.status).toBe(200);
|
||||
const { secret } = (await res.json()) as { secret: string };
|
||||
const enabled = await configure(token, 'enable', {
|
||||
code: liveTotp(seatName, secret),
|
||||
});
|
||||
expect(enabled.status).toBe(200);
|
||||
expect((await otpState(userId)).enabled).toBe(true);
|
||||
|
||||
const released = await fetch(new URL('/teams', env.apiOrigin), {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(released.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,413 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Signup bonus codes through the real controller: the check route, the signup
|
||||
* hand-off to `puter.signup-bonus.validate`, and the awaited
|
||||
* `user.card-verified` a grant on card verification depends on. Tests stand in
|
||||
* for the extension with shared listeners (EventClient has no `off()`).
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import type { EventMap } from '../../clients/event/types.js';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
|
||||
let server: PuterServer;
|
||||
let controller: any;
|
||||
|
||||
type Handler<K extends keyof EventMap> = ((data: EventMap[K]) => void) | null;
|
||||
|
||||
let onBonusCheck: Handler<'puter.signup-bonus.check'> = null;
|
||||
let onBonusValidate: Handler<'puter.signup-bonus.validate'> = null;
|
||||
let onSignupValidate: ((data: Record<string, unknown>) => void) | null = null;
|
||||
let onCardConfirm: ((data: Record<string, unknown>) => void) | null = null;
|
||||
let onCardVerified: (() => Promise<void>) | null = null;
|
||||
const heardSignupSuccess: Array<Record<string, unknown>> = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
controller = server.controllers.auth;
|
||||
const events = server.clients.event;
|
||||
events.on('puter.signup-bonus.check', (_k, data) => onBonusCheck?.(data));
|
||||
events.on('puter.signup-bonus.validate', (_k, data) =>
|
||||
onBonusValidate?.(data),
|
||||
);
|
||||
events.on('puter.signup.validate', (_k, data) =>
|
||||
onSignupValidate?.(data as Record<string, unknown>),
|
||||
);
|
||||
events.on('puter.signup.success', (_k, data) => {
|
||||
heardSignupSuccess.push(data as Record<string, unknown>);
|
||||
});
|
||||
events.on('puter.card-verification.confirm', (_k, data) =>
|
||||
onCardConfirm?.(data as Record<string, unknown>),
|
||||
);
|
||||
events.on('user.card-verified' as never, async () => {
|
||||
await onCardVerified?.();
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
onBonusCheck = null;
|
||||
onBonusValidate = null;
|
||||
onSignupValidate = null;
|
||||
onCardConfirm = null;
|
||||
onCardVerified = null;
|
||||
});
|
||||
|
||||
const uniq = () => Math.random().toString(36).slice(2, 10);
|
||||
|
||||
/** A check listener that finds the code open, as an extension would. */
|
||||
const openCheck: Handler<'puter.signup-bonus.check'> = (data) => {
|
||||
data.valid = true;
|
||||
data.display = { title: 'T', description: 'D' };
|
||||
};
|
||||
|
||||
const makeReq = (
|
||||
body: Record<string, unknown> = {},
|
||||
extra: { actor?: Actor; ip?: string } = {},
|
||||
) => ({
|
||||
body,
|
||||
headers: {},
|
||||
connection: { remoteAddress: extra.ip ?? '127.0.0.1' },
|
||||
socket: { remoteAddress: extra.ip ?? '127.0.0.1' },
|
||||
ip: extra.ip ?? '127.0.0.1',
|
||||
params: {},
|
||||
actor: extra.actor,
|
||||
});
|
||||
|
||||
const makeRes = () => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(b: unknown) {
|
||||
this.body = b;
|
||||
return this;
|
||||
},
|
||||
cookie() {
|
||||
return this;
|
||||
},
|
||||
clearCookie() {
|
||||
return this;
|
||||
},
|
||||
send() {
|
||||
return this;
|
||||
},
|
||||
end() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
};
|
||||
|
||||
const signupBody = (extra: Record<string, unknown> = {}) => {
|
||||
const username = `b_${uniq()}`;
|
||||
return {
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
...extra,
|
||||
};
|
||||
};
|
||||
|
||||
const signup = async (body: Record<string, unknown>) => {
|
||||
const res = makeRes();
|
||||
await controller.handleSignup(makeReq(body), res);
|
||||
return res;
|
||||
};
|
||||
|
||||
const findUser = (username: unknown) =>
|
||||
server.stores.user.getByUsername(username as string, { force: true });
|
||||
|
||||
/** `puter.signup.success` is fire-and-forget; wait for it to land. */
|
||||
const waitForSuccess = async (userUuid: string) => {
|
||||
const deadline = Date.now() + 2000;
|
||||
while (Date.now() < deadline) {
|
||||
const hit = heardSignupSuccess.find((e) => e.user_uuid === userUuid);
|
||||
if (hit) return hit;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
throw new Error('puter.signup.success never arrived');
|
||||
};
|
||||
|
||||
// -- POST /signup/bonus-code/check -----------------------------------
|
||||
|
||||
describe('AuthController.handleSignupBonusCheck', () => {
|
||||
const check = async (body: Record<string, unknown>) => {
|
||||
const res = makeRes();
|
||||
await controller.handleSignupBonusCheck(makeReq(body), res);
|
||||
return res.body;
|
||||
};
|
||||
|
||||
it('answers invalid when no extension is listening', async () => {
|
||||
expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({
|
||||
valid: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the display and requirements a listener fills in', async () => {
|
||||
let seen: EventMap['puter.signup-bonus.check'] | null = null;
|
||||
onBonusCheck = (data) => {
|
||||
seen = { ...data };
|
||||
data.valid = true;
|
||||
data.display = { title: '3 months of Basic', description: 'Free' };
|
||||
data.requirements = { phone: true, card: false };
|
||||
};
|
||||
expect(
|
||||
await check({
|
||||
bonusCode: 'Startup3M-ABCD1234',
|
||||
fingerprint: 'fp1',
|
||||
}),
|
||||
).toEqual({
|
||||
valid: true,
|
||||
display: { title: '3 months of Basic', description: 'Free' },
|
||||
requirements: { phone: true, card: false },
|
||||
});
|
||||
// Listeners only ever see the canonical form.
|
||||
expect(seen).toMatchObject({
|
||||
code: 'startup3mabcd1234',
|
||||
fingerprint: 'fp1',
|
||||
ip: '127.0.0.1',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards an opaque reason for a refused code', async () => {
|
||||
onBonusCheck = (data) => {
|
||||
data.reason = 'ended';
|
||||
};
|
||||
expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({
|
||||
valid: false,
|
||||
reason: 'ended',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats valid without display as invalid', async () => {
|
||||
onBonusCheck = (data) => {
|
||||
data.valid = true;
|
||||
};
|
||||
expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({
|
||||
valid: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('answers a malformed code without consulting listeners', async () => {
|
||||
let called = false;
|
||||
onBonusCheck = () => {
|
||||
called = true;
|
||||
};
|
||||
expect(await check({ bonusCode: 'a:b' })).toEqual({ valid: false });
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('400s a non-string bonusCode', async () => {
|
||||
await expect(
|
||||
controller.handleSignupBonusCheck(
|
||||
makeReq({ bonusCode: 42 }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
// -- POST /signup with bonusCode -------------------------------------
|
||||
|
||||
describe('AuthController.handleSignup with a bonus code', () => {
|
||||
it('refuses the signup when nothing accepts the code', async () => {
|
||||
const body = signupBody({ bonusCode: 'startup3m-abcd1234' });
|
||||
await expect(signup(body)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bonus_code_invalid',
|
||||
});
|
||||
expect(await findUser(body.username)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('refuses a dead code before the abuse gate records the attempt', async () => {
|
||||
let validated = false;
|
||||
onSignupValidate = () => {
|
||||
validated = true;
|
||||
};
|
||||
await expect(
|
||||
signup(signupBody({ bonusCode: 'startup3m-abcd1234' })),
|
||||
).rejects.toMatchObject({ legacyCode: 'bonus_code_invalid' });
|
||||
expect(validated).toBe(false);
|
||||
});
|
||||
|
||||
it('still refuses a code that passed the check but is refused after the gate', async () => {
|
||||
onBonusCheck = openCheck;
|
||||
const body = signupBody({ bonusCode: 'startup3m-abcd1234' });
|
||||
await expect(signup(body)).rejects.toMatchObject({
|
||||
legacyCode: 'bonus_code_invalid',
|
||||
});
|
||||
expect(await findUser(body.username)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('refuses a malformed code before any listener runs', async () => {
|
||||
let called = false;
|
||||
onBonusValidate = () => {
|
||||
called = true;
|
||||
};
|
||||
await expect(
|
||||
signup(signupBody({ bonusCode: 'nope' + '!'.repeat(3) })),
|
||||
).rejects.toMatchObject({ legacyCode: 'bonus_code_invalid' });
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('400s a non-string bonusCode', async () => {
|
||||
await expect(
|
||||
signup(signupBody({ bonusCode: ['startup3m'] })),
|
||||
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' });
|
||||
});
|
||||
|
||||
it('applies the requirements an accepted code raises and reports the code', async () => {
|
||||
onBonusCheck = openCheck;
|
||||
let seen: EventMap['puter.signup-bonus.validate'] | null = null;
|
||||
onBonusValidate = (data) => {
|
||||
seen = { ...data };
|
||||
data.accepted = true;
|
||||
data.requires_card_verification = true;
|
||||
};
|
||||
const body = signupBody({
|
||||
bonusCode: 'STARTUP6M-abcd1234',
|
||||
fingerprint: 'fp-bonus',
|
||||
});
|
||||
const res = await signup(body);
|
||||
expect(
|
||||
(res.body as { user: Record<string, unknown> }).user,
|
||||
).toMatchObject({ requires_card_verification: true });
|
||||
|
||||
expect(seen).toMatchObject({
|
||||
code: 'startup6mabcd1234',
|
||||
email: body.email,
|
||||
fingerprint: 'fp-bonus',
|
||||
requires_phone_verification: false,
|
||||
requires_card_verification: false,
|
||||
});
|
||||
const user = await findUser(body.username);
|
||||
expect(Boolean(user!.requires_card_verification)).toBe(true);
|
||||
expect(Boolean(user!.requires_phone_verification)).toBe(false);
|
||||
const success = await waitForSuccess(user!.uuid);
|
||||
expect(success.bonus_code).toBe('startup6mabcd1234');
|
||||
});
|
||||
|
||||
it('keeps a requirement the abuse harness set even if the listener clears it', async () => {
|
||||
onBonusCheck = openCheck;
|
||||
onSignupValidate = (data) => {
|
||||
data.requires_phone_verification = true;
|
||||
};
|
||||
onBonusValidate = (data) => {
|
||||
expect(data.requires_phone_verification).toBe(true);
|
||||
data.accepted = true;
|
||||
data.requires_phone_verification = false;
|
||||
};
|
||||
const body = signupBody({ bonusCode: 'startup3m-abcd1234' });
|
||||
await signup(body);
|
||||
const user = await findUser(body.username);
|
||||
expect(Boolean(user!.requires_phone_verification)).toBe(true);
|
||||
});
|
||||
|
||||
it('never offers the code for a signup the abuse harness blocked', async () => {
|
||||
onBonusCheck = openCheck;
|
||||
let called = false;
|
||||
onSignupValidate = (data) => {
|
||||
data.allow = false;
|
||||
data.message = 'Signup blocked';
|
||||
};
|
||||
onBonusValidate = () => {
|
||||
called = true;
|
||||
};
|
||||
await expect(
|
||||
signup(signupBody({ bonusCode: 'startup3m-abcd1234' })),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the success event without a code when none was presented', async () => {
|
||||
const body = signupBody();
|
||||
await signup(body);
|
||||
const user = await findUser(body.username);
|
||||
const success = await waitForSuccess(user!.uuid);
|
||||
expect(success).not.toHaveProperty('bonus_code');
|
||||
});
|
||||
|
||||
it('ignores a bonus code on a temp signup', async () => {
|
||||
let called = false;
|
||||
onBonusValidate = () => {
|
||||
called = true;
|
||||
};
|
||||
const res = makeRes();
|
||||
await controller.handleSignup(
|
||||
makeReq({ is_temp: true, bonusCode: 'startup3m-abcd1234' }),
|
||||
res,
|
||||
);
|
||||
expect((res.body as { user: { is_temp: boolean } }).user.is_temp).toBe(
|
||||
true,
|
||||
);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -- /card-verification/confirm awaits user.card-verified ------------
|
||||
|
||||
describe('AuthController.handleCardVerificationConfirm', () => {
|
||||
it('finishes user.card-verified listeners before responding', async () => {
|
||||
const body = signupBody();
|
||||
await signup(body);
|
||||
const created = await findUser(body.username);
|
||||
await server.stores.user.update(created!.id, {
|
||||
requires_card_verification: 1,
|
||||
});
|
||||
const actor = {
|
||||
user: {
|
||||
id: created!.id,
|
||||
uuid: created!.uuid,
|
||||
username: created!.username,
|
||||
email: created!.email ?? null,
|
||||
email_confirmed: false,
|
||||
},
|
||||
} as Actor;
|
||||
|
||||
onCardConfirm = (data) => {
|
||||
data.enabled = true;
|
||||
data.verified = true;
|
||||
data.fingerprint = 'fp_card_1';
|
||||
};
|
||||
let listenerDone = false;
|
||||
onCardVerified = async () => {
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
listenerDone = true;
|
||||
};
|
||||
|
||||
const res = makeRes();
|
||||
await controller.handleCardVerificationConfirm(
|
||||
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
|
||||
res,
|
||||
);
|
||||
expect(res.body).toMatchObject({ card_verified: true });
|
||||
expect(listenerDone).toBe(true);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -47,8 +47,7 @@ export class BroadcastController extends PuterController {
|
||||
@Post('/webhook', { subdomain: '*' })
|
||||
async webhook(req: Request, res: Response): Promise<void> {
|
||||
const broadcast = this.services.broadcast as unknown as
|
||||
| BroadcastService
|
||||
| undefined;
|
||||
BroadcastService | undefined;
|
||||
if (!broadcast) {
|
||||
res.status(503).json({
|
||||
error: { message: 'Broadcast service not registered' },
|
||||
@@ -80,8 +79,7 @@ export class BroadcastController extends PuterController {
|
||||
@Post('/events', { subdomain: '*' })
|
||||
async events(req: Request, res: Response): Promise<void> {
|
||||
const broadcast = this.services.broadcast as unknown as
|
||||
| BroadcastService
|
||||
| undefined;
|
||||
BroadcastService | undefined;
|
||||
if (!broadcast) {
|
||||
res.status(503).json({
|
||||
error: { message: 'Broadcast service not registered' },
|
||||
@@ -106,6 +104,7 @@ export class BroadcastController extends PuterController {
|
||||
|
||||
const reply = await this.services.eventForward.receive(
|
||||
(req.body ?? {}) as ForwardBatch,
|
||||
verified.peerId ?? '',
|
||||
);
|
||||
res.status(200).json({ ok: true, ...reply });
|
||||
}
|
||||
|
||||
@@ -179,6 +179,28 @@ const throwing = (payload: unknown) => () => {
|
||||
// -- Upstream status extraction --------------------------------------
|
||||
|
||||
describe('DriverController upstream error translation', () => {
|
||||
it('maps an upstream 402 to sanitized credit exhaustion', async () => {
|
||||
const { err } = await callWith(
|
||||
throwing(
|
||||
Object.assign(
|
||||
new Error(
|
||||
'<html>Insufficient credits. Add more using https://openrouter.ai/settings/credits (request id: req-secret)</html>',
|
||||
),
|
||||
{ status: 402 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 503,
|
||||
legacyCode: 'upstream_credits_exhausted',
|
||||
message: 'AI provider out of credits',
|
||||
fields: { upstreamStatus: 402 },
|
||||
});
|
||||
expect(JSON.stringify(err)).not.toContain('http');
|
||||
expect(JSON.stringify(err)).not.toMatch(/request id|<html>/i);
|
||||
});
|
||||
|
||||
it('maps an upstream 429 to a Puter 429 with upstream_rate_limited', async () => {
|
||||
const { err } = await callWith(
|
||||
throwing(
|
||||
|
||||
@@ -21,6 +21,7 @@ import { metrics } from '@opentelemetry/api';
|
||||
import type { Request, Response } from 'express';
|
||||
import { actorUid } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { abortOnDisconnect } from '../../core/http/abortOnDisconnect.js';
|
||||
import { Controller } from '../../core/http/decorators.js';
|
||||
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
|
||||
import { assertNotUserSession } from '../../core/http/middleware/gates.js';
|
||||
@@ -31,7 +32,11 @@ import {
|
||||
} from '../../core/http/middleware/rateLimit.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import type { DriverMeta } from '../../drivers/meta.js';
|
||||
import { isUpstreamTimeoutError } from '../../drivers/util/upstreamErrors.js';
|
||||
import {
|
||||
isCreditExhaustion,
|
||||
isUpstreamTimeoutError,
|
||||
sanitizeUpstreamMessage,
|
||||
} from '../../drivers/util/upstreamErrors.js';
|
||||
import {
|
||||
isDriverStreamResult,
|
||||
resolveCallableMethods,
|
||||
@@ -120,6 +125,18 @@ const translateProviderError = (err: unknown): unknown => {
|
||||
cause?: unknown;
|
||||
};
|
||||
const status = extractUpstreamStatus(e);
|
||||
const msg = sanitizeUpstreamMessage(
|
||||
e.error?.message ?? e.message ?? 'Upstream provider error',
|
||||
);
|
||||
const upstreamCode = e.error?.code ?? e.code;
|
||||
const fields = { upstreamStatus: status, upstreamCode };
|
||||
|
||||
if (isCreditExhaustion(status, upstreamCode, msg)) {
|
||||
return new HttpError(503, 'AI provider out of credits', {
|
||||
legacyCode: 'upstream_credits_exhausted',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
if (typeof status !== 'number') {
|
||||
if (isUpstreamTimeoutError(e)) {
|
||||
const cause = e.cause as { code?: string } | undefined;
|
||||
@@ -132,10 +149,6 @@ const translateProviderError = (err: unknown): unknown => {
|
||||
return err;
|
||||
}
|
||||
|
||||
const msg = e.error?.message ?? e.message ?? 'Upstream provider error';
|
||||
const upstreamCode = e.error?.code ?? e.code;
|
||||
const fields = { upstreamStatus: status, upstreamCode };
|
||||
|
||||
if (status === 429) {
|
||||
return new HttpError(429, msg, {
|
||||
legacyCode: 'upstream_rate_limited',
|
||||
@@ -313,7 +326,8 @@ export class DriverController extends PuterController {
|
||||
|
||||
if (req.actor) {
|
||||
const permService = this.services.permission as unknown as
|
||||
PermissionService | undefined;
|
||||
| PermissionService
|
||||
| undefined;
|
||||
if (permService) {
|
||||
// Build via PermissionUtil.join so any `:` in a driver or
|
||||
// interface name is escaped — raw interpolation would let a
|
||||
@@ -447,12 +461,7 @@ export class DriverController extends PuterController {
|
||||
|
||||
// A caller that hangs up mid-call gets nothing back, so long-running
|
||||
// drivers watch this to stop working (and metering) as soon as it does.
|
||||
// `close` after `finish` is the normal end of a response, not an abort.
|
||||
const abort = new AbortController();
|
||||
res.once('close', () => {
|
||||
if (!res.writableFinished) abort.abort();
|
||||
});
|
||||
Context.set('abortSignal', abort.signal);
|
||||
Context.set('abortSignal', abortOnDisconnect(res));
|
||||
|
||||
// Per-method lifecycle events, scoped to `driver.<iface>.<method>`.
|
||||
// Subscribers can listen on `driver.*`, `driver.<iface>.*`, or the
|
||||
|
||||
@@ -287,12 +287,14 @@ export class EventsController extends PuterController {
|
||||
|
||||
// -- Events workers ------------------------------------------------
|
||||
//
|
||||
// The billable artifact a published handler set implies. Account-scoped
|
||||
// like the kv-handle surface above: this is the owner's own view of what
|
||||
// it is paying for, so — unlike the handler routes — an app token cannot
|
||||
// act here on its owner's behalf.
|
||||
// The billable artifact a published handler set implies. An account
|
||||
// session or full-access token sees every app it owns; an app sees only
|
||||
// its own worker, the same scope publish and destroy use.
|
||||
|
||||
/** GET /events/workers — the caller's own events workers, one per app. */
|
||||
/**
|
||||
* GET /events/workers — an account's events workers, one per app, or an
|
||||
* app's own worker (0 or 1 item).
|
||||
*/
|
||||
@Get('/workers', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
ORG_SEAT_FREE_SUBSCRIPTION,
|
||||
} from '../../services/metering/consts.js';
|
||||
import {
|
||||
EVENTS_BROADCAST_DELIVERY_LIMIT,
|
||||
@@ -35,6 +36,8 @@ import {
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER,
|
||||
EVENTS_KV_HANDLES_PER_APP,
|
||||
EVENTS_KV_HANDLES_PER_USER,
|
||||
EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT,
|
||||
EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT,
|
||||
EVENTS_SINGLE_DELIVERY_LIMIT,
|
||||
EVENTS_WORKER_INVOCATION_LIMIT,
|
||||
limitFor,
|
||||
@@ -58,6 +61,23 @@ describe('the tiered subscription quotas', () => {
|
||||
expect(tier.bySubscription[DEFAULT_TEMP_SUBSCRIPTION]).toBe(0);
|
||||
});
|
||||
|
||||
it('holds a free plan nobody enumerated to the free cap, not the paid one', () => {
|
||||
// A team seat resolves to `org_seat_free`, which no tier names. Falling
|
||||
// through to `limit` would give it more than an ordinary free account.
|
||||
for (const tier of tiers.map(([, t]) => t)) {
|
||||
expect(limitFor(tier, ORG_SEAT_FREE_SUBSCRIPTION)).toBe(
|
||||
tier.bySubscription[DEFAULT_FREE_SUBSCRIPTION],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('still holds an unresolved or paid plan to the base', () => {
|
||||
for (const tier of tiers.map(([, t]) => t)) {
|
||||
expect(limitFor(tier, null)).toBe(tier.limit);
|
||||
expect(limitFor(tier, 'some-paid-tier')).toBe(tier.limit);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps what one app may take below what the account may hold', () => {
|
||||
for (const plan of [
|
||||
null,
|
||||
@@ -101,6 +121,34 @@ describe('the tiered subscription quotas', () => {
|
||||
});
|
||||
|
||||
describe('the delivery budgets', () => {
|
||||
it('sets KV fan-out and filter work by the namespace owner tier', () => {
|
||||
for (const [plan, matched, evaluated] of [
|
||||
[null, 512, 2048],
|
||||
[DEFAULT_FREE_SUBSCRIPTION, 128, 512],
|
||||
[DEFAULT_TEMP_SUBSCRIPTION, 128, 512],
|
||||
[ORG_SEAT_FREE_SUBSCRIPTION, 128, 512],
|
||||
] as const) {
|
||||
expect(
|
||||
limitFor(EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT, plan),
|
||||
).toBe(matched);
|
||||
expect(
|
||||
limitFor(EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT, plan),
|
||||
).toBe(evaluated);
|
||||
expect(evaluated).toBeGreaterThanOrEqual(matched);
|
||||
}
|
||||
});
|
||||
|
||||
it('pins the share-handle quotas by plan', () => {
|
||||
expect(EVENTS_KV_HANDLES_PER_USER.limit).toBe(512);
|
||||
expect(EVENTS_KV_HANDLES_PER_APP.limit).toBe(512);
|
||||
expect(
|
||||
limitFor(EVENTS_KV_HANDLES_PER_USER, DEFAULT_FREE_SUBSCRIPTION),
|
||||
).toBe(200);
|
||||
expect(
|
||||
limitFor(EVENTS_KV_HANDLES_PER_APP, DEFAULT_FREE_SUBSCRIPTION),
|
||||
).toBe(128);
|
||||
});
|
||||
|
||||
it('keeps `single` well under broadcast — each one costs far more', () => {
|
||||
expect(EVENTS_SINGLE_DELIVERY_LIMIT.limit).toBeLessThan(
|
||||
EVENTS_BROADCAST_DELIVERY_LIMIT.limit,
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { RouteRateLimit } from '../../core/http/types';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
subscriptionOverride,
|
||||
} from '../../services/metering/consts.js';
|
||||
|
||||
// -- Shared event limits ---------------------------------------------
|
||||
@@ -43,10 +44,9 @@ const userWindow = (
|
||||
): RouteRateLimit => ({ scope, limit, window, key: 'user' });
|
||||
|
||||
/**
|
||||
* A cap that varies by plan, in the shape route gates already declare theirs
|
||||
* in: the base is what a subscribed account sees, and `bySubscription` carves
|
||||
* the free tiers out beneath it. A plan nobody enumerated falls through to the
|
||||
* base, so a new one is generous rather than accidentally throttled.
|
||||
* A plan-varying cap in route-gate shape: `limit` is what a subscribed account
|
||||
* gets, `bySubscription` carves out the free tiers, unlisted paid plans get the
|
||||
* base.
|
||||
*/
|
||||
export interface TieredLimit {
|
||||
limit: number;
|
||||
@@ -61,14 +61,16 @@ const tiered = (paid: number, free: number, temp: number): TieredLimit => ({
|
||||
},
|
||||
});
|
||||
|
||||
/** The cap one plan sees. An unresolved plan is held to the base. */
|
||||
/** The cap one plan sees; an unlisted free plan takes the free one, not `limit`. */
|
||||
export const limitFor = (
|
||||
tier: TieredLimit,
|
||||
subscriptionId: string | null,
|
||||
): number =>
|
||||
(subscriptionId === null
|
||||
? undefined
|
||||
: tier.bySubscription[subscriptionId]) ?? tier.limit;
|
||||
): number => {
|
||||
if (subscriptionId === null) return tier.limit;
|
||||
return (
|
||||
subscriptionOverride(tier.bySubscription, subscriptionId) ?? tier.limit
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* What a plan-tiered quota resolves to: what an account may hold, and what one
|
||||
@@ -124,14 +126,20 @@ export const EVENTS_DURABLE_SUBSCRIPTIONS_MAX =
|
||||
export const SUSPENDED_ROW_TTL_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Subscribe + unsubscribe calls per minute, per user.
|
||||
* Subscribe calls per minute, per user.
|
||||
*
|
||||
* Both resolve a path and take a write, so a loop over them is a write loop.
|
||||
* Resolves a path and takes a write, so a loop over subscribes is a write loop.
|
||||
* Sized like the sharing verbs, which are the closest existing analogue: a
|
||||
* client sets its subscriptions up once and then leaves them alone.
|
||||
*/
|
||||
export const EVENTS_SUBSCRIBE_LIMIT = userWindow('events:subscribe', 60);
|
||||
|
||||
/**
|
||||
* Unsubscribe calls per minute, per user — its own budget, never the subscribe
|
||||
* one.
|
||||
*/
|
||||
export const EVENTS_UNSUBSCRIBE_LIMIT = userWindow('events:unsubscribe', 600);
|
||||
|
||||
/**
|
||||
* Subscription listings per minute, per user. Reads an index and returns one
|
||||
* page, so it is budgeted well above the verbs that write.
|
||||
@@ -163,16 +171,15 @@ export const EVENTS_KV_HANDLE_LIMIT = userWindow('events:kvHandles', 60);
|
||||
* grow forever. A temporary account holds none: the grant outlives the session
|
||||
* that made it, and no account is left to take it back.
|
||||
*/
|
||||
export const EVENTS_KV_HANDLES_PER_USER = tiered(500, 200, 0);
|
||||
export const EVENTS_KV_HANDLES_PER_USER = tiered(512, 200, 0);
|
||||
|
||||
/**
|
||||
* Live share handles one app may hold out for one account.
|
||||
*
|
||||
* Below the per-account cap so that one app cannot spend an account's whole
|
||||
* budget: a handle names one namespace, so without this a second app on the
|
||||
* same account would be left with nothing.
|
||||
* Separately bounded from the account cap so one namespace cannot consume the
|
||||
* whole budget on free accounts.
|
||||
*/
|
||||
export const EVENTS_KV_HANDLES_PER_APP = tiered(100, 50, 0);
|
||||
export const EVENTS_KV_HANDLES_PER_APP = tiered(512, 128, 0);
|
||||
|
||||
// -- Handler surface -------------------------------------------------
|
||||
|
||||
@@ -252,12 +259,33 @@ export const EVENTS_WORKER_LIST_LIMIT = userWindow('events:workers:list', 120);
|
||||
// -- Dispatch fan-out ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Subscriptions one event may deliver to before dispatch stops and reports a
|
||||
* gap. The amplification ceiling: without it, one write costs as many
|
||||
* Subscriptions one non-KV event may deliver to before dispatch stops and
|
||||
* reports a gap. The amplification ceiling: without it, one write costs as many
|
||||
* deliveries as an account cared to register.
|
||||
*/
|
||||
export const EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT = 50;
|
||||
|
||||
/**
|
||||
* Key-value subscriptions one mutation may deliver to. A namespace owner's plan
|
||||
* sets this ceiling; the listeners' plans do not affect it.
|
||||
*/
|
||||
export const EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT = tiered(512, 128, 128);
|
||||
|
||||
/**
|
||||
* Filter evaluations a key-value mutation may spend. This leaves room for
|
||||
* non-matching rows while retaining a finite bound on dispatch work.
|
||||
*/
|
||||
export const EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT = tiered(2048, 512, 512);
|
||||
|
||||
/** Omit all inline KV values when the matched audience exceeds this count. */
|
||||
export const EVENTS_KV_VALUE_OMIT_MATCHED_SUBSCRIPTIONS = 128;
|
||||
|
||||
/**
|
||||
* Largest inline KV value in serialized bytes. Larger values are omitted;
|
||||
* deliveries retain the key metadata.
|
||||
*/
|
||||
export const EVENTS_KV_VALUE_MAX_BYTES = 16 * 1024;
|
||||
|
||||
/**
|
||||
* Broadcast deliveries per minute, per subscription.
|
||||
*
|
||||
@@ -297,6 +325,15 @@ export const EVENTS_WORKER_INVOCATION_LIMIT = userWindow(
|
||||
60,
|
||||
);
|
||||
|
||||
/**
|
||||
* Handler runs one chain of events may take, by the holder's plan.
|
||||
*
|
||||
* A write made with a handler's token is one run deeper than the event that
|
||||
* invoked it. An event at this depth still reaches sockets but runs no handler,
|
||||
* which is what ends handlers whose writes trigger each other.
|
||||
*/
|
||||
export const EVENTS_HANDLER_DEPTH = tiered(12, 4, 4);
|
||||
|
||||
/**
|
||||
* Filter evaluations one event may spend. Lives with the matcher because the
|
||||
* primitive that enforces it does; re-exported here so every published number
|
||||
|
||||
@@ -54,12 +54,7 @@ import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
|
||||
|
||||
/** Why a deploy could not happen, for the callers that answer differently. */
|
||||
export type EventsDeployOutcome =
|
||||
| 'deployed'
|
||||
| 'stale'
|
||||
| 'no-handlers'
|
||||
| 'no-owner'
|
||||
| 'throttled'
|
||||
| 'failed';
|
||||
'deployed' | 'stale' | 'no-handlers' | 'no-owner' | 'throttled' | 'failed';
|
||||
|
||||
interface DeployLayers {
|
||||
config: IConfig;
|
||||
@@ -238,7 +233,7 @@ export class EventsWorkerDeployer {
|
||||
const user = await stores.user.getById(ownerUserId);
|
||||
if (!user?.id || !user.uuid || !user.username || user.suspended)
|
||||
return null;
|
||||
return { user } as Actor & {
|
||||
return { user, effectiveApp: null } as Actor & {
|
||||
user: { id: number; uuid: string; username: string };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -353,4 +353,199 @@ describe('GET /fs/readdir over HTTP', () => {
|
||||
expect(stat.shares).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the root listing', () => {
|
||||
const mint = async (permissions: string[]) => {
|
||||
const response = await fetch(
|
||||
new URL('/auth/create-access-token', env.apiOrigin),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${env.users.user.token}`,
|
||||
},
|
||||
body: JSON.stringify({ permissions }),
|
||||
},
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
return ((await response.json()) as { token: string }).token;
|
||||
};
|
||||
|
||||
const listRoot = async (token: string) => {
|
||||
const response = await fetch(
|
||||
readdirUrl({ path: '/', auth_token: token }),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
return response.text();
|
||||
};
|
||||
|
||||
const rootPaths = async (token: string) => {
|
||||
const entries = JSON.parse(await listRoot(token)) as Array<{
|
||||
path: string;
|
||||
}>;
|
||||
return entries.map((entry) => entry.path);
|
||||
};
|
||||
|
||||
const entryAt = async (path: string) =>
|
||||
(await env.server.stores.fsEntry.getEntryByPath(path))!;
|
||||
|
||||
it('lists the home for a session and a full-access token', async () => {
|
||||
const { username, token, apiToken } = env.users.user;
|
||||
for (const credential of [token, apiToken]) {
|
||||
expect(await rootPaths(credential)).toEqual([`/${username}`]);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the home out for a token scoped below it', async () => {
|
||||
const { username } = env.users.user;
|
||||
const home = await entryAt(`/${username}`);
|
||||
const documents = await entryAt(`/${username}/Documents`);
|
||||
const scoped = await mint([`fs:${documents.uuid}:read`]);
|
||||
|
||||
const raw = await listRoot(scoped);
|
||||
|
||||
expect(JSON.parse(raw)).toEqual([]);
|
||||
expect(raw).not.toContain(home.uuid);
|
||||
expect(raw).not.toContain(username);
|
||||
});
|
||||
|
||||
it('lists the home for a token that can list it', async () => {
|
||||
const { username } = env.users.user;
|
||||
const home = await entryAt(`/${username}`);
|
||||
const scoped = await mint([`fs:${home.uuid}:list`]);
|
||||
|
||||
expect(await rootPaths(scoped)).toEqual([`/${username}`]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /fs/completeWrite over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
it('refuses completion of a session whose object never arrived', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const started = await fetch(new URL('/fs/startWrite', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileMetadata: {
|
||||
path: `/${username}/Documents/http-nobytes-${Date.now()}.bin`,
|
||||
size: 4,
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(started.status).toBe(200);
|
||||
const { sessionId } = (await started.json()) as { sessionId: string };
|
||||
|
||||
// No PUT to the presigned URL.
|
||||
const response = await fetch(
|
||||
new URL('/fs/completeWrite', env.apiOrigin),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ uploadId: sessionId }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as {
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
expect(body.code).toBe('bad_request');
|
||||
expect(body.message).toBe('Upload content was not received');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /fs/startWrite storage quota over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
is_storage_limited: true,
|
||||
storage_capacity: 10 * 1024,
|
||||
} as never);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
it('rejects the excess of a parallel burst of startWrite calls', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const user = await env.server.stores.user.getByUsername(username);
|
||||
const fs = env.server.services.fs as unknown as {
|
||||
getUsersStorageAllowance: (
|
||||
userId: number,
|
||||
) => Promise<{ curr: number; max: number }>;
|
||||
};
|
||||
const { curr, max } = await fs.getUsersStorageAllowance(user!.id);
|
||||
const size = Math.floor((max - curr) / 3) + 1;
|
||||
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: 8 }, (_, i) =>
|
||||
fetch(new URL('/fs/startWrite', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileMetadata: {
|
||||
path: `/${username}/Documents/http-quota-burst-${i}.bin`,
|
||||
size,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const ok = responses.filter((response) => response.status === 200);
|
||||
const rejected = responses.filter(
|
||||
(response) => response.status === 413,
|
||||
);
|
||||
expect(ok).toHaveLength(2);
|
||||
expect(rejected).toHaveLength(6);
|
||||
|
||||
for (const response of rejected) {
|
||||
const body = (await response.json()) as { code?: string };
|
||||
expect(body.code).toBe('storage_limit_reached');
|
||||
}
|
||||
});
|
||||
|
||||
// A raw JSON body carries `1e400` as a numeral, not a computed value, so
|
||||
// `JSON.stringify` can't produce it (it turns `Infinity` into `null`) —
|
||||
// the wire text is built by hand to exercise what the server's own
|
||||
// `JSON.parse` does with it.
|
||||
it('rejects a declared size of 1e400 (parses to Infinity) with 400', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const body = `{"fileMetadata":{"path":"/${username}/Documents/http-huge.bin","size":1e400}}`;
|
||||
|
||||
const response = await fetch(new URL('/fs/startWrite', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const responseBody = (await response.json()) as { code?: string };
|
||||
expect(responseBody.code).toBe('bad_request');
|
||||
});
|
||||
});
|
||||
@@ -23,9 +23,11 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { SHARE_LIST_LIMIT } from '../share/limits.js';
|
||||
import type { FSController } from './FSController.js';
|
||||
import type {
|
||||
ClientSignedWriteResponse,
|
||||
@@ -69,7 +71,7 @@ const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
|
||||
const refreshed = (await server.stores.user.getById(created.id))!;
|
||||
return {
|
||||
userId: refreshed.id,
|
||||
actor: {
|
||||
actor: makeActor({
|
||||
user: {
|
||||
id: refreshed.id,
|
||||
uuid: refreshed.uuid,
|
||||
@@ -77,7 +79,7 @@ const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
|
||||
email: refreshed.email ?? null,
|
||||
email_confirmed: true,
|
||||
} as Actor['user'],
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -340,9 +342,16 @@ describe('FSController.completeBatchWrites', () => {
|
||||
.body as ClientSignedWriteResponse[];
|
||||
expect(startResponses).toHaveLength(2);
|
||||
|
||||
// 2) Complete via the controller. Single-mode completion only
|
||||
// needs the session row → it doesn't read the S3 object back,
|
||||
// so we can skip the actual upload step in this test.
|
||||
// 2) PUT the declared bytes through each presigned URL, then
|
||||
// complete via the controller.
|
||||
await fetch(startResponses[0]!.url!, {
|
||||
method: 'PUT',
|
||||
body: '12345',
|
||||
});
|
||||
await fetch(startResponses[1]!.url!, {
|
||||
method: 'PUT',
|
||||
body: '1234567',
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
const completeBody: CompleteWriteRequest[] = startResponses.map(
|
||||
(r) => ({ uploadId: r.sessionId }),
|
||||
@@ -414,6 +423,7 @@ describe('FSController.completeBatchWrites', () => {
|
||||
);
|
||||
const [firstResponse] = firstStart.captured
|
||||
.body as ClientSignedWriteResponse[];
|
||||
await fetch(firstResponse!.url!, { method: 'PUT', body: 'x' });
|
||||
const firstComplete = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
@@ -545,6 +555,7 @@ describe('FSController.completeBatchWrites', () => {
|
||||
);
|
||||
const [firstResponse] = firstStart.captured
|
||||
.body as ClientSignedWriteResponse[];
|
||||
await fetch(firstResponse!.url!, { method: 'PUT', body: 'x' });
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
@@ -798,6 +809,124 @@ describe('FSController.statEntry', () => {
|
||||
statusCode: 401,
|
||||
});
|
||||
});
|
||||
|
||||
it('draws return_shares from the share-listing budget', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const path = `/${username}/Documents/share-budget`;
|
||||
await withActor(actor, () =>
|
||||
controller.mkdirEntry(makeReq({ body: { path }, actor }), makeRes().res),
|
||||
);
|
||||
|
||||
// Spend the whole share:list bucket, as /share/shares' gate would.
|
||||
const chargeReq = makeReq({ body: {}, actor });
|
||||
for (let i = 0; i < SHARE_LIST_LIMIT.limit; i++) {
|
||||
await consumeRouteRateLimit(chargeReq, SHARE_LIST_LIMIT);
|
||||
}
|
||||
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.statEntry(
|
||||
makeReq({ body: { path, return_shares: true }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
|
||||
// A plain stat spends only its own budget, so it still admits.
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.statEntry(makeReq({ body: { path }, actor }), res),
|
||||
);
|
||||
expect((captured.body as { name: string }).name).toBe('share-budget');
|
||||
});
|
||||
|
||||
describe('parentUid of a home child', () => {
|
||||
const tokenActorFor = async (userId: number, permission: string) => {
|
||||
const user = (await server.stores.user.getById(userId))!;
|
||||
const token = await server.services.auth.createAccessToken(
|
||||
makeActor({ user }),
|
||||
[[permission]],
|
||||
);
|
||||
return (await server.services.auth.authenticateFromToken(token))!;
|
||||
};
|
||||
|
||||
it('nulls it for a token scoped below the home', async () => {
|
||||
const { userId } = await makeUser();
|
||||
const user = (await server.stores.user.getById(userId))!;
|
||||
const username = user.username!;
|
||||
const documents = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
))!;
|
||||
const scoped = await tokenActorFor(
|
||||
userId,
|
||||
`fs:${documents.uuid}:read`,
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(scoped, () =>
|
||||
controller.statEntry(
|
||||
makeReq({
|
||||
body: { path: `/${username}/Documents` },
|
||||
actor: scoped,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect((captured.body as { parentUid: unknown }).parentUid).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps it for a token that can list the home', async () => {
|
||||
const { userId } = await makeUser();
|
||||
const user = (await server.stores.user.getById(userId))!;
|
||||
const username = user.username!;
|
||||
const home = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}`,
|
||||
))!;
|
||||
const scoped = await tokenActorFor(userId, `fs:${home.uuid}:list`);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(scoped, () =>
|
||||
controller.statEntry(
|
||||
makeReq({
|
||||
body: { path: `/${username}/Documents` },
|
||||
actor: scoped,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect((captured.body as { parentUid: unknown }).parentUid).toBe(
|
||||
home.uuid,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps it for a session actor', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const home = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}`,
|
||||
))!;
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.statEntry(
|
||||
makeReq({
|
||||
body: { path: `/${username}/Documents` },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect((captured.body as { parentUid: unknown }).parentUid).toBe(
|
||||
home.uuid,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── /readdir (readdirEntries) ───────────────────────────────────────
|
||||
@@ -1822,6 +1951,44 @@ describe('FSController.readdirEntries recursive', () => {
|
||||
).toEqual(['l1a', 'l1a/l2a', 'l1a/l2a/l3a', 'l1a/l2a/l3a/l4a', 'l1b']);
|
||||
});
|
||||
|
||||
it('sorts a recursive listing, paging in the same order', async () => {
|
||||
const { actor, base } = await makeTree();
|
||||
// A recursive `name` sort is path order, so descending walks the
|
||||
// subtree backwards.
|
||||
const descending = (await readdir(actor, {
|
||||
path: base,
|
||||
recursive: true,
|
||||
depth: 10,
|
||||
sortBy: 'name',
|
||||
sortOrder: 'desc',
|
||||
})) as { items: Array<{ path: string }> };
|
||||
expect(
|
||||
descending.items.map((e) => e.path.slice(base.length + 1)),
|
||||
).toEqual(['l1b', 'l1a/l2a/l3a/l4a', 'l1a/l2a/l3a', 'l1a/l2a', 'l1a']);
|
||||
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null | undefined = null;
|
||||
do {
|
||||
const page = (await readdir(actor, {
|
||||
path: base,
|
||||
recursive: true,
|
||||
depth: 10,
|
||||
sortOrder: 'desc',
|
||||
limit: 2,
|
||||
cursor,
|
||||
})) as { items: Array<{ path: string }>; cursor?: string };
|
||||
seen.push(...page.items.map((e) => e.path.slice(base.length + 1)));
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
expect(seen).toEqual([
|
||||
'l1b',
|
||||
'l1a/l2a/l3a/l4a',
|
||||
'l1a/l2a/l3a',
|
||||
'l1a/l2a',
|
||||
'l1a',
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts the subtree with includeTotal', async () => {
|
||||
const { actor, base } = await makeTree();
|
||||
const page = (await readdir(actor, {
|
||||
@@ -2657,6 +2824,7 @@ describe('FSController associatedAppId entitlement gate', () => {
|
||||
),
|
||||
);
|
||||
const [started] = startRes.captured.body as ClientSignedWriteResponse[];
|
||||
await fetch(started.url!, { method: 'PUT', body: 'abc' });
|
||||
const completeRes = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { Actor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { Controller, Get, Post } from '../../core/http/decorators.js';
|
||||
import { clientParentUid } from '../../services/fs/rootListing.js';
|
||||
import {
|
||||
expandTildePath,
|
||||
isOwnersTrash,
|
||||
@@ -47,10 +48,14 @@ import {
|
||||
} from '../../util/concurrency.js';
|
||||
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
|
||||
import { listClientShares } from '../share/clientShare.js';
|
||||
import { SHARE_LIST_LIMIT } from '../share/limits.js';
|
||||
import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { STORAGE_OP_COSTS } from '../../services/metering/costs.js';
|
||||
import {
|
||||
FS_BATCH_WRITE_MAX_ITEMS,
|
||||
FS_MULTIPART_LIMIT,
|
||||
FS_MULTIPART_MAX_PARTS,
|
||||
FS_MUTATE_LIMIT,
|
||||
FS_READ_CONCURRENT,
|
||||
FS_READ_LIMIT,
|
||||
@@ -220,6 +225,7 @@ export class FSController extends PuterController {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req);
|
||||
const appUidLookupCache = new Map<string, Promise<number | null>>();
|
||||
this.#assertBatchItemCount(req.body);
|
||||
const requests = Array.isArray(req.body)
|
||||
? await Promise.all(
|
||||
req.body.map(async (requestBody) => {
|
||||
@@ -328,6 +334,9 @@ export class FSController extends PuterController {
|
||||
req.body,
|
||||
);
|
||||
this.#assertNoInlineSignedThumbnailData(requestBody.thumbnailData);
|
||||
await this.#assertUploadSessionWriteAccess(req, userId, [
|
||||
requestBody.uploadId,
|
||||
]);
|
||||
|
||||
const response = await this.services.fs.completeUrlWrite(
|
||||
userId,
|
||||
@@ -362,6 +371,7 @@ export class FSController extends PuterController {
|
||||
res: Response<ClientCompleteWriteResponse[]>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
this.#assertBatchItemCount(req.body);
|
||||
const requests = Array.isArray(req.body)
|
||||
? req.body.map((requestBody) => {
|
||||
return this.#withGuiMetadata(
|
||||
@@ -373,6 +383,11 @@ export class FSController extends PuterController {
|
||||
for (const requestBody of requests) {
|
||||
this.#assertNoInlineSignedThumbnailData(requestBody.thumbnailData);
|
||||
}
|
||||
await this.#assertUploadSessionWriteAccess(
|
||||
req,
|
||||
userId,
|
||||
requests.map((requestBody) => requestBody.uploadId),
|
||||
);
|
||||
const response = await this.services.fs.batchCompleteUrlWrite(
|
||||
userId,
|
||||
requests,
|
||||
@@ -433,6 +448,20 @@ export class FSController extends PuterController {
|
||||
res: Response<ClientSignMultipartPartsResponse>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const partNumbers = req.body?.partNumbers;
|
||||
if (
|
||||
Array.isArray(partNumbers) &&
|
||||
partNumbers.length > FS_MULTIPART_MAX_PARTS
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Too many partNumbers in one request (max ${FS_MULTIPART_MAX_PARTS})`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
await this.#assertUploadSessionWriteAccess(req, userId, [
|
||||
req.body?.uploadId,
|
||||
]);
|
||||
const response = await this.services.fs.signMultipartParts(
|
||||
userId,
|
||||
req.body,
|
||||
@@ -850,6 +879,7 @@ export class FSController extends PuterController {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#assertBatchItemCount(req.body);
|
||||
const requests = Array.isArray(req.body)
|
||||
? await Promise.all(
|
||||
req.body.map(async (requestBody) => {
|
||||
@@ -991,14 +1021,25 @@ export class FSController extends PuterController {
|
||||
await this.#assertAccess(actor, entry.path, 'see');
|
||||
|
||||
const wantsSize = this.#toBoolean(body.return_size);
|
||||
const [subtreeSize, suggestedApps, shareFlags, shares] =
|
||||
const wantsShares = this.#toBoolean(body.return_shares);
|
||||
// `return_shares` is the share-listing route's work, so it spends from
|
||||
// the share-listing budget too, not just `fs:stat`'s.
|
||||
if (
|
||||
wantsShares &&
|
||||
!(await consumeRouteRateLimit(req, SHARE_LIST_LIMIT))
|
||||
) {
|
||||
throw new HttpError(429, 'Too many requests.', {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
}
|
||||
const [subtreeSize, suggestedApps, shareFlags, shares, parentUid] =
|
||||
await Promise.all([
|
||||
entry.isDir && wantsSize
|
||||
? this.services.fs.getSubtreeSize(userId, entry.path)
|
||||
: undefined,
|
||||
this.services.suggestedApps.getSuggestedApps(entry),
|
||||
this.services.share.shareFlags(actor, [entry]),
|
||||
this.#toBoolean(body.return_shares)
|
||||
wantsShares
|
||||
? listClientShares(
|
||||
this.services.share,
|
||||
this.clients.event,
|
||||
@@ -1006,11 +1047,21 @@ export class FSController extends PuterController {
|
||||
entry.uuid,
|
||||
)
|
||||
: undefined,
|
||||
clientParentUid(
|
||||
actor,
|
||||
entry,
|
||||
this.services.acl,
|
||||
this.stores.permission,
|
||||
),
|
||||
]);
|
||||
entry.suggestedApps = suggestedApps;
|
||||
|
||||
res.json({
|
||||
...this.#toClientEntry(entry, shareFlags.get(entry.uuid) ?? null),
|
||||
...this.#toClientEntry(
|
||||
entry,
|
||||
shareFlags.get(entry.uuid) ?? null,
|
||||
parentUid,
|
||||
),
|
||||
...(subtreeSize !== undefined ? { size: subtreeSize } : {}),
|
||||
...(shares !== undefined ? { shares } : {}),
|
||||
});
|
||||
@@ -1023,8 +1074,16 @@ export class FSController extends PuterController {
|
||||
* callers who only hold `see`/`list` on the entry — a share recipient, or
|
||||
* (with public folders enabled) any authenticated user. The legacy read
|
||||
* path already curates its output; this does the same for the v2 routes.
|
||||
*
|
||||
* `parentUid` is `entry.parentUid` unless `parentUidOverride` is passed
|
||||
* (even as `null`) — callers reading a scoped access token's home pass the
|
||||
* result of `clientParentUid` to hide it there.
|
||||
*/
|
||||
#toClientEntry(entry: FSEntry, isShared?: boolean | null): ClientFSEntry {
|
||||
#toClientEntry(
|
||||
entry: FSEntry,
|
||||
isShared?: boolean | null,
|
||||
parentUidOverride?: string | null,
|
||||
): ClientFSEntry {
|
||||
// Allowlist, not a denylist: a denylist silently ships every column
|
||||
// added to `fsentries` later. Omits the numeric primary keys (`id`,
|
||||
// `parentId`, `associatedAppId`), the storage columns, the owning
|
||||
@@ -1036,7 +1095,10 @@ export class FSController extends PuterController {
|
||||
return {
|
||||
uuid: entry.uuid,
|
||||
uid: entry.uid ?? entry.uuid,
|
||||
parentUid: entry.parentUid ?? null,
|
||||
parentUid:
|
||||
parentUidOverride !== undefined
|
||||
? parentUidOverride
|
||||
: (entry.parentUid ?? null),
|
||||
path: maskEntryPath(entry),
|
||||
name: entry.name,
|
||||
isDir: entry.isDir,
|
||||
@@ -1140,8 +1202,8 @@ export class FSController extends PuterController {
|
||||
Object.prototype.hasOwnProperty.call(body, 'cursor') ||
|
||||
includeTotal;
|
||||
|
||||
// Undocumented: `recursive` lists descendants (prefix scan) up to
|
||||
// `depth` levels below the target. Always paginated; sorts by path.
|
||||
// `recursive` lists descendants (prefix scan) up to `depth` levels
|
||||
// below the target. Always paginated.
|
||||
const recursive = this.#toBoolean(body.recursive) === true;
|
||||
|
||||
if (this.#isRootPathRef(body)) {
|
||||
@@ -1157,6 +1219,8 @@ export class FSController extends PuterController {
|
||||
const rootChildren = await listRootEntries(
|
||||
actor,
|
||||
this.stores.fsEntry,
|
||||
this.services.acl,
|
||||
this.stores.permission,
|
||||
);
|
||||
const rootSuggestions =
|
||||
await this.services.suggestedApps.getSuggestedAppsForEntries(
|
||||
@@ -1230,6 +1294,8 @@ export class FSController extends PuterController {
|
||||
? body.cursor
|
||||
: undefined,
|
||||
maxDepth,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
},
|
||||
);
|
||||
await this.#attachSuggestedApps(page.entries);
|
||||
@@ -1296,6 +1362,12 @@ export class FSController extends PuterController {
|
||||
...this.#toClientEntry(
|
||||
entry,
|
||||
shareFlags.get(entry.uuid) ?? null,
|
||||
await clientParentUid(
|
||||
actor,
|
||||
entry,
|
||||
this.services.acl,
|
||||
this.stores.permission,
|
||||
),
|
||||
),
|
||||
// Fields the client cannot derive on its own.
|
||||
type: fsEntryMimeType(entry),
|
||||
@@ -2234,8 +2306,7 @@ export class FSController extends PuterController {
|
||||
// the ActorUser type. Access via the escape hatch until a proper
|
||||
// storage-quota mechanism is in place.
|
||||
const actorUser = req.actor?.user as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
|
||||
const candidates = [
|
||||
this.#toStorageCapacityCandidate(actorUser?.free_storage),
|
||||
@@ -2357,6 +2428,16 @@ export class FSController extends PuterController {
|
||||
return guiMetadata;
|
||||
}
|
||||
|
||||
#assertBatchItemCount(items: unknown): void {
|
||||
if (Array.isArray(items) && items.length > FS_BATCH_WRITE_MAX_ITEMS) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Too many items in one request (max ${FS_BATCH_WRITE_MAX_ITEMS})`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The write handlers build on `req.body` being an object. A request whose
|
||||
* body never parsed leaves it undefined, and the first field read after
|
||||
@@ -2509,6 +2590,40 @@ export class FSController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An upload session stays usable for as long as it lives, so the access
|
||||
* `startWrite` checked has to be re-checked against the session's recorded
|
||||
* target every time the caller signs more parts or completes the upload —
|
||||
* otherwise a revoked sharee still lands bytes in the owner's tree.
|
||||
*/
|
||||
async #assertUploadSessionWriteAccess(
|
||||
req: Request,
|
||||
userId: number,
|
||||
uploadIds: Array<string | undefined>,
|
||||
): Promise<void> {
|
||||
// A malformed id is left to the service, which owns that error shape.
|
||||
const knownUploadIds = uploadIds.filter(
|
||||
(uploadId): uploadId is string =>
|
||||
typeof uploadId === 'string' && uploadId.length > 0,
|
||||
);
|
||||
if (knownUploadIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const sessions = await this.services.fs.getUploadSessions(
|
||||
userId,
|
||||
knownUploadIds,
|
||||
);
|
||||
await this.#assertBatchWriteAccess(
|
||||
req,
|
||||
sessions.map((session) => ({
|
||||
path: session.targetPath,
|
||||
size: session.size,
|
||||
overwrite: Boolean(session.overwriteTargetUid),
|
||||
})),
|
||||
{ pathAlreadyNormalized: true },
|
||||
);
|
||||
}
|
||||
|
||||
#toEventGuiMetadata(
|
||||
guiMetadata: WriteGuiMetadata | undefined,
|
||||
includeOriginalClientSocketId = true,
|
||||
@@ -2537,8 +2652,9 @@ export class FSController extends PuterController {
|
||||
};
|
||||
}
|
||||
|
||||
/** `forOwner` — these events go to the owner, not to the acting user. */
|
||||
async #toGuiFsEntry(entry: FSEntry): Promise<Record<string, unknown>> {
|
||||
return toLegacyEntry(this.clients.event, entry);
|
||||
return toLegacyEntry(this.clients.event, entry, { forOwner: true });
|
||||
}
|
||||
|
||||
async #emitGuiWriteEvent(
|
||||
@@ -2644,10 +2760,13 @@ export class FSController extends PuterController {
|
||||
return fsEntry;
|
||||
}
|
||||
|
||||
const thumbnailPayload = { url: requestedThumbnail };
|
||||
const thumbnailPayload = {
|
||||
url: requestedThumbnail,
|
||||
uuid: fsEntry.uuid,
|
||||
};
|
||||
// emitAndWait — the thumbnails extension may rewrite `url` from a
|
||||
// data URL to an `s3://` pointer; plain `emit` races with the DB
|
||||
// update below.
|
||||
// data URL to an `s3://` pointer bound to `uuid`, or drop a pointer
|
||||
// not minted for it; plain `emit` races with the DB update below.
|
||||
await this.clients.event.emitAndWait(
|
||||
'thumbnail.created',
|
||||
thumbnailPayload,
|
||||
@@ -2736,6 +2855,9 @@ export class FSController extends PuterController {
|
||||
index: item.index,
|
||||
contentType: item.contentType,
|
||||
...(item.size !== undefined ? { size: item.size } : {}),
|
||||
// The entry the upload lands on; its thumbnail key is
|
||||
// bound to it.
|
||||
item_uid: responses[item.index]?.objectKey ?? '',
|
||||
}) as ThumbnailUploadPrepareItem,
|
||||
),
|
||||
};
|
||||
@@ -2959,6 +3081,7 @@ export class FSController extends PuterController {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
this.#assertBatchItemCount(manifest.items);
|
||||
|
||||
const manifestGuiMetadata = this.#extractGuiMetadata(
|
||||
manifest,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import type { FSController } from './FSController.js';
|
||||
import { FS_BATCH_WRITE_MAX_ITEMS, FS_MULTIPART_MAX_PARTS } from './limits.js';
|
||||
import type {
|
||||
ClientSignedWriteResponse,
|
||||
CompleteWriteRequest,
|
||||
@@ -166,8 +167,18 @@ const buildMultipartBody = (parts: MultipartPart[]): Buffer => {
|
||||
return Buffer.from(chunks.join(''), 'utf8');
|
||||
};
|
||||
|
||||
const makeMultipartReq = (parts: MultipartPart[], actor: Actor): Request => {
|
||||
const stream = Readable.from([buildMultipartBody(parts)]);
|
||||
const makeMultipartReq = (
|
||||
parts: MultipartPart[],
|
||||
actor: Actor,
|
||||
chunkSize?: number,
|
||||
): Request => {
|
||||
const body = buildMultipartBody(parts);
|
||||
const chunks: Buffer[] = [];
|
||||
const step = chunkSize ?? body.length;
|
||||
for (let i = 0; i < body.length; i += step) {
|
||||
chunks.push(body.subarray(i, i + step));
|
||||
}
|
||||
const stream = Readable.from(chunks);
|
||||
return Object.assign(stream, {
|
||||
body: undefined,
|
||||
query: {},
|
||||
@@ -228,6 +239,46 @@ describe('FSController.write', () => {
|
||||
expect(stored?.size).toBe(5);
|
||||
});
|
||||
|
||||
it('emits an upload.progress socket event reaching loaded === total', async () => {
|
||||
const { actor, userId, username } = await makeUser();
|
||||
const target = `/${username}/Documents/write-progress.txt`;
|
||||
const content = 'progress-bytes';
|
||||
const send = vi.spyOn(server.services.socket, 'send');
|
||||
const { res } = makeRes();
|
||||
|
||||
try {
|
||||
await withActor(actor, () =>
|
||||
controller.write(
|
||||
makeReq<WriteRequest>({
|
||||
body: {
|
||||
fileMetadata: {
|
||||
path: target,
|
||||
size: content.length,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: content,
|
||||
} as WriteRequest,
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
{ room: userId },
|
||||
'upload.progress',
|
||||
expect.objectContaining({
|
||||
total: content.length,
|
||||
loaded: content.length,
|
||||
loaded_diff: content.length,
|
||||
item_path: target,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
send.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports wasOverwrite and updates the row when overwriting', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const target = `/${username}/Documents/write-overwrite.txt`;
|
||||
@@ -611,6 +662,60 @@ describe('batch write handlers with a malformed element', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch write handlers at the item cap', () => {
|
||||
// Null elements fail the per-item body check, so which 400 comes back
|
||||
// shows whether the cap ran before any item was read.
|
||||
const handlers: Array<
|
||||
[string, (c: typeof controller, r: Request, res: Response) => unknown]
|
||||
> = [
|
||||
[
|
||||
'startBatchWrites',
|
||||
(c, r, res) => c.startBatchWrites(r as never, res as never),
|
||||
],
|
||||
['batchWrites', (c, r, res) => c.batchWrites(r as never, res as never)],
|
||||
[
|
||||
'completeBatchWrites',
|
||||
(c, r, res) => c.completeBatchWrites(r as never, res as never),
|
||||
],
|
||||
];
|
||||
|
||||
const callWith = async (
|
||||
call: (typeof handlers)[number][1],
|
||||
length: number,
|
||||
) => {
|
||||
const { actor } = await makeUser();
|
||||
const req = makeReq({ actor });
|
||||
(req as { body?: unknown }).body = Array(length).fill(null);
|
||||
const { res } = makeRes();
|
||||
return withActor(actor, async () => call(controller, req, res));
|
||||
};
|
||||
|
||||
it.each(handlers)(
|
||||
'%s rejects one item over the cap up front',
|
||||
async (_label, call) => {
|
||||
await expect(
|
||||
callWith(call, FS_BATCH_WRITE_MAX_ITEMS + 1),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
message: `Too many items in one request (max ${FS_BATCH_WRITE_MAX_ITEMS})`,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(handlers)(
|
||||
'%s lets exactly the cap through to the per-item checks',
|
||||
async (_label, call) => {
|
||||
await expect(
|
||||
callWith(call, FS_BATCH_WRITE_MAX_ITEMS),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: 'A request body is required',
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('FSController.write storage allowance', () => {
|
||||
let limitedServer: PuterServer;
|
||||
let limitedController: FSController;
|
||||
@@ -807,15 +912,18 @@ describe('FSController.startWrite', () => {
|
||||
|
||||
it('attaches signed thumbnail upload targets published by a listener', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const itemUids: string[] = [];
|
||||
const listener = (_key: string, data: unknown) => {
|
||||
const payload = data as {
|
||||
items: Array<{
|
||||
index: number;
|
||||
item_uid: string;
|
||||
uploadUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
}>;
|
||||
};
|
||||
for (const item of payload.items) {
|
||||
itemUids.push(item.item_uid);
|
||||
item.uploadUrl = `https://thumbs.test/put/${item.index}`;
|
||||
item.thumbnailUrl = `https://thumbs.test/get/${item.index}`;
|
||||
}
|
||||
@@ -844,6 +952,13 @@ describe('FSController.startWrite', () => {
|
||||
const body = captured.body as ClientSignedWriteResponse;
|
||||
expect(body.thumbnailUploadUrl).toBe('https://thumbs.test/put/0');
|
||||
expect(body.thumbnailUrl).toBe('https://thumbs.test/get/0');
|
||||
// Listeners are told the entry the upload will complete into, so
|
||||
// the thumbnail can be bound to it.
|
||||
const session =
|
||||
await server.stores.fsEntry.getPendingEntryBySessionId(
|
||||
body.sessionId,
|
||||
);
|
||||
expect(itemUids).toEqual([session?.objectKey]);
|
||||
} finally {
|
||||
server.clients.event.off(
|
||||
'thumbnail.upload.prepare',
|
||||
@@ -1024,6 +1139,7 @@ describe('FSController.completeWrite', () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const target = `/${username}/Documents/complete-single.txt`;
|
||||
const started = await startSignedWrite(actor, target, 4);
|
||||
await fetch(started.url!, { method: 'PUT', body: 'wxyz' });
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
@@ -1073,6 +1189,7 @@ describe('FSController.completeWrite', () => {
|
||||
`/${username}/Documents/complete-thumb.txt`,
|
||||
2,
|
||||
);
|
||||
await fetch(started.url!, { method: 'PUT', body: 'yz' });
|
||||
const { res } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.completeWrite(
|
||||
@@ -1092,6 +1209,38 @@ describe('FSController.completeWrite', () => {
|
||||
expect(stored?.thumbnail).toBe('https://thumbs.test/x.png');
|
||||
});
|
||||
|
||||
it('tells thumbnail listeners which entry the thumbnail is for', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const target = `/${username}/Documents/complete-bound-thumb.txt`;
|
||||
const started = await startSignedWrite(actor, target, 2);
|
||||
await fetch(started.url!, { method: 'PUT', body: 'yz' });
|
||||
|
||||
const seen: unknown[] = [];
|
||||
const listener = (_key: string, data: unknown) => {
|
||||
seen.push(data);
|
||||
};
|
||||
server.clients.event.on('thumbnail.created', listener as never);
|
||||
try {
|
||||
const { res } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.completeWrite(
|
||||
makeReq<CompleteWriteRequest>({
|
||||
body: {
|
||||
uploadId: started.sessionId,
|
||||
thumbnailData: 'https://thumbs.test/x.png',
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
server.clients.event.off('thumbnail.created', listener as never);
|
||||
}
|
||||
const stored = await server.stores.fsEntry.getEntryByPath(target);
|
||||
expect(seen).toEqual([expect.objectContaining({ uuid: stored?.uuid })]);
|
||||
});
|
||||
|
||||
it('404s an unknown upload id', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
@@ -1231,6 +1380,29 @@ describe('FSController.signMultipartParts', () => {
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
// The session is unknown, so reaching the lookup would answer 404.
|
||||
it.each([
|
||||
[FS_MULTIPART_MAX_PARTS + 1, 400],
|
||||
[FS_MULTIPART_MAX_PARTS, 404],
|
||||
])('answers %i part numbers with %i', async (length, statusCode) => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.signMultipartParts(
|
||||
makeReq<SignMultipartPartsRequest>({
|
||||
body: {
|
||||
uploadId: uuidv4(),
|
||||
partNumbers: Array(length).fill(1),
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode });
|
||||
});
|
||||
});
|
||||
|
||||
// -- /fs/batchWrite (JSON mode) ---------------------------------------
|
||||
@@ -1467,6 +1639,60 @@ describe('FSController.batchWrites (multipart)', () => {
|
||||
expect(stored?.size).toBe(5);
|
||||
});
|
||||
|
||||
it('coalesces streamed upload progress when the declared size is 0', async () => {
|
||||
const { actor, userId, username } = await makeUser();
|
||||
const path = `/${username}/Documents/mp-progress.bin`;
|
||||
const content = 'x'.repeat(64 * 1024);
|
||||
const chunkSize = 4 * 1024;
|
||||
const send = vi.spyOn(server.services.socket, 'send');
|
||||
const { res } = makeRes();
|
||||
// Scoped to this user: earlier tests' trailing flushes can land here.
|
||||
const progressSends = () =>
|
||||
send.mock.calls.filter(
|
||||
([spec, key]) =>
|
||||
key === 'upload.progress' &&
|
||||
(spec as { room?: unknown }).room === userId,
|
||||
);
|
||||
|
||||
try {
|
||||
await withActor(actor, () =>
|
||||
controller.batchWrites(
|
||||
makeMultipartReq(
|
||||
[
|
||||
{
|
||||
kind: 'field',
|
||||
name: 'manifest',
|
||||
value: manifestFor([path]),
|
||||
},
|
||||
{
|
||||
kind: 'file',
|
||||
name: 'file-0',
|
||||
filename: 'progress.bin',
|
||||
content,
|
||||
},
|
||||
],
|
||||
actor,
|
||||
chunkSize,
|
||||
),
|
||||
res,
|
||||
),
|
||||
);
|
||||
|
||||
const sumDiffs = () =>
|
||||
progressSends().reduce(
|
||||
(sum, [, , data]) =>
|
||||
sum + (data as { loaded_diff: number }).loaded_diff,
|
||||
0,
|
||||
);
|
||||
await vi.waitFor(() => expect(sumDiffs()).toBe(content.length));
|
||||
expect(progressSends().length).toBeLessThan(
|
||||
content.length / chunkSize,
|
||||
);
|
||||
} finally {
|
||||
send.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('maps positional `file` parts onto manifest order', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const paths = [`/${username}/Documents/mp-pos.txt`];
|
||||
@@ -1610,6 +1836,35 @@ describe('FSController.batchWrites (multipart)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a manifest over the item cap', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.batchWrites(
|
||||
makeMultipartReq(
|
||||
[
|
||||
{
|
||||
kind: 'field',
|
||||
name: 'manifest',
|
||||
value: JSON.stringify({
|
||||
items: Array(
|
||||
FS_BATCH_WRITE_MAX_ITEMS + 1,
|
||||
).fill(null),
|
||||
}),
|
||||
},
|
||||
],
|
||||
actor,
|
||||
),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: `Too many items in one request (max ${FS_BATCH_WRITE_MAX_ITEMS})`,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a manifest item without fileMetadata', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
@@ -1921,3 +2176,215 @@ describe('FSController.batchWrites (multipart)', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -- upload sessions outliving their share ----------------------------
|
||||
//
|
||||
// An upload session is authorized once, at `/fs/startWrite`. Resuming or
|
||||
// completing one has to re-check that the caller still has write access to the
|
||||
// session's target, or a revoked sharee lands bytes in the owner's tree.
|
||||
|
||||
describe('FSController upload session access re-checks', () => {
|
||||
const shareWritableFolder = async (folderName: string) => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const folder = `/${owner.username}/Documents/${folderName}`;
|
||||
await withActor(owner.actor, () =>
|
||||
controller.startWrite(
|
||||
makeReq<SignedWriteRequest>({
|
||||
body: {
|
||||
fileMetadata: {
|
||||
path: folder,
|
||||
size: 0,
|
||||
createMissingParents: true,
|
||||
},
|
||||
directory: true,
|
||||
},
|
||||
actor: owner.actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const entry = await server.stores.fsEntry.getEntryByPath(folder);
|
||||
// Read stays granted so the denial is a plain 403 rather than the
|
||||
// existence-masking 404 an invisible path gets.
|
||||
for (const mode of ['read', 'write']) {
|
||||
await server.services.permission.grantUserUserPermission(
|
||||
owner.actor,
|
||||
recipient.username,
|
||||
`fs:${entry!.uuid}:${mode}`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
const revokeWrite = () =>
|
||||
server.services.permission.revokeUserUserPermission(
|
||||
owner.actor,
|
||||
recipient.username,
|
||||
`fs:${entry!.uuid}:write`,
|
||||
);
|
||||
return { owner, recipient, folder, revokeWrite };
|
||||
};
|
||||
|
||||
const startUpload = async (
|
||||
actor: Actor,
|
||||
path: string,
|
||||
extra: Partial<SignedWriteRequest> = {},
|
||||
) => {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.startWrite(
|
||||
makeReq<SignedWriteRequest>({
|
||||
body: { fileMetadata: { path, size: 8 }, ...extra },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
return captured.body as ClientSignedWriteResponse;
|
||||
};
|
||||
|
||||
it('refuses to sign more parts once the share is revoked', async () => {
|
||||
const { recipient, folder, revokeWrite } =
|
||||
await shareWritableFolder('revoked-parts');
|
||||
const started = await startUpload(
|
||||
recipient.actor,
|
||||
`${folder}/upload.bin`,
|
||||
{ uploadMode: 'multipart' },
|
||||
);
|
||||
await revokeWrite();
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(recipient.actor, () =>
|
||||
controller.signMultipartParts(
|
||||
makeReq<SignMultipartPartsRequest>({
|
||||
body: {
|
||||
uploadId: started.sessionId,
|
||||
partNumbers: [1],
|
||||
},
|
||||
actor: recipient.actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('refuses to complete a session once the share is revoked', async () => {
|
||||
const { recipient, folder, revokeWrite } =
|
||||
await shareWritableFolder('revoked-complete');
|
||||
const target = `${folder}/upload.bin`;
|
||||
const started = await startUpload(recipient.actor, target);
|
||||
await revokeWrite();
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(recipient.actor, () =>
|
||||
controller.completeWrite(
|
||||
makeReq<CompleteWriteRequest>({
|
||||
body: { uploadId: started.sessionId },
|
||||
actor: recipient.actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
expect(await server.stores.fsEntry.getEntryByPath(target)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses the batch completion once the share is revoked', async () => {
|
||||
const { recipient, folder, revokeWrite } =
|
||||
await shareWritableFolder('revoked-batch');
|
||||
const targets = [`${folder}/batch-a.bin`, `${folder}/batch-b.bin`];
|
||||
const started = [
|
||||
await startUpload(recipient.actor, targets[0]!),
|
||||
await startUpload(recipient.actor, targets[1]!),
|
||||
];
|
||||
await revokeWrite();
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(recipient.actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
body: started.map((s) => ({ uploadId: s.sessionId })),
|
||||
actor: recipient.actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
for (const target of targets) {
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(target),
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('still signs parts and completes while the share stands', async () => {
|
||||
const { recipient, folder } = await shareWritableFolder('kept-share');
|
||||
const multipart = await startUpload(
|
||||
recipient.actor,
|
||||
`${folder}/kept-parts.bin`,
|
||||
{ uploadMode: 'multipart' },
|
||||
);
|
||||
const signed = makeRes();
|
||||
await withActor(recipient.actor, () =>
|
||||
controller.signMultipartParts(
|
||||
makeReq<SignMultipartPartsRequest>({
|
||||
body: { uploadId: multipart.sessionId, partNumbers: [1] },
|
||||
actor: recipient.actor,
|
||||
}),
|
||||
signed.res,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(signed.captured.body as { multipartPartUrls: unknown[] })
|
||||
.multipartPartUrls,
|
||||
).toHaveLength(1);
|
||||
|
||||
const target = `${folder}/kept-complete.bin`;
|
||||
const single = await startUpload(recipient.actor, target);
|
||||
await fetch(single.url!, { method: 'PUT', body: '12345678' });
|
||||
await withActor(recipient.actor, () =>
|
||||
controller.completeWrite(
|
||||
makeReq<CompleteWriteRequest>({
|
||||
body: { uploadId: single.sessionId },
|
||||
actor: recipient.actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(target),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('completes a batch while the share stands', async () => {
|
||||
const { recipient, folder } = await shareWritableFolder('kept-batch');
|
||||
const targets = [
|
||||
`${folder}/kept-batch-a.bin`,
|
||||
`${folder}/kept-batch-b.bin`,
|
||||
];
|
||||
const started = [
|
||||
await startUpload(recipient.actor, targets[0]!),
|
||||
await startUpload(recipient.actor, targets[1]!),
|
||||
];
|
||||
for (const s of started) {
|
||||
await fetch(s.url!, { method: 'PUT', body: '12345678' });
|
||||
}
|
||||
await withActor(recipient.actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
body: started.map((s) => ({ uploadId: s.sessionId })),
|
||||
actor: recipient.actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
for (const target of targets) {
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(target),
|
||||
).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
@@ -534,6 +535,36 @@ describe('LegacyFSController.updateFsentryThumbnail', () => {
|
||||
expect(row?.thumbnail).toBe(thumbnail);
|
||||
});
|
||||
|
||||
it('tells thumbnail listeners which entry the thumbnail is for', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const path = `/${username}/Documents/bound-thumb.txt`;
|
||||
const { uuid } = await writeFileEntry(actor, path, 'body');
|
||||
|
||||
const seen: unknown[] = [];
|
||||
const listener = (_key: string, data: unknown) => {
|
||||
seen.push(data);
|
||||
};
|
||||
server.clients.event.on('thumbnail.created', listener as never);
|
||||
try {
|
||||
const { res } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.updateFsentryThumbnail(
|
||||
makeReq({
|
||||
body: {
|
||||
uid: uuid,
|
||||
thumbnail: 'data:image/png;base64,aGVsbG8=',
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
server.clients.event.off('thumbnail.created', listener as never);
|
||||
}
|
||||
expect(seen).toEqual([expect.objectContaining({ uuid })]);
|
||||
});
|
||||
|
||||
it("refuses to retag another user's entry", async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
@@ -735,6 +766,26 @@ describe('LegacyFSController.file (signed streaming)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Metering resolves the billed account's plan from this actor, and some
|
||||
// plans are keyed on the account's email.
|
||||
it('bills an anonymous read to the owner, email included', async () => {
|
||||
const { actor, userId, username } = await makeUser();
|
||||
const { query } = await signedQuery(
|
||||
actor,
|
||||
`/${username}/Documents/signed-billed.txt`,
|
||||
'billed',
|
||||
);
|
||||
const req = makeReq({ query });
|
||||
const { res, finished } = makeStreamRes();
|
||||
await controller.file(req, res);
|
||||
await finished;
|
||||
expect(req.egressActor?.user).toMatchObject({
|
||||
id: userId,
|
||||
uuid: actor.user.uuid,
|
||||
email: `${username}@test.local`,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a signature minted before the owner was suspended', async () => {
|
||||
const { actor, userId, username } = await makeUser();
|
||||
const { query } = await signedQuery(
|
||||
@@ -755,6 +806,341 @@ describe('LegacyFSController.file (signed streaming)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -- signed URLs across an ownership change ----------------------------
|
||||
|
||||
describe('LegacyFSController signed URLs across an ownership change', () => {
|
||||
type Signed = { uid: string; read_url: string; write_url?: string };
|
||||
|
||||
const queryOf = (url: string) => {
|
||||
const parsed = new URL(url);
|
||||
return {
|
||||
uid: parsed.searchParams.get('uid')!,
|
||||
expires: parsed.searchParams.get('expires')!,
|
||||
signature: parsed.searchParams.get('signature')!,
|
||||
};
|
||||
};
|
||||
|
||||
// No actor: a link holder needs no account.
|
||||
const listSigned = async (url: string) => {
|
||||
const { res, captured } = makeRes();
|
||||
await controller.file(makeReq({ query: queryOf(url) }), res);
|
||||
return captured.body as Array<Signed & { fsentry_name: string }>;
|
||||
};
|
||||
|
||||
const readSigned = async (url: string) => {
|
||||
const { res, captured, finished } = makeStreamRes();
|
||||
await controller.file(makeReq({ query: queryOf(url) }), res);
|
||||
await finished;
|
||||
return captured.text();
|
||||
};
|
||||
|
||||
const signAs = async (actor: Actor, path: string): Promise<Signed> => {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.sign(
|
||||
makeReq({
|
||||
body: { items: [{ path, action: 'write' }] },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
return (captured.body as { signatures: Signed[] }).signatures[0];
|
||||
};
|
||||
|
||||
const moveAs = async (
|
||||
actor: Actor,
|
||||
body: { source: string; destination: string },
|
||||
) =>
|
||||
withActor(actor, () =>
|
||||
controller.move(makeReq({ body, actor }), makeRes().res),
|
||||
);
|
||||
|
||||
it('refuses the URL and the child URLs it minted once a write share hands the entry to another user', async () => {
|
||||
const alice = await makeUser();
|
||||
const bob = await makeUser();
|
||||
const dir = `/${alice.username}/Documents/signed-dir`;
|
||||
await writeFileEntry(alice.actor, `${dir}/a.txt`, 'alice-bytes');
|
||||
const signed = await signAs(alice.actor, dir);
|
||||
|
||||
const [child] = await listSigned(signed.read_url);
|
||||
expect(child.fsentry_name).toBe('a.txt');
|
||||
expect(await readSigned(child.read_url)).toBe('alice-bytes');
|
||||
|
||||
// Bob lets Alice write into his folder, and moving hers there makes
|
||||
// it Bob's. Then he revokes the share and adds a file of his own.
|
||||
const inbox = await server.services.fs.mkdir(bob.userId, {
|
||||
path: `/${bob.username}/Documents/inbox`,
|
||||
});
|
||||
const grant = `fs:${inbox.uuid}:write`;
|
||||
await server.services.permission.grantUserUserPermission(
|
||||
bob.actor,
|
||||
alice.username,
|
||||
grant,
|
||||
{},
|
||||
);
|
||||
await moveAs(alice.actor, { source: dir, destination: inbox.path });
|
||||
await server.services.permission.revokeUserUserPermission(
|
||||
bob.actor,
|
||||
alice.username,
|
||||
grant,
|
||||
);
|
||||
const movedDir = `${inbox.path}/signed-dir`;
|
||||
await writeFileEntry(bob.actor, `${movedDir}/bob.txt`, 'bob-bytes');
|
||||
expect(
|
||||
(await server.stores.fsEntry.getEntryByPath(movedDir))!.userId,
|
||||
).toBe(bob.userId);
|
||||
|
||||
await expect(listSigned(signed.read_url)).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
// Refused on the signature, even for a caller allowed to write there.
|
||||
await expect(
|
||||
withActor(bob.actor, () =>
|
||||
controller.writeFile(
|
||||
makeReq({
|
||||
query: {
|
||||
...queryOf(signed.write_url!),
|
||||
operation: 'mkdir',
|
||||
},
|
||||
body: { name: 'via-old-url' },
|
||||
actor: bob.actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
// Moved descendants keep a cached owner until the entry cache expires.
|
||||
await server.stores.fsEntry.invalidateEntryCacheByUuid(child.uid);
|
||||
await expect(readSigned(child.read_url)).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the URL working across moves and renames by the same owner', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const dir = `/${username}/Documents/kept-dir`;
|
||||
await writeFileEntry(actor, `${dir}/a.txt`, 'kept-bytes');
|
||||
const signed = await signAs(actor, dir);
|
||||
const [child] = await listSigned(signed.read_url);
|
||||
|
||||
await moveAs(actor, {
|
||||
source: dir,
|
||||
destination: `/${username}/Pictures`,
|
||||
});
|
||||
await withActor(actor, () =>
|
||||
controller.rename(
|
||||
makeReq({
|
||||
body: { uid: signed.uid, new_name: 'renamed-dir' },
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
|
||||
const [listed] = await listSigned(signed.read_url);
|
||||
expect(listed.fsentry_name).toBe('a.txt');
|
||||
expect(await readSigned(listed.read_url)).toBe('kept-bytes');
|
||||
expect(await readSigned(child.read_url)).toBe('kept-bytes');
|
||||
});
|
||||
|
||||
it('still serves a URL signed before signatures were bound to the owner', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const path = `/${username}/Documents/legacy.txt`;
|
||||
const { uuid } = await writeFileEntry(actor, path, 'legacy-bytes');
|
||||
const expires = Math.ceil(Date.now() / 1000) + 9_999_999_999_999;
|
||||
const signature = createHash('sha256')
|
||||
.update(`${uuid}/read/${signingCfg().secret}/${expires}`)
|
||||
.digest('hex');
|
||||
const url = `${signingCfg().apiBaseUrl}/file?uid=${uuid}&expires=${expires}&signature=${signature}`;
|
||||
expect(await readSigned(url)).toBe('legacy-bytes');
|
||||
});
|
||||
|
||||
it('refuses that same pre-binding URL once legacy_file_signatures is off', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const path = `/${username}/Documents/legacy-off.txt`;
|
||||
const { uuid } = await writeFileEntry(actor, path, 'legacy-bytes');
|
||||
const expires = Math.ceil(Date.now() / 1000) + 9_999_999_999_999;
|
||||
const signature = createHash('sha256')
|
||||
.update(`${uuid}/read/${signingCfg().secret}/${expires}`)
|
||||
.digest('hex');
|
||||
const url = `${signingCfg().apiBaseUrl}/file?uid=${uuid}&expires=${expires}&signature=${signature}`;
|
||||
|
||||
const config = (
|
||||
controller as unknown as { config: { legacy_file_signatures?: boolean } }
|
||||
).config;
|
||||
config.legacy_file_signatures = false;
|
||||
try {
|
||||
await expect(readSigned(url)).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
} finally {
|
||||
config.legacy_file_signatures = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('still serves an owner-bound signature while legacy_file_signatures is off', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const path = `/${username}/Documents/owner-bound-while-off.txt`;
|
||||
await writeFileEntry(actor, path, 'owner-bytes');
|
||||
const signed = await signAs(actor, path);
|
||||
|
||||
const config = (
|
||||
controller as unknown as { config: { legacy_file_signatures?: boolean } }
|
||||
).config;
|
||||
config.legacy_file_signatures = false;
|
||||
try {
|
||||
expect(await readSigned(signed.read_url)).toBe('owner-bytes');
|
||||
} finally {
|
||||
config.legacy_file_signatures = undefined;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- /file: non-UUID uid skips the entry-store lookup -------------------
|
||||
|
||||
describe('LegacyFSController.file (non-UUID uid)', () => {
|
||||
it('rejects a non-UUID uid without touching the entry store', async () => {
|
||||
const spy = vi.spyOn(server.stores.fsEntry, 'getEntryByUuid');
|
||||
const expires = Math.ceil(Date.now() / 1000) + 9_999_999_999_999;
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
controller.file(
|
||||
makeReq({
|
||||
query: {
|
||||
uid: 'not-a-uuid',
|
||||
expires: String(expires),
|
||||
signature: 'deadbeef',
|
||||
},
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('still looks up a UUID-shaped uid that does not exist', async () => {
|
||||
const spy = vi.spyOn(server.stores.fsEntry, 'getEntryByUuid');
|
||||
const expires = Math.ceil(Date.now() / 1000) + 9_999_999_999_999;
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
controller.file(
|
||||
makeReq({
|
||||
query: {
|
||||
uid: uuidv4(),
|
||||
expires: String(expires),
|
||||
signature: 'deadbeef',
|
||||
},
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('looks up a uuid whose version and variant nibbles are nonstandard', async () => {
|
||||
const spy = vi.spyOn(server.stores.fsEntry, 'getEntryByUuid');
|
||||
const expires = Math.ceil(Date.now() / 1000) + 9_999_999_999_999;
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
controller.file(
|
||||
makeReq({
|
||||
query: {
|
||||
uid: '0123abcd-0000-0000-0000-0123456789ab',
|
||||
expires: String(expires),
|
||||
signature: 'deadbeef',
|
||||
},
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// -- /file via a signature minted by someone other than the owner -------
|
||||
//
|
||||
// `/sign` binds the signature to the entry's owner, not to whoever calls
|
||||
// `/sign` — a grantee reading a shared file, or an app signing inside its
|
||||
// own AppData, both mint signatures over an entry they don't own. Covers
|
||||
// that those still read through `/file`.
|
||||
|
||||
describe('LegacyFSController /file via a non-owner-minted signature', () => {
|
||||
const queryOf = (url: string) => {
|
||||
const parsed = new URL(url);
|
||||
return {
|
||||
uid: parsed.searchParams.get('uid')!,
|
||||
expires: parsed.searchParams.get('expires')!,
|
||||
signature: parsed.searchParams.get('signature')!,
|
||||
};
|
||||
};
|
||||
|
||||
it('reads through /file when a grantee with read access signs the URL', async () => {
|
||||
const owner = await makeUser();
|
||||
const grantee = await makeUser();
|
||||
const path = `/${owner.username}/Documents/grantee-read.txt`;
|
||||
const { uuid } = await writeFileEntry(owner.actor, path, 'grantee-bytes');
|
||||
await server.services.permission.grantUserUserPermission(
|
||||
owner.actor,
|
||||
grantee.username,
|
||||
`fs:${uuid}:read`,
|
||||
{},
|
||||
);
|
||||
|
||||
const { res: signRes, captured: signCaptured } = makeRes();
|
||||
await withActor(grantee.actor, () =>
|
||||
controller.sign(
|
||||
makeReq({
|
||||
body: { items: [{ uid: uuid, action: 'read' }] },
|
||||
actor: grantee.actor,
|
||||
}),
|
||||
signRes,
|
||||
),
|
||||
);
|
||||
const signed = (
|
||||
signCaptured.body as { signatures: Array<{ read_url: string }> }
|
||||
).signatures[0];
|
||||
|
||||
const { res, captured, finished } = makeStreamRes();
|
||||
await controller.file(makeReq({ query: queryOf(signed.read_url) }), res);
|
||||
await finished;
|
||||
expect(captured.statusCode).toBe(200);
|
||||
expect(captured.text()).toBe('grantee-bytes');
|
||||
});
|
||||
|
||||
it('reads through /file when an app actor signs inside its AppData root', async () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const appUid = `routes-sign-app-${uuidv4()}`;
|
||||
const appActor = makeActor({ ...actor, app: { uid: appUid } });
|
||||
const path = `/${username}/AppData/${appUid}/app-read.txt`;
|
||||
await writeFileEntry(actor, path, 'app-bytes');
|
||||
|
||||
const { res: signRes, captured: signCaptured } = makeRes();
|
||||
await withActor(appActor, () =>
|
||||
controller.sign(
|
||||
makeReq({
|
||||
body: { items: [{ path, action: 'read' }] },
|
||||
actor: appActor,
|
||||
}),
|
||||
signRes,
|
||||
),
|
||||
);
|
||||
const signed = (
|
||||
signCaptured.body as { signatures: Array<{ read_url: string }> }
|
||||
).signatures[0];
|
||||
|
||||
const { res, captured, finished } = makeStreamRes();
|
||||
await controller.file(makeReq({ query: queryOf(signed.read_url) }), res);
|
||||
await finished;
|
||||
expect(captured.statusCode).toBe(200);
|
||||
expect(captured.text()).toBe('app-bytes');
|
||||
});
|
||||
});
|
||||
|
||||
// -- /writeFile operation dispatch -------------------------------------
|
||||
|
||||
describe('LegacyFSController.writeFile operations', () => {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -24,6 +24,7 @@ import { posix as pathPosix } from 'node:path';
|
||||
import {
|
||||
assertResolvedActor,
|
||||
isAccessTokenActor,
|
||||
isAppActor,
|
||||
makeActor,
|
||||
} from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
@@ -31,25 +32,39 @@ import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { RouteOptions } from '../../core/http/index.js';
|
||||
import {
|
||||
assertNotSuspended,
|
||||
assertTeam2fa,
|
||||
assertVerifiedAccount,
|
||||
} from '../../core/http/middleware/gates.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import type { ACLService } from '../../services/acl/ACLService.js';
|
||||
import { assertActorHasCredits } from '../../services/metering/enforcement.js';
|
||||
import type { SignedFile } from '../../util/fileSigning.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import type {
|
||||
SignAction,
|
||||
SignedFile,
|
||||
SigningConfig,
|
||||
} from '../../util/fileSigning.js';
|
||||
import {
|
||||
NON_OWNER_SIGNATURE_TTL_SECONDS,
|
||||
parseSignedQuery,
|
||||
verifySignature,
|
||||
} from '../../util/fileSigning.js';
|
||||
import { APP_ICON_SIZES, getAppIconCdnUrl } from '../../util/appIcon.js';
|
||||
import { expandTildePath } from '../../services/fs/resolveNode.js';
|
||||
import {
|
||||
joinChildPath,
|
||||
normalizeAbsolutePath,
|
||||
splitParentAndName,
|
||||
} from '../../services/fs/resolveNode.js';
|
||||
import { maskEntryPath } from '../../services/fs/sharePathMask.js';
|
||||
import { clientParentUid } from '../../services/fs/rootListing.js';
|
||||
import {
|
||||
buildHostedBackingDenial,
|
||||
hostedIndexUrlBackingIsUnavailable,
|
||||
} from '../../util/hostedAppBacking.js';
|
||||
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
|
||||
import { listClientShares } from '../share/clientShare.js';
|
||||
import { SHARE_LIST_LIMIT } from '../share/limits.js';
|
||||
import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import {
|
||||
FS_BATCH_CONCURRENT,
|
||||
@@ -64,6 +79,7 @@ import {
|
||||
FS_SEARCH_CONCURRENT,
|
||||
FS_SEARCH_LIMIT,
|
||||
FS_SIGN_LIMIT,
|
||||
FS_SIGN_MAX_ITEMS,
|
||||
FS_SIGNED_CONCURRENT,
|
||||
FS_SIGNED_READ_LIMIT,
|
||||
FS_SIGNED_WRITE_LIMIT,
|
||||
@@ -74,6 +90,7 @@ import {
|
||||
assertAccess,
|
||||
assertCanCreate,
|
||||
assertCanMoveInto,
|
||||
expandClientPath,
|
||||
getBoolean,
|
||||
getString,
|
||||
loadLegacyAssociatedApps,
|
||||
@@ -87,15 +104,55 @@ type RouterCache = Map<string, RequestHandler | null>;
|
||||
|
||||
const additionalRoutePaths: Record<string, string> = {};
|
||||
|
||||
// Shape only: version and variant nibbles aren't checked, so a row whose uuid
|
||||
// wasn't minted by an RFC-conforming generator still resolves.
|
||||
const UUID_SHAPE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
// Legacy `/batch` multipart upload caps. Each file is buffered fully into
|
||||
// memory before any quota / storage check runs, so without these limits an
|
||||
// authenticated caller could grow the process heap proportional to whatever
|
||||
// they sent. Streaming uploads go through `/writeFile`; this path is for
|
||||
// pre-v2 clients only.
|
||||
const BATCH_MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MiB per file
|
||||
const BATCH_MAX_TOTAL_SIZE = 256 * 1024 * 1024; // 256 MiB buffered per request
|
||||
const BATCH_MAX_FILES = 64;
|
||||
const BATCH_MAX_PARTS = 256;
|
||||
const BATCH_MAX_FIELD_SIZE = 1 * 1024 * 1024; // 1 MiB per operation/fileinfo JSON
|
||||
// Multipart carries at most one op per part, so JSON mode gets the same bound.
|
||||
const BATCH_MAX_OPS = BATCH_MAX_PARTS;
|
||||
const BATCH_MUTATING_OPS = new Set(['mkdir', 'shortcut', 'move', 'delete']);
|
||||
|
||||
// An op's `as` names the path it produced; later ops in the same batch refer
|
||||
// to it as `$name` or `$name/rest`, since a deduped mkdir can land elsewhere.
|
||||
function resolveBatchRef(raw: string, refs: Map<string, string>): string {
|
||||
if (!raw.startsWith('$')) return raw;
|
||||
const slashIdx = raw.indexOf('/');
|
||||
const name = slashIdx === -1 ? raw.slice(1) : raw.slice(1, slashIdx);
|
||||
const rest = slashIdx === -1 ? '' : raw.slice(slashIdx);
|
||||
if (!name) {
|
||||
throw new HttpError(400, 'Invalid batch reference', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const base = refs.get(name);
|
||||
if (base === undefined) {
|
||||
throw new HttpError(400, `Unknown batch reference: $${name}`, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
// Rejects non-normalized results, so `$ref/../x` can't escape.
|
||||
return normalizeAbsolutePath(base + rest);
|
||||
}
|
||||
|
||||
const malformedMultipart = (err: unknown): HttpError =>
|
||||
err instanceof HttpError
|
||||
? err
|
||||
: new HttpError(
|
||||
400,
|
||||
`Malformed multipart body${err instanceof Error ? `: ${err.message}` : ''}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
|
||||
async function loadAdditionalRouter(
|
||||
key: string,
|
||||
@@ -458,11 +515,18 @@ export class LegacyFSController extends PuterController {
|
||||
'see',
|
||||
);
|
||||
|
||||
const [suggestedApps, appsById, shareFlags] = await Promise.all([
|
||||
this.services.suggestedApps.getSuggestedApps(entry),
|
||||
loadLegacyAssociatedApps(this.stores.app, [entry]),
|
||||
this.services.share.shareFlags(actor, [entry]),
|
||||
]);
|
||||
const [suggestedApps, appsById, shareFlags, parentUid] =
|
||||
await Promise.all([
|
||||
this.services.suggestedApps.getSuggestedApps(entry),
|
||||
loadLegacyAssociatedApps(this.stores.app, [entry]),
|
||||
this.services.share.shareFlags(actor, [entry]),
|
||||
clientParentUid(
|
||||
actor,
|
||||
entry,
|
||||
this.services.acl,
|
||||
this.stores.permission,
|
||||
),
|
||||
]);
|
||||
entry.suggestedApps = suggestedApps;
|
||||
|
||||
const shaped = await toLegacyEntry(this.clients.event, entry, {
|
||||
@@ -474,6 +538,7 @@ export class LegacyFSController extends PuterController {
|
||||
},
|
||||
appsById,
|
||||
isShared: shareFlags.get(entry.uuid) ?? null,
|
||||
parentUid,
|
||||
});
|
||||
|
||||
// Optional hydrations:
|
||||
@@ -487,6 +552,13 @@ export class LegacyFSController extends PuterController {
|
||||
// callers working. `return_owner` is a no-op — `owner` is always set.
|
||||
if (getBoolean(body, 'return_versions')) shaped.versions = [];
|
||||
if (getBoolean(body, 'return_shares')) {
|
||||
// Share-listing work spends the share-listing budget, not just
|
||||
// `fs:stat`'s.
|
||||
if (!(await consumeRouteRateLimit(req, SHARE_LIST_LIMIT))) {
|
||||
throw new HttpError(429, 'Too many requests.', {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
}
|
||||
shaped.shares = await listClientShares(
|
||||
this.services.share,
|
||||
this.clients.event,
|
||||
@@ -515,6 +587,8 @@ export class LegacyFSController extends PuterController {
|
||||
const rootChildren = await listRootEntries(
|
||||
actor,
|
||||
this.stores.fsEntry,
|
||||
this.services.acl,
|
||||
this.stores.permission,
|
||||
);
|
||||
const rootSuggestions =
|
||||
await this.services.suggestedApps.getSuggestedAppsForEntries(
|
||||
@@ -651,14 +725,21 @@ export class LegacyFSController extends PuterController {
|
||||
// When `parent` is a path string, use it directly without requiring
|
||||
// the entry to exist — `services.fs.mkdir` honors `create_missing_parents`
|
||||
// and will materialize any missing intermediate directories.
|
||||
let targetPath = rawPath;
|
||||
const username = actor.user?.username;
|
||||
let targetPath = rawPath.startsWith('/')
|
||||
? await expandClientPath(this.stores.fsEntry, rawPath, username)
|
||||
: rawPath;
|
||||
if (body.parent !== undefined && !rawPath.startsWith('/')) {
|
||||
let parentPath: string;
|
||||
if (
|
||||
typeof body.parent === 'string' &&
|
||||
(body.parent.startsWith('/') || body.parent.startsWith('~'))
|
||||
) {
|
||||
parentPath = expandTildePath(body.parent, actor.user?.username);
|
||||
parentPath = await expandClientPath(
|
||||
this.stores.fsEntry,
|
||||
body.parent,
|
||||
username,
|
||||
);
|
||||
} else {
|
||||
const parent = await resolveV1Selector(
|
||||
this.stores.fsEntry,
|
||||
@@ -859,9 +940,7 @@ export class LegacyFSController extends PuterController {
|
||||
// Trash, and `null`/`{}` when restoring. See
|
||||
// `src/gui/src/helpers.js` → `window.move_items`.
|
||||
newMetadata: (body.new_metadata ?? undefined) as
|
||||
| Record<string, unknown>
|
||||
| null
|
||||
| undefined,
|
||||
Record<string, unknown> | null | undefined,
|
||||
});
|
||||
const oldPath = source.path;
|
||||
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
|
||||
@@ -994,9 +1073,13 @@ export class LegacyFSController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
const parentPath = pathPosix.dirname(
|
||||
rawPath.startsWith('/') ? rawPath : `/${rawPath}`,
|
||||
const expanded = await expandClientPath(
|
||||
this.stores.fsEntry,
|
||||
rawPath,
|
||||
actor.user?.username,
|
||||
);
|
||||
const targetPath = expanded.startsWith('/') ? expanded : `/${expanded}`;
|
||||
const parentPath = pathPosix.dirname(targetPath);
|
||||
if (parentPath === '/') {
|
||||
throw new HttpError(400, 'Cannot touch in root', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -1011,7 +1094,7 @@ export class LegacyFSController extends PuterController {
|
||||
);
|
||||
|
||||
await this.services.fs.touch(userId, {
|
||||
path: rawPath,
|
||||
path: targetPath,
|
||||
setAccessed: getBoolean(body, 'set_accessed_to_now') ?? false,
|
||||
setModified: getBoolean(body, 'set_modified_to_now') ?? false,
|
||||
setCreated: getBoolean(body, 'set_created_to_now') ?? false,
|
||||
@@ -1120,7 +1203,7 @@ export class LegacyFSController extends PuterController {
|
||||
// emitAndWait is required: the thumbnails extension rewrites
|
||||
// `event.url` from a data URL to an `s3://` pointer, and the DB
|
||||
// write below needs to see that rewrite.
|
||||
const event = { url: thumbnail };
|
||||
const event = { url: thumbnail, uuid: entry.uuid };
|
||||
await this.clients.event.emitAndWait('thumbnail.created', event, {});
|
||||
|
||||
await this.clients.db.write(
|
||||
@@ -1256,6 +1339,7 @@ export class LegacyFSController extends PuterController {
|
||||
// that guard every other authenticated FS route have to run here.
|
||||
assertNotSuspended(actor!.user);
|
||||
assertVerifiedAccount(actor!.user);
|
||||
await assertTeam2fa(actor!.user, this.stores.team);
|
||||
|
||||
req.actor = assertResolvedActor(actor!);
|
||||
Context.set('actor', actor);
|
||||
@@ -1290,16 +1374,21 @@ export class LegacyFSController extends PuterController {
|
||||
throw new HttpError(400, '`items` is required', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
if (items.length > FS_SIGN_MAX_ITEMS)
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Too many items in one request (max ${FS_SIGN_MAX_ITEMS})`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
|
||||
const isApp = Boolean((actor as { app?: unknown }).app);
|
||||
const actingApp = actor.effectiveApp;
|
||||
const signingCfg = signingConfigFromAppConfig(this.config);
|
||||
|
||||
// Apps can only sign inside their AppData root.
|
||||
let appDataRoot: string | null = null;
|
||||
if (isApp) {
|
||||
const username = (actor as { user?: { username?: string } }).user
|
||||
?.username;
|
||||
const appUid = (actor as { app?: { uid?: string } }).app?.uid;
|
||||
if (actingApp) {
|
||||
const username = actor.user?.username;
|
||||
const appUid = actingApp.uid;
|
||||
if (!username || !appUid)
|
||||
throw new HttpError(403, 'Forbidden', {
|
||||
legacyCode: 'forbidden',
|
||||
@@ -1308,8 +1397,7 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
type SignedOrEmpty =
|
||||
| (SignedFile & { path?: string })
|
||||
| Record<string, never>;
|
||||
(SignedFile & { path?: string }) | Record<string, never>;
|
||||
const result: { signatures: SignedOrEmpty[]; token?: string } = {
|
||||
signatures: [],
|
||||
};
|
||||
@@ -1427,25 +1515,12 @@ export class LegacyFSController extends PuterController {
|
||||
writeFile = async (req: Request, res: Response): Promise<void> => {
|
||||
const query = asRecord(req.query);
|
||||
const signingCfg = signingConfigFromAppConfig(this.config);
|
||||
verifySignature(
|
||||
{
|
||||
uid: query.uid as string,
|
||||
expires: query.expires as string,
|
||||
signature: query.signature as string,
|
||||
},
|
||||
const targetEntry = await this.#resolveSignedEntry(
|
||||
query,
|
||||
'write',
|
||||
signingCfg,
|
||||
);
|
||||
|
||||
const uid = typeof query.uid === 'string' ? query.uid : '';
|
||||
const targetEntry = await resolveV1Selector(this.stores.fsEntry, {
|
||||
uid,
|
||||
});
|
||||
if (!targetEntry)
|
||||
throw new HttpError(404, 'Item not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
|
||||
// Owner suspension check.
|
||||
const owner = await this.stores.user.getById(targetEntry.userId);
|
||||
if (!owner)
|
||||
@@ -1669,18 +1744,7 @@ export class LegacyFSController extends PuterController {
|
||||
file = async (req: Request, res: Response): Promise<void> => {
|
||||
const query = asRecord(req.query);
|
||||
const signingCfg = signingConfigFromAppConfig(this.config);
|
||||
verifySignature(
|
||||
{
|
||||
uid: query.uid as string,
|
||||
expires: query.expires as string,
|
||||
signature: query.signature as string,
|
||||
},
|
||||
'read',
|
||||
signingCfg,
|
||||
);
|
||||
|
||||
const uid = typeof query.uid === 'string' ? query.uid : '';
|
||||
const entry = await resolveV1Selector(this.stores.fsEntry, { uid });
|
||||
const entry = await this.#resolveSignedEntry(query, 'read', signingCfg);
|
||||
|
||||
// Owner-suspension guard — matches v1's /file. A signed URL stays
|
||||
// valid forever by default, so a signature minted before a suspension
|
||||
@@ -1704,6 +1768,7 @@ export class LegacyFSController extends PuterController {
|
||||
uuid: owner.uuid,
|
||||
id: owner.id,
|
||||
username: owner.username,
|
||||
email: owner.email ?? null,
|
||||
suspended: !!(owner as { suspended?: unknown }).suspended,
|
||||
},
|
||||
};
|
||||
@@ -1795,7 +1860,7 @@ export class LegacyFSController extends PuterController {
|
||||
*/
|
||||
openItem = async (req: Request, res: Response): Promise<void> => {
|
||||
const actor = this.#requireActor(req);
|
||||
if ((actor as { app?: unknown }).app) {
|
||||
if (actor.effectiveApp) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'This endpoint is only available to user sessions',
|
||||
@@ -1889,8 +1954,10 @@ export class LegacyFSController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
const actorApp = (actor as { app?: { uid?: string } }).app;
|
||||
if (!actorApp?.uid || actorApp.uid !== appUid) {
|
||||
// The app acting directly, not one that merely issued the credential:
|
||||
// claiming a root dir is the app itself asking, and a token is not it.
|
||||
const callerApp = isAppActor(actor) ? actor.effectiveApp : null;
|
||||
if (callerApp?.uid !== appUid) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Only the app itself may request its root dir',
|
||||
@@ -1924,25 +1991,40 @@ export class LegacyFSController extends PuterController {
|
||||
* access to a subject FS entry.
|
||||
*/
|
||||
checkAppAcl = async (req: Request, res: Response): Promise<void> => {
|
||||
this.#requireActor(req);
|
||||
const actor = this.#requireActor(req);
|
||||
const body = asRecord(req.body);
|
||||
|
||||
const subjectRef = body.subject;
|
||||
const appRef = body.app;
|
||||
const mode = (getString(body, 'mode') ?? 'read') as
|
||||
| 'see'
|
||||
| 'list'
|
||||
| 'read'
|
||||
| 'write';
|
||||
'see' | 'list' | 'read' | 'write';
|
||||
if (!subjectRef || !appRef)
|
||||
throw new HttpError(400, '`subject` and `app` are required', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
const subject = await resolveV1Selector(
|
||||
this.stores.fsEntry,
|
||||
subjectRef,
|
||||
);
|
||||
// A missing subject and one the caller can't see get the same 404,
|
||||
// so this can't be used to probe for other users' entries.
|
||||
let subject: import('../../stores/fs/FSEntry.js').FSEntry | null = null;
|
||||
try {
|
||||
subject = await resolveV1Selector(this.stores.fsEntry, subjectRef);
|
||||
} catch (err) {
|
||||
if (!(err instanceof HttpError && err.statusCode === 404))
|
||||
throw err;
|
||||
}
|
||||
let ancestors: Promise<Array<{ uid: string; path: string }>> | null =
|
||||
null;
|
||||
const descriptor = subject && {
|
||||
path: subject.path,
|
||||
resolveAncestors: () =>
|
||||
(ancestors ??= this.services.fs.getAncestorChain(subject.path)),
|
||||
};
|
||||
const acl = this.services.acl as ACLService;
|
||||
if (!descriptor || !(await acl.check(actor, descriptor, 'see')))
|
||||
throw new HttpError(404, 'Subject does not exist', {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
|
||||
let app: { uid: string } | null = null;
|
||||
if (typeof appRef === 'string') {
|
||||
app =
|
||||
@@ -1956,19 +2038,10 @@ export class LegacyFSController extends PuterController {
|
||||
|
||||
// Build an actor-under-user shape for the check.
|
||||
const actorForApp = makeActor({
|
||||
user: req.actor!.user,
|
||||
user: actor.user,
|
||||
app: { uid: (app as { uid: string }).uid },
|
||||
});
|
||||
const descriptor = {
|
||||
path: subject.path,
|
||||
resolveAncestors: () =>
|
||||
this.services.fs.getAncestorChain(subject.path),
|
||||
};
|
||||
const allowed = await (this.services.acl as ACLService).check(
|
||||
actorForApp,
|
||||
descriptor,
|
||||
mode,
|
||||
);
|
||||
const allowed = await acl.check(actorForApp, descriptor, mode);
|
||||
res.json({ allowed });
|
||||
};
|
||||
|
||||
@@ -2052,9 +2125,12 @@ export class LegacyFSController extends PuterController {
|
||||
// silently ignores. Run the entry through `toLegacyEntry` first so
|
||||
// the event payload matches what /stat et al. return, then overlay
|
||||
// per-op extras (e.g. `old_path` for moves).
|
||||
// `forOwner` — the audience is the owner, who can't read the actor's mask.
|
||||
try {
|
||||
const response = {
|
||||
...(await toLegacyEntry(this.clients.event, entry)),
|
||||
...(await toLegacyEntry(this.clients.event, entry, {
|
||||
forOwner: true,
|
||||
})),
|
||||
...extra,
|
||||
from_new_service: true,
|
||||
};
|
||||
@@ -2184,6 +2260,7 @@ export class LegacyFSController extends PuterController {
|
||||
const results: unknown[] = [];
|
||||
let hasError = false;
|
||||
let sequentialFileIdx = 0;
|
||||
const pathRefs = new Map<string, string>();
|
||||
|
||||
for (const spec of operationSpecs) {
|
||||
try {
|
||||
@@ -2191,6 +2268,16 @@ export class LegacyFSController extends PuterController {
|
||||
const op = typeof record.op === 'string' ? record.op : '';
|
||||
let shaped: unknown;
|
||||
|
||||
// Non-write ops draw the same budget as the standalone routes.
|
||||
if (
|
||||
BATCH_MUTATING_OPS.has(op) &&
|
||||
!(await consumeRouteRateLimit(req, FS_MUTATE_LIMIT))
|
||||
) {
|
||||
throw new HttpError(429, 'Too many requests.', {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
}
|
||||
|
||||
if (op === 'write') {
|
||||
// Pair with a file part — prefer `item_upload_id`
|
||||
// index (what puter-js sets), fall back to the op's
|
||||
@@ -2224,15 +2311,22 @@ export class LegacyFSController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const parentPath = getString(record, 'path') ?? '';
|
||||
const expandedParent = expandTildePath(
|
||||
const parentPath = resolveBatchRef(
|
||||
getString(record, 'path') ?? '',
|
||||
pathRefs,
|
||||
);
|
||||
const expandedParent = await expandClientPath(
|
||||
this.stores.fsEntry,
|
||||
parentPath,
|
||||
username,
|
||||
);
|
||||
const targetPath =
|
||||
expandedParent && expandedParent !== '/'
|
||||
? `${expandedParent.replace(/\/+$/, '')}/${name}`
|
||||
: `/${name}`;
|
||||
// joinChildPath rejects a `name` carrying a slash or a
|
||||
// `.`/`..` segment, so it can't reach outside the parent
|
||||
// the ACL check below actually authorized.
|
||||
const targetPath = joinChildPath(
|
||||
expandedParent || '/',
|
||||
name,
|
||||
);
|
||||
// Mirrors the per-op /write|/mkdir routes: assert write
|
||||
// on the parent dir, but when the parent resolves to `/`
|
||||
// fall back to the target path so the ACL check rides
|
||||
@@ -2283,25 +2377,12 @@ export class LegacyFSController extends PuterController {
|
||||
response.fsEntry,
|
||||
);
|
||||
} else if (op === 'mkdir') {
|
||||
const parentPath = getString(record, 'path') ?? '';
|
||||
const name = getString(record, 'name');
|
||||
if (!name) {
|
||||
throw new HttpError(400, 'mkdir op missing `name`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const expandedParent = expandTildePath(
|
||||
parentPath,
|
||||
username,
|
||||
);
|
||||
const targetPath =
|
||||
expandedParent && expandedParent !== '/'
|
||||
? `${expandedParent.replace(/\/+$/, '')}/${name}`
|
||||
: `/${name}`;
|
||||
const writeAclPath =
|
||||
expandedParent && expandedParent !== '/'
|
||||
? expandedParent.replace(/\/+$/, '')
|
||||
: targetPath;
|
||||
const { targetPath, writeAclPath } =
|
||||
await this.#resolveBatchMkdirTarget(
|
||||
record,
|
||||
username,
|
||||
pathRefs,
|
||||
);
|
||||
await assertAccess(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
@@ -2319,12 +2400,13 @@ export class LegacyFSController extends PuterController {
|
||||
'create_missing_parents',
|
||||
) ?? false,
|
||||
});
|
||||
this.#registerBatchRef(record, pathRefs, entry.path);
|
||||
await this.#emitGuiEvent('outer.gui.item.added', entry);
|
||||
shaped = await toLegacyEntry(this.clients.event, entry);
|
||||
} else if (op === 'shortcut') {
|
||||
const parentPath = getString(record, 'path') ?? '';
|
||||
const name = getString(record, 'name');
|
||||
const shortcutToUid =
|
||||
const shortcutToRaw =
|
||||
getString(record, 'shortcut_to_uid') ??
|
||||
getString(record, 'shortcut_to');
|
||||
if (!name) {
|
||||
@@ -2332,19 +2414,21 @@ export class LegacyFSController extends PuterController {
|
||||
legacyCode: 'shortcut_target_not_found',
|
||||
});
|
||||
}
|
||||
if (!shortcutToUid) {
|
||||
if (!shortcutToRaw) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'shortcut op missing `shortcut_to_uid`',
|
||||
{ legacyCode: 'shortcut_target_not_found' },
|
||||
);
|
||||
}
|
||||
// A path or a uid; the desktop sends a path.
|
||||
const target = await resolveV1Selector(
|
||||
this.stores.fsEntry,
|
||||
{ uid: shortcutToUid },
|
||||
resolveBatchRef(shortcutToRaw, pathRefs),
|
||||
);
|
||||
const expandedParent = expandTildePath(
|
||||
parentPath,
|
||||
const expandedParent = await expandClientPath(
|
||||
this.stores.fsEntry,
|
||||
resolveBatchRef(parentPath, pathRefs),
|
||||
username,
|
||||
);
|
||||
const parent = await resolveV1Selector(
|
||||
@@ -2374,13 +2458,21 @@ export class LegacyFSController extends PuterController {
|
||||
await this.#emitGuiEvent('outer.gui.item.added', link);
|
||||
shaped = await toLegacyEntry(this.clients.event, link);
|
||||
} else if (op === 'move') {
|
||||
const sourceRef =
|
||||
typeof record.source === 'string'
|
||||
? resolveBatchRef(record.source, pathRefs)
|
||||
: record.source;
|
||||
const destinationRef =
|
||||
typeof record.destination === 'string'
|
||||
? resolveBatchRef(record.destination, pathRefs)
|
||||
: record.destination;
|
||||
const source = await resolveV1Selector(
|
||||
this.stores.fsEntry,
|
||||
record.source,
|
||||
sourceRef,
|
||||
);
|
||||
const destinationParent = await resolveV1Selector(
|
||||
this.stores.fsEntry,
|
||||
record.destination,
|
||||
destinationRef,
|
||||
);
|
||||
await assertAccess(
|
||||
this.services.acl,
|
||||
@@ -2403,14 +2495,20 @@ export class LegacyFSController extends PuterController {
|
||||
overwrite: getBoolean(record, 'overwrite') ?? false,
|
||||
dedupeName: getBoolean(record, 'dedupe_name') ?? false,
|
||||
});
|
||||
this.#registerBatchRef(record, pathRefs, moved.path);
|
||||
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
|
||||
old_path: source.path,
|
||||
});
|
||||
shaped = await toLegacyEntry(this.clients.event, moved);
|
||||
} else if (op === 'delete') {
|
||||
const pathField = getString(record, 'path');
|
||||
const selectorRef =
|
||||
pathField !== undefined
|
||||
? resolveBatchRef(pathField, pathRefs)
|
||||
: record;
|
||||
const entry = await resolveV1Selector(
|
||||
this.stores.fsEntry,
|
||||
getString(record, 'path') ?? record,
|
||||
selectorRef,
|
||||
);
|
||||
await assertAccess(
|
||||
this.services.acl,
|
||||
@@ -2459,64 +2557,100 @@ export class LegacyFSController extends PuterController {
|
||||
}> = [];
|
||||
const fileinfos: Array<Record<string, unknown>> = [];
|
||||
let parseError: Error | null = null;
|
||||
const bb = Busboy({
|
||||
headers: req.headers,
|
||||
limits: {
|
||||
fileSize: BATCH_MAX_FILE_SIZE,
|
||||
files: BATCH_MAX_FILES,
|
||||
parts: BATCH_MAX_PARTS,
|
||||
fieldSize: BATCH_MAX_FIELD_SIZE,
|
||||
},
|
||||
});
|
||||
let totalBufferedBytes = 0;
|
||||
// First error wins. Buffered files are dropped right away rather
|
||||
// than held while the rest of the body drains.
|
||||
const fail = (err: Error) => {
|
||||
if (parseError) return;
|
||||
parseError = err;
|
||||
files.length = 0;
|
||||
};
|
||||
const tooLarge = (message: string) =>
|
||||
new HttpError(413, message, {
|
||||
legacyCode: 'too_large' as never,
|
||||
});
|
||||
const reserve = (bytes: number): boolean => {
|
||||
totalBufferedBytes += bytes;
|
||||
if (totalBufferedBytes <= BATCH_MAX_TOTAL_SIZE) return true;
|
||||
fail(
|
||||
tooLarge(
|
||||
`Batch exceeds ${BATCH_MAX_TOTAL_SIZE} bytes in total`,
|
||||
),
|
||||
);
|
||||
return false;
|
||||
};
|
||||
let bb: ReturnType<typeof Busboy>;
|
||||
try {
|
||||
bb = Busboy({
|
||||
headers: req.headers,
|
||||
limits: {
|
||||
fileSize: BATCH_MAX_FILE_SIZE,
|
||||
files: BATCH_MAX_FILES,
|
||||
parts: BATCH_MAX_PARTS,
|
||||
fieldSize: BATCH_MAX_FIELD_SIZE,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Missing boundary or unparseable content-type.
|
||||
reject(malformedMultipart(err));
|
||||
return;
|
||||
}
|
||||
|
||||
// Busboy emits these `*Limit` events when a configured cap is
|
||||
// hit. Capture the first one as a 413 so callers get a clean
|
||||
// signal instead of a silently-truncated upload.
|
||||
bb.on('filesLimit', () => {
|
||||
if (!parseError) {
|
||||
parseError = new HttpError(
|
||||
413,
|
||||
bb.on('filesLimit', () =>
|
||||
fail(
|
||||
tooLarge(
|
||||
`Too many files in batch (max ${BATCH_MAX_FILES})`,
|
||||
{ legacyCode: 'too_large' as never },
|
||||
);
|
||||
}
|
||||
});
|
||||
bb.on('partsLimit', () => {
|
||||
if (!parseError) {
|
||||
parseError = new HttpError(
|
||||
413,
|
||||
),
|
||||
),
|
||||
);
|
||||
bb.on('partsLimit', () =>
|
||||
fail(
|
||||
tooLarge(
|
||||
`Too many parts in batch (max ${BATCH_MAX_PARTS})`,
|
||||
{ legacyCode: 'too_large' as never },
|
||||
);
|
||||
}
|
||||
});
|
||||
bb.on('fieldsLimit', () => {
|
||||
if (!parseError) {
|
||||
parseError = new HttpError(
|
||||
413,
|
||||
'Too many fields in batch',
|
||||
{ legacyCode: 'too_large' as never },
|
||||
);
|
||||
}
|
||||
});
|
||||
),
|
||||
),
|
||||
);
|
||||
bb.on('fieldsLimit', () =>
|
||||
fail(tooLarge('Too many fields in batch')),
|
||||
);
|
||||
|
||||
bb.on('field', (fieldName, value) => {
|
||||
bb.on('field', (fieldName, value, info) => {
|
||||
if (parseError) return;
|
||||
if (info?.valueTruncated) {
|
||||
fail(
|
||||
tooLarge(
|
||||
`\`${fieldName}\` field exceeds ${BATCH_MAX_FIELD_SIZE} bytes`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!reserve(Buffer.byteLength(value))) return;
|
||||
// Ignore operation_id / socket_id / misc fields — not
|
||||
// needed for v2 batch semantics.
|
||||
if (fieldName !== 'operation' && fieldName !== 'fileinfo') {
|
||||
return;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
if (fieldName === 'operation') {
|
||||
ops.push(JSON.parse(value));
|
||||
} else if (fieldName === 'fileinfo') {
|
||||
const parsed = JSON.parse(value);
|
||||
fileinfos.push(
|
||||
parsed && typeof parsed === 'object'
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {},
|
||||
);
|
||||
}
|
||||
// Ignore operation_id / socket_id / misc fields — not
|
||||
// needed for v2 batch semantics.
|
||||
} catch (err) {
|
||||
parseError =
|
||||
err instanceof Error ? err : new Error(String(err));
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
fail(
|
||||
new HttpError(
|
||||
400,
|
||||
`\`${fieldName}\` field is not valid JSON`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (fieldName === 'operation') {
|
||||
ops.push(parsed);
|
||||
} else {
|
||||
fileinfos.push(
|
||||
parsed && typeof parsed === 'object'
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2525,24 +2659,26 @@ export class LegacyFSController extends PuterController {
|
||||
// For streaming uploads use the signed `/writeFile` endpoint.
|
||||
bb.on('file', (_fieldName, stream, info) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let truncated = false;
|
||||
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
// Busboy emits `limit` after writing the first byte past
|
||||
// `fileSize`. The stream continues being drained so the
|
||||
// multipart parser stays in sync, but we discard the (now
|
||||
// truncated) buffer and mark the batch as failed.
|
||||
stream.on('limit', () => {
|
||||
truncated = true;
|
||||
if (!parseError) {
|
||||
parseError = new HttpError(
|
||||
413,
|
||||
`File in batch exceeds ${BATCH_MAX_FILE_SIZE} bytes`,
|
||||
{ legacyCode: 'too_large' as never },
|
||||
);
|
||||
// After a failure the stream keeps draining so the multipart
|
||||
// parser reaches `close`, but nothing more is held.
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
if (parseError || !reserve(chunk.length)) {
|
||||
chunks.length = 0;
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
// Busboy emits `limit` after the first byte past `fileSize`.
|
||||
stream.on('limit', () => {
|
||||
chunks.length = 0;
|
||||
fail(
|
||||
tooLarge(
|
||||
`File in batch exceeds ${BATCH_MAX_FILE_SIZE} bytes`,
|
||||
),
|
||||
);
|
||||
});
|
||||
stream.on('end', () => {
|
||||
if (truncated) return;
|
||||
if (parseError) return;
|
||||
files.push({
|
||||
content: Buffer.concat(chunks),
|
||||
mimeType:
|
||||
@@ -2555,16 +2691,19 @@ export class LegacyFSController extends PuterController {
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
stream.on('error', (err: Error) => {
|
||||
parseError = err;
|
||||
});
|
||||
stream.on('error', (err: Error) => fail(err));
|
||||
});
|
||||
|
||||
bb.on('close', () => {
|
||||
if (parseError) reject(parseError);
|
||||
if (parseError) reject(malformedMultipart(parseError));
|
||||
else resolve({ ops, files, fileinfos });
|
||||
});
|
||||
bb.on('error', (err) => reject(err));
|
||||
bb.on('error', (err) => reject(malformedMultipart(err)));
|
||||
// A client that disconnects mid-body never lets busboy close.
|
||||
req.on('error', (err) => reject(malformedMultipart(err)));
|
||||
req.on('aborted', () =>
|
||||
reject(malformedMultipart(new Error('Request aborted'))),
|
||||
);
|
||||
|
||||
req.pipe(bb);
|
||||
});
|
||||
@@ -2572,9 +2711,100 @@ export class LegacyFSController extends PuterController {
|
||||
|
||||
#parseJsonBatch(req: Request): unknown[] {
|
||||
const body = asRecord(req.body);
|
||||
if (Array.isArray(body.operations)) return body.operations;
|
||||
if (Array.isArray(body.ops)) return body.ops;
|
||||
return [];
|
||||
const ops = Array.isArray(body.operations)
|
||||
? body.operations
|
||||
: Array.isArray(body.ops)
|
||||
? body.ops
|
||||
: [];
|
||||
if (ops.length > BATCH_MAX_OPS) {
|
||||
throw new HttpError(
|
||||
413,
|
||||
`Too many operations in batch (max ${BATCH_MAX_OPS})`,
|
||||
{ legacyCode: 'too_large' as never },
|
||||
);
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch `mkdir` shapes: `{ parent, path }` with `path` relative to
|
||||
* `parent`, `{ path, name }` with `path` as the parent, or an absolute `{
|
||||
* path }`.
|
||||
*/
|
||||
async #resolveBatchMkdirTarget(
|
||||
record: Record<string, unknown>,
|
||||
username: string | undefined,
|
||||
pathRefs: Map<string, string>,
|
||||
): Promise<{ targetPath: string; writeAclPath: string }> {
|
||||
// Write is checked on the immediate parent: a multi-segment relative
|
||||
// path must not be authorized by a grant on the target itself. Under
|
||||
// `/` there is no parent to check, so the target stands in.
|
||||
const withAclPath = (targetPath: string) => {
|
||||
const parentPath = pathPosix.dirname(targetPath);
|
||||
return {
|
||||
targetPath,
|
||||
writeAclPath: parentPath === '/' ? targetPath : parentPath,
|
||||
};
|
||||
};
|
||||
|
||||
const name = getString(record, 'name');
|
||||
|
||||
if (typeof record.parent === 'string' && !name) {
|
||||
const parentPath = await expandClientPath(
|
||||
this.stores.fsEntry,
|
||||
resolveBatchRef(record.parent, pathRefs),
|
||||
username,
|
||||
);
|
||||
const segments = (getString(record, 'path') ?? '')
|
||||
.split('/')
|
||||
.filter((s) => s.length > 0);
|
||||
if (segments.length === 0) {
|
||||
throw new HttpError(400, 'mkdir op missing `name`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
let targetPath = parentPath || '/';
|
||||
for (const segment of segments) {
|
||||
targetPath = joinChildPath(targetPath, segment);
|
||||
}
|
||||
return withAclPath(targetPath);
|
||||
}
|
||||
|
||||
if (name) {
|
||||
const parentPath = await expandClientPath(
|
||||
this.stores.fsEntry,
|
||||
resolveBatchRef(getString(record, 'path') ?? '', pathRefs),
|
||||
username,
|
||||
);
|
||||
return withAclPath(joinChildPath(parentPath || '/', name));
|
||||
}
|
||||
|
||||
const rawPath = getString(record, 'path');
|
||||
if (!rawPath) {
|
||||
throw new HttpError(400, 'mkdir op missing `name`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const { parentPath, name: derivedName } = splitParentAndName(
|
||||
await expandClientPath(
|
||||
this.stores.fsEntry,
|
||||
resolveBatchRef(rawPath, pathRefs),
|
||||
username,
|
||||
),
|
||||
);
|
||||
return withAclPath(joinChildPath(parentPath, derivedName));
|
||||
}
|
||||
|
||||
/** Record an op's `as` (a bare name, no slash) for later `$name` refs. */
|
||||
#registerBatchRef(
|
||||
record: Record<string, unknown>,
|
||||
pathRefs: Map<string, string>,
|
||||
path: string,
|
||||
): void {
|
||||
const as = record.as;
|
||||
if (typeof as === 'string' && as.length > 0 && !as.includes('/')) {
|
||||
pathRefs.set(as, path);
|
||||
}
|
||||
}
|
||||
|
||||
#serializeBatchError(err: unknown): Record<string, unknown> {
|
||||
@@ -2602,6 +2832,36 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
// -- Helpers ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve the entry a signed URL names and verify the signature against its
|
||||
* current owner. Malformed or expired params fail before the lookup.
|
||||
*/
|
||||
async #resolveSignedEntry(
|
||||
query: Record<string, unknown>,
|
||||
action: SignAction,
|
||||
signingCfg: SigningConfig,
|
||||
): Promise<FSEntry> {
|
||||
const signed = {
|
||||
uid: query.uid as string,
|
||||
expires: query.expires as string,
|
||||
signature: query.signature as string,
|
||||
};
|
||||
const { uid } = parseSignedQuery(signed);
|
||||
// A non-UUID uid can never match a real entry — skip the cache/DB
|
||||
// round trip a forged request would otherwise pay for on every hit.
|
||||
const entry = UUID_SHAPE.test(uid)
|
||||
? await this.stores.fsEntry.getEntryByUuid(uid)
|
||||
: null;
|
||||
verifySignature(signed, action, signingCfg, entry?.userId ?? null);
|
||||
// Only a signature from before owner binding verifies without an entry.
|
||||
if (!entry)
|
||||
throw new HttpError(404, 'Subject does not exist', {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
return entry;
|
||||
}
|
||||
|
||||
#requireActor(req: Request) {
|
||||
const actor = req.actor;
|
||||
if (!actor) {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* 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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
// Launch adopts whatever directory already sits at `AppData/<appUid>`, and an
|
||||
// origin app's uid is derived from its origin, so another app knows where a
|
||||
// not-yet-opened app's root will be.
|
||||
describe('AppData roots over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const call = (path: string, token: string, body?: unknown) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
...(body === undefined
|
||||
? {}
|
||||
: { 'content-type': 'application/json' }),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
const origin = (label: string) =>
|
||||
`https://${label}-${Math.random().toString(36).slice(2, 10)}.example.com`;
|
||||
|
||||
const launch = async (userToken: string, appOrigin: string) => {
|
||||
const res = await call('/auth/get-user-app-token', userToken, {
|
||||
origin: appOrigin,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()) as { token: string; app_uid: string };
|
||||
};
|
||||
|
||||
const appRoot = async (app: { token: string; app_uid: string }) => {
|
||||
const res = await call('/auth/request-app-root-dir', app.token, {
|
||||
app_uid: app.app_uid,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()) as { uid: string; path: string };
|
||||
};
|
||||
|
||||
it('refuses an app renaming its root onto another app’s uid', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const a = await launch(token, origin('appdata-a'));
|
||||
const bOrigin = origin('appdata-b');
|
||||
const bUid = await env.server.services.auth.appUidFromOrigin(bOrigin);
|
||||
|
||||
const root = await appRoot(a);
|
||||
// A uid-bound grant on its own root, which would follow the root.
|
||||
const sign = await call('/sign', a.token, {
|
||||
items: [{ uid: root.uid, action: 'write' }],
|
||||
app_uid: a.app_uid,
|
||||
});
|
||||
expect(sign.status).toBe(200);
|
||||
|
||||
const rename = await call('/rename', a.token, {
|
||||
uid: root.uid,
|
||||
new_name: bUid,
|
||||
});
|
||||
expect(rename.status).toBe(403);
|
||||
const unmoved = await env.server.stores.fsEntry.getEntryByUuid(
|
||||
root.uid,
|
||||
);
|
||||
expect(unmoved?.path).toBe(`/${username}/AppData/${a.app_uid}`);
|
||||
|
||||
// B gets a root of its own, out of A's reach.
|
||||
const b = await launch(token, bOrigin);
|
||||
expect(b.app_uid).toBe(bUid);
|
||||
const bRoot = await appRoot(b);
|
||||
expect(bRoot.uid).not.toBe(root.uid);
|
||||
|
||||
const secret = `/${username}/AppData/${bUid}/secret.txt`;
|
||||
const write = await call('/fs/write', b.token, {
|
||||
fileMetadata: {
|
||||
path: secret,
|
||||
size: Buffer.byteLength('b-private'),
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: 'b-private',
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(write.status).toBe(200);
|
||||
|
||||
const read = await call(
|
||||
`/read?file=${encodeURIComponent(secret)}`,
|
||||
a.token,
|
||||
);
|
||||
expect(read.status).toBe(404);
|
||||
});
|
||||
|
||||
it('refuses an app moving its root into the Trash', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const a = await launch(token, origin('appdata-trash'));
|
||||
const root = await appRoot(a);
|
||||
|
||||
const move = await call('/move', a.token, {
|
||||
source: root.uid,
|
||||
destination: `/${username}/Trash`,
|
||||
});
|
||||
expect(move.status).toBe(403);
|
||||
const unmoved = await env.server.stores.fsEntry.getEntryByUuid(
|
||||
root.uid,
|
||||
);
|
||||
expect(unmoved?.path).toBe(`/${username}/AppData/${a.app_uid}`);
|
||||
});
|
||||
|
||||
it('rewrites a forged trash original_path from an app instead of trusting it', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const a = await launch(token, origin('appdata-origin'));
|
||||
const bUid = await env.server.services.auth.appUidFromOrigin(
|
||||
origin('appdata-origin-b'),
|
||||
);
|
||||
const root = await appRoot(a);
|
||||
const made = await call('/mkdir', a.token, {
|
||||
path: `${root.path}/inner`,
|
||||
});
|
||||
expect(made.status).toBe(200);
|
||||
const inner = (await made.json()) as { uid: string; path: string };
|
||||
|
||||
// Forging the other app's root as the trash origin would let the
|
||||
// owner's (unchecked) restore recreate the file there. Refusing the
|
||||
// move outright would also break an ordinary delete by anyone who
|
||||
// isn't the account, so the forged keys are corrected instead.
|
||||
const trash = await call('/move', a.token, {
|
||||
source: inner.uid,
|
||||
destination: `/${username}/Trash`,
|
||||
new_name: inner.uid,
|
||||
new_metadata: {
|
||||
original_name: bUid,
|
||||
original_path: `/${username}/AppData/${bUid}`,
|
||||
trashed_ts: 1,
|
||||
},
|
||||
});
|
||||
expect(trash.status).toBe(200);
|
||||
|
||||
const trashed = await env.server.stores.fsEntry.getEntryByUuid(
|
||||
inner.uid,
|
||||
);
|
||||
const metadata = JSON.parse(trashed!.metadata!) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(metadata.original_path).toBe(inner.path);
|
||||
expect(metadata.original_name).toBe('inner');
|
||||
expect(metadata.trashed_ts).not.toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -256,7 +256,21 @@ describe('signingConfigFromAppConfig', () => {
|
||||
url_signature_secret: 's3cret',
|
||||
api_base_url: 'https://api.test',
|
||||
} as never),
|
||||
).toEqual({ secret: 's3cret', apiBaseUrl: 'https://api.test' });
|
||||
).toEqual({
|
||||
secret: 's3cret',
|
||||
apiBaseUrl: 'https://api.test',
|
||||
allowLegacySignatures: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('stops accepting pre-binding signatures when legacy_file_signatures is false', () => {
|
||||
expect(
|
||||
signingConfigFromAppConfig({
|
||||
url_signature_secret: 's3cret',
|
||||
api_base_url: 'https://api.test',
|
||||
legacy_file_signatures: false,
|
||||
} as never).allowLegacySignatures,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('fails loudly when the signing secret is missing', () => {
|
||||
@@ -412,6 +426,23 @@ describe('toLegacyEntry', () => {
|
||||
);
|
||||
expect(shaped.associated_app).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the parentUid override, including null, over the entry’s own', async () => {
|
||||
const hidden = await toLegacyEntry(undefined, baseEntry(), {
|
||||
parentUid: null,
|
||||
});
|
||||
expect(hidden.parent_id).toBeNull();
|
||||
expect(hidden.parent_uid).toBeNull();
|
||||
|
||||
const overridden = await toLegacyEntry(undefined, baseEntry(), {
|
||||
parentUid: 'other-parent',
|
||||
});
|
||||
expect(overridden.parent_id).toBe('other-parent');
|
||||
expect(overridden.parent_uid).toBe('other-parent');
|
||||
|
||||
const defaulted = await toLegacyEntry(undefined, baseEntry());
|
||||
expect(defaulted.parent_uid).toBe('parent-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadLegacyAssociatedApps short-circuit', () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { contentType as contentTypeFromMime } from 'mime-types';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js';
|
||||
import type { FSService } from '../../services/fs/FSService.js';
|
||||
import type { NodeRef } from '../../services/fs/resolveNode.js';
|
||||
import type { ACLService, AclMode } from '../../services/acl/ACLService.js';
|
||||
import { isAppActor, type Actor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
resolveNode,
|
||||
normalizeAbsolutePath,
|
||||
isOwnersTrash,
|
||||
assertChildName,
|
||||
joinChildPath,
|
||||
expandTildePath,
|
||||
} from '../../services/fs/resolveNode.js';
|
||||
@@ -115,9 +117,32 @@ export async function expandClientPath(
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveV1Selector(
|
||||
// Nested `{parent, name}` selectors recurse; unbounded depth overflows the stack.
|
||||
const MAX_SELECTOR_DEPTH = 4;
|
||||
|
||||
/**
|
||||
* Resolve `ref`, reporting any miss as the same 404 an unreadable entry gets,
|
||||
* so a lookup never echoes a resolved path or tells missing from hidden.
|
||||
*/
|
||||
async function resolveOrNotFound(
|
||||
fsEntryStore: FSEntryStore,
|
||||
ref: NodeRef,
|
||||
): Promise<FSEntry> {
|
||||
try {
|
||||
const entry = await resolveNode(fsEntryStore, ref, { required: true });
|
||||
if (entry) return entry;
|
||||
} catch (err) {
|
||||
if (!(err instanceof HttpError && err.statusCode === 404)) throw err;
|
||||
}
|
||||
throw new HttpError(404, 'Subject does not exist', {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveV1SelectorAtDepth(
|
||||
fsEntryStore: FSEntryStore,
|
||||
raw: unknown,
|
||||
depth: number,
|
||||
): Promise<FSEntry> {
|
||||
const username = Context.get('actor')?.user?.username;
|
||||
|
||||
@@ -130,30 +155,29 @@ export async function resolveV1Selector(
|
||||
const ref = isPath
|
||||
? { path: await expandClientPath(fsEntryStore, raw, username) }
|
||||
: { uid: raw };
|
||||
const entry = await resolveNode(fsEntryStore, ref, { required: true });
|
||||
if (!entry)
|
||||
throw new HttpError(404, `Entry not found: ${raw}`, {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
return entry;
|
||||
return resolveOrNotFound(fsEntryStore, ref);
|
||||
}
|
||||
|
||||
const record = asRecord(raw);
|
||||
|
||||
// {parent, name}: "child selector" — resolve parent, then child by name.
|
||||
if (record.parent !== undefined && typeof record.name === 'string') {
|
||||
const parent = await resolveV1Selector(fsEntryStore, record.parent);
|
||||
const childPath = joinChildPath(parent.path, record.name);
|
||||
const child = await resolveNode(
|
||||
fsEntryStore,
|
||||
{ path: childPath },
|
||||
{ required: true },
|
||||
);
|
||||
if (!child)
|
||||
throw new HttpError(404, `Entry not found: ${childPath}`, {
|
||||
legacyCode: 'not_found',
|
||||
if (depth >= MAX_SELECTOR_DEPTH) {
|
||||
throw new HttpError(400, 'Selector is nested too deeply', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
return child;
|
||||
}
|
||||
// Before the parent lookup, so a bad name can't tell whether the
|
||||
// parent exists.
|
||||
assertChildName(record.name);
|
||||
const parent = await resolveV1SelectorAtDepth(
|
||||
fsEntryStore,
|
||||
record.parent,
|
||||
depth + 1,
|
||||
);
|
||||
return resolveOrNotFound(fsEntryStore, {
|
||||
path: joinChildPath(parent.path, record.name),
|
||||
});
|
||||
}
|
||||
|
||||
const rawPath = typeof record.path === 'string' ? record.path : undefined;
|
||||
@@ -173,12 +197,14 @@ export async function resolveV1Selector(
|
||||
? record.id
|
||||
: undefined,
|
||||
};
|
||||
const entry = await resolveNode(fsEntryStore, ref, { required: true });
|
||||
if (!entry)
|
||||
throw new HttpError(404, 'Entry not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
return entry;
|
||||
return resolveOrNotFound(fsEntryStore, ref);
|
||||
}
|
||||
|
||||
export async function resolveV1Selector(
|
||||
fsEntryStore: FSEntryStore,
|
||||
raw: unknown,
|
||||
): Promise<FSEntry> {
|
||||
return resolveV1SelectorAtDepth(fsEntryStore, raw, 0);
|
||||
}
|
||||
|
||||
// -- ACL --------------------------------------------------------------
|
||||
@@ -225,7 +251,7 @@ export async function assertAccess(
|
||||
// through the error code. User-actor denials keep the real 403.
|
||||
|
||||
if (isAppActor(actor)) {
|
||||
throw new HttpError(404, `Entry not found: path=${path}`, {
|
||||
throw new HttpError(404, 'Subject does not exist', {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
}
|
||||
@@ -438,7 +464,9 @@ export async function signEntryThumbnail(
|
||||
* URL for a signed one. Pass `fsEntryStore`/`userStore` to hydrate `is_empty`
|
||||
* (directories) and `owner` — both are required fields per the legacy stat
|
||||
* contract but need extra DB lookups. Pass `appsById` (built via
|
||||
* `loadLegacyAssociatedApps`) to populate `associated_app`.
|
||||
* `loadLegacyAssociatedApps`) to populate `associated_app`. Pass `parentUid`
|
||||
* (even as `null`, via `clientParentUid`) to override `parent_id`/`parent_uid`
|
||||
* — callers reading a scoped access token's home use it to hide the uuid.
|
||||
*/
|
||||
export async function toLegacyEntry(
|
||||
eventClient: EventClient | undefined,
|
||||
@@ -451,11 +479,14 @@ export async function toLegacyEntry(
|
||||
appsById?: Map<number, Record<string, unknown>>;
|
||||
/** Omitted from the response when undefined. */
|
||||
isShared?: boolean | null;
|
||||
/** Skip the mask: the mask is the actor's, and only they can read it. */
|
||||
forOwner?: boolean;
|
||||
parentUid?: string | null;
|
||||
} = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
// Someone else's entry is published under its masked path; the owner's
|
||||
// real one, and everything above the share, stays server-side.
|
||||
const publishedPath = maskEntryPath(entry);
|
||||
const publishedPath = opts.forOwner ? entry.path : maskEntryPath(entry);
|
||||
const dirname = pathPosix.dirname(publishedPath);
|
||||
const mimeType = fsEntryMimeType(entry);
|
||||
|
||||
@@ -463,12 +494,17 @@ export async function toLegacyEntry(
|
||||
const appdata_app =
|
||||
pathComponents[2] === 'AppData' ? pathComponents[3] : undefined;
|
||||
|
||||
const parentUid =
|
||||
opts.parentUid !== undefined
|
||||
? opts.parentUid
|
||||
: (entry.parentUid ?? null);
|
||||
|
||||
const response: Record<string, unknown> = {
|
||||
id: entry.uuid,
|
||||
uid: entry.uuid,
|
||||
uuid: entry.uuid,
|
||||
parent_id: entry.parentUid,
|
||||
parent_uid: entry.parentUid,
|
||||
parent_id: parentUid,
|
||||
parent_uid: parentUid,
|
||||
path: publishedPath,
|
||||
dirname,
|
||||
dirpath: dirname,
|
||||
@@ -563,7 +599,11 @@ export function signingConfigFromAppConfig(config: IConfig): SigningConfig {
|
||||
{ legacyCode: 'internal_error' },
|
||||
);
|
||||
}
|
||||
return { secret, apiBaseUrl };
|
||||
return {
|
||||
secret,
|
||||
apiBaseUrl,
|
||||
allowLegacySignatures: config.legacy_file_signatures !== false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -571,7 +611,7 @@ export function signingConfigFromAppConfig(config: IConfig): SigningConfig {
|
||||
*
|
||||
* Pass `actorUserId` so a signature over someone else's entry — a shared file —
|
||||
* expires rather than outliving the share (see
|
||||
* NON_OWNER_SIGNATURE_TTL_SECONDS).
|
||||
* NON_OWNER_SIGNATURE_TTL_SECONDS). The signature is bound to `entry.userId`.
|
||||
*/
|
||||
export function signEntry(
|
||||
entry: {
|
||||
@@ -582,7 +622,7 @@ export function signEntry(
|
||||
accessed: number | null;
|
||||
modified: number;
|
||||
created: number | null;
|
||||
userId?: number;
|
||||
userId: number;
|
||||
},
|
||||
config: SigningConfig,
|
||||
opts: { actorUserId?: number; ttlSeconds?: number } = {},
|
||||
|
||||
@@ -28,7 +28,10 @@ import type { RouteOptions, RouteRateLimit } from '../../core/http/types';
|
||||
|
||||
type Spec = RouteRateLimit | NonNullable<RouteOptions['concurrent']>;
|
||||
|
||||
const all = Object.entries(limits) as Array<[string, Spec | RouteRateLimit[]]>;
|
||||
// Rate and concurrency specs only; the per-request item caps are plain numbers.
|
||||
const all = Object.entries(limits).filter(
|
||||
([, value]) => typeof value === 'object',
|
||||
) as Array<[string, Spec | RouteRateLimit[]]>;
|
||||
const flat: Array<[string, Spec]> = all.flatMap(([name, value]) =>
|
||||
Array.isArray(value)
|
||||
? value.map((v, i): [string, Spec] => [`${name}[${i}]`, v])
|
||||
|
||||
@@ -185,15 +185,37 @@ export const FS_HELPER_LIMIT = userWindow('fs:helper', 120, 60, 30);
|
||||
*/
|
||||
export const FS_POLL_LIMIT = userWindow('fs:poll', 240, 240, 120);
|
||||
|
||||
// -- Per-request item caps -------------------------------------------
|
||||
//
|
||||
// The windows above count requests; these bound what one request can fan out
|
||||
// to. Each item costs its own path resolution and ACL check, and an upload item
|
||||
// also a pending session and presigned URLs. Checked before any per-item work.
|
||||
|
||||
/**
|
||||
* `startBatchWrite` / `completeBatchWrite` / `batchWrite`. Puter.js chunks
|
||||
* uploads at this size.
|
||||
*/
|
||||
export const FS_BATCH_WRITE_MAX_ITEMS = 500;
|
||||
|
||||
/** Entries per `/sign`. Each may also write an app grant. */
|
||||
export const FS_SIGN_MAX_ITEMS = 500;
|
||||
|
||||
/**
|
||||
* Part numbers per `signMultipartParts`: every part a multipart upload can have
|
||||
* (1-10,000).
|
||||
*/
|
||||
export const FS_MULTIPART_MAX_PARTS = 10_000;
|
||||
|
||||
// -- Legacy multipart upload -----------------------------------------
|
||||
|
||||
/**
|
||||
* `/batch` buffers every file fully into memory before any quota or storage
|
||||
* check runs, up to BATCH_MAX_FILES × BATCH_MAX_FILE_SIZE. The concurrency slot
|
||||
* is doing the real work here; the window is secondary, and sized to say so —
|
||||
* one upload is one call, so a per-minute ceiling in the tens is a cap on how
|
||||
* many files someone may upload rather than a bound on cost. What actually
|
||||
* bounds the memory this route can tie up is how many run at once.
|
||||
* check runs, up to BATCH_MAX_FILES × BATCH_MAX_FILE_SIZE, capped in aggregate
|
||||
* per request by BATCH_MAX_TOTAL_SIZE. The concurrency slot is doing the real
|
||||
* work here; the window is secondary, and sized to say so — one upload is one
|
||||
* call, so a per-minute ceiling in the tens is a cap on how many files someone
|
||||
* may upload rather than a bound on cost. What actually bounds the memory this
|
||||
* route can tie up is how many run at once.
|
||||
*/
|
||||
export const FS_BATCH_LIMIT = userWindow('fs:batch', 600, 300, 300);
|
||||
export const FS_BATCH_CONCURRENT = userConcurrent('fs:batch', 5, 2, 2);
|
||||
|
||||
@@ -135,24 +135,11 @@ export interface WriteResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* An `FSEntry` as it is safe to hand to a client. Built by an allowlist, so the
|
||||
* `fsentries` primary key (`id`) and its `parentId`/`associatedAppId`
|
||||
* references, the storage columns (`bucket`, `bucketRegion`), the owning
|
||||
* `userId`, and the `publicToken`/`fileRequestToken` capability tokens never
|
||||
* reach the wire. Entries are addressed by `uuid`.
|
||||
*
|
||||
* `shortcutTo` is the one numeric row reference that stays: the v1 contract has
|
||||
* always exposed it as `shortcut_to` and the desktop resolves shortcuts through
|
||||
* it, so dropping it would break them.
|
||||
*
|
||||
* The `?: never` members below are guards, not fields: they make a raw
|
||||
* `FSEntry` fail to typecheck wherever a `ClientFSEntry` is expected. Without
|
||||
* them this type is just a structural subset of `FSEntry`, so an unsanitized
|
||||
* row would be silently assignable and the distinction would buy nothing.
|
||||
*
|
||||
* Checked by `tsc -p tsconfig.json` (the strict config). Note that
|
||||
* `tsconfig.build.json` sets `noCheck: true` — it only transpiles, so it will
|
||||
* not catch a violation here.
|
||||
* An `FSEntry` safe to send to a client. Built by allowlist so row ids, storage
|
||||
* columns and capability tokens never reach the wire; `shortcutTo` stays
|
||||
* because the v1 contract exposes it. The `?: never` members make a raw
|
||||
* `FSEntry` fail to typecheck where a `ClientFSEntry` is expected; only the
|
||||
* strict `tsconfig.json` catches that, since the build config sets `noCheck`.
|
||||
*/
|
||||
export interface ClientFSEntry {
|
||||
uuid: string;
|
||||
@@ -189,17 +176,12 @@ export interface ClientFSEntry {
|
||||
fileRequestToken?: never;
|
||||
}
|
||||
|
||||
// Wire counterparts of the write responses: what the controller sends after
|
||||
// sanitizing, kept distinct so the compiler can tell the two apart.
|
||||
|
||||
/**
|
||||
* Wire counterparts of the write responses. The bare `…Response` types describe
|
||||
* what the service produces internally (a real `FSEntry`); these describe what
|
||||
* the controller sends after sanitizing. Keeping them distinct is what makes
|
||||
* "did this response get sanitized?" a question the compiler answers.
|
||||
*/
|
||||
/**
|
||||
* The presigned-upload envelope minus the storage internals. A client uploads
|
||||
* to the presigned `url` / `multipartPartUrls`, which already carry everything
|
||||
* S3 needs, so `bucket`, `bucketRegion`, and `objectKey` are ours to keep —
|
||||
* they name where a user's bytes physically live.
|
||||
* The presigned-upload envelope minus storage internals; the presigned URLs
|
||||
* already carry what the store needs.
|
||||
*/
|
||||
export type ClientSignedWriteResponse = Omit<
|
||||
SignedWriteResponse,
|
||||
@@ -221,10 +203,8 @@ export type ClientWriteResponse = Omit<WriteResponse, 'fsEntry'> & {
|
||||
};
|
||||
|
||||
/**
|
||||
* A directory-listing entry: a sanitized entry plus the three fields a client
|
||||
* cannot derive on its own — the MIME `type`, a _signed_ `thumbnail` URL (the
|
||||
* stored value is an S3 key, which is useless to a client), and the resolved
|
||||
* `associatedApp`.
|
||||
* A sanitized entry plus what a client cannot derive: MIME `type`, a signed
|
||||
* `thumbnail` URL, the resolved `associatedApp`.
|
||||
*/
|
||||
export type ClientReaddirEntry = ClientFSEntry & {
|
||||
type: string | null;
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* 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 { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
|
||||
// A recipient addresses the owner's entries by a masked `/<owner>/<uuid>/<name>`
|
||||
// path. Routes that create an entry have no row to resolve, so they unmask it
|
||||
// themselves before the ACL check runs against the result.
|
||||
describe('writes inside a folder shared with write', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const post = async (route: string, token: string, body: unknown) => {
|
||||
const res = await fetch(new URL(route, env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
return {
|
||||
status: res.status,
|
||||
json: text ? (JSON.parse(text) as Record<string, unknown>) : null,
|
||||
};
|
||||
};
|
||||
|
||||
const makeUser = () =>
|
||||
createTestUser(env.server, {
|
||||
username: `sfw${Math.random().toString(36).slice(2, 9)}`,
|
||||
password: 'puter-test-user-password',
|
||||
});
|
||||
|
||||
/** `/<owner>/SharedDir` with `note.txt`, shared `write` with a recipient. */
|
||||
const shareAFolder = async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const dir = await post('/mkdir', owner.token, {
|
||||
path: `/${owner.username}/SharedDir`,
|
||||
});
|
||||
await post('/touch', owner.token, {
|
||||
path: `/${owner.username}/SharedDir/note.txt`,
|
||||
});
|
||||
const note = await post('/stat', owner.token, {
|
||||
path: `/${owner.username}/SharedDir/note.txt`,
|
||||
});
|
||||
await post('/share', owner.token, {
|
||||
recipients: [recipient.username],
|
||||
items: [{ uid: dir.json!.uid }],
|
||||
mode: 'write',
|
||||
});
|
||||
return {
|
||||
owner,
|
||||
recipient,
|
||||
realDir: `/${owner.username}/SharedDir`,
|
||||
maskedDir: `/${owner.username}/${dir.json!.uid}/SharedDir`,
|
||||
noteUid: note.json!.uid as string,
|
||||
};
|
||||
};
|
||||
|
||||
const ownerListing = async (s: { owner: { token: string; username: string } }) => {
|
||||
const res = await post('/readdir', s.owner.token, {
|
||||
path: `/${s.owner.username}/SharedDir`,
|
||||
});
|
||||
return (res.json as unknown as Array<{ name: string }>).map(
|
||||
(entry) => entry.name,
|
||||
);
|
||||
};
|
||||
|
||||
// `{parent, path}` is the shape puter.js sends — the GUI's "New Folder".
|
||||
it('mkdir accepts a masked parent', async () => {
|
||||
const s = await shareAFolder();
|
||||
|
||||
const made = await post('/mkdir', s.recipient.token, {
|
||||
parent: s.maskedDir,
|
||||
path: 'New Folder',
|
||||
});
|
||||
|
||||
expect(made.status).toBe(200);
|
||||
expect(made.json).toMatchObject({
|
||||
name: 'New Folder',
|
||||
is_dir: true,
|
||||
// Anchored to the share root the request came through, so the
|
||||
// recipient can navigate into what they just made.
|
||||
path: `${s.maskedDir}/New Folder`,
|
||||
});
|
||||
expect(await ownerListing(s)).toContain('New Folder');
|
||||
}, 180_000);
|
||||
|
||||
it('mkdir accepts a masked absolute path', async () => {
|
||||
const s = await shareAFolder();
|
||||
|
||||
const made = await post('/mkdir', s.recipient.token, {
|
||||
path: `${s.maskedDir}/Reports`,
|
||||
});
|
||||
|
||||
expect(made.status).toBe(200);
|
||||
expect(await ownerListing(s)).toContain('Reports');
|
||||
}, 180_000);
|
||||
|
||||
it('touch accepts a masked path', async () => {
|
||||
const s = await shareAFolder();
|
||||
|
||||
const touched = await post('/touch', s.recipient.token, {
|
||||
path: `${s.maskedDir}/from-recipient.txt`,
|
||||
});
|
||||
|
||||
expect(touched.status).toBe(200);
|
||||
expect(await ownerListing(s)).toContain('from-recipient.txt');
|
||||
}, 180_000);
|
||||
|
||||
it('touch still expands a tilde path', async () => {
|
||||
const s = await shareAFolder();
|
||||
|
||||
const touched = await post('/touch', s.owner.token, {
|
||||
path: '~/SharedDir/from-tilde.txt',
|
||||
});
|
||||
|
||||
expect(touched.status).toBe(200);
|
||||
expect(await ownerListing(s)).toContain('from-tilde.txt');
|
||||
}, 180_000);
|
||||
|
||||
// Addressed to the owner, so it carries the path the owner can resolve.
|
||||
it('publishes the owner’s real path on events about their entries', async () => {
|
||||
const s = await shareAFolder();
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
const emit = env.server.clients.event.emit.bind(
|
||||
env.server.clients.event,
|
||||
);
|
||||
vi.spyOn(env.server.clients.event, 'emit').mockImplementation(
|
||||
((name: string, payload: Record<string, unknown>, meta: unknown) => {
|
||||
if (name.startsWith('outer.gui.item.')) {
|
||||
seen.push(payload);
|
||||
}
|
||||
return emit(
|
||||
name as never,
|
||||
payload as never,
|
||||
meta as never,
|
||||
);
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const moved = await post('/move', s.recipient.token, {
|
||||
source: s.noteUid,
|
||||
destination: s.maskedDir,
|
||||
new_name: 'renamed.txt',
|
||||
});
|
||||
expect(moved.status).toBe(200);
|
||||
|
||||
const ownerId = (await env.server.stores.user.getByUsername(
|
||||
s.owner.username,
|
||||
))!.id;
|
||||
const event = seen.find(
|
||||
(payload) =>
|
||||
(payload.response as { uid?: string })?.uid === s.noteUid,
|
||||
);
|
||||
expect(event?.user_id_list).toEqual([ownerId]);
|
||||
expect(event?.response).toMatchObject({
|
||||
path: `${s.realDir}/renamed.txt`,
|
||||
dirpath: s.realDir,
|
||||
});
|
||||
|
||||
// The recipient's own response stays masked.
|
||||
expect(
|
||||
(moved.json!.moved as { path: string }).path,
|
||||
).toBe(`${s.maskedDir}/renamed.txt`);
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -427,6 +427,25 @@ describe('HomepageController GET /app/:name', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -- app landing rate limit --
|
||||
|
||||
describe('HomepageController app landing rate limit', () => {
|
||||
it('rate-limits /app/:name and /desktop/app/:name per network', () => {
|
||||
for (const path of ['/app/:name', '/desktop/app/:name']) {
|
||||
const route = router.routes.find(
|
||||
(r) => r.method === 'get' && r.path === path,
|
||||
);
|
||||
if (!route) throw new Error(`No GET ${path} route`);
|
||||
expect(route.options.rateLimit).toEqual({
|
||||
scope: 'app-landing',
|
||||
limit: 600,
|
||||
window: 60_000,
|
||||
key: 'ip',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── /show/* ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('HomepageController GET /show/*splat', () => {
|
||||
|
||||
@@ -31,6 +31,17 @@ import type {
|
||||
/** Longest description/og:description/twitter:description on `/app/<name>`. */
|
||||
export const APP_META_DESCRIPTION_MAX = 150;
|
||||
|
||||
/**
|
||||
* Unauthenticated, rendered on every request, so the bucket is the network
|
||||
* address rather than an account.
|
||||
*/
|
||||
const APP_LANDING_LIMIT = {
|
||||
scope: 'app-landing',
|
||||
limit: 600,
|
||||
window: 60_000,
|
||||
key: 'ip' as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* App descriptions can run long; search snippets and social cards want a
|
||||
* short blurb. Collapses whitespace, then cuts at a word boundary with an
|
||||
@@ -160,8 +171,16 @@ export class HomepageController extends PuterController {
|
||||
});
|
||||
};
|
||||
|
||||
router.get('/app/:name', {}, sendAppShell);
|
||||
router.get('/desktop/app/:name', {}, sendAppShell);
|
||||
router.get(
|
||||
'/app/:name',
|
||||
{ rateLimit: APP_LANDING_LIMIT },
|
||||
sendAppShell,
|
||||
);
|
||||
router.get(
|
||||
'/desktop/app/:name',
|
||||
{ rateLimit: APP_LANDING_LIMIT },
|
||||
sendAppShell,
|
||||
);
|
||||
|
||||
// -- /show/* - launch explorer with the requested file path --
|
||||
|
||||
|
||||
@@ -93,6 +93,22 @@ export class HostingController extends PuterController {
|
||||
userId: req.actor.user.id,
|
||||
});
|
||||
|
||||
// Same event the `puter-subdomains` driver fires on delete —
|
||||
// listeners (CDN purge, abuse tracking, worker cleanup)
|
||||
// depend on it.
|
||||
try {
|
||||
this.clients.event.emit(
|
||||
'subdomain.delete',
|
||||
{
|
||||
subdomain: row.subdomain,
|
||||
uid: String(row.uuid),
|
||||
},
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
// Non-critical.
|
||||
}
|
||||
|
||||
res.json({});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -181,4 +181,24 @@ describe('HostingController /delete-site', () => {
|
||||
expect(captured.body).toEqual({});
|
||||
expect(await server.stores.subdomain.getByUuid(site.uuid)).toBeNull();
|
||||
});
|
||||
|
||||
it('emits subdomain.delete so listeners can purge caches', async () => {
|
||||
const { userId, actor } = await makeUser();
|
||||
const site = await makeSite(userId);
|
||||
const emitSpy = vi.spyOn(server.clients.event, 'emit');
|
||||
|
||||
try {
|
||||
await deleteSite(
|
||||
makeReq({ body: { site_uuid: site.uuid }, actor }),
|
||||
makeRes().res,
|
||||
);
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
'subdomain.delete',
|
||||
{ subdomain: site.subdomain, uid: String(site.uuid) },
|
||||
{},
|
||||
);
|
||||
} finally {
|
||||
emitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loaded 100 of 891 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user