Valcraven Docs
Features

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:

#BackendChosen whenNotes
1R2 bindingRunning on Workers (D1 driver) and the STORAGE binding is presentUses getCloudflareContext().env.STORAGE directly — no API tokens, no S3 round-trip. Preferred for Workers deploys.
2S3-compatibleS3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY are all setWorks on any host (AWS S3, R2 via its S3 API, or any S3-compatible store). Signs requests with fetch.
3Local filesystemNot on Workers and no S3 varsWrites 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 from

uploadFile() 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, sets Content-Disposition: inline, X-Content-Type-Options: nosniff, and a long-lived private cache 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):

ColumnPurpose
idPrimary key
userIdOwner — FK to user(id), cascade delete
keyUnique storage key returned by uploadFile()
filenameOriginal filename
contentTypeMIME type
sizeBytes
storageBackendWhich backend stored it (r2-binding / s3 / local)
createdAtUpload 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/images takes 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 with ai-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:

  1. Enable R2 on the Cloudflare account (dashboard → R2).
  2. 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 as NEXT_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:

VariableDescription
S3_ENDPOINTS3-compatible endpoint URL
S3_BUCKETBucket name
S3_ACCESS_KEYAccess key id
S3_SECRET_KEYSecret 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 override getFileUrl() in your tenant code.
  • Different limits or types. Adjust the size cap and the ALLOWED_TYPES allowlist in app/api/files/route.ts for your app's needs.
  • New file-backed features. Reuse uploadFile / downloadFile / deleteFile and add your own table (or reuse files) 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.writeFileSync won'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.

On this page