--- title: "Upload a file" description: "Add private Todo attachments with an upload component, server-side ownership checks, file limits, and memory-storage tests." url: "https://www.beignetjs.com/guides/upload-file" markdown_url: "https://www.beignetjs.com/markdown/guides/upload-file.md" framework_version: "0.0.65" documentation_scope: "current-checkout" source_revision: "75f3e5c03f08daca9bba10940ebc777570febfd0" source_modified: true --- # Upload a file Add a private attachment uploader to each Todo. Accept one PDF or plain-text file up to 5 MiB, verify that the signed-in user owns the Todo, and test that another user cannot upload to it. ## Before you start Use the Next.js and SQLite app from the [quickstart](https://www.beignetjs.com/markdown/getting-started.md). Run commands from its root with Bun. The generated local storage provider writes files under `storage/app`; this walkthrough needs no cloud storage account. The result is a working upload flow. It stores the file and returns its object key. Listing attachments, downloading them, and removing them with a Todo need application-owned records and authorization; they are not added by this guide. ## Generate the upload workflow ```bash bun beignet make upload todos.attachment --ui bun install ``` The generator adds an upload definition, a browser-safe manifest, an upload route, storage wiring, and `AttachmentUploader`. Keep the generated registrations and the manifest's limits: `application/pdf` or `text/plain`, one file, 5 MiB maximum, and private visibility. If your app already has a storage provider, inspect the existing configuration before changing it. The [Uploads reference](https://www.beignetjs.com/markdown/uploads.md) explains the generated files and alternative delivery modes. ## Check ownership on the server The browser supplies a Todo ID. Treat it as a lookup key, then check ownership against the stored Todo before accepting the file. Replace the generated definition: ```typescript // features/todos/uploads/attachment.ts import { z } from "zod"; import { defineUpload } from "@/lib/uploads"; import { attachmentUploadFile } from "../attachment-upload-manifest"; export const AttachmentUploadMetadataSchema = z.object({ resourceId: z.uuid(), }); export type AttachmentUploadMetadata = z.infer; export const AttachmentUpload = defineUpload("todos.attachment", { metadata: AttachmentUploadMetadataSchema, file: attachmentUploadFile, async authorize({ ctx, metadata }) { if (ctx.actor.type !== "user") return false; const todo = await ctx.ports.todos.findById(metadata.resourceId); return todo?.userId === ctx.actor.id; }, key({ metadata, uploadId }) { return "todos/" + metadata.resourceId + "/" + uploadId; }, storageMetadata({ metadata }) { return { resourceId: metadata.resourceId }; }, async onComplete({ ctx, metadata, files }) { const objectKeys = files.map((file) => file.key); ctx.ports.logger.info("Todo attachment uploaded", { resourceId: metadata.resourceId, objectKeys, }); return { objectKeys }; }, }); ``` The object key uses the validated Todo ID and a generated upload ID, not a filename supplied by the browser. Returning `{ objectKeys }` keeps the result compatible with the generated uploader. ## Add the uploader to each Todo Import `AttachmentUploader` and give it the row's Todo ID. Allow the row to wrap so the uploader sits beneath the existing controls: ```diff --- a/features/todos/components/todo-app.tsx +++ b/features/todos/components/todo-app.tsx @@ -18,4 +18,5 @@ } from "@/features/todos/contracts"; import { cn } from "@/lib/utils"; +import { AttachmentUploader } from "./attachment-uploader"; const createTodoForm = rhf(createTodo); @@ -101,5 +102,5 @@