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
diskStorageandfileFilterinstead of accepting the defaults - Double-checking file type: the claimed MIME type and the file extension must both pass
- Generating filenames with
node:cryptoand 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, andimage/webpare accepted; anything else is rejected with400by thefileFilter - The extension must match the MIME type — a
.gifclaiming to beimage/pngis rejected - Files over 2 MB are rejected with
413via Multer'slimits - 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/:filenameviaexpress.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
| Method | Endpoint | Request | Success Response |
|---|---|---|---|
| POST | /api/images | Multipart form, file field image | 201 — metadata plus a public url |
| GET | /api/images | — | 200 — array of metadata, newest first |
| GET | /uploads/:filename | — | 200 — the image bytes, 404 if missing |
| DELETE | /api/images/:id | — | 204 — file and metadata gone, 404 if unknown |
Edge Cases to Handle
| Request | Expected result |
|---|---|
| POST with no file attached | 400 — an image file is required |
POST with the file under field name photo | 400 — unexpected field (Multer's LIMIT_UNEXPECTED_FILE) |
A .txt file uploaded as-is (notes.txt) | 400 — text/plain fails the MIME check |
A .txt file renamed to notes.jpg | Accepted — clients derive the MIME type from the extension, so both checks pass; only magic-byte inspection (stretch goal) catches this |
| A 5 MB photograph | 413 — over the 2 MB limit, nothing stored |
| DELETE with an id that doesn't exist | 404 — and no file is touched |
Two users upload files both named profile.jpg | Both 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:
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:
- If
req.fileis missing (the client sent no file, or an empty form), respond400. - Build the metadata record: a generated id,
req.file.originalname,req.file.size,req.file.mimetype, the storedreq.file.filename, and the current date. - Save it to your store and respond
201with the record plusurl: "/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.
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
# 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
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
- 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
- Promote the metadata store to Mongoose — an
Imagemodel, and pagination on the list endpoint using the Blog API patterns - Add ownership — bolt on the Authentication API so uploads belong to a user and only the owner can delete
- Generate thumbnails with the
sharppackage on upload, and serve/uploads/thumbs/:filenamealongside the original