@lunora/cli 1.0.0-alpha.40 → 1.0.0-alpha.401
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +33 -0
- package/README.md +3 -1
- package/dist/bin.mjs +2 -10
- package/dist/index.d.mts +1034 -374
- package/dist/index.d.ts +1034 -374
- package/dist/index.mjs +1 -19
- package/dist/packem_chunks/dispatch.mjs +1 -0
- package/dist/packem_chunks/handler.mjs +1 -160
- package/dist/packem_chunks/handler10.mjs +1 -16
- package/dist/packem_chunks/handler11.mjs +1 -22
- package/dist/packem_chunks/handler12.mjs +1 -192
- package/dist/packem_chunks/handler13.mjs +3 -131
- package/dist/packem_chunks/handler14.mjs +1 -65
- package/dist/packem_chunks/handler15.mjs +1 -58
- package/dist/packem_chunks/handler16.mjs +1 -80
- package/dist/packem_chunks/handler17.mjs +7 -43
- package/dist/packem_chunks/handler18.mjs +1 -105
- package/dist/packem_chunks/handler19.mjs +1 -170
- package/dist/packem_chunks/handler2.mjs +1 -114
- package/dist/packem_chunks/handler20.mjs +3 -94
- package/dist/packem_chunks/handler21.mjs +3 -94
- package/dist/packem_chunks/handler22.mjs +1 -0
- package/dist/packem_chunks/handler23.mjs +2 -0
- package/dist/packem_chunks/handler24.mjs +99 -0
- package/dist/packem_chunks/handler25.mjs +2 -0
- package/dist/packem_chunks/handler26.mjs +9 -0
- package/dist/packem_chunks/handler27.mjs +1 -0
- package/dist/packem_chunks/handler3.mjs +1 -204
- package/dist/packem_chunks/handler4.mjs +3 -33
- package/dist/packem_chunks/handler5.mjs +1 -49
- package/dist/packem_chunks/handler6.mjs +1 -91
- package/dist/packem_chunks/handler7.mjs +1 -42
- package/dist/packem_chunks/handler8.mjs +2 -174
- package/dist/packem_chunks/handler9.mjs +1 -315
- package/dist/packem_chunks/runCodegenCommand.mjs +4 -52
- package/dist/packem_chunks/runDeployCommand.mjs +11 -594
- package/dist/packem_chunks/runDevCommand.mjs +2 -0
- package/dist/packem_chunks/runInitCommand.mjs +165 -1432
- package/dist/packem_chunks/runResetCommand.mjs +1 -41
- package/dist/packem_chunks/runRpcCommand.mjs +1 -68
- package/dist/packem_shared/COMMANDS-aSTSMwZS.mjs +1 -0
- package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-CyIsI3S1.mjs +1 -0
- package/dist/packem_shared/EXIT_CODE-D_XCixv8.mjs +1 -0
- package/dist/packem_shared/admin-token-VdUnvnKW.mjs +1 -0
- package/dist/packem_shared/admin-url-DJB8P6Jh.mjs +1 -0
- package/dist/packem_shared/api-spec-lYyq4vL6.mjs +1 -0
- package/dist/packem_shared/binding-manifest-file-CQ1eQYy1.mjs +2 -0
- package/dist/packem_shared/buildRegistryIndex-Dc8D8AR6.mjs +1 -0
- package/dist/packem_shared/cap-error-body-YBKO32BF.mjs +1 -0
- package/dist/packem_shared/catalog-CahzmDLV.mjs +1 -0
- package/dist/packem_shared/cli-CFsmCYWp.mjs +2 -0
- package/dist/packem_shared/cli-manifest-DoQ_3bzs.mjs +1 -0
- package/dist/packem_shared/cloudflare-credentials-BVmZnDZk.mjs +1 -0
- package/dist/packem_shared/codegen-error-AmH54ofi.mjs +3 -0
- package/dist/packem_shared/command-BgjsvUFg.mjs +1 -0
- package/dist/packem_shared/commands-DvSfLvyt.mjs +13 -0
- package/dist/packem_shared/createLogger-4TujevyB.mjs +2 -0
- package/dist/packem_shared/createRecordingSpawner-Ch7zuQQE.mjs +1 -0
- package/dist/packem_shared/deploy-target-resolve-BTCW1Iv4.mjs +1 -0
- package/dist/packem_shared/diffSnapshots-htqNZT8Q.mjs +7 -0
- package/dist/packem_shared/docker-AqLAkOyY.mjs +1 -0
- package/dist/packem_shared/health-probe-Bw9JFfIy.mjs +1 -0
- package/dist/packem_shared/import-BRM1hs7j.mjs +12 -0
- package/dist/packem_shared/index-oHZBGBwB.mjs +1 -0
- package/dist/packem_shared/insertSchemaExtension-DuiV6cba.mjs +8 -0
- package/dist/packem_shared/lint-ignore-report-DKZagpqk.mjs +2 -0
- package/dist/packem_shared/mcp-config-file-CcDTeXIq.mjs +2 -0
- package/dist/packem_shared/open-url-EnKy--w-.mjs +1 -0
- package/dist/packem_shared/output-format-DWe_tkTR.mjs +2 -0
- package/dist/packem_shared/parseManifest-CwPTKdtS.mjs +1 -0
- package/dist/packem_shared/path-containment-CgxYZggb.mjs +1 -0
- package/dist/packem_shared/plan-DVfi3TrN.mjs +5 -0
- package/dist/packem_shared/planDevCommand-IglbXy5A.mjs +1 -0
- package/dist/packem_shared/platform-diagnostics-Cn2g6Jh-.mjs +4 -0
- package/dist/packem_shared/prompt-cancelled-BvsNxg_Q.mjs +1 -0
- package/dist/packem_shared/render-lunora-error-C3OJY5wk.mjs +2 -0
- package/dist/packem_shared/resolve-Bdc9KEUv.mjs +5 -0
- package/dist/packem_shared/resolve-target-C_ZloTvd.mjs +1 -0
- package/dist/packem_shared/runAddCommand-BChdJQ5L.mjs +1 -0
- package/dist/packem_shared/runExportCommand-slAq5BCF.mjs +5 -0
- package/dist/packem_shared/runMigrateGenerateCommand-BwemPIOz.mjs +11 -0
- package/dist/packem_shared/schemaIrToSnapshot-BHuNOfgs.mjs +1 -0
- package/dist/packem_shared/secret-key-8DPOH98z.mjs +1 -0
- package/dist/packem_shared/shared-D-zCOmgY.mjs +1 -0
- package/dist/packem_shared/storage-DNzYhXR-.mjs +1 -0
- package/dist/packem_shared/tui-prompts-B3YwUhGw.mjs +4 -0
- package/dist/packem_shared/variables-CPYyJtsL.mjs +1 -0
- package/dist/packem_shared/wrangler-name-Dsk5K1f-.mjs +1 -0
- package/dist/packem_shared/wrangler-secrets-CTQPpalP.mjs +1 -0
- package/package.json +39 -19
- package/skills/README.md +35 -17
- package/skills/lunora/SKILL.md +157 -65
- package/skills/lunora-create-package/SKILL.md +97 -84
- package/skills/lunora-deploy/SKILL.md +175 -105
- package/skills/lunora-functions/SKILL.md +363 -56
- package/skills/lunora-migration-helper/SKILL.md +162 -137
- package/skills/lunora-performance-audit/SKILL.md +137 -95
- package/skills/lunora-quickstart/SKILL.md +137 -136
- package/skills/lunora-realtime/SKILL.md +201 -97
- package/skills/lunora-setup-auth/SKILL.md +162 -102
- package/skills/lunora-setup-hyperdrive/SKILL.md +130 -109
- package/skills/lunora-setup-hyperdrive-global/SKILL.md +93 -90
- package/skills/lunora-setup-mail/SKILL.md +85 -113
- package/skills/lunora-setup-scheduler/SKILL.md +132 -96
- package/skills/lunora-setup-storage/SKILL.md +189 -95
- package/dist/packem_chunks/planDevCommand.mjs +0 -541
- package/dist/packem_chunks/runMigrateGenerateCommand.mjs +0 -397
- package/dist/packem_shared/COMMANDS-B0ftFD_3.mjs +0 -948
- package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +0 -244
- package/dist/packem_shared/admin-url-4UzT-CI4.mjs +0 -19
- package/dist/packem_shared/api-spec-CtA6ilu4.mjs +0 -13
- package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +0 -38
- package/dist/packem_shared/codegen-error-DJG-ghs_.mjs +0 -31
- package/dist/packem_shared/command-D3lB_4Az.mjs +0 -19
- package/dist/packem_shared/commands-B-gR09Z_.mjs +0 -845
- package/dist/packem_shared/createLogger-B40gPzQo.mjs +0 -78
- package/dist/packem_shared/createRecordingSpawner-DxI3mebw.mjs +0 -43
- package/dist/packem_shared/detect-package-manager-DYp7n3mJ.mjs +0 -61
- package/dist/packem_shared/diffSnapshots-BeDvvNiF.mjs +0 -161
- package/dist/packem_shared/docker-hMQ97KSQ.mjs +0 -21
- package/dist/packem_shared/insertSchemaExtension-DAqbfr9Z.mjs +0 -64
- package/dist/packem_shared/open-url-Dfq6fAyT.mjs +0 -41
- package/dist/packem_shared/output-format-wUvAN6AL.mjs +0 -17
- package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +0 -56
- package/dist/packem_shared/parseManifest--vZf2FY1.mjs +0 -94
- package/dist/packem_shared/prompt-cancelled-APzX1Im-.mjs +0 -9
- package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +0 -16
- package/dist/packem_shared/runAddCommand-bnY6-HKb.mjs +0 -4
- package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +0 -79
- package/dist/packem_shared/schemaIrToSnapshot-DdsljJT-.mjs +0 -43
- package/dist/packem_shared/storage-BIsph-Vk.mjs +0 -84
- package/dist/packem_shared/tui-prompts-BjEN8XgP.mjs +0 -658
- package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +0 -12
- package/dist/packem_shared/wrangler-secrets-P2_ZUR-k.mjs +0 -47
|
@@ -1,89 +1,135 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lunora-setup-storage
|
|
3
|
-
description: Adds R2-backed file storage to a Lunora app
|
|
3
|
+
description: Adds R2-backed file storage to a Lunora app with the `storage` registry item (`@lunora/storage`). Covers worker-signed PUT/GET URLs, the `UPLOADS` R2 bucket binding, `STORAGE_SIGNING_SECRET` and `STORAGE_PUBLIC_BASE_URL`, per-user key scoping, and the `/storage/*` Worker route that verifies signatures and moves the bytes. Use when the user wants file or image uploads, avatars, attachments or gated downloads, runs `lunora add storage` or `lunora registry add storage`, edits `lunora/storage/index.ts`, or sees signed storage URLs return 404/403 or uploads fail CORS.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Lunora Setup Storage
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
and
|
|
11
|
-
downloads, delete, and list — so the bytes never proxy through your Worker.
|
|
8
|
+
The `storage` registry item wraps `@lunora/storage`, an R2 adapter with HMAC
|
|
9
|
+
signed-URL helpers. It exposes Lunora functions for browser uploads, gated
|
|
10
|
+
downloads, delete and list. The client never holds a bucket credential.
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
A worker-signed URL points at your Worker, not at R2. Its shape is
|
|
13
|
+
`<base>/<key>?exp&method&bucket&sig`. The base is `ctx.origin` unless
|
|
14
|
+
`STORAGE_PUBLIC_BASE_URL` overrides it. The `/storage/*` route from Step 3
|
|
15
|
+
verifies the signature and moves the bytes, for both upload and download.
|
|
16
|
+
`@lunora/storage`'s `getPresignedUrl` takes the Worker out of the path, but it
|
|
17
|
+
needs S3 credentials and none of your rules apply to it.
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
- Serving private/gated downloads via short-lived signed URLs.
|
|
17
|
-
- Listing or deleting a caller's stored objects.
|
|
18
|
-
|
|
19
|
-
## When Not to Use
|
|
20
|
-
|
|
21
|
-
- The project has no Lunora backend yet — use `lunora-quickstart` first.
|
|
22
|
-
- Storage is already installed and you just want to upload — call
|
|
23
|
-
`client.action("storage/generateUploadUrl", …)` and `PUT` to the returned URL.
|
|
24
|
-
|
|
25
|
-
## Workflow
|
|
26
|
-
|
|
27
|
-
1. Add the `storage` item.
|
|
28
|
-
2. Configure the `UPLOADS` R2 bucket binding and the signing secret.
|
|
29
|
-
3. Regenerate types with `lunora codegen`.
|
|
30
|
-
4. Verify signed downloads in the Worker's `GET /storage/:key` route.
|
|
31
|
-
5. Upload/download from the client.
|
|
19
|
+
If the project has no Lunora backend yet, start with `lunora-quickstart`.
|
|
32
20
|
|
|
33
21
|
## Step 1: Add the item
|
|
34
22
|
|
|
35
23
|
```bash
|
|
36
|
-
lunora
|
|
24
|
+
lunora add storage # asks for the R2 bucket name
|
|
25
|
+
lunora add storage --bucket my-app-uploads
|
|
26
|
+
lunora registry add storage # low-level: writes bucket_name "replace-me-uploads"
|
|
37
27
|
```
|
|
38
28
|
|
|
39
|
-
|
|
29
|
+
Then run `pnpm install` and `lunora codegen`. The item does the following:
|
|
40
30
|
|
|
41
|
-
1. Adds `@lunora/storage
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
bucket). It **merges** into any existing `r2_buckets`.
|
|
46
|
-
3. Scaffolds `STORAGE_SIGNING_SECRET` (a secret) and `STORAGE_PUBLIC_BASE_URL`
|
|
47
|
-
into `.dev.vars`.
|
|
48
|
-
4. Copies `lunora/storage/index.ts` (the `generateUploadUrl` /
|
|
49
|
-
`getDownloadUrl` / `deleteObject` / `listObjects` functions) into your
|
|
50
|
-
project — it is **yours** to edit.
|
|
31
|
+
1. Adds `@lunora/storage`, `@lunora/server`, `@lunora/errors` and `@lunora/ratelimit`.
|
|
32
|
+
2. Merges an R2 binding named `UPLOADS` into `r2_buckets` in `wrangler.jsonc`.
|
|
33
|
+
3. Writes `STORAGE_SIGNING_SECRET` (a secret) to `.dev.vars`, plus an empty, optional `STORAGE_PUBLIC_BASE_URL`.
|
|
34
|
+
4. Copies `lunora/storage/index.ts` into the project. The project owns this file. Codegen exposes its functions as `api.storage.generateUploadUrl`, `getDownloadUrl` and `listObjects` (actions) and `deleteObject` (mutation). Every one requires a signed-in user, because `requireOwner` throws `UNAUTHORIZED` otherwise, so set up auth first (`lunora-setup-auth`). Each is rate-limited per user to 60 requests a minute through an in-memory, per-isolate limiter. For a durable limiter, run `lunora add ratelimit`.
|
|
51
35
|
|
|
52
|
-
## Step 2: Configure the binding
|
|
36
|
+
## Step 2: Configure the binding and secrets
|
|
53
37
|
|
|
54
|
-
| Name | Where
|
|
55
|
-
| ------------------------- |
|
|
56
|
-
| `UPLOADS` | `wrangler.jsonc` → `r2_buckets
|
|
57
|
-
| `STORAGE_SIGNING_SECRET` | secret
|
|
58
|
-
| `STORAGE_PUBLIC_BASE_URL` | var
|
|
38
|
+
| Name | Where | Notes |
|
|
39
|
+
| ------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
40
|
+
| `UPLOADS` | `wrangler.jsonc` → `r2_buckets` | Set `bucket_name` to a real bucket: lowercase letters, digits and hyphens, 3–63 chars. Otherwise wrangler rejects it. |
|
|
41
|
+
| `STORAGE_SIGNING_SECRET` | secret | At least 32 chars; a shorter secret throws on the first call. Use `openssl rand -base64 32`, then `wrangler secret put` for production. |
|
|
42
|
+
| `STORAGE_PUBLIC_BASE_URL` | optional var | See below. |
|
|
59
43
|
|
|
60
|
-
|
|
61
|
-
`
|
|
44
|
+
Leave `STORAGE_PUBLIC_BASE_URL` empty for most apps. URLs are then signed
|
|
45
|
+
against `ctx.origin`, which is correct in dev, in previews and in production.
|
|
46
|
+
Set it only in two cases:
|
|
62
47
|
|
|
63
|
-
|
|
48
|
+
- Another host, such as a CDN, serves `/storage/*`.
|
|
49
|
+
- You sign where no request is behind the call: a query, a scheduled job or a workflow step. None of these has a `ctx.origin`, so signing there throws.
|
|
64
50
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
51
|
+
The value must be a bare origin. The key is verified from the whole URL
|
|
52
|
+
pathname, so the signer rejects a base that includes a path. Outside local dev
|
|
53
|
+
the value must use `https://`, because a signed URL is a bearer credential.
|
|
68
54
|
|
|
69
|
-
|
|
70
|
-
`api.storage.getDownloadUrl`, `api.storage.deleteObject`, and
|
|
71
|
-
`api.storage.listObjects`.
|
|
55
|
+
## Step 3: Add the `/storage/*` route to the Worker
|
|
72
56
|
|
|
73
|
-
|
|
57
|
+
The app needs this route. Without it, every minted URL falls through to the
|
|
58
|
+
Lunora catch-all and returns 404. The route is also the only place the signature
|
|
59
|
+
is checked, so a route that skips `verifySignedUrl` lets anyone read any key.
|
|
74
60
|
|
|
75
|
-
|
|
76
|
-
`
|
|
77
|
-
|
|
61
|
+
`@lunora/server`'s `serveStorageObject(ctx, key, request, authorize)` covers the
|
|
62
|
+
download half. It handles `Range`/206, `ETag`, `nosniff` and the inline-safe
|
|
63
|
+
`content-disposition` list. It verifies nothing on its own, so call
|
|
64
|
+
`verifySignedUrl` inside its `authorize` gate. It also doesn't handle uploads.
|
|
65
|
+
Use it from an `httpAction`, where `ctx.storage` is in scope, when you need
|
|
66
|
+
`Range` seeking.
|
|
67
|
+
|
|
68
|
+
The standalone route below is a plain Worker `fetch` that only has the R2
|
|
69
|
+
binding. It handles both verbs and serves whole objects:
|
|
78
70
|
|
|
79
71
|
```ts
|
|
72
|
+
import { isSafeHeaderValue } from "@lunora/server";
|
|
80
73
|
import { verifySignedUrl } from "@lunora/storage";
|
|
81
74
|
|
|
75
|
+
/** Cap what a single signed PUT may store. */
|
|
76
|
+
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Origins allowed to upload cross-origin. Leave it empty when
|
|
80
|
+
* `STORAGE_PUBLIC_BASE_URL` is your app's own origin — then `cors` is inert and
|
|
81
|
+
* no browser ever preflights these routes.
|
|
82
|
+
*/
|
|
83
|
+
const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Types safe to render in the browser. Everything else downloads — an uploader
|
|
87
|
+
* who pinned `text/html` or `image/svg+xml` must never get a same-origin script.
|
|
88
|
+
* (`serveStorageObject` applies this same list.)
|
|
89
|
+
*/
|
|
90
|
+
const INLINE_SAFE = new Set([
|
|
91
|
+
"audio/mpeg",
|
|
92
|
+
"audio/ogg",
|
|
93
|
+
"audio/wav",
|
|
94
|
+
"image/apng",
|
|
95
|
+
"image/avif",
|
|
96
|
+
"image/gif",
|
|
97
|
+
"image/jpeg",
|
|
98
|
+
"image/png",
|
|
99
|
+
"image/webp",
|
|
100
|
+
"video/mp4",
|
|
101
|
+
"video/webm",
|
|
102
|
+
]);
|
|
103
|
+
|
|
82
104
|
export default {
|
|
83
105
|
async fetch(request: Request, env: Env): Promise<Response> {
|
|
84
106
|
const url = new URL(request.url);
|
|
85
107
|
|
|
86
108
|
if (url.pathname.startsWith("/storage/")) {
|
|
109
|
+
const origin = request.headers.get("origin");
|
|
110
|
+
// `vary` rides on EVERY response, allowed origin or not: a shared
|
|
111
|
+
// cache keyed on the URL alone would otherwise replay one origin's
|
|
112
|
+
// `access-control-allow-origin` to another.
|
|
113
|
+
const cors = {
|
|
114
|
+
vary: "origin",
|
|
115
|
+
...(origin !== null && ALLOWED_ORIGINS.has(origin)
|
|
116
|
+
? { "access-control-allow-headers": "content-type", "access-control-allow-methods": "GET, PUT", "access-control-allow-origin": origin }
|
|
117
|
+
: {}),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Before the verb check and the signature check: a preflight carries
|
|
121
|
+
// neither the signed method nor any credentials, so answering it
|
|
122
|
+
// later would 405 every cross-origin upload.
|
|
123
|
+
if (request.method === "OPTIONS") {
|
|
124
|
+
return new Response(null, { headers: cors, status: 204 });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The method is signed, so a GET URL cannot be replayed as a PUT —
|
|
128
|
+
// check the verb anyway rather than relying on that alone.
|
|
129
|
+
if (request.method !== (url.searchParams.get("method") ?? "GET")) {
|
|
130
|
+
return new Response("method not allowed", { status: 405 });
|
|
131
|
+
}
|
|
132
|
+
|
|
87
133
|
const result = await verifySignedUrl(url, env.STORAGE_SIGNING_SECRET);
|
|
88
134
|
|
|
89
135
|
if (!result.valid || result.key === undefined) {
|
|
@@ -91,14 +137,64 @@ export default {
|
|
|
91
137
|
return new Response("forbidden", { status: 403 });
|
|
92
138
|
}
|
|
93
139
|
|
|
140
|
+
if (request.method === "PUT") {
|
|
141
|
+
// Store the content type the SIGNATURE pins, never the request's
|
|
142
|
+
// own header: the allowlist ran when the URL was minted, so
|
|
143
|
+
// trusting the header lets a caller mint for `image/png` and PUT
|
|
144
|
+
// `text/html` — stored XSS on this origin.
|
|
145
|
+
if (result.contentType === undefined) {
|
|
146
|
+
return new Response("upload URL carries no content type", { status: 400 });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// A declared length is the contract: R2 takes `request.body` as a
|
|
150
|
+
// stream, so there is nothing to measure before the write, and
|
|
151
|
+
// treating an ABSENT header as oversized would 413 every valid
|
|
152
|
+
// streamed upload. Demand it (411) and enforce it (413).
|
|
153
|
+
const declared = request.headers.get("content-length");
|
|
154
|
+
|
|
155
|
+
if (declared === null) {
|
|
156
|
+
return new Response("content-length required", { status: 411 });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const length = Number(declared);
|
|
160
|
+
|
|
161
|
+
if (!Number.isFinite(length) || length > MAX_UPLOAD_BYTES) {
|
|
162
|
+
return new Response("upload too large", { status: 413 });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await env.UPLOADS.put(result.key, request.body, { httpMetadata: { contentType: result.contentType } });
|
|
166
|
+
|
|
167
|
+
// The preflight's answer does not carry over: without CORS
|
|
168
|
+
// headers HERE too the browser passes preflight and then rejects
|
|
169
|
+
// the actual response.
|
|
170
|
+
return new Response(null, { headers: cors, status: 204 });
|
|
171
|
+
}
|
|
172
|
+
|
|
94
173
|
const object = await env.UPLOADS.get(result.key);
|
|
95
174
|
|
|
96
175
|
if (!object) {
|
|
97
176
|
return new Response("not found", { status: 404 });
|
|
98
177
|
}
|
|
99
178
|
|
|
179
|
+
// The stored content type came off an uploader-signed URL, so it is
|
|
180
|
+
// attacker-influenced: a CR/LF/NUL in it either throws inside
|
|
181
|
+
// `Headers` (an unhandled 500) or, on a permissive runtime, splits
|
|
182
|
+
// the response. Reject the value rather than reflect it — this is
|
|
183
|
+
// exactly what `isSafeHeaderValue` does inside `serveStorageObject`.
|
|
184
|
+
const rawContentType = object.httpMetadata?.contentType;
|
|
185
|
+
const contentType = rawContentType !== undefined && isSafeHeaderValue(rawContentType) ? rawContentType : "application/octet-stream";
|
|
186
|
+
|
|
100
187
|
return new Response(object.body, {
|
|
101
|
-
headers: {
|
|
188
|
+
headers: {
|
|
189
|
+
...cors,
|
|
190
|
+
// The URL expires; a cached copy would not. Without this a
|
|
191
|
+
// browser or CDN can keep serving private bytes past `exp`,
|
|
192
|
+
// with `verifySignedUrl` never consulted again.
|
|
193
|
+
"cache-control": "private, no-store",
|
|
194
|
+
...(INLINE_SAFE.has(contentType.split(";")[0]?.trim().toLowerCase() ?? "") ? {} : { "content-disposition": "attachment" }),
|
|
195
|
+
"content-type": contentType,
|
|
196
|
+
"x-content-type-options": "nosniff",
|
|
197
|
+
},
|
|
102
198
|
});
|
|
103
199
|
}
|
|
104
200
|
|
|
@@ -108,51 +204,49 @@ export default {
|
|
|
108
204
|
};
|
|
109
205
|
```
|
|
110
206
|
|
|
111
|
-
`
|
|
112
|
-
|
|
113
|
-
|
|
207
|
+
CORS: if `STORAGE_PUBLIC_BASE_URL` is a different origin from the app, the
|
|
208
|
+
browser preflights the `PUT`. The browser then reads
|
|
209
|
+
`access-control-allow-origin` from the real response as well, which is why the
|
|
210
|
+
204 and the download response also spread `...cors`. Keep the base same-origin
|
|
211
|
+
and `ALLOWED_ORIGINS` can stay empty.
|
|
212
|
+
|
|
213
|
+
On a CDN or host-rewrite setup, pass `{ expectedHost }` to `verifySignedUrl`,
|
|
214
|
+
set to the host of `STORAGE_PUBLIC_BASE_URL`, so the signature is checked
|
|
215
|
+
against the host the URL was minted for.
|
|
114
216
|
|
|
115
|
-
## Step
|
|
217
|
+
## Step 4: Upload and download from the client
|
|
116
218
|
|
|
117
219
|
```ts
|
|
118
|
-
|
|
119
|
-
const { key, url } = await client.action("storage/generateUploadUrl", {
|
|
120
|
-
key: "avatar.png",
|
|
121
|
-
contentType: file.type,
|
|
122
|
-
});
|
|
220
|
+
import { api } from "../lunora/_generated/api"; // path relative to your client file
|
|
123
221
|
|
|
124
|
-
//
|
|
222
|
+
// 1. Mint a signed PUT URL. contentType must be in ALLOWED_UPLOAD_CONTENT_TYPES
|
|
223
|
+
// in lunora/storage/index.ts. The type is pinned into the signature, and the
|
|
224
|
+
// route stores that pinned value, not the request header.
|
|
225
|
+
const { key, url } = await client.action(api.storage.generateUploadUrl, { key: "avatar.png", contentType: file.type });
|
|
226
|
+
|
|
227
|
+
// 2. Upload straight to the Worker's /storage/* route.
|
|
125
228
|
await fetch(url, { method: "PUT", headers: { "content-type": file.type }, body: file });
|
|
126
229
|
|
|
127
|
-
// 3.
|
|
128
|
-
const { url: downloadUrl } = await client.action(
|
|
230
|
+
// 3. Later, mint a signed GET URL to display it.
|
|
231
|
+
const { url: downloadUrl } = await client.action(api.storage.getDownloadUrl, { key: "avatar.png" });
|
|
129
232
|
```
|
|
130
233
|
|
|
131
|
-
Every key is scoped
|
|
132
|
-
|
|
133
|
-
functions return the
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
## Checklist
|
|
152
|
-
|
|
153
|
-
- [ ] `lunora registry add storage` run, `pnpm install` done.
|
|
154
|
-
- [ ] `UPLOADS` bucket bound to a real bucket; `STORAGE_SIGNING_SECRET` (≥32
|
|
155
|
-
chars) and `STORAGE_PUBLIC_BASE_URL` set.
|
|
156
|
-
- [ ] `lunora codegen` run so `api.storage.*` is generated.
|
|
157
|
-
- [ ] `GET /storage/:key` route verifies signed URLs before streaming.
|
|
158
|
-
- [ ] Verified a client upload → signed download round-trip.
|
|
234
|
+
Every key is scoped as `storage/<userId>/<key>`, so a key from the client can't
|
|
235
|
+
reach another user's data. The `storage/` prefix is also what routes the minted
|
|
236
|
+
URL to `/storage/*`. The functions return the scoped key: persist it if you
|
|
237
|
+
like, but always pass the bare key back in, because the function scopes it
|
|
238
|
+
again. `listObjects` is an action, not a query, because R2 isn't reactive.
|
|
239
|
+
Refetch it after an upload or a delete.
|
|
240
|
+
|
|
241
|
+
## Pitfalls
|
|
242
|
+
|
|
243
|
+
- **Signing secret reuse.** Signed URLs carry the bucket name inside the HMAC, so they can't be replayed across buckets. They can be replayed across deployments: two apps that share a secret will each honor URLs the other minted. Give each deployment its own secret.
|
|
244
|
+
- **File bodies through functions.** Bytes stream through the `/storage/*` route. Don't pass file contents as a `query`/`mutation`/`action` argument or return value.
|
|
245
|
+
- **Content types.** Widen the upload list through `ALLOWED_UPLOAD_CONTENT_TYPES`. Leave out `text/html` and `image/svg+xml`, since an uploaded one served from your origin is stored XSS.
|
|
246
|
+
|
|
247
|
+
## Verify
|
|
248
|
+
|
|
249
|
+
1. Run `lunora dev`, sign in, and upload a file through `generateUploadUrl` and `PUT`. The PUT should return 204.
|
|
250
|
+
2. Open the URL from `getDownloadUrl`. It should return the bytes with `cache-control: private, no-store`.
|
|
251
|
+
3. Change one character of `sig` in that URL. The route should return 403.
|
|
252
|
+
4. Before deploying, check that `bucket_name` is a real bucket and that `STORAGE_SIGNING_SECRET` was set with `wrangler secret put`.
|