Quickback Docs

Using R2

File URLs, public buckets, and signed uploads with Cloudflare R2

Most "files" on a listing, profile, or gallery are a URL. Host the bytes in R2 only when this app must own the upload.

Pick the first row that fits:

What you needWhat to build
Photo already on the web, or the user pastes a linkphotoUrl: q.url() — no R2, no secrets
This app hosts the bytes; anyone with the link may viewManaged public bucket (readScope: "public") + UUID in the key
Large or confidential bytes; the URL itself must expireSigned PUT/GET — needs an R2 API token

Public-if-you-have-the-link is the usual read model (listing photos, avatars). Signed URLs are the smaller case. Details: R2 Setup → Read models.

By default defineFileStorage("cloudflare-r2") is presign-only: you get the ctx.storage signer and nothing else. The /storage/v1/* endpoints further down exist only in the managed subsystem (managed: true).

Store a URL instead

A property listing, avatar, or hero image that is already a link does not need R2. Store the URL on the row. The CMS gets a URL input; the public listing page renders it. A browser deploy does not ask for R2 secrets.

quickback/features/listings/listings.ts
import { q, defineTable } from "@quickback/compiler";

export const listings = q.table("listings", {
  id: q.id(),
  title: q.text().required(),
  photoUrl: q.url(),
  organizationId: q.scope("organization"),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(listings, {
  read: { access: { roles: ["member+"] } },
  crud: {
    create: { access: { roles: ["member+"] } },
    update: { access: { roles: ["admin+"] } },
    delete: { access: { roles: ["admin+"] }, mode: "soft" },
  },
  guards: { createable: ["title", "photoUrl"], updatable: ["title", "photoUrl"] },
});

q.url() rejects javascript: / data: URIs at the validation edge. Use q.text() only when you must store a non-http value.

Upload into a public bucket (managed)

With managed: true, create a bucket with readScope: "public" and PUT bytes through the Worker. No R2 API token. The files worker serves the object at /public/... with no auth. Put a UUID in the path so the URL is not guessable.

# 1. Create the bucket once (write stays org-gated)
curl -X POST https://api.example.com/storage/v1/bucket \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{ "name": "listings", "readScope": "public", "writeScope": "organization", "writeRoles": ["admin", "member"] }'

# 2. Upload — bytes go through the Worker, binding only
curl -X POST https://api.example.com/storage/v1/object/listings/3f2a9c…-hero.jpg \
  -H "Authorization: Bearer <token>" -H "Content-Type: image/jpeg" \
  --data-binary @hero.jpg
# → { "key": "public/org_abc/listings/3f2a9c…-hero.jpg", "readScope": "public", … }

# 3. Store that URL on the listing (or POST /storage/v1/url to resolve it)

Serve:

https://files.yourdomain.com/public/org_abc/listings/3f2a9c…-hero.jpg

Signed upload (large or confidential files)

Declare the key column as q.storageKey(...) and let db.allocateUpload write it. Bytes go directly to R2 — the Worker only signs. The client never names the key, so it can't point a row at another tenant's object.

quickback/quickback.config.ts
import { defineConfig, defineRuntime, defineDatabase, defineAuth, defineFileStorage } from "@quickback/compiler";

export default defineConfig({
  name: "events-app",
  template: "hono",
  features: { organizations: true },
  providers: {
    runtime: defineRuntime("cloudflare"),
    database: defineDatabase("cloudflare-d1"),
    auth: defineAuth("better-auth", { plugins: { organization: true } }),
    fileStorage: defineFileStorage("cloudflare-r2", { bucketName: "events-app-media" }),
  },
  bindings: {
    r2Buckets: [{ binding: "MEDIA", bucketName: "events-app-media" }],
  },
});
// quickback/features/media/media.ts
import { feature, q } from "@quickback/compiler";

export default feature("media", {
  columns: {
    id:             q.id(),
    eventId:        q.text().required(),
    title:          q.text({ maxLength: 200 }).required(),
    r2Key: q.storageKey({
      bucket: "MEDIA",
      key: "media/{organizationId}/{eventId}/{uuid}.{ext}",
      mime: ["image/*", "application/pdf"],
      maxBytes: 10_000_000,
    }),
    organizationId: q.scope("organization"),
    ...q.audit(),
  },
  read:   { access: { roles: ["member"] } },
  update: { access: { roles: ["member"] } },
  delete: { access: { roles: ["admin"] }, mode: "hard" },
  guards: { updatable: ["title"] },
});

// quickback/features/media/actions/upload.ts
import { z } from "zod";
import { defineAction, media } from "../.quickback/define-action";

export default defineAction({
  description: "Create a media row and sign its direct-to-R2 upload.",
  path: "/media/upload",
  method: "POST",
  input: z.object({ eventId: z.string(), title: z.string(), ext: z.string(), mime: z.string(), size: z.number().int() }),
  access: { roles: ["member"] },
  async execute({ db, input }) {
    const { row, putUrl, headers } = await db.allocateUpload(media, input);
    return { id: row.id, putUrl, headers };
  },
});
# 1. Ask your action for a signed URL (your auth/roles run here)
curl -X POST https://api.example.com/api/v1/media/upload \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{ "eventId": "evt_1", "title": "Floor plan", "ext": "pdf", "mime": "application/pdf", "size": 48213 }'
# → { "id": "…", "putUrl": "https://<account>.r2.cloudflarestorage.com/events-app-media/media/org_abc/evt_1/<uuid>.pdf?…X-Amz-Signature=…", "headers": { "Content-Length": "48213", "Content-Type": "application/pdf" } }

# 2. Upload the bytes straight to R2 (replay the returned headers verbatim)
curl -X PUT "<putUrl>" -H "Content-Type: application/pdf" -H "Content-Length: 48213" --data-binary @plan.pdf

db.allocateUpload(table, { ...rowValues, ext, mime, size }, { id?, expiresIn? }):

  1. Checks mime against the column's mime list (415 STORAGE_UPLOAD_TYPE_REFUSED), size against maxBytes (413 STORAGE_UPLOAD_TOO_LARGE), and ext (1–16 letters or digits, lower-cased).
  2. Stamps the row exactly as the scoped db.insert would (organizationId from the caller's active org), then renders the key from the template: {column} from the row, a fresh {uuid}, {ext} from the call. A value that cannot fill a whole path segment (/, .., anything outside A–Z a–z 0–9 _ -) is a 400 STORAGE_KEY_SEGMENT_INVALID.
  3. Signs a PUT with ctx.storage.signPutUrl underneath. The mime and size are bound into the signature as Content-Type / Content-Length. When the project also declares fileStorage.allowedTypes, the type must be on that list too.
  4. Inserts the row (or, with { id }, re-keys that row and deletes its previous object) and returns { row, key, putUrl, headers, expiresAt }.

If the table also has columns named mime, size, or ext, the upload's values fill them.

Reads. db.signDownload(table, id, { expiresIn?, downloadFilename? }) loads the row through the firewall and signs a GET for its key. It refuses with 403 STORAGE_KEY_OUTSIDE_PREFIX when the stored key is not under the row's own prefix.

The prefix is the template up to {uuid} with the row's values substituted: media/org_abc/evt_1/. Every column placeholder fills a whole /-separated segment, so "starts with the row's prefix" can't match a neighbouring tenant. Reads, deletes, and tenant purge all refuse a key outside its row's prefix — a row whose key was tampered to name another tenant's object never reaches that object.

Deletes. Deleting a row deletes its object:

  • The generated hard DELETE /:id and DELETE /batch delete the objects of the rows they erased.
  • In an action, db.delete(table) on a storage-key table deletes the objects of the rows it removed — from a full .returning(), or else by selecting the matching rows through the same scoped db first. Inside db.transaction(...) the objects are deleted after the outermost commit. db.batch([...]) refuses a storage-key delete, because a batch skips this step.
  • Soft deletes keep the object, because the row still exists.
  • unsafeDb, schedules, and queue handlers bypass the scoped db. After a hard delete there, call deleteStorageObjects(env, table, deletedRows) from src/lib/storage-keys. It runs the same prefix check.

What the compiler refuses (each is a compile error):

CodeCause
STORAGE_KEY_GUARDEDThe column is listed in guards.createable / updatable / immutable / protected. It is never client-writable, and no generated write body carries it.
STORAGE_KEY_TENANT_PREFIXA tenant column of the table's firewall (organizationId, ownerId, a scope column) is not a placeholder before {uuid}.
STORAGE_KEY_TEMPLATENo {uuid}, {uuid} not starting its own segment, a placeholder that isn't a column, a column after {uuid} (only {ext} may follow), .., or characters outside A–Z a–z 0–9 . _ - /.
STORAGE_KEY_BUCKETbucket is not a bindings.r2Buckets binding (or the managed fileStorage binding), or an environment maps that binding to a different bucket name.
STORAGE_KEY_PRESIGNNo providers.fileStorage: defineFileStorage("cloudflare-r2", …) — the presigner signs the PUT.
STORAGE_KEY_MIME / STORAGE_KEY_MAX_BYTESA malformed MIME pattern, or maxBytes that isn't a positive integer or exceeds fileStorage.maxFileSize.
STORAGE_KEY_REQUIRED_WITH_CREATEThe column is .required() and the table has a generic create route, which could never supply the key.
STORAGE_KEY_CASCADEThe table declares onDelete: 'cascade'. A cascaded delete removes rows without deleting their objects.
STORAGE_KEY_MULTIPLE / STORAGE_KEY_NO_RESOURCEMore than one storage key on a table, or a storage key on a table with no resource (no firewall to check the row against).

In action code, writing the column directly (db.insert(...).values({ r2Key }), db.update(...).set({ r2Key }), an upsert's set) throws STORAGE_KEY_NOT_WRITABLE.

Lower level: storage.signPutUrl

When the key does not belong to a row (a one-off export, an import staging area), sign it yourself. You own the key's tenancy in that case.

const upload = await storage.signPutUrl(`exports/${ctx.activeOrgId}/${crypto.randomUUID()}.csv`, {
  contentType: "text/csv",
  contentLength: input.size, // required — bound into the signature
  expiresIn: 600,
});

storage.signPutUrl(key, { contentLength, contentType?, expiresIn?, bucket? }) returns { url, method, headers, key, expiresAt }; storage.signGetUrl(key, { expiresIn?, downloadFilename?, bucket? }) returns a presigned GET URL. Pass bucket to target a bucket other than the configured default.

The size and type are signed, and the declared limits are enforced. contentLength is required: it is checked against fileStorage.maxFileSize (the presign is refused above it) and bound into the signature as the Content-Length header, so the URL cannot upload a different size than the action approved. contentType is bound the same way, and when the project declares fileStorage.allowedTypes it is required and must be on the list. A PUT that does not replay the returned headers verbatim is rejected by R2. A project that declares neither limit gets no ceiling and no allowlist — the signature is still size-bound.

Presigning needs an R2 API token — set the R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY secrets. A browser deploy refuses the project until those exist; it cannot mint them. Create the token in the Cloudflare dashboard (R2 → Manage API Tokens, Object Read & Write) and set the secrets with wrangler secret put / quickback deploy. See R2 Setup → Presigned uploads.

Managed presign endpoints

With managed: true, the generated API also exposes turnkey presigned endpoints backed by FILES_DB:

# 1. Request a presigned upload URL
curl -X POST https://api.example.com/storage/v1/presign/documents/report.pdf \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{ "contentType": "application/pdf", "size": 248000 }'
# → { "id": "obj_abc", "key": "...", "uploadUrl": "...", "headers": {...}, "expiresAt": "..." }

# 2. Upload directly to R2
curl -X PUT "<uploadUrl>" -H "Content-Type: application/pdf" --data-binary @report.pdf

# 3. Confirm (reconciles size + etag)
curl -X POST https://api.example.com/storage/v1/confirm/documents/report.pdf \
  -H "Authorization: Bearer <token>"

Download Flow

Public bucket / URL field — GET the stored URL. No session, no signature.

Presign-only, confidential files — mint a short-lived download URL with storage.signGetUrl(key, { downloadFilename }) from your action.

Managed, session-gated buckets — the files worker re-checks membership:

# Download a file (auth + firewall enforced)
curl https://api.example.com/api/v1/files/file_abc123/download \
  -H "Authorization: Bearer <token>"

The download endpoint:

  1. Validates the user's session
  2. Checks firewall — the file must belong to the user's organization
  3. Checks access — the user must have the required role
  4. Streams the file from R2

Role-Based Access

File writes always respect the same security layers as your API:

  • Firewall — Users can only upload files belonging to their organization
  • Access — Role-based upload / delete permissions

File reads follow the bucket's readScope. public does not run those checks on GET — the URL is the capability.

See Also

On this page