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

yatta/backend/upload.tsts
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.

what it checkstext
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.

yatta/backend/files/[key].tsts
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.

download linkts
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.

yatta/backend/files/[key].tsts
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.

yatta/func/storage.tsts
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.