Guides
Upload files
Accepting a file is easy. Accepting a file safely — checking that the bytes are what they claim, and not letting one user read another user's files — is the part worth copying.
The upload route
import { API, createAPI } from "yatta/api";import { storage } from "../func/storage"; const api = createAPI(); api.post(async (ctx) => { const form = await ctx.formData(); const file = form.get("file"); if (!(file instanceof File)) { return API.json({ error: "No file uploaded" }, { status: 400 }); } const result = await storage .file(`avatars/${crypto.randomUUID()}.png`) .from(file) .maxSize("2MB") // rejected before anything is written .verifyMagic() // inspects the bytes, not the declared type .save(); return API.json({ key: result.key, url: result.url }, { status: 201 });}); export default api;Validate the real bytes
A browser can be told a file is a PNG. verifyMagic() reads the file signature instead of trusting file.type, which stops an attacker uploading a script under an image name.
PNG → 89 50 4E 47JPEG → FF D8 FFGIF → "GIF8"PDF → "%PDF"ZIP → "PK\x03\x04"Warning
verifyMagic() checks the type, not the intent. Pair it with maxSize() and store user uploads under a generated name rather than the one they supplied.Serve it back
serve handles range requests, so a video or large PDF can be seeked and resumed.
import { API, createAPI } from "yatta/api";import { storage } from "../../func/storage"; const api = createAPI(); api.get(async (ctx) => { return storage.file(ctx.params.key).serve(ctx.req);}); export default api;Signed URLs
For private files, hand out a time-limited link instead of a public one.
const url = await storage.disk("local").signedUrl("reports/q1.pdf", { expiresIn: "2h",}); return API.json({ url });User-scoped keys
The security question is not “can they fetch a file” but “can they fetch someone else's file”. Scope the key path by user id, then check ownership.
api.get(async (ctx) => { const session = await getSession(ctx.req); if (!session) return API.json({ error: "Unauthorized" }, { status: 401 }); const key = ctx.params.key; // "user-42/avatar.png" // Reject anything outside the caller's own prefix. const expected = `user-${session.user.id}/`; if (!key.startsWith(expected)) { return API.json({ error: "Forbidden" }, { status: 403 }); } // Defence in depth: even a prefix guess cannot escape via "..". if (key.includes("..")) { return API.json({ error: "Forbidden" }, { status: 403 }); } return storage.file(key).serve(ctx.req);});Note
Key sanitisation runs inside the storage layer too — path traversal is rejected there — but authorising in the route is what actually matters. The check belongs where you can read the session.
Using S3 instead
Switch the default disk and nothing else changes: the same routes serve the same keys.
export const storage = createStorage({ default: "s3", // ← was "local" disks: { local: { driver: "local", baseDir: "./storage/uploads" }, s3: { driver: "s3", bucket: process.env.S3_BUCKET!, endpoint: process.env.S3_ENDPOINT, // R2 / MinIO / S3 accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, }, },});Warning
Local disk is per-machine. If you run more than one container or node, local storage will serve different bytes on each. This is the one setting that must change before scaling out.
Limits and clean-up
- Always set
maxSize(). Without it a single request can exhaust memory. - Use a generated filename — never the one the user uploaded.
- Serve images and PDFs from a separate disk if you need stricter content rules than uploads.
- Delete abandoned uploads with a cron job; Jobs makes that a few lines.