Skip to main content

Image Upload Service

Difficulty: Intermediate · Builds on: middleware, file uploads with Multer, error handling

You are building the image service behind a profile-avatar feature. Users upload a picture from a form, the server validates it, stores it on disk under a name it generated itself, and hands back a public URL. There is also a list endpoint for browsing what has been uploaded and a delete endpoint for cleaning up.

The point of this exercise is treating uploaded files as untrusted input. A request body is just JSON you can validate field by field — a file upload is a filename you didn't choose, a MIME type the client claims, and a payload of arbitrary size. Every requirement below closes one of those gaps. The mechanics of Multer itself are covered in File Uploads with Multer — read that first if you haven't.

The Upload Flow

Notice that the request can fail inside the middleware, before your route handler ever runs. That's why Multer errors surface in your central error handler, not in the route — more on that in the build guide.

What You'll Practice

  • Configuring Multer's diskStorage and fileFilter instead of accepting the defaults
  • Double-checking file type: the claimed MIME type and the file extension must both pass
  • Generating filenames with node:crypto and why the client's filename must never touch your disk
  • Enforcing a size limit and mapping Multer's error codes to proper HTTP statuses
  • Serving a directory of uploads statically with express.static
  • Keeping a lightweight metadata store in sync with the files on disk

Requirements

  • Only image/jpeg, image/png, and image/webp are accepted; anything else is rejected with 400 by the fileFilter
  • The extension must match the MIME type — a .gif claiming to be image/png is rejected
  • Files over 2 MB are rejected with 413 via Multer's limits
  • The stored filename is random hex from crypto.randomBytes, keeping only the original extension — the client's filename is recorded as metadata but never used as a path
  • Uploaded images are served at /uploads/:filename via express.static
  • Each upload records metadata: an id, the original filename, size in bytes, MIME type, and upload date — a JSON file or an in-memory array is enough (a real database is a stretch goal)
  • Deleting an upload removes both the file on disk and its metadata record; a dangling entry in either direction is a bug

API Contract

MethodEndpointRequestSuccess Response
POST/api/imagesMultipart form, file field image201 — metadata plus a public url
GET/api/images200 — array of metadata, newest first
GET/uploads/:filename200 — the image bytes, 404 if missing
DELETE/api/images/:id204 — file and metadata gone, 404 if unknown

Edge Cases to Handle

RequestExpected result
POST with no file attached400 — an image file is required
POST with the file under field name photo400 — unexpected field (Multer's LIMIT_UNEXPECTED_FILE)
A .txt file uploaded as-is (notes.txt)400text/plain fails the MIME check
A .txt file renamed to notes.jpgAccepted — clients derive the MIME type from the extension, so both checks pass; only magic-byte inspection (stretch goal) catches this
A 5 MB photograph413 — over the 2 MB limit, nothing stored
DELETE with an id that doesn't exist404 — and no file is touched
Two users upload files both named profile.jpgBoth succeed — random filenames never collide or overwrite

Build Guide

Step 1 — Project setup

npm install express multer, an uploads/ directory created at startup with fs.mkdirSync(path, { recursive: true }), and the usual skeleton with a central error handler — same shape as the Task Manager. Mount express.static("uploads") under the /uploads path so stored images are publicly reachable. You do not need express.json() for the upload route — multipart bodies are Multer's job — but keep it for the rest of the API.

Step 2 — The Multer configuration

This is the heart of the exercise and the only new code you need:

middleware/upload.js
import multer from "multer";
import crypto from "node:crypto";
import path from "node:path";

const ALLOWED = {
"image/jpeg": [".jpg", ".jpeg"],
"image/png": [".png"],
"image/webp": [".webp"],
};

const storage = multer.diskStorage({
destination: "uploads/",
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, crypto.randomBytes(16).toString("hex") + ext);
},
});

const fileFilter = (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const validExts = ALLOWED[file.mimetype];
if (validExts && validExts.includes(ext)) return cb(null, true);
cb(new Error("Only jpeg, png, or webp images are allowed"));
};

export const upload = multer({
storage,
fileFilter,
limits: { fileSize: 2 * 1024 * 1024 },
});

Why the random filename is non-negotiable: file.originalname is attacker-controlled text. A name like ../../server.js could escape your uploads directory and overwrite source code (path traversal), and two honest users uploading profile.jpg would silently clobber each other. Generating the name yourself kills both problems at once — the only thing you keep from the client is the extension, and only after the fileFilter verified it matches an allowed MIME type.

One honest caveat to note in your code: the MIME type is also client-supplied. Checking it stops accidents and lazy attackers, not determined ones — verifying the actual file bytes is a stretch goal.

Step 3 — The upload route

Mount upload.single("image") on POST /api/images, then in the handler:

  1. If req.file is missing (the client sent no file, or an empty form), respond 400.
  2. Build the metadata record: a generated id, req.file.originalname, req.file.size, req.file.mimetype, the stored req.file.filename, and the current date.
  3. Save it to your store and respond 201 with the record plus url: "/uploads/" + req.file.filename.

Step 4 — The metadata store

Keep it deliberately small: an in-memory array, or a metadata.json file you read at startup and rewrite on every change. Wrap it in three functions — add, list, removeById — so swapping in Mongoose later touches one file. This is the same "store behind a tiny interface" idea the other exercises use for models.

Step 5 — List and delete

GET /api/images returns the store sorted by upload date, newest first. DELETE /api/images/:id looks up the record (unknown id is a 404), deletes the file with fs.promises.unlink, removes the record, and responds 204. Delete the file first — if unlink fails you still have the record and the operation is safely retryable, whereas the opposite order can leave an orphaned file no endpoint can reach.

Step 6 — Error handling

Multer rejections arrive at your central error handler as errors, not as req.file being empty. Check err instanceof multer.MulterError and map the codes: LIMIT_FILE_SIZE becomes 413, LIMIT_UNEXPECTED_FILE becomes 400. The custom error thrown by your fileFilter should also map to 400. Everything else falls through to the generic 500 — the same pattern as the error handling doc.

Test It with curl

Multipart uploads use -F instead of -d — curl sets the multipart/form-data content type and boundary for you.

Happy path — Hafsa uploads her avatar
curl -s -F "image=@profile.jpg" http://localhost:3000/api/images
# expect 201 with a url like /uploads/9f2c4e...a1.jpg

# the returned URL should serve the actual image
curl -s -o downloaded.jpg http://localhost:3000/uploads/PASTE_FILENAME_HERE
Failure paths
# a plain text file -> 400, curl sends text/plain for .txt
echo "definitely not an image" > notes.txt
curl -s -F "image=@notes.txt" http://localhost:3000/api/images

# the same file renamed to .jpg sails through -> 201.
# curl (and browsers) set the Content-Type from the file NAME, so the
# MIME check never sees text/plain. Catching liars needs magic-byte
# inspection - see the stretch goals.
cp notes.txt fake.jpg
curl -s -F "image=@fake.jpg" http://localhost:3000/api/images

# wrong field name -> 400 unexpected field
curl -s -F "photo=@profile.jpg" http://localhost:3000/api/images

# a 3 MB file -> 413
dd if=/dev/zero of=big.jpg bs=1024 count=3072
curl -s -F "image=@big.jpg" http://localhost:3000/api/images

# no file at all -> 400
curl -s -X POST http://localhost:3000/api/images
List and delete
curl -s http://localhost:3000/api/images

curl -s -X DELETE http://localhost:3000/api/images/PASTE_ID_HERE
# repeat the same delete -> 404, and the /uploads URL should now 404 too

Stretch Goals

  1. Verify the real file bytes — read the first few bytes (magic numbers) and confirm they match the claimed type, so a renamed file fails even with a spoofed MIME header
  2. Promote the metadata store to Mongoose — an Image model, and pagination on the list endpoint using the Blog API patterns
  3. Add ownership — bolt on the Authentication API so uploads belong to a user and only the owner can delete
  4. Generate thumbnails with the sharp package on upload, and serve /uploads/thumbs/:filename alongside the original