Skip to content

File upload

rocambille edited this page Sep 10, 2026 · 5 revisions

Summary: StartER handles multipart file uploads through multer, configured by a reusable factory. Uploaded files live outside the document root and are served through Express static middleware. CSRF protection and contract tests apply to upload routes without special casing.

What you'll learn:

  • Add a file upload route to an Express module
  • Use createUploader() and deleteUploadedFile() from the upload helper
  • Integrate file uploads in the React frontend via MeContext
  • Write contract tests for multipart requests

How file uploads work

An upload request goes through five steps:

  1. Client submission: the React frontend sends a POST request with a FormData body and an X-CSRF-Token header.
  2. Multipart parsing: the request passes through multer middleware, which validates the MIME type, enforces the size limit, and writes the file to disk with a UUID filename.
  3. Action handler: the action reads req.file.filename, removes any previous file, and persists the new relative URL to the database.
  4. JSON response: the endpoint returns 201 Created with the new file URL.
  5. Static serving: Express resolves /uploads/... requests against ./data/uploads/ via express.static().

API reference

Method Route Status Body
POST /api/users/me/avatar 201 { avatar_url: string }
DELETE /api/users/me/avatar 204 (empty)

Accepted types: image/jpeg, image/png, image/webp, image/gif. Maximum size: 2 MB.

The upload helper (src/express/helpers/upload.ts)

Using the helper

createUploader(options) from src/express/helpers/upload.ts returns a configured multer.Multer instance ready to use with Express. It follows the same factory pattern as createValidator() and createParamConverter().

import { createUploader } from "../../helpers/upload";

const avatarUploader = createUploader({
  subfolder: "avatars",          // required: sub-folder under ./data/uploads/
  maxSizeBytes: 2 * 1024 * 1024, // optional, this is the default value (2 MB)
  allowedMimeTypes: ["image/jpeg", "image/png", "image/webp", "image/gif"], // optional, this is the default value
});

deleteUploadedFile(relativeUrl?: string | null) removes a file from disk. It only acts on URLs starting with /uploads/ and does nothing if the file is absent, so it is always safe to call even when no file has been uploaded yet:

import { deleteUploadedFile } from "../../helpers/upload";

deleteUploadedFile(req.me.avatar_url); // safe when avatar_url is null

Internal implementation

See src/express/helpers/upload.ts and multer documentation.

Registering routes and action handlers

The uploader is instantiated in the route file. It is an HTTP infrastructure concern rather than a validation rule, so it belongs alongside the route definitions, not in the validator:

import { createUploader } from "../../helpers/upload";

const avatarUploader = createUploader({
  subfolder: "avatars",
  maxSizeBytes: 2 * 1024 * 1024,
});

router
  .route("/api/users/me/avatar")
  .all(authActions.verifyAccessToken)
  .post(avatarUploader.single("avatar"), userActions.uploadMeAvatar)
  .delete(userActions.deleteMeAvatar);

The action handlers delegate file deletion to deleteUploadedFile and URL persistence to the repository:

const uploadMeAvatar: RequestHandler = (req, res) => {
  if (!req.file) {
    res.status(400).json({ message: "No file attached" });
    return;
  }

  const oldAvatarUrl = req.me.avatar_url;
  const newAvatarUrl = `/uploads/avatars/${req.file.filename}`;

  userRepository.updateAvatar(req.me.id, newAvatarUrl);
  deleteUploadedFile(oldAvatarUrl);

  res.status(201).json({ avatar_url: newAvatarUrl });
};

const deleteMeAvatar: RequestHandler = (req, res) => {
  const oldAvatarUrl = req.me.avatar_url;

  userRepository.updateAvatar(req.me.id, null);
  deleteUploadedFile(oldAvatarUrl);

  res.sendStatus(204);
};

Important

The string passed to single() is the expected multipart field name. It must match the key used in formData.append() on the client and the name attribute on the file input. A mismatch silently leaves req.file undefined without throwing an error.

Note

CSRF tokens are sent in the X-CSRF-Token header by mutate(), not in the form body. The CSRF middleware validates the header before multer processes the multipart body, so upload routes require no special CSRF handling.

Static asset serving

Uploaded files are served from server.ts:

app.use("/uploads", express.static("./data/uploads"));

A request to /uploads/avatars/550e8400-e29b-...webp maps to the local file ./data/uploads/avatars/550e8400-e29b-...webp.

Security

Path traversal prevention

Original browser-supplied filenames are never used. Characters like ../ in a filename could overwrite arbitrary files on disk. crypto.randomUUID() produces unique, unpredictable filenames that cannot escape the upload directory.

File extension handling

multer.diskStorage() strips extensions by default. Without an extension, express.static() cannot determine the MIME type and falls back to application/octet-stream. With X-Content-Type-Options: nosniff set by Helmet, browsers refuse to display images sent with that type. The MIME_TO_EXTENSION map ensures each stored file has the correct extension.

MIME type validation

File extensions are trivially renamed. The fileFilter callback verifies the mimetype field reported during multipart parsing against an explicit allowlist. Rejected files are never written to disk.

Note

file.mimetype comes from the Content-Type attribute declared in the multipart section by the HTTP client, not from inspecting the file's actual bytes. A crafted request can declare any MIME type. For the current setup (Node.js static serving with X-Content-Type-Options: nosniff), the practical risk is low: stored files are served with extension-based types and browsers will not re-interpret them. If you add file types that carry execution risk when rendered inline (SVG, HTML, PDF), magic bytes validation via a library such as file-type becomes necessary.

Storage location

Files go to ./data/uploads/ (excluded from Git) rather than inside src/. This prevents any uploaded file from being loaded as application code.

Frontend integration with MeContext

MeContext.tsx exposes updateMeAvatar(fileOrNull). Passing a File uploads a new avatar; passing null deletes it.

AvatarUploadForm validates the selected file on the client using Zod before attempting any network request, giving immediate inline feedback:

import { useId, useState } from "react";
import { z } from "zod";
import type { $ZodIssue as ZodIssue } from "zod/v4/core";
import { FormError, hasError } from "../FormError";
import { useMe } from "./MeContext";

const schema = z.object({
  avatar: z
    .file()
    .min(1, "Image is required")
    .max(2_000_000, "Image is too heavy")
    .mime(
      ["image/jpeg", "image/png", "image/webp", "image/gif"],
      "Invalid file type",
    ),
});

function AvatarUploadForm() {
  const { user, updateMeAvatar } = useMe();
  const fileInputId = useId();

  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const [errors, setErrors] = useState<ZodIssue[]>([]);

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const parsed = schema.safeParse({ avatar: e.target.files?.[0] });

    if (!parsed.success) {
      setErrors(parsed.error.issues);
      return;
    }

    setErrors([]);
    setSelectedFile(parsed.data.avatar);
  };

  const handleUploadAction = async () => {
    await updateMeAvatar(selectedFile);
    setSelectedFile(null);
  };

  const handleDeleteAction = async () => {
    await updateMeAvatar(null);
  };

  return (
    <form action={handleUploadAction}>
      <FormError
        issues={errors}
        name="avatar"
        id={`${fileInputId}-error`}
        aria-invalid={hasError(errors, "avatar")}
      />

      <label htmlFor={fileInputId}>Choose a new image</label>
      <input
        id={fileInputId}
        name="avatar"
        type="file"
        accept="image/jpeg,image/png,image/webp,image/gif"
        onChange={handleFileChange}
      />

      <button type="submit" disabled={!selectedFile}>
        Save Avatar
      </button>
      <button
        type="submit"
        formAction={handleDeleteAction}
        disabled={!user?.avatar_url || selectedFile != null}
      >
        Remove Avatar
      </button>
    </form>
  );
}

mutate() in src/react/helpers/mutate.ts detects FormData payloads automatically. It omits Content-Type so the browser can set the multipart boundary, while still attaching the required X-CSRF-Token header.

Contract tests (tests/contracts/users.ts)

Upload routes use the same contract system as all other endpoints. See Contracts and verification for the testing guide.

The attach field maps to supertest's .attach() method. dummyImageBuffer is a Buffer holding a minimal base64-encoded WEBP, declared once at the top of the contract file:

const dummyImageBuffer = Buffer.from(
  "UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA=",
  "base64",
);
upload_me_avatar: {
  method: "post",
  path: "/api/users/me/avatar",
  cases: {
    as_me: {
      request: {
        jwtPayload: { sub: standardUser.id },
        attach: {
          name: "avatar",
          file: dummyImageBuffer,
          options: { filename: "avatar.webp", contentType: "image/webp" },
        },
      },
      response: {
        status: 201,
        body: {
          avatar_url: expect.stringMatching(/^\/uploads\/avatars\/.*\.webp$/),
        },
      },
    },
    invalid_file_type: {
      request: {
        jwtPayload: { sub: standardUser.id },
        attach: {
          name: "avatar",
          file: Buffer.from("plain text"),
          options: { filename: "doc.txt", contentType: "text/plain" },
        },
      },
      response: {
        status: 400,
        body: { message: expect.stringMatching(/Invalid file type/i) },
      },
    },
    no_attached_file: {
      request: { jwtPayload: { sub: standardUser.id } },
      response: {
        status: 400,
        body: { message: expect.stringMatching(/No file attached/i) },
      },
    },
    unauthorized: {
      request: { jwtPayload: null },
      response: { status: 401, body: {} },
    },
  },
},
delete_me_avatar: {
  method: "delete",
  path: "/api/users/me/avatar",
  cases: {
    as_me: {
      request: { jwtPayload: { sub: standardUser.id } },
      response: { status: 204, body: {} },
    },
    unauthorized: {
      request: { jwtPayload: null },
      response: { status: 401, body: {} },
    },
  },
},

See also

Clone this wiki locally