UNPKG

@lunora/cli

Version:

The Lunora CLI: init, dev, deploy, codegen, migrate, seed, doctor, insights, logs, registry, and the rest of the project commands

253 lines (201 loc) • 13.7 kB
--- name: lunora-setup-storage 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. --- # Lunora Setup Storage The `storage` registry item wraps `@lunora/storage`, an R2 adapter with HMAC signed-URL helpers. It exposes Lunora functions for browser uploads, gated downloads, delete and list. The client never holds a bucket credential. A worker-signed URL points at your Worker, not at R2. Its shape is `<base>/<key>?exp&method&bucket&sig`. The base is `ctx.origin` unless `STORAGE_PUBLIC_BASE_URL` overrides it. The `/storage/*` route from Step 3 verifies the signature and moves the bytes, for both upload and download. `@lunora/storage`'s `getPresignedUrl` takes the Worker out of the path, but it needs S3 credentials and none of your rules apply to it. If the project has no Lunora backend yet, start with `lunora-quickstart`. ## Step 1: Add the item ```bash lunora add storage # asks for the R2 bucket name lunora add storage --bucket my-app-uploads lunora registry add storage # low-level: writes bucket_name "replace-me-uploads" ``` Then run `pnpm install` and `lunora codegen`. The item does the following: 1. Adds `@lunora/storage`, `@lunora/server`, `@lunora/errors` and `@lunora/ratelimit`. 2. Merges an R2 binding named `UPLOADS` into `r2_buckets` in `wrangler.jsonc`. 3. Writes `STORAGE_SIGNING_SECRET` (a secret) to `.dev.vars`, plus an empty, optional `STORAGE_PUBLIC_BASE_URL`. 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`. ## Step 2: Configure the binding and secrets | Name | Where | Notes | | ------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `UPLOADS` | `wrangler.jsonc` → `r2_buckets` | Set `bucket_name` to a real bucket: lowercase letters, digits and hyphens, 3–63 chars. Otherwise wrangler rejects it. | | `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. | | `STORAGE_PUBLIC_BASE_URL` | optional var | See below. | Leave `STORAGE_PUBLIC_BASE_URL` empty for most apps. URLs are then signed against `ctx.origin`, which is correct in dev, in previews and in production. Set it only in two cases: - Another host, such as a CDN, serves `/storage/*`. - 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. The value must be a bare origin. The key is verified from the whole URL pathname, so the signer rejects a base that includes a path. Outside local dev the value must use `https://`, because a signed URL is a bearer credential. ## Step 3: Add the `/storage/*` route to the Worker The app needs this route. Without it, every minted URL falls through to the Lunora catch-all and returns 404. The route is also the only place the signature is checked, so a route that skips `verifySignedUrl` lets anyone read any key. `@lunora/server`'s `serveStorageObject(ctx, key, request, authorize)` covers the download half. It handles `Range`/206, `ETag`, `nosniff` and the inline-safe `content-disposition` list. It verifies nothing on its own, so call `verifySignedUrl` inside its `authorize` gate. It also doesn't handle uploads. Use it from an `httpAction`, where `ctx.storage` is in scope, when you need `Range` seeking. The standalone route below is a plain Worker `fetch` that only has the R2 binding. It handles both verbs and serves whole objects: ```ts import { isSafeHeaderValue } from "@lunora/server"; import { verifySignedUrl } from "@lunora/storage"; /** Cap what a single signed PUT may store. */ const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; /** * Origins allowed to upload cross-origin. Leave it empty when * `STORAGE_PUBLIC_BASE_URL` is your app's own origin — then `cors` is inert and * no browser ever preflights these routes. */ const ALLOWED_ORIGINS = new Set(["https://app.example.com"]); /** * Types safe to render in the browser. Everything else downloads — an uploader * who pinned `text/html` or `image/svg+xml` must never get a same-origin script. * (`serveStorageObject` applies this same list.) */ const INLINE_SAFE = new Set([ "audio/mpeg", "audio/ogg", "audio/wav", "image/apng", "image/avif", "image/gif", "image/jpeg", "image/png", "image/webp", "video/mp4", "video/webm", ]); export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url); if (url.pathname.startsWith("/storage/")) { const origin = request.headers.get("origin"); // `vary` rides on EVERY response, allowed origin or not: a shared // cache keyed on the URL alone would otherwise replay one origin's // `access-control-allow-origin` to another. const cors = { vary: "origin", ...(origin !== null && ALLOWED_ORIGINS.has(origin) ? { "access-control-allow-headers": "content-type", "access-control-allow-methods": "GET, PUT", "access-control-allow-origin": origin } : {}), }; // Before the verb check and the signature check: a preflight carries // neither the signed method nor any credentials, so answering it // later would 405 every cross-origin upload. if (request.method === "OPTIONS") { return new Response(null, { headers: cors, status: 204 }); } // The method is signed, so a GET URL cannot be replayed as a PUT — // check the verb anyway rather than relying on that alone. if (request.method !== (url.searchParams.get("method") ?? "GET")) { return new Response("method not allowed", { status: 405 }); } const result = await verifySignedUrl(url, env.STORAGE_SIGNING_SECRET); if (!result.valid || result.key === undefined) { // Expose only `valid` — a precise reason is a signing oracle. return new Response("forbidden", { status: 403 }); } if (request.method === "PUT") { // Store the content type the SIGNATURE pins, never the request's // own header: the allowlist ran when the URL was minted, so // trusting the header lets a caller mint for `image/png` and PUT // `text/html` — stored XSS on this origin. if (result.contentType === undefined) { return new Response("upload URL carries no content type", { status: 400 }); } // A declared length is the contract: R2 takes `request.body` as a // stream, so there is nothing to measure before the write, and // treating an ABSENT header as oversized would 413 every valid // streamed upload. Demand it (411) and enforce it (413). const declared = request.headers.get("content-length"); if (declared === null) { return new Response("content-length required", { status: 411 }); } const length = Number(declared); if (!Number.isFinite(length) || length > MAX_UPLOAD_BYTES) { return new Response("upload too large", { status: 413 }); } await env.UPLOADS.put(result.key, request.body, { httpMetadata: { contentType: result.contentType } }); // The preflight's answer does not carry over: without CORS // headers HERE too the browser passes preflight and then rejects // the actual response. return new Response(null, { headers: cors, status: 204 }); } const object = await env.UPLOADS.get(result.key); if (!object) { return new Response("not found", { status: 404 }); } // The stored content type came off an uploader-signed URL, so it is // attacker-influenced: a CR/LF/NUL in it either throws inside // `Headers` (an unhandled 500) or, on a permissive runtime, splits // the response. Reject the value rather than reflect it — this is // exactly what `isSafeHeaderValue` does inside `serveStorageObject`. const rawContentType = object.httpMetadata?.contentType; const contentType = rawContentType !== undefined && isSafeHeaderValue(rawContentType) ? rawContentType : "application/octet-stream"; return new Response(object.body, { headers: { ...cors, // The URL expires; a cached copy would not. Without this a // browser or CDN can keep serving private bytes past `exp`, // with `verifySignedUrl` never consulted again. "cache-control": "private, no-store", ...(INLINE_SAFE.has(contentType.split(";")[0]?.trim().toLowerCase() ?? "") ? {} : { "content-disposition": "attachment" }), "content-type": contentType, "x-content-type-options": "nosniff", }, }); } // ... your Lunora handler return new Response("not found", { status: 404 }); }, }; ``` CORS: if `STORAGE_PUBLIC_BASE_URL` is a different origin from the app, the browser preflights the `PUT`. The browser then reads `access-control-allow-origin` from the real response as well, which is why the 204 and the download response also spread `...cors`. Keep the base same-origin and `ALLOWED_ORIGINS` can stay empty. On a CDN or host-rewrite setup, pass `{ expectedHost }` to `verifySignedUrl`, set to the host of `STORAGE_PUBLIC_BASE_URL`, so the signature is checked against the host the URL was minted for. ## Step 4: Upload and download from the client ```ts import { api } from "../lunora/_generated/api"; // path relative to your client file // 1. Mint a signed PUT URL. contentType must be in ALLOWED_UPLOAD_CONTENT_TYPES // in lunora/storage/index.ts. The type is pinned into the signature, and the // route stores that pinned value, not the request header. const { key, url } = await client.action(api.storage.generateUploadUrl, { key: "avatar.png", contentType: file.type }); // 2. Upload straight to the Worker's /storage/* route. await fetch(url, { method: "PUT", headers: { "content-type": file.type }, body: file }); // 3. Later, mint a signed GET URL to display it. const { url: downloadUrl } = await client.action(api.storage.getDownloadUrl, { key: "avatar.png" }); ``` Every key is scoped as `storage/<userId>/<key>`, so a key from the client can't reach another user's data. The `storage/` prefix is also what routes the minted URL to `/storage/*`. The functions return the scoped key: persist it if you like, but always pass the bare key back in, because the function scopes it again. `listObjects` is an action, not a query, because R2 isn't reactive. Refetch it after an upload or a delete. ## Pitfalls - **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. - **File bodies through functions.** Bytes stream through the `/storage/*` route. Don't pass file contents as a `query`/`mutation`/`action` argument or return value. - **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. ## Verify 1. Run `lunora dev`, sign in, and upload a file through `generateUploadUrl` and `PUT`. The PUT should return 204. 2. Open the URL from `getDownloadUrl`. It should return the bytes with `cache-control: private, no-store`. 3. Change one character of `sig` in that URL. The route should return 403. 4. Before deploying, check that `bucket_name` is a real bucket and that `STORAGE_SIGNING_SECRET` was set with `wrangler secret put`.