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)

Write a defineAction that signs a presigned URL and stores the key in your own table. Bytes go directly to R2 — the Worker only signs.

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

export default defineAction({
  description: "Sign a direct-to-R2 upload URL and record the object key on the media row.",
  path: "/media/sign-upload",
  method: "POST",
  input: z.object({ filename: z.string(), contentType: z.string() }),
  access: { roles: ["member"] },
  async execute({ input, ctx, db, storage }) {
    const key = `org/${ctx.activeOrgId}/${crypto.randomUUID()}-${input.filename}`;
    const upload = await storage.signPutUrl(key, { contentType: input.contentType, expiresIn: 600 });
    await db.insert(media).values({ key /* … */ });
    return { uploadUrl: upload.url, key, headers: upload.headers };
  },
});
# 1. Ask your action for a signed URL (your auth/roles run here)
curl -X POST https://api.example.com/api/v1/media/sign-upload \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{ "filename": "report.pdf", "contentType": "application/pdf" }'
# → { "uploadUrl": "https://<account>.r2.cloudflarestorage.com/...&X-Amz-Signature=...", "key": "...", "headers": { "Content-Type": "application/pdf" } }

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

storage.signPutUrl(key, { 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.

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