Storage & files
Pluggable object storage (R2, S3, or local disk), authenticated user file uploads, and stored AI-generated images.
Valcraven stores binary files — user uploads and AI-generated images — through one storage abstraction with three pluggable backends. The abstraction is lib/storage.ts; user uploads go through app/api/files/, and AI images through app/api/images/. On Cloudflare Workers the preferred backend is an R2 bucket bound as STORAGE; off Workers it falls back to S3-compatible storage or the local filesystem.
Your code calls the same helpers (uploadFile, downloadFile, deleteFile, getFileUrl) regardless of which backend is active.
The storage abstraction
lib/storage.ts picks a backend at call time based on the environment. In priority order:
| # | Backend | Chosen when | Notes |
|---|---|---|---|
| 1 | R2 binding | Running on Workers (D1 driver) and the STORAGE binding is present | Uses getCloudflareContext().env.STORAGE directly — no API tokens, no S3 round-trip. Preferred for Workers deploys. |
| 2 | S3-compatible | S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY are all set | Works on any host (AWS S3, R2 via its S3 API, or any S3-compatible store). Signs requests with fetch. |
| 3 | Local filesystem | Not on Workers and no S3 vars | Writes under ./data/uploads. Dev only — crashes on Workers (no fs). |
If none of these apply on a Workers deploy, the first storage call throws with a message telling you to configure R2 or S3 — a deliberate fail-fast rather than a silent crash at request time.
The helper API
import {
uploadFile,
downloadFile,
deleteFile,
getFileUrl,
generateFileKey,
} from "@/lib/storage";
// Store a buffer; returns { key, size, contentType }
const { key } = await uploadFile(buffer, filename, contentType);
const bytes = await downloadFile(key); // Buffer
await deleteFile(key);
const url = getFileUrl(key); // where to fetch it fromuploadFile() generates a unique, random key for each object (via generateFileKey()), so filenames never collide and the original name is kept separately (in the files table, below). For the R2 and local backends, getFileUrl() returns a path to Valcraven's own /api/files/<key> route — the R2 binding doesn't expose public object URLs, so files are served back through the app. The S3 backend returns the direct ${endpoint}/${bucket}/${key} URL.
getStorageBackendName() returns which backend is active ("r2-binding", "s3", "local", or "none") — handy for diagnostics and health checks.
User file uploads
The files API lets an authenticated user upload, list, download, and delete their own files. Every route scopes to auth.api.getSession(...) and filters by userId, so users only ever see their own files. See Authentication.
Uploading — POST /api/files
Send a multipart/form-data body with a file field. The route is rate-limited and enforces:
- Max size: 10 MB.
- Allowed types:
image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain,text/csv,application/json,application/zip,application/gzip. Anything else is rejected with a 400.
On success it stores the bytes via uploadFile(), writes a row to the files table, and returns { file, url } with a 201.
const form = new FormData();
form.append("file", file); // a File/Blob
const res = await fetch("/api/files", { method: "POST", body: form });
const { file, url } = await res.json();Valcraven also ships a ready-made uploader component at components/ui/file-upload.tsx (FileUpload). It posts to /api/files and calls onUpload({ key, url, filename }) when done — it's used on the settings page for avatar uploads. Drop it in rather than wiring the fetch yourself.
Listing, downloading, deleting
GET /api/files— returns{ files }, the current user's file rows, newest first.GET /api/files/[key]— streams the file back. It verifies the key belongs to the requesting user before serving, setsContent-Disposition: inline,X-Content-Type-Options: nosniff, and a long-livedprivatecache header. A key that isn't yours returns 404 (not found — it doesn't leak existence).DELETE /api/files/[key]— deletes the object from storage and removes the DB row (again, only if you own it).
Because downloads are auth-gated, a client fetching a file must send the session cookie — files aren't publicly reachable by URL under the R2/local backends.
The files table
Each upload is tracked in the files table (defined in lib/schema.ts):
| Column | Purpose |
|---|---|
id | Primary key |
userId | Owner — FK to user(id), cascade delete |
key | Unique storage key returned by uploadFile() |
filename | Original filename |
contentType | MIME type |
size | Bytes |
storageBackend | Which backend stored it (r2-binding / s3 / local) |
createdAt | Upload timestamp |
AI-generated images
The chat assistant can generate images (via the realtime worker's generate_image tool — see AI chat and Realtime worker). Those images are stored through a separate, tighter pair of routes under app/api/images/.
POST /api/imagestakes the raw image bytes as the request body (not a form field). It's auth-gated, sniffs the real image format from the file's magic bytes (the model labels output as PNG but may actually return JPEG), enforces a per-user daily quota of 25 images, and stores the object under an auth-scoped key:ai-images/<userId>/<date>/<uuid>.<ext>. It returns{ key }. In practice this route is called by the realtime worker over its service binding, forwarding the user's session cookie.GET /api/images/[...key]serves an image back. It's a catch-all route (the keys contain slashes) and authorizes purely from the key prefix: the key must start withai-images/<your-userId>/, so one user can never read another's image. It also guards against path-traversal segments. A key outside your namespace returns 404.
The key difference from user uploads: AI images carry the owner's id in the key itself, so ownership is enforced from the prefix without a DB lookup. There's no files row for them.
Configuring R2 (Workers deploy)
The production path uses an R2 bucket. wrangler.toml declares the binding:
[[r2_buckets]]
binding = "STORAGE"
bucket_name = "valcraven-storage"One-time setup before your first deploy:
- Enable R2 on the Cloudflare account (dashboard → R2).
- Create the bucket:
wrangler r2 bucket create valcraven-storage.
That's it — with the binding present and the D1 driver active, lib/storage.ts uses R2 automatically. See Cloudflare Workers and Secrets & env.
Valcraven reuses the same bucket for the OpenNext incremental cache under a separate
incremental-cache/key prefix (bound asNEXT_INC_CACHE_R2_BUCKET). User uploads and AI images live under their own prefixes, so they never collide with cache entries. See Caching.
Configuring S3 (non-Workers deploy)
If you run Valcraven somewhere other than Workers and don't want local disk, set these env vars and the S3 backend activates automatically:
| Variable | Description |
|---|---|
S3_ENDPOINT | S3-compatible endpoint URL |
S3_BUCKET | Bucket name |
S3_ACCESS_KEY | Access key id |
S3_SECRET_KEY | Secret access key |
Local development
With no R2 binding and no S3 vars, uploads land in ./data/uploads on disk. This is the default dev experience and needs no configuration. Don't rely on it in production — the local backend can't run on Workers.
Extending storage
- Public URLs. To serve uploads from a public R2 domain instead of proxying through
/api/files, configure an R2 public bucket / custom domain and overridegetFileUrl()in your tenant code. - Different limits or types. Adjust the size cap and the
ALLOWED_TYPESallowlist inapp/api/files/route.tsfor your app's needs. - New file-backed features. Reuse
uploadFile/downloadFile/deleteFileand add your own table (or reusefiles) rather than talking to a backend directly.
Gotchas
- Downloads are authenticated. Because
/api/files/[key]and/api/images/[...key]require a session, an<img src>pointing at them only works while the user is logged in and the cookie is sent. There are no public URLs under R2/local by default. - Local backend + nested keys. The AI-images route pre-creates nested directories for the local backend, because its keys contain slashes and
fs.writeFileSyncwon't create parent dirs. R2 and S3 treat keys as flat strings, so this only matters in dev. - The daily image quota is authoritative here. The per-conversation cap in the chat tool is a soft first guard; the real 25-per-user-per-day ceiling is enforced in
POST /api/images, since that route is directly callable by any authenticated client.