People often want to keep the resume they sent with each application. File upload is a feature that looks simple and hides several security traps, because you are letting strangers put files on your server. This lesson builds it carefully: only the owner can attach or read a file, only PDF and Word files are accepted, the size is limited, and the name on disk is never chosen by the client.
How an upload travels
A browser sends a file in a request with the content type multipart/form-data: a body made of parts, one for each field, each with its own headers. express.json() does not understand it, so we use multer, a middleware that reads the parts, saves the file and puts the details in req.file. The other form fields end up in req.body.
The upload middleware
// Lesson 36: accept one resume file per request, safely.
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import multer from 'multer';
const here = path.dirname(fileURLToPath(import.meta.url));
export const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(here, '..', 'uploads');
export const MAX_BYTES = 2 * 1024 * 1024; // 2 MB
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const ALLOWED = { '.pdf': 'application/pdf', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' };
const storage = multer.diskStorage({
destination: UPLOAD_DIR,
// Never use the name the client sent as a path. A random name cannot clash and cannot escape the folder.
filename: (req, file, cb) => cb(null, crypto.randomBytes(16).toString('hex') + path.extname(file.originalname).toLowerCase()),
});
export const upload = multer({
storage,
limits: { fileSize: MAX_BYTES, files: 1 },
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (!ALLOWED[ext]) return cb(Object.assign(new Error('Only PDF, DOC or DOCX files are accepted'), { status: 400 }));
cb(null, true);
},
});
// The browser can lie about a file's type, so also look at the first bytes ("magic numbers") of what was saved.
export function looksLikeAllowedFile(filePath) {
const fd = fs.openSync(filePath, 'r');
const head = Buffer.alloc(8);
fs.readSync(fd, head, 0, 8, 0);
fs.closeSync(fd);
const ext = path.extname(filePath).toLowerCase();
if (ext === '.pdf') return head.subarray(0, 5).toString() === '%PDF-';
if (ext === '.docx') return head[0] === 0x50 && head[1] === 0x4b; // "PK": a zip container
if (ext === '.doc') return head[0] === 0xd0 && head[1] === 0xcf; // old Office container
return false;
}
export const removeFile = (storedName) => {
if (!/^[a-f0-9]{32}\.(pdf|doc|docx)$/.test(storedName || '')) return; // only ever delete names we made
fs.rm(path.join(UPLOAD_DIR, storedName), { force: true }, () => {});
};
- Limits:
fileSizeis 2 MB andfilesis 1. Without a size limit, one visitor could fill your disk. - A random file name made with
crypto.randomBytes(16). The client's file name is never used as a path. A name such as../../etc/passwdorevil.jscannot clash with another file or leave the folder. We keep the original name only as text to show the person. - `fileFilter` accepts only the extensions
.pdf,.docand.docx. - The magic bytes check. The extension and the type the browser reports are claims made by the client, and a person can rename any file to
.pdf.looksLikeAllowedFilereads the first bytes of the saved file: a PDF starts with%PDF-, a.docxis a zip and starts withPK, an old.dochas its own signature. A file that does not match is deleted. - `removeFile` only deletes names matching the exact pattern we generate, so it can never be tricked into deleting another file.
The data and the controller
import mongoose from 'mongoose';
export const STATUSES = ['saved', 'applied', 'interview', 'offer', 'rejected'];
// The attached resume. We keep the file on disk and only its details in MongoDB.
const resumeSchema = new mongoose.Schema(
{
originalName: { type: String, required: true }, // what the person called it (shown to them)
storedName: { type: String, required: true }, // random name on disk (never shown, never trusted from the client)
size: { type: Number, required: true },
uploadedAt: { type: Date, default: Date.now },
},
{ _id: false },
);
const jobSchema = new mongoose.Schema(
{
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true },
company: { type: String, required: [true, 'company is required'], trim: true, maxlength: 120 },
role: { type: String, required: [true, 'role is required'], trim: true, maxlength: 120 },
location: { type: String, trim: true, maxlength: 120, default: '' },
status: { type: String, enum: { values: STATUSES, message: 'status must be one of: ' + STATUSES.join(', ') }, default: 'saved' },
salaryNote: { type: String, trim: true, maxlength: 120, default: '' },
notes: { type: String, trim: true, maxlength: 2000, default: '' },
appliedOn: { type: Date },
resume: { type: resumeSchema, default: undefined },
},
{ timestamps: true },
);
jobSchema.index({ user: 1, createdAt: -1 });
jobSchema.index({ user: 1, status: 1, createdAt: -1 });
jobSchema.set('toJSON', {
virtuals: true,
versionKey: false,
transform: (doc, ret) => {
delete ret._id;
delete ret.user;
if (ret.resume) ret.resume = { name: ret.resume.originalName, size: ret.resume.size, uploadedAt: ret.resume.uploadedAt }; // hide storedName
return ret;
},
});
export default mongoose.model('Job', jobSchema);
The file stays on disk and only its details go in MongoDB, in a small embedded resume object. The toJSON transform shows the client only name, size and uploadedAt. It hides storedName, the real file name.
import path from 'node:path';
import Job from '../models/Job.js';
import { HttpError } from '../utils/HttpError.js';
import { UPLOAD_DIR, looksLikeAllowedFile, removeFile } from '../middleware/upload.js';
const ownJob = async (req) => {
const job = await Job.findOne({ _id: req.params.id, user: req.user._id });
if (!job) throw new HttpError(404, 'Job not found');
return job;
};
export const uploadResume = async (req, res) => {
if (!req.file) throw new HttpError(400, 'Choose a file to upload (form field "resume")');
const saved = path.join(UPLOAD_DIR, req.file.filename);
try {
const job = await ownJob(req);
if (!looksLikeAllowedFile(saved)) throw new HttpError(400, 'That file does not look like a real PDF or Word document');
removeFile(job.resume?.storedName); // replace the old file
job.resume = { originalName: path.basename(req.file.originalname).slice(0, 120), storedName: req.file.filename, size: req.file.size };
await job.save();
res.status(201).json(job);
} catch (err) {
removeFile(req.file.filename); // do not leave orphan files behind
throw err;
}
};
export const downloadResume = async (req, res) => {
const job = await ownJob(req);
if (!job.resume) throw new HttpError(404, 'No resume attached');
// download() sets Content-Disposition so the browser saves it with the original name
res.download(path.join(UPLOAD_DIR, job.resume.storedName), job.resume.originalName);
};
export const deleteResume = async (req, res) => {
const job = await ownJob(req);
if (job.resume) {
removeFile(job.resume.storedName);
job.resume = undefined;
await job.save();
}
res.status(204).end();
};
- `ownJob` finds the job with the owner filter from lesson 22. All three routes use it, so another person's job gives a
404, whatever they try. - Upload checks the signature, deletes the old file if there is one, saves the new details, and, if anything fails, removes the new file so that no orphan stays on disk (the
catchblock). - Download uses
res.download(path, originalName), which setsContent-Disposition: attachmentso the browser saves the file with the person's own file name. - Delete removes the file and the details together. Deleting the whole job also removes its file (see
controllers/jobs.jsin this lesson).
import { Router } from 'express';
import { listJobs, getJob, createJob, updateJob, deleteJob } from '../controllers/jobs.js';
import { jobStats } from '../controllers/stats.js';
import { uploadResume, downloadResume, deleteResume } from '../controllers/files.js';
import { checkId } from '../middleware/validate.js';
import { protect } from '../middleware/auth.js';
import { upload } from '../middleware/upload.js';
const router = Router();
router.use(protect);
router.param('id', checkId);
router.get('/', listJobs);
router.get('/stats', jobStats);
router.post('/', createJob);
router.get('/:id', getJob);
router.put('/:id', updateJob);
router.delete('/:id', deleteJob);
// the resume file of one job
router.post('/:id/resume', upload.single('resume'), uploadResume);
router.get('/:id/resume', downloadResume);
router.delete('/:id/resume', deleteResume);
export default router;
upload.single('resume') is route middleware in front of the handler, as lesson 10 described: the string must match the form field name used by the client. Errors from multer, such as a file that is too large, are turned into friendly answers by the error handler (413 for size, 400 for the rest).
The React side
const TOKEN_KEY = 'jt_token';
export const getToken = () => localStorage.getItem(TOKEN_KEY);
export const setToken = (t) => (t ? localStorage.setItem(TOKEN_KEY, t) : localStorage.removeItem(TOKEN_KEY));
export class ApiError extends Error {
constructor(status, message, details) {
super(message);
this.status = status;
this.details = details;
}
}
const authHeader = () => (getToken() ? { Authorization: `Bearer ${getToken()}` } : {});
async function fail(res, path) {
const data = await res.json().catch(() => null);
if (res.status === 401 && getToken() && !path.startsWith('/auth/login')) window.dispatchEvent(new Event('jt:unauthorized'));
throw new ApiError(res.status, data?.message || `Request failed (${res.status})`, data?.details);
}
export async function api(path, { method = 'GET', body, signal } = {}) {
const headers = { ...authHeader() };
let payload;
if (body instanceof FormData) {
payload = body; // the browser adds the multipart Content-Type (with its boundary) itself. Do not set it.
} else if (body !== undefined) {
headers['Content-Type'] = 'application/json';
payload = JSON.stringify(body);
}
const res = await fetch(`/api${path}`, { method, headers, signal, body: payload });
if (res.status === 204) return null;
if (!res.ok) await fail(res, path);
return res.json();
}
// A plain <a href> cannot send our Authorization header, so fetch the file and save it from memory.
export async function downloadFile(path, filename) {
const res = await fetch(`/api${path}`, { headers: authHeader() });
if (!res.ok) await fail(res, path);
const url = URL.createObjectURL(await res.blob());
const a = Object.assign(document.createElement('a'), { href: url, download: filename });
document.body.append(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
api() now accepts a FormData body and does not set the Content-Type header. The browser sets it and adds the *boundary* that separates the parts. If you set it yourself, the boundary is missing and the upload fails. downloadFile exists because a plain <a href> cannot send our Authorization header: it fetches the file with the token and saves it from memory.
import { useState } from 'react';
import { api, downloadFile } from '../api.js';
const MAX = 2 * 1024 * 1024;
// Attach, download or remove the resume you sent for one job.
export default function ResumeFile({ job, onChange }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function pick(e) {
const file = e.target.files[0];
e.target.value = ''; // so choosing the same file again still fires onChange
if (!file) return;
if (file.size > MAX) { setError('That file is larger than 2 MB.'); return; } // check early to save the upload
setBusy(true);
setError('');
try {
const body = new FormData();
body.append('resume', file); // the field name must match upload.single('resume') on the server
onChange(await api(`/jobs/${job.id}/resume`, { method: 'POST', body }));
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
async function remove() {
setBusy(true);
try {
await api(`/jobs/${job.id}/resume`, { method: 'DELETE' });
onChange({ ...job, resume: undefined });
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
return (
<section className="card" aria-labelledby="resume-heading">
<h2 id="resume-heading">Resume you sent</h2>
{job.resume ? (
<p>
<button className="link-btn" onClick={() => downloadFile(`/jobs/${job.id}/resume`, job.resume.name).catch((e) => setError(e.message))}>{job.resume.name}</button>
{' '}<span className="muted">({Math.max(1, Math.round(job.resume.size / 1024))} KB)</span>{' · '}
<button className="link-btn danger" onClick={remove} disabled={busy}>Remove</button>
</p>
) : <p className="muted">Nothing attached yet.</p>}
<div className="field">
<label htmlFor="resume-file">{job.resume ? 'Replace with another file' : 'Attach a PDF or Word file (2 MB at most)'}</label>
<input id="resume-file" type="file" accept=".pdf,.doc,.docx" onChange={pick} disabled={busy} />
</div>
{error && <p className="error" role="alert">{error}</p>}
</section>
);
}
The component checks the size on the client first (a courtesy that saves the upload time), then sends the file with new FormData() and append('resume', file). e.target.value = '' resets the input so that choosing the same file twice still fires a change. The server checks everything again, because the client can be bypassed.
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '../api.js';
import { useAuth } from '../context/AuthContext.jsx';
import JobForm from '../components/JobForm.jsx';
import ResumeFile from '../components/ResumeFile.jsx';
export default function JobDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const { ready } = useAuth();
const [job, setJob] = useState(null);
const [error, setError] = useState('');
const [saved, setSaved] = useState(false);
useEffect(() => {
if (!ready) return undefined;
const controller = new AbortController();
api(`/jobs/${id}`, { signal: controller.signal })
.then(setJob)
.catch((err) => err.name !== 'AbortError' && setError(err.status === 404 ? 'That job was not found.' : err.message));
return () => controller.abort();
}, [id, ready]);
async function save(values) {
const updated = await api(`/jobs/${id}`, { method: 'PUT', body: values });
setJob(updated);
setSaved(true);
}
async function remove() {
if (!window.confirm('Delete this job?')) return;
await api(`/jobs/${id}`, { method: 'DELETE' });
navigate('/'); // go back to the list
}
if (error) return <p className="error" role="alert">{error} <Link to="/">Back to my jobs</Link></p>;
if (!job) return <p className="muted">Loading…</p>;
return (
<>
<h1>{job.role} at {job.company}</h1>
{/* key={job.updatedAt} makes React build a fresh form when the saved job changes */}
<JobForm key={job.updatedAt} initial={job} onSubmit={save} submitLabel="Save changes" resetAfter={false} />
{saved && <p role="status" className="ok">Saved.</p>}
<ResumeFile job={job} onChange={setJob} />
<p className="muted">
<Link to="/">← Back to my jobs</Link>{' · '}
<button className="link-btn danger" onClick={remove}>Delete this job</button>
</p>
</>
);
}
Your task
- Run
npm installinserver(addsmulter), and attach a PDF to a job on its detail page. Download it again. - Rename a text file to
fake.pdfand try to upload it. Read the message. - Try a file larger than 2 MB, and a
.exefile. - Look in
server/uploads. What are the files called? Delete the job and check that its file is gone.
How to check it worked
- A real PDF is accepted and downloads with its original name.
- A fake PDF, a big file and a wrong type are refused with clear messages.
- A second user cannot download the first user's file.
Summary
- Use multer for
multipart/form-data; limit size and count; filter by extension. - Check the file's first bytes, store it under a random name, and serve it only through an owner-checked route.
- In React send a
FormDataand let the browser set the content type.