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

Validating Uploads

Validate before saving

Every upload must be validated before any bytes touch the disk. Three checks: is the file small enough? Is it the right type? Is the filename safe?

File size limits

Set a maximum file size. Reject files that exceed it before they finish uploading:

const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB

export function parseMultipartWithLimits(request: Request): Promise<ParsedUpload> {
  return new Promise(async (resolve, reject) => {
    const contentType = request.headers.get("content-type");
    if (!contentType?.includes("multipart/form-data")) {
      reject(new Error("Expected multipart/form-data"));
      return;
    }

    const busboy = Busboy({
      headers: { "content-type": contentType },
      limits: {
        fileSize: MAX_FILE_SIZE, // busboy enforces this per-file
        files: 1, // only accept one file
      },
    });

    let fileTruncated = false;

    busboy.on("file", (fieldName, stream, info) => {
      stream.on("limit", () => {
        fileTruncated = true;
        // The stream will end — busboy truncates it
      });

      // ... handle file
    });

    busboy.on("close", () => {
      if (fileTruncated) {
        reject(new Error(`File exceeds ${MAX_FILE_SIZE / 1024 / 1024} MB limit`));
        return;
      }
      resolve({ fields, file: fileResult });
    });

    // ... pipe request body
  });
}

Busboy’s limits.fileSize stops reading after the limit is reached. The limit event fires on the file stream, and we reject with an error.

[!WARNING] Always check the Content-Length header too, as a first line of defense. But do not trust it alone — the header can lie. Busboy’s streaming limit is the authoritative check.

MIME type validation

Do not trust the client’s declared MIME type. Validate by checking the file’s magic bytes (the first few bytes that identify the format):

import { fileTypeFromBuffer } from "file-type";

const ALLOWED_TYPES = new Set([
  "image/jpeg",
  "image/png",
  "image/gif",
  "image/webp",
  "application/pdf",
]);

async function validateMimeType(firstChunk: Buffer, declaredType: string): Promise<string | null> {
  // Check magic bytes
  const detected = await fileTypeFromBuffer(firstChunk);
  const actualType = detected?.mime ?? declaredType;

  if (!ALLOWED_TYPES.has(actualType)) {
    return null; // Rejected
  }

  return actualType;
}

[!NOTE] The file-type package reads the first bytes of a file to detect its actual type, regardless of the extension or declared Content-Type. Install it with npm install file-type.

The declared MIME type (image/jpeg) can be faked. The magic bytes cannot — a JPEG always starts with FF D8 FF, a PNG with 89 50 4E 47. Checking magic bytes prevents an attacker from uploading a script disguised as an image.

Filename sanitization

Never use the original filename as-is. It could contain path traversal characters (../../etc/passwd), special characters that break file systems, or be unreasonably long.

import { extname } from "node:path";

function sanitizeFilename(original: string): string {
  // Extract extension
  const ext = extname(original).toLowerCase();

  // Generate a safe, unique name
  return `${crypto.randomUUID()}${ext}`;
}

The simplest approach: ignore the original filename entirely and generate a UUID. Store the original name in the database (for display) and use the UUID on disk.

If you need to preserve the original name for some reason, strip dangerous characters:

function sanitizePreserveName(original: string): string {
  return original
    .replace(/[^a-zA-Z0-9._-]/g, "_") // Replace special chars with underscore
    .replace(/\.{2,}/g, ".") // Collapse multiple dots
    .replace(/^\./, "_") // No leading dot (hidden files)
    .slice(0, 200); // Limit length
}

Putting it together

route.post("/upload", {
  resolve: async (c) => {
    try {
      const { fields, file } = await parseMultipartWithLimits(c.request);

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

      // Validate MIME type (check first chunk)
      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. Allowed: ${[...ALLOWED_TYPES].join(", ")}` },
          { status: 400 },
        );
      }

      // Sanitize filename
      const storedName = sanitizeFilename(file.filename);

      // Next lesson: save to disk
      return Response.json({
        originalName: file.filename,
        storedName,
        mimeType: validType,
        size: buffer.length,
      });
    } catch (err: any) {
      if (err.message?.includes("limit")) {
        return Response.json({ error: err.message }, { status: 413 });
      }
      return Response.json({ error: "Upload failed" }, { status: 500 });
    }
  },
});

Status 413 (Payload Too Large) for files that exceed the size limit.

Exercises

Exercise 1: Upload a file larger than the limit. Verify you get 413.

Exercise 2: Upload a .txt file when only images are allowed. Verify it is rejected.

Exercise 3: Upload a file named ../../etc/passwd.jpg. Verify the stored name is a UUID, not the malicious path.

Exercise 4: Rename a .txt file to .jpg and upload it. Verify the magic byte check catches the mismatch.

Why do we check magic bytes instead of trusting the declared MIME type?

← Multipart Form Data Saving to Disk →

© 2026 hectoday. All rights reserved.