File Uploads & Storage
Upload images to durable Cloudflare R2 and serve them through the Worker.
Storage is Integrated with Cloudflare R2: authenticated uploads are written
to the UPLOADS binding and returned through a Worker read route. Unlike a local
uploads directory, R2 objects survive application builds and Worker deployments.
The included access policy is still an example that needs product hardening.
Request flow
POST /api/storage/upload-image
→ verify Better Auth session
→ parse FormData entries named "files"
→ validate count, MIME prefix, and size
→ env.UPLOADS.put(generatedKey, body, httpMetadata)
→ { success: true, data: { urls: ["/api/storage/<key>"] } }
GET /api/storage/<key>
→ env.UPLOADS.get(key)
→ stream body with stored HTTP metadata and ETag| File | Role |
|---|---|
src/routes/api/storage/upload-image.ts | Session check, multipart parsing, validation response, upload response |
src/lib/storage/image-upload.ts | Limits, MIME/extension mapping, generated keys, R2 writes, returned URL paths |
src/routes/api/storage/$key.ts | R2 lookup and Worker-proxied object response |
wrangler.jsonc | UPLOADS R2 binding and bucket name |
src/lib/storage/image-upload.test.ts | Validation and write behavior tests |
The current limits are five files per request and 5 MB per file. Keys use
crypto.randomUUID() plus a derived extension, so user filenames do not become
object paths. The upload preserves the declared content type as R2 HTTP
metadata. Returned URLs are relative application paths, not direct bucket URLs.
Configure R2
Create the production bucket
pnpm wrangler r2 bucket create soar-tanstack-uploadsUse your own name if desired.
Bind it to the Worker
The application code expects the binding name UPLOADS:
"r2_buckets": [
{ "binding": "UPLOADS", "bucket_name": "soar-tanstack-uploads" }
]UPLOADS is a binding, not a secret or URL. image-upload.ts and $key.ts
import env from cloudflare:workers and read env.UPLOADS during the request.
Do not initialize an R2 handle at module load.
Run locally
pnpm dev uses the Cloudflare Vite plugin and Miniflare-backed local R2 state.
Exercise uploads through the application rather than creating a local
public/uploads fallback; that would test a different storage model.
Access policy
Creation requires a valid session, but GET /api/storage/$key is currently
public to anyone who knows the key. This is appropriate only for public product
images. For private objects, store ownership/visibility in D1 and authorize each
read, or return short-lived signed access through a dedicated policy layer.
Worker proxying keeps URLs on the application origin and gives you a place to authorize requests, but every read consumes Worker resources. For public, high-volume assets, consider an R2 custom domain/CDN and deliberate cache headers. Keep the Worker route for content that needs application policy.
Validation gaps to close
The current validator checks file.type.startsWith("image/") and size; it does
not inspect magic bytes, decode the image, validate dimensions, remove
metadata, or restrict every accepted MIME type to the extension map. A client
can spoof multipart metadata. Add server-side content inspection before
treating uploads as hardened.
Production policy should also cover:
- per-user storage quota, total request size, and upload rate limits;
- object ownership, read/delete/replace authorization, and audit data;
- collision-safe prefixes or tenant/user namespaces where useful;
- abandoned upload cleanup, retention, deletion, and backup expectations;
- cache headers, content disposition, CSP/content sniffing, and malware checks;
- cost monitoring and abuse response.
Verify
- A logged-out upload returns 401.
- A valid image below 5 MB returns
/api/storage/<generated-key>and is readable. - Missing files, more than five files, non-image MIME, and oversized files return 4xx.
- Unknown keys return 404; responses preserve content type and ETag.
- Objects remain available after rebuilding/redeploying the Worker.
- Private-object tests reject unauthorized reads after you add access policy.
- Signature/dimension tests reject content that only claims to be an image.
