Chapter 15: File Uploads, Static Files & Cloud Storage: Multer, Magic Bytes, Sharp, and S3 Presigned URLs
Chapter 15: File Uploads, Static Files & Cloud Storage: Multer, Magic Bytes, Sharp, and S3 Presigned URLs
Handling file uploads and serving static media are fundamental requirements for production web applications—whether you are storing user profile avatars, processing scanned PDF invoices, or streaming high-resolution videos.
However, handling files incorrectly is one of the quickest ways to crash a Node.js process. Inexperienced implementations often buffer 500MB video uploads into the V8 JavaScript heap, trust unvalidated client file extensions, expose the server to directory traversal attacks, or choke application bandwidth by routing gigabytes of binary data directly through the primary Node.js process.
In this chapter, you will learn the end-to-end architecture of production file management. We will explore how multipart form data streams across TCP sockets, examine Multer’s internal storage strategies (memoryStorage vs. diskStorage), implement file validation using magic bytes (file signatures), process images with sharp via the libuv thread pool, configure secure local static file serving, and master the industry-standard Presigned S3/R2 URL architecture for zero-server-overhead uploads.
1. The Architecture of File Uploads: Multipart Form Data
When a client submits a standard JSON payload (Content-Type: application/json), the body is a contiguous UTF-8 string that Node.js can easily parse into a JavaScript object.
However, binary files—such as images, compressed archives, and PDFs—cannot simply be concatenated into JSON without encoding overhead. While you could encode a file as a Base64 string inside JSON, Base64 introduces a ~33% bandwidth penalty and forces the server to buffer the entire encoded string in memory before decoding it.
How multipart/form-data Works
To transmit binary files efficiently alongside textual metadata, HTTP specifies the multipart/form-data content type (RFC 7578).
When the browser sends a multipart request, it generates a unique random string called a boundary delimiter and includes it in the Content-Type header:
POST /api/v1/users/avatar HTTP/1.1
Host: api.consistcode.com
Content-Type: multipart/form-data; boundary=---------------------------974767299852498929531610575
Content-Length: 1048576
-----------------------------974767299852498929531610575
Content-Disposition: form-data; name="caption"
My Profile Picture
-----------------------------974767299852498929531610575
Content-Disposition: form-data; name="avatar"; filename="photo.jpg"
Content-Type: image/jpeg
<binary stream of raw JPEG bytes>
-----------------------------974767299852498929531610575--
The Anatomy of a Multipart Stream
- Boundary Parameter: The client announces the delimiter string in the header (
boundary=...). - Part Headers: Each section begins with
--<boundary>followed by headers likeContent-Disposition(which holds the form field name and original filename) andContent-Type. - Double CRLF (
\r\n\r\n): Signals the boundary between part metadata and the raw payload. - Binary Payload: The stream of raw bytes.
- Closing Boundary: The payload concludes with
--<boundary>--.
The Danger: Why express.json() Cannot Parse Multipart Data
The default express.json() and express.urlencoded() body parsers expect a single stream of text. They do not know how to parse boundary delimiters or stream raw binary streams to disk. If you send a multipart/form-data request to an endpoint with only express.json(), req.body will remain {}.
Furthermore, if you attempt to read the entire request into a single Buffer using fs.readFile() or a custom buffer accumulator, you run a major risk of exhausting the Node.js process heap:
Incoming 1GB File Upload
│
▼
┌────────────────────────────────────────────────────────┐
│ Node.js V8 Heap (Default Limit: ~1.4GB - 2GB RAM) │
│ │
│ [Buffer: 100MB] [Buffer: 300MB] [Buffer: 800MB] ... │
│ │
│ 💥 FATAL ERROR: Reached heap limit Allocation failed │
│ - JavaScript heap out of memory │
└────────────────────────────────────────────────────────┘
A robust backend must stream multipart chunks incrementally as they arrive off the network socket.
2. Multer Internals: MemoryStorage vs. DiskStorage
In the Node.js ecosystem, Multer is the standard middleware for handling multipart/form-data. Under the hood, Multer relies on busboy, a high-performance streaming parser that scans incoming network chunks for boundary delimiters without buffering the whole file in memory.
Multer provides two fundamental storage engines: multer.memoryStorage() and multer.diskStorage(). Choosing the wrong one can degrade performance or crash your server.
Incoming Multipart Stream
│
▼
Multer / Busboy
(Stream Parser)
│
┌───────────────────┴───────────────────┐
▼ ▼
multer.memoryStorage() multer.diskStorage()
┌───────────────────────────────────┐ ┌───────────────────────────────────┐
│ Stores file as Buffer in RAM │ │ Streams chunks directly to disk │
│ (file.buffer) │ │ (file.path) │
│ │ │ │
│ ✅ Zero disk I/O overhead │ │ ✅ Minimal V8 heap usage │
│ ✅ Instant in-memory transforms │ │ ✅ Safely handles large files │
│ ❌ Exposes server to OOM crashes │ │ ❌ Requires disk cleanup on error │
│ ⚠️ Use ONLY for files < 5MB │ │ ⚠️ Requires local file management│
└───────────────────────────────────┘ └───────────────────────────────────┘
1. multer.memoryStorage()
With memoryStorage(), Multer buffers each uploaded file into an in-memory Node.js Buffer attached to req.file.buffer.
import multer from 'multer';
// Files stored in V8 Heap memory as Buffers
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 5 * 1024 * 1024, // 5MB maximum file size
files: 1, // Allow at most 1 file per request
},
});
- Pros: Blazingly fast. No temporary files written to the server's hard drive. Perfect for small avatars or documents that are immediately transformed (e.g., resized by
sharp) and uploaded to S3. - Cons: Every uploaded byte resides in RAM. If 100 users simultaneously upload a 5MB image, your server consumes 500MB of RAM solely for inflight uploads. If limits are misconfigured, a single attacker uploading a large file can trigger an Out-Of-Memory (OOM) crash.
2. multer.diskStorage()
With diskStorage(), Multer streams incoming data directly from the network socket to a temporary directory on the server's filesystem (/tmp or a custom directory).
import path from 'node:path';
import crypto from 'node:crypto';
import multer from 'multer';
const diskStorage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, path.join(process.cwd(), 'uploads', 'temp'));
},
filename: (req, file, cb) => {
// Generate an unguessable, collision-resistant UUID filename
const uniqueSuffix = crypto.randomUUID();
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${uniqueSuffix}${ext}`);
},
});
export const uploadDisk = multer({
storage: diskStorage,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB
},
});
- Pros: Minimal V8 heap memory footprint. Node.js only buffers a few 16KB/64KB chunks in memory while piping data to disk.
- Cons: Incurs disk I/O overhead. Crucially, your application is responsible for cleaning up temporary files. If an upload fails validation, or an exception occurs downstream, the file will remain on disk forever unless an error handler unlinks it.
3. Production File Security: The Magic Bytes Principle
One of the most dangerous vulnerabilities in file handling is trusting client-supplied file metadata.
When a user uploads a file, the browser sends two pieces of metadata:
file.originalname(e.g.,invoice.pdf)file.mimetype(e.g.,application/pdf)
Both values are completely controlled by the client. An attacker can take a malicious executable (exploit.exe or webshell.php), rename it to avatar.png, and send Content-Type: image/png in the HTTP header. If your server verifies file types using only extension checks or req.file.mimetype, the attack succeeds.
Attacker sends:
Original Name: exploit.exe ──► Renamed to: exploit.png
HTTP Header: Content-Type: image/png
Naive Server Check:
if (file.mimetype === 'image/png') { // ❌ PASSES!
saveToDisk(file);
}
The Solution: Magic Bytes (File Signatures)
Virtually every binary file format begins with a sequence of fixed identifier bytes known as magic bytes or file signatures. Magic numbers cannot be spoofed simply by renaming the file extension.
| File Type | Expected Extension | Magic Bytes (Hex) | ASCII Representation |
|---|---|---|---|
| JPEG | .jpg, .jpeg |
FF D8 FF |
ÿØÿ |
| PNG | .png |
89 50 4E 47 0D 0A 1A 0A |
.PNG.... |
| GIF | .gif |
47 49 46 38 37 61 or 47 49 46 38 39 61 |
GIF87a / GIF89a |
.pdf |
25 50 44 46 |
%PDF |
|
| ZIP (including docx/xlsx) | .zip |
50 4B 03 04 |
PK.. |
| WebP | .webp |
52 49 46 46 ... 57 45 42 50 |
RIFF....WEBP |
Implementing Magic Bytes Validation in Node.js
We can inspect the first few bytes of the incoming Buffer using native Node.js Buffer comparison methods without relying on external dependencies for core types:
// src/common/utils/file-validator.ts
export interface AllowedFileType {
mime: string;
ext: string;
}
export class FileSecurityValidator {
/**
* Verifies the actual file signature (magic bytes) against expected formats.
*/
public static validateMagicBytes(buffer: Buffer): AllowedFileType | null {
if (!buffer || buffer.length < 8) {
return null;
}
// PNG: 89 50 4E 47 0D 0A 1A 0A
if (
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47 &&
buffer[4] === 0x0d &&
buffer[5] === 0x0a &&
buffer[6] === 0x1a &&
buffer[7] === 0x0a
) {
return { mime: 'image/png', ext: 'png' };
}
// JPEG: FF D8 FF
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return { mime: 'image/jpeg', ext: 'jpg' };
}
// WebP: RIFF (bytes 0-3) + WEBP (bytes 8-11)
if (
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return { mime: 'image/webp', ext: 'webp' };
}
// PDF: %PDF (25 50 44 46)
if (
buffer[0] === 0x25 &&
buffer[1] === 0x50 &&
buffer[2] === 0x44 &&
buffer[3] === 0x46
) {
return { mime: 'application/pdf', ext: 'pdf' };
}
return null;
}
}
Path Traversal Defense
When saving files to disk, never use file.originalname directly. An attacker can set filename: "../../etc/cron.d/malicious_job".
Always sanitize and isolate uploaded files:
- Strip path separators with
path.basename(). - Discard the original name and generate a cryptographically random UUID (
crypto.randomUUID()). - Append only verified file extensions derived from magic byte validation.
4. High-Performance Image Optimization with Sharp
Storing raw uploaded images is wasteful and dangerous. User images frequently contain:
- Excessive dimensions (e.g., 6000x4000 pixels, 15MB from modern smartphone cameras).
- Hidden EXIF Geolocation metadata (presenting serious privacy and security risks by exposing the user's home GPS coordinates).
- Legacy formats (uncompressed PNGs or heavy JPEGs instead of modern WebP or AVIF).
Why Sharp?
The sharp library is the gold standard for Node.js image processing. Unlike pure-JS libraries which block the single-threaded event loop for seconds while computing convolutions, sharp wraps the high-performance C++ libvips library. It processes images in parallel across worker threads in the libuv thread pool, with a memory footprint 4x to 5x lower than ImageMagick.
Building an Image Processing Pipeline
Here is a production-grade utility that strips EXIF data, limits maximum dimensions, and transcodes the image to compressed WebP format:
// src/common/utils/image-processor.ts
import sharp from 'sharp';
import { ApiError } from '../errors/api-error';
export interface ProcessedImage {
buffer: Buffer;
mimeType: string;
extension: string;
width: number;
height: number;
sizeBytes: number;
}
export class ImageProcessor {
/**
* Resizes, compresses, strips EXIF metadata, and converts images to WebP.
*/
public static async processAvatar(inputBuffer: Buffer): Promise<ProcessedImage> {
try {
const pipeline = sharp(inputBuffer, { failOn: 'error' })
.rotate() // Automatically orient based on EXIF before stripping
.resize({
width: 512,
height: 512,
fit: 'cover', // Crop to fit aspect ratio
position: 'center', // Focus on center of image
withoutEnlargement: true,
})
.webp({ quality: 80 }) // 80% quality provides ~70% size reduction with imperceptible loss
.withMetadata(false); // 🔒 CRITICAL: Strip all EXIF metadata (GPS, camera details)
const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
return {
buffer: data,
mimeType: 'image/webp',
extension: 'webp',
width: info.width,
height: info.height,
sizeBytes: info.size,
};
} catch (error) {
throw ApiError.badRequest('Failed to process image. The file may be corrupt or malformed.');
}
}
}
5. Serving Static Files Securely
Node.js backends frequently need to serve local static assets (documentation, icons, public assets, or temporary reports).
Configuring express.static() for Production
import express from 'express';
import path from 'node:path';
const app = express();
const PUBLIC_DIR = path.join(process.cwd(), 'public');
app.use(
'/static',
express.static(PUBLIC_DIR, {
dotfiles: 'ignore', // 🔒 Never serve hidden files (.env, .git, etc.)
etag: true, // Enable ETag calculation for HTTP 304 validation
lastModified: true, // Send Last-Modified header
maxAge: '1d', // Default cache: 1 day
setHeaders: (res, filePath) => {
// For hashed immutable assets (e.g., images with UUID in filename)
if (filePath.match(/\.[a-f0-9]{8,}\.(webp|jpg|png|css|js)$/)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
} else {
res.setHeader('Cache-Control', 'public, max-age=86400, must-revalidate');
}
// Security: Prevent browsers from MIME-sniffing away from declared Content-Type
res.setHeader('X-Content-Type-Options', 'nosniff');
},
})
);
The Stored XSS Danger of User-Uploaded Files
[!CAUTION] Never serve user-uploaded files directly from your primary API or application domain.
If a user uploads an SVG image or an HTML file containing
<script>alert(document.cookie)</script>, and your server serves it athttps://app.example.com/uploads/user-graphic.svg, an attacker can link another user to that URL.The victim's browser executes the script in the security context of
app.example.com, giving the attacker full access to non-httpOnly session tokens, local storage, and CSRF capabilities!
Production Defenses for Uploaded Media:
- Dedicated Domain: Serve user uploads from a separate, isolated origin or CDN (e.g.,
https://user-content-consistcode.comor an S3 bucket URL) that does not share authentication cookies with your main app. Content-Disposition: attachment: Force the browser to download the file rather than rendering it inline:TSres.setHeader('Content-Disposition', `attachment; filename="${sanitizedFileName}"`);- Strict Content Security Policy (CSP): Disallow inline script execution on static asset endpoints.
6. The Cloud Scale Architecture: Direct-to-S3 Presigned URLs
While streaming uploads through your Node.js server using Multer is suitable for low-to-medium volume systems, it becomes an architectural bottleneck at scale.
The Problem with Server-Mediated Uploads
Consider what happens when 500 users upload 100MB video files simultaneously:
- Your Node.js API servers must maintain 500 open HTTP connections.
- Your server ingress bandwidth must consume 50GB of inbound traffic.
- Your server must then re-upload that data outbound to S3, consuming another 50GB of egress bandwidth.
- Your API event loop and network interfaces become saturated, degrading response times for critical REST endpoints.
❌ Server-Mediated Upload (Bottleneck):
Client ────────► Node.js API Server (Holds 500 sockets) ────────► S3 Bucket
(100MB) High Bandwidth & Memory Consumption (100MB)
The Solution: Direct Upload via S3 Presigned URLs
The industry standard for large files and high-throughput systems is the Presigned URL Pattern:
- The client sends a lightweight JSON request to the Node.js API asking for permission to upload.
- The Node.js API validates user permissions, enforces metadata constraints (file size, allowed extensions), and asks AWS S3 to generate a short-lived Presigned Upload URL.
- The client uploads the binary payload directly to AWS S3/Cloudflare R2 via an HTTP
PUTrequest. - The client notifies the Node.js API when the upload is finished.
✅ Direct-to-S3 Presigned URL Pattern:
1. POST /api/v1/uploads/presigned-url (JSON: { fileName, fileType, size })
Client ─────────────────────────────────────────────────────────────► Node.js API
Client ◄───────────────────────────────────────────────────────────── Node.js API
2. Returns { presignedUrl, key, expiresIn: 900 }
3. Direct HTTP PUT to S3 with Binary Payload (Zero server load!)
Client ─────────────────────────────────────────────────────────────► AWS S3 Bucket
Implementing Presigned URLs with @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner
Let's implement a production S3 service supporting both presigned upload URLs (PutObjectCommand) and secure download URLs (GetObjectCommand):
// src/common/services/s3-storage.service.ts
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import crypto from 'node:crypto';
import path from 'node:path';
export interface PresignedUploadRequest {
fileName: string;
contentType: string;
fileSizeBytes: number;
userId: string;
}
export interface PresignedUploadResponse {
uploadUrl: string;
fileKey: string;
expiresInSeconds: number;
}
export class S3StorageService {
private readonly s3Client: S3Client;
private readonly bucketName: string;
constructor() {
this.bucketName = process.env.AWS_S3_BUCKET || 'consistcode-media-bucket';
this.s3Client = new S3Client({
region: process.env.AWS_REGION || 'us-east-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
},
});
}
/**
* Generates a secure, temporary PUT URL allowing direct client-to-S3 upload.
*/
public async generatePresignedUploadUrl(
request: PresignedUploadRequest
): Promise<PresignedUploadResponse> {
const fileExtension = path.extname(request.fileName).toLowerCase();
const uniqueKey = `uploads/${request.userId}/${crypto.randomUUID()}${fileExtension}`;
const expiresIn = 900; // URL valid for 15 minutes
const command = new PutObjectCommand({
Bucket: this.bucketName,
Key: uniqueKey,
ContentType: request.contentType,
ContentLength: request.fileSizeBytes,
Metadata: {
'uploaded-by': request.userId,
'original-name': request.fileName,
},
});
const uploadUrl = await getSignedUrl(this.s3Client, command, {
expiresIn,
});
return {
uploadUrl,
fileKey: uniqueKey,
expiresInSeconds: expiresIn,
};
}
/**
* Generates a temporary GET URL for private files (e.g., invoices, KYC docs).
*/
public async generatePresignedDownloadUrl(fileKey: string): Promise<string> {
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: fileKey,
});
return getSignedUrl(this.s3Client, command, { expiresIn: 300 }); // 5 minutes
}
/**
* Deletes a file from the S3 bucket.
*/
public async deleteFile(fileKey: string): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: this.bucketName,
Key: fileKey,
});
await this.s3Client.send(command);
}
}
7. End-to-End Feature: Production Avatar Upload System
Let's bring these architectural principles together into a clean, modular feature implementation following our established project standards:
- Strong typing and schema validation using
BaseDtoand Zod. - Centralized
ApiErrorfactory methods. - Consistent
ApiResponseenvelopes. - Multer memory buffer staging + magic bytes verification + Sharp optimization.
1. The DTO Layer (src/features/upload/upload.dto.ts)
import { z } from 'zod';
import { BaseDto } from '../../common/dto/base.dto';
export class RequestPresignedUrlDto extends BaseDto {
public static readonly schema = z.object({
fileName: z
.string()
.min(1, 'File name is required')
.max(255, 'File name is too long')
.regex(/^[^\\/:\*\?"<>\|]+$/, 'Invalid file name characters'),
contentType: z.enum([
'image/jpeg',
'image/png',
'image/webp',
'application/pdf',
'video/mp4',
]),
fileSizeBytes: z
.number()
.int()
.positive()
.max(500 * 1024 * 1024, 'Maximum allowed file size is 500MB'),
});
public readonly fileName!: string;
public readonly contentType!: string;
public readonly fileSizeBytes!: number;
}
2. The Multer & Validation Middleware (src/features/upload/upload.middleware.ts)
import multer from 'multer';
import { Request, Response, NextFunction } from 'express';
import { ApiError } from '../../common/errors/api-error';
import { FileSecurityValidator } from '../../common/utils/file-validator';
// 1. Configure Multer with strict memory limits
const multerUpload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 5 * 1024 * 1024, // 5MB limit
files: 1,
},
});
export const requireSingleFile = (fieldName: string) => {
const uploadMiddleware = multerUpload.single(fieldName);
return (req: Request, res: Response, next: NextFunction): void => {
uploadMiddleware(req, res, (err: any) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return next(ApiError.badRequest('File size exceeds the 5MB limit.'));
}
return next(ApiError.badRequest(`File upload error: ${err.message}`));
} else if (err) {
return next(err);
}
if (!req.file) {
return next(ApiError.badRequest(`Missing required file in field '${fieldName}'.`));
}
// 2. Validate magic bytes on the buffered memory stream
const detectedType = FileSecurityValidator.validateMagicBytes(req.file.buffer);
if (!detectedType) {
return next(
ApiError.badRequest(
'Invalid file content. The file signature does not match allowed types (JPEG, PNG, WebP, PDF).'
)
);
}
// Attach verified metadata to the file object
req.file.mimetype = detectedType.mime;
next();
});
};
};
3. The Service Layer (src/features/upload/upload.service.ts)
import { ImageProcessor } from '../../common/utils/image-processor';
import { S3StorageService } from '../../common/services/s3-storage.service';
import { RequestPresignedUrlDto } from './upload.dto';
export interface ProcessedAvatarResult {
url: string;
width: number;
height: number;
sizeBytes: number;
format: string;
}
export class UploadService {
constructor(private readonly s3Service: S3StorageService) {}
/**
* Processes an uploaded avatar image and saves it to storage.
*/
public async uploadUserAvatar(
userId: string,
fileBuffer: Buffer
): Promise<ProcessedAvatarResult> {
// 1. Run through Sharp image optimization pipeline
const optimized = await ImageProcessor.processAvatar(fileBuffer);
// 2. Upload to S3/Cloud Storage
const fileKey = `avatars/${userId}-${Date.now()}.webp`;
// (In production, write buffer to S3 client using PutObjectCommand)
// For this demonstration, we formulate the public/CDN URL:
const cdnUrl = `https://cdn.consistcode.com/${fileKey}`;
return {
url: cdnUrl,
width: optimized.width,
height: optimized.height,
sizeBytes: optimized.sizeBytes,
format: optimized.extension,
};
}
/**
* Generates a presigned URL for direct client-to-cloud upload.
*/
public async getPresignedUploadUrl(
userId: string,
dto: RequestPresignedUrlDto
) {
return this.s3Service.generatePresignedUploadUrl({
fileName: dto.fileName,
contentType: dto.contentType,
fileSizeBytes: dto.fileSizeBytes,
userId,
});
}
}
4. The Controller Layer (src/features/upload/upload.controller.ts)
import { Request, Response, NextFunction } from 'express';
import { UploadService } from './upload.service';
import { ApiResponse } from '../../common/responses/api-response';
import { RequestPresignedUrlDto } from './upload.dto';
import { ApiError } from '../../common/errors/api-error';
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
public uploadAvatar = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const userId = (req as any).user?.id || 'usr_anonymous';
const file = req.file;
if (!file) {
throw ApiError.badRequest('No file provided for upload.');
}
const result = await this.uploadService.uploadUserAvatar(userId, file.buffer);
ApiResponse.created(res, 'Avatar uploaded and processed successfully', result);
} catch (error) {
next(error);
}
};
public getPresignedUrl = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const userId = (req as any).user?.id || 'usr_anonymous';
const dto = req.body as RequestPresignedUrlDto;
const presignedData = await this.uploadService.getPresignedUploadUrl(userId, dto);
ApiResponse.ok(res, 'Presigned upload URL generated successfully', presignedData);
} catch (error) {
next(error);
}
};
}
5. The Route Definition (src/features/upload/upload.routes.ts)
import { Router } from 'express';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
import { S3StorageService } from '../../common/services/s3-storage.service';
import { requireSingleFile } from './upload.middleware';
import { validate } from '../../common/middleware/validate.middleware';
import { RequestPresignedUrlDto } from './upload.dto';
const router = Router();
const s3Service = new S3StorageService();
const uploadService = new UploadService(s3Service);
const controller = new UploadController(uploadService);
// Route 1: Direct in-memory image upload with Sharp optimization
router.post(
'/avatar',
requireSingleFile('avatar'),
controller.uploadAvatar
);
// Route 2: Scalable S3 Presigned URL request
router.post(
'/presigned-url',
validate(RequestPresignedUrlDto),
controller.getPresignedUrl
);
export const uploadRouter = router;
8. Production Checklist & Summary
Before deploying file upload and static asset services to production, verify your architecture against this checklist:
┌────────────────────────────────────────────────────────────────────────────┐
│ PRODUCTION FILE UPLOAD CHECKLIST │
├────────────────────────────────────────────────────────────────────────────┤
│ [ ] Size Limits Enforced: Limits set on Multer, Nginx (client_max_body_size)│
│ and cloud reverse proxies (e.g. Cloudflare 100MB limit). │
│ │
│ [ ] Magic Bytes Verification: File signatures verified via buffer header; │
│ client-supplied MIME types and extensions are strictly untrusted. │
│ │
│ [ ] Safe Filename Generation: Original filenames are stripped; UUIDs used │
│ to prevent directory traversal attacks and namespace collisions. │
│ │
│ [ ] EXIF Metadata Stripping: Privacy-sensitive geolocation and camera │
│ metadata removed during image processing. │
│ │
│ [ ] Temp Disk Cleanup: Guaranteed unlinking of temporary staging files │
│ even when downstream processing or database writes fail. │
│ │
│ [ ] Separate Media Domain: Uploaded user files are served from an isolated │
│ domain or S3 bucket to prevent Stored XSS session hijacking. │
│ │
│ [ ] Presigned URLs for Large Payloads: Media > 10MB bypasses API servers │
│ completely, uploading directly to AWS S3 / Cloudflare R2. │
└────────────────────────────────────────────────────────────────────────────┘
Summary of Architectural Decisions
| Use Case | Recommended Storage Strategy | Validation Strategy | Delivery Method |
|---|---|---|---|
| User Profile Avatars (< 5MB) | multer.memoryStorage() |
Magic bytes (FF D8 FF, 89 50 4E 47) + Sharp resize/WebP |
CDN over isolated static domain |
| Large Documents / Archives (5MB - 50MB) | multer.diskStorage() with temp staging |
Magic bytes check on stream start | S3 / R2 via server stream with cleanup |
| High-Volume Video / Datasets (> 50MB) | Direct-to-S3 via Presigned URL | Pre-signed metadata check (size & type) + S3 Lambda inspection | Direct S3 / Cloudflare R2 bucket with CDN |
| Private Files (Invoices, KYC docs) | S3 Private Bucket | Strict user-ownership DB check | Short-lived Presigned GetObject URL (5 min) |
In the next chapter, we will examine Chapter 16: Environment Configuration & Secrets Management: The 12-Factor App, Zod Validation, and Zero-Downtime Rotation, learning how to safely inject, validate, and rotate API keys, database credentials, and cloud secrets across development, staging, and production environments.