hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses File Uploads and Storage with @hectoday/http

The Basics

  • Why File Uploads Are Hard
  • Project Setup

Receiving Files

  • Multipart Form Data
  • Validating Uploads
  • Saving to Disk

Serving Files

  • Serving Static Files
  • Range Requests and Resumable Downloads
  • Access Control on Files

Production Patterns

  • Streaming Uploads
  • Image Processing
  • Upload Progress and Cancellation

Cloud Storage

  • Presigned URLs
  • Moving from Local to Cloud

Putting It All Together

  • File Upload Checklist
  • Capstone: File Sharing API

Saving to Disk

Writing the file

After validation passes, write the file to the uploads directory and record metadata in the database:

// src/storage.ts — add save function
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import db from "./db.js";
import { UPLOAD_DIR } from "./storage.js";

export function saveFile(
  userId: string,
  originalName: string,
  storedName: string,
  mimeType: string,
  buffer: Buffer,
): { id: string; size: number } {
  const filePath = join(UPLOAD_DIR, storedName);

  // Write to disk
  writeFileSync(filePath, buffer);

  // Record in database
  const id = crypto.randomUUID();
  const size = buffer.length;

  db.prepare(
    "INSERT INTO files (id, user_id, original_name, stored_name, mime_type, size) VALUES (?, ?, ?, ?, ?, ?)",
  ).run(id, userId, originalName, storedName, mimeType, size);

  return { id, size };
}

The complete upload route

import { saveFile } from "../storage.js";

route.post("/files", {
  resolve: async (c) => {
    // Authentication (simplified — use your auth course patterns)
    const userId = "user-alice"; // Replace with real auth

    try {
      const { fields, file } = await parseMultipartWithLimits(c.request);

      if (!file) {
        return Response.json({ error: "No file uploaded" }, { status: 400 });
      }

      // Read and validate
      const chunks: Buffer[] = [];
      for await (const chunk of file.stream) {
        chunks.push(Buffer.from(chunk));
      }
      const buffer = Buffer.concat(chunks);

      const validType = await validateMimeType(buffer, file.mimeType);
      if (!validType) {
        return Response.json({ error: "File type not allowed" }, { status: 400 });
      }

      const storedName = sanitizeFilename(file.filename);
      const { id, size } = saveFile(userId, file.filename, storedName, validType, buffer);

      return Response.json(
        {
          id,
          name: file.filename,
          mimeType: validType,
          size,
          url: `/files/${id}`,
        },
        { status: 201 },
      );
    } catch (err: any) {
      if (err.message?.includes("limit")) {
        return Response.json({ error: err.message }, { status: 413 });
      }
      return Response.json({ error: "Upload failed" }, { status: 500 });
    }
  },
});

Listing and deleting files

// List user's files
route.get("/files", {
  resolve: (c) => {
    const userId = "user-alice"; // Replace with real auth
    const files = db.prepare(
      "SELECT id, original_name, mime_type, size, is_public, created_at FROM files WHERE user_id = ? ORDER BY created_at DESC"
    ).all(userId);
    return Response.json({ data: files });
  },
}),

// Delete a file
route.delete("/files/:id", {
  resolve: (c) => {
    const userId = "user-alice";
    const file = db.prepare("SELECT stored_name FROM files WHERE id = ? AND user_id = ?")
      .get(c.params.id, userId) as { stored_name: string } | undefined;

    if (!file) return Response.json({ error: "File not found" }, { status: 404 });

    // Delete from disk
    const filePath = join(UPLOAD_DIR, file.stored_name);
    try { unlinkSync(filePath); } catch { /* file might already be gone */ }

    // Delete from database
    db.prepare("DELETE FROM files WHERE id = ?").run(c.params.id);

    return new Response(null, { status: 204 });
  },
}),

Try it

# Upload a file
curl -X POST http://localhost:3000/files \
  -F "[email protected]"
# { "id": "...", "name": "photo.jpg", "mimeType": "image/jpeg", "size": 12345, "url": "/files/..." }

# List files
curl http://localhost:3000/files

# Delete a file
curl -X DELETE http://localhost:3000/files/FILE_ID

Why stored_name is not the file ID

The file ID is a UUID used in URLs and the API. The stored name is a UUID with an extension, used on disk. They are separate because:

  • The API ID might change format (from UUID to something shorter)
  • The stored name includes the extension (needed for MIME type detection on serve)
  • Separating them avoids coupling the API surface to the storage format

Exercises

Exercise 1: Upload a file. Check the uploads/ directory. Verify the file exists with the UUID-based name.

Exercise 2: Upload, then delete. Verify the file is removed from both the database and the disk.

Exercise 3: Upload two files with the same original name. Verify they get different stored names (no collisions).

Why do we delete the file from disk before deleting the database record?

← Validating Uploads Serving Static Files →

© 2026 hectoday. All rights reserved.