Welcome to Chapter 6 of our Node.js backend journey! In this chapter, we’ll tackle two essential components for many modern web applications: securely handling file uploads and efficiently serving static assets. From user profile pictures to document attachments, robust and secure file management is a non-negotiable feature for production-ready systems.
We’ll build upon the authentication and authorization mechanisms established in previous chapters, ensuring that only authorized users can upload files. We’ll leverage fastify-multer (a Fastify plugin for multer) for handling multipart/form-data, focusing on crucial aspects like file type validation, size limits, and secure storage practices. Additionally, we’ll configure our Fastify server to serve static content, such as public assets (CSS, JavaScript, images) and the files uploaded by users, all while adhering to security best practices.
By the end of this chapter, you will have a fully functional and secure API endpoint for file uploads, capable of storing files locally (with a strong emphasis on transitioning to cloud storage in production), and a robust system for serving both general static content and user-uploaded files. You’ll understand the underlying security risks associated with file uploads and how to mitigate them, preparing your application for real-world media management challenges.
Planning & Design
Before diving into code, let’s outline the architecture, API endpoints, and file structure for our file upload and static asset serving features.
Component Architecture
Our architecture will involve the client sending file upload requests to our Fastify server. The server will use fastify-multer to process the incoming multipart/form-data, validate the file, and store it. For serving, we’ll use fastify-static to expose both public assets and the uploaded files.
API Endpoints Design
We’ll define the following endpoints:
POST /api/v1/uploads/profile-picture: Allows an authenticated user to upload a single profile picture.GET /uploads/:filename: Serves a specific uploaded file. While this is publicly accessible for simplicity in this chapter, in a real application, you might add authorization checks here for private files.GET /static/*: Serves general static assets from apublic/directory.
File Structure
We’ll introduce new directories and files:
.
├── src/
│ ├── plugins/
│ │ ├── ... (existing plugins)
│ │ └── uploadPlugin.ts # Multer configuration and registration
│ ├── routes/
│ │ ├── ... (existing routes)
│ │ └── uploadRoutes.ts # API routes for file uploads
│ ├── utils/
│ │ └── fileValidation.ts # Helper for file type and size validation
│ └── app.ts # Main application file, registers plugins and routes
├── public/ # Directory for general static assets (e.g., `index.html`, `styles.css`)
├── uploads/ # Directory for user-uploaded files
└── package.json
Step-by-Step Implementation
4.1. Setup & Dependencies
First, let’s install the necessary packages for file uploads and static asset serving.
npm install fastify-multer multer fastify-static mime-types
npm install --save-dev @types/multer @types/mime-types
fastify-multer: Fastify’s official plugin for integratingmulter.multer: A Node.js middleware for handlingmultipart/form-data, which is primarily used for uploading files.fastify-static: A Fastify plugin to serve static files (e.g., images, CSS, JavaScript, HTML).mime-types: A utility to work with MIME types, useful for file validation.
Next, create the directories for our static and uploaded files:
mkdir public
mkdir uploads
4.2. File Storage Configuration (Multer)
We’ll create a Fastify plugin to encapsulate our Multer configuration. This includes defining where files are stored, how they are named, and applying initial validation.
Create src/plugins/uploadPlugin.ts:
// src/plugins/uploadPlugin.ts
import { FastifyPluginAsync } from 'fastify';
import fp from 'fastify-plugin';
import multer from 'fastify-multer';
import { randomUUID } from 'crypto';
import path from 'path';
import { fileTypeValidator } from '../utils/fileValidation';
import { logger } from '../utils/logger'; // Assuming logger is already configured
// Define allowed image MIME types and file size limit
const ALLOWED_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
const uploadPlugin: FastifyPluginAsync = async (fastify) => {
// Configure Multer's disk storage engine
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// Ensure the 'uploads' directory exists. Multer will create it if not.
// For production, consider using a dedicated file storage service like AWS S3.
cb(null, path.resolve(__dirname, '../../uploads'));
},
filename: (req, file, cb) => {
// Generate a unique filename to prevent collisions and overwrite existing files.
// Important: Never use original filename directly for security reasons (e.g., path traversal).
const uniqueSuffix = randomUUID();
const fileExtension = path.extname(file.originalname);
const newFilename = `${file.fieldname}-${uniqueSuffix}${fileExtension}`;
cb(null, newFilename);
},
});
// Initialize Multer instance with storage and file filter
const upload = multer({
storage: storage,
limits: {
fileSize: MAX_FILE_SIZE_BYTES, // Limit file size
},
fileFilter: (req, file, cb) => {
// Use our custom file type validator
if (!fileTypeValidator(file, ALLOWED_IMAGE_MIME_TYPES)) {
logger.warn(`File upload rejected: Invalid file type for ${file.originalname}. MIME: ${file.mimetype}`);
// Reject file with a custom error message
cb(new Error(`Invalid file type. Only ${ALLOWED_IMAGE_MIME_TYPES.join(', ')} are allowed.`), false);
} else {
cb(null, true); // Accept file
}
},
});
// Register Multer as a Fastify plugin
fastify.decorate('upload', upload);
logger.info('Multer upload plugin registered successfully.');
};
// Export the plugin
export default fp(uploadPlugin, {
name: 'uploadPlugin',
});
Here’s a breakdown of the uploadPlugin.ts code:
multer.diskStorage: Configures how files are stored on disk.destination: Specifies the directory where files will be saved. We usepath.resolveto ensure a consistent path relative to our project root, placing uploads in theuploads/directory.filename: Generates a unique filename usingrandomUUID()and preserves the original file extension. This is crucial for security, preventing malicious users from uploading files with dangerous names (e.g.,../../../etc/passwdor.php).
multerinstance:storage: Uses thediskStorageconfiguration.limits.fileSize: Sets a maximum file size (5MB in this example). This prevents denial-of-service attacks by uploading excessively large files.fileFilter: This is where robust server-side validation happens. We callfileTypeValidatorto check the MIME type against a list ofALLOWED_IMAGE_MIME_TYPES. If the type is not allowed, an error is passed to the callback, rejecting the file.
fastify.decorate('upload', upload): We decorate the Fastify instance with the configured Multer instance, making it accessible asfastify.uploadin our routes.fp(uploadPlugin, { name: 'uploadPlugin' }): Wraps our plugin withfastify-pluginto ensure it’s registered correctly and its decorators are available throughout the application.
Next, create src/utils/fileValidation.ts for our file type validation logic:
// src/utils/fileValidation.ts
import { File } from 'fastify-multer/lib/interfaces'; // Type definition for Multer file object
import { logger } from './logger'; // Assuming logger is already configured
import mime from 'mime-types'; // Using mime-types for reliable MIME checking
/**
* Validates the file type based on a list of allowed MIME types.
* This performs a basic check on the `mimetype` property provided by Multer.
* For true production-grade security, consider using a library that performs
* "magic number" validation to prevent MIME type spoofing.
*
* @param file The Multer file object.
* @param allowedMimeTypes An array of allowed MIME type strings (e.g., ['image/jpeg', 'image/png']).
* @returns True if the file type is allowed, false otherwise.
*/
export function fileTypeValidator(file: File, allowedMimeTypes: string[]): boolean {
if (!file || !file.mimetype) {
logger.warn('File object or mimetype is missing for validation.');
return false;
}
// Check if the file's MIME type is in the allowed list
const isMimeTypeAllowed = allowedMimeTypes.includes(file.mimetype);
// Additionally, you can try to infer the MIME type from the file extension
// and compare it, though `file.mimetype` from the client can be spoofed.
const extension = mime.extension(file.mimetype);
const originalExtension = mime.extension(file.originalname);
// This is an extra layer of caution. If the inferred extension from mimetype
// doesn't match the original extension, it might indicate a suspicious file.
// This check can be refined based on specific security requirements.
const isExtensionConsistent = extension === originalExtension;
if (!isMimeTypeAllowed) {
logger.warn(`Invalid MIME type detected: ${file.mimetype} for file ${file.originalname}`);
}
// For production, you might want to be more strict and return false if `!isExtensionConsistent`
// or if the `extension` cannot be determined, as it could indicate a malformed file.
// For now, we prioritize the `mimetype` provided by the client, but log inconsistencies.
if (isMimeTypeAllowed && !isExtensionConsistent) {
logger.warn(`MIME type ${file.mimetype} is allowed, but extension inconsistency detected. Original: ${originalExtension}, Inferred: ${extension}`);
}
return isMimeTypeAllowed;
}
This fileTypeValidator provides a robust check for file types, and logs any inconsistencies that might indicate a malicious upload attempt.
4.3. Secure Upload Route Implementation
Now, let’s create an API route that uses our configured Multer plugin to handle file uploads. This route will be protected by our existing authentication and authorization middleware.
Create src/routes/uploadRoutes.ts:
// src/routes/uploadRoutes.ts
import { FastifyPluginAsync } from 'fastify';
import { verifyAccessToken } from '../plugins/jwtAuthPlugin'; // Assuming this is from Chapter 5
import { logger } from '../utils/logger'; // Assuming logger is already configured
import { AppError } from '../utils/appError'; // Assuming AppError from Chapter 4
import { HTTPStatusCodes } from '../utils/httpStatusCodes';
interface UploadedFile {
fieldname: string;
originalname: string;
encoding: string;
mimetype: string;
destination: string;
filename: string;
path: string;
size: number;
}
// Extend FastifyRequest to include `file` property from Multer
declare module 'fastify' {
interface FastifyRequest {
file?: UploadedFile;
}
}
const uploadRoutes: FastifyPluginAsync = async (fastify) => {
// Ensure the upload plugin has been registered and decorated Fastify
if (!fastify.upload) {
throw new Error('Multer upload plugin not registered. Please register uploadPlugin before uploadRoutes.');
}
// Route to upload a profile picture
fastify.post<{ Body: { file: File } }>(
'/profile-picture',
{
preHandler: [verifyAccessToken, fastify.upload.single('profilePicture')], // 'profilePicture' is the field name from the form
schema: {
tags: ['Uploads'],
summary: 'Upload a user profile picture',
description: 'Allows an authenticated user to upload a single profile picture. Max size 5MB, allowed types: JPEG, PNG, GIF, WebP.',
security: [{ bearerAuth: [] }],
response: {
200: {
type: 'object',
properties: {
message: { type: 'string', example: 'Profile picture uploaded successfully.' },
filePath: { type: 'string', example: '/uploads/profilePicture-uuid.jpeg' },
},
},
400: { $ref: 'ErrorResponse#' }, // Assuming ErrorResponse schema from previous chapters
401: { $ref: 'ErrorResponse#' },
403: { $ref: 'ErrorResponse#' },
500: { $ref: 'ErrorResponse#' },
},
},
},
async (request, reply) => {
try {
if (!request.file) {
logger.warn('File upload failed: No file provided or Multer error occurred.');
throw new AppError('No file uploaded or file upload failed.', HTTPStatusCodes.BAD_REQUEST);
}
// In a real application, you would save `request.file.path` to the user's database record.
// For this example, we'll just return the path.
const filePath = `/uploads/${request.file.filename}`;
logger.info(`File uploaded successfully: ${request.file.filename} by user ID: ${request.user?.id}`);
reply.status(HTTPStatusCodes.OK).send({
message: 'Profile picture uploaded successfully.',
filePath: filePath,
});
} catch (error: any) {
// Multer errors are caught here
if (error.message && error.message.includes('file type')) {
logger.warn(`Multer file type error: ${error.message}`);
throw new AppError(error.message, HTTPStatusCodes.BAD_REQUEST);
}
if (error.code === 'LIMIT_FILE_SIZE') {
logger.warn(`Multer file size limit exceeded: ${error.message}`);
throw new AppError('File too large. Maximum 5MB allowed.', HTTPStatusCodes.BAD_REQUEST);
}
if (error.code === 'LIMIT_UNEXPECTED_FILE') {
logger.warn(`Multer unexpected file error: ${error.message}`);
throw new AppError('Too many files uploaded or unexpected field name.', HTTPStatusCodes.BAD_REQUEST);
}
logger.error(`Error during file upload: ${error.message}`, { error });
throw error; // Re-throw to be caught by global error handler
}
},
);
};
export default uploadRoutes;
Key points in uploadRoutes.ts:
fastify.decorate('upload', upload): We access the Multer instance viafastify.upload.preHandler: [verifyAccessToken, fastify.upload.single('profilePicture')]:verifyAccessToken: Ensures only authenticated users can access this route.fastify.upload.single('profilePicture'): This is the Multer middleware. It expects a single file under the field nameprofilePicturein the incoming form data. If successful, the file details will be available inrequest.file.
- Error Handling: We explicitly catch and handle common Multer errors (
LIMIT_FILE_SIZE,LIMIT_UNEXPECTED_FILE, and our custom file type error message) and map them to appropriateAppErrorresponses. This provides clear feedback to the client. - Database Integration (Future): Currently, we just return the
filePath. In a real application, you’d save this path to the authenticated user’s record in your database.
4.4. Serving Uploaded Files & General Static Assets
To serve files, we’ll use the fastify-static plugin. This allows us to expose directories as static content.
First, let’s create a simple index.html in our public/ directory for testing:
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Static Content Served</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #333; }
p { color: #666; }
</style>
</head>
<body>
<h1>Welcome to our Static Content Server!</h1>
<p>This page is served from the `public` directory.</p>
<p>Try uploading a file via the API and then accessing it at `/uploads/:filename`.</p>
</body>
</html>
Now, let’s register fastify-static in our main src/app.ts file. We’ll register it twice: once for public assets and once for uploaded files.
Modify src/app.ts (showing only relevant changes):
// src/app.ts
import fastify from 'fastify';
import fastifyEnv from '@fastify/env';
import fastifyHelmet from '@fastify/helmet';
import fastifyCors from '@fastify/cors';
import fastifyRateLimit from '@fastify/rate-limit';
import fastifyStatic from '@fastify/static'; // Import fastify-static
import path from 'path';
// ... other imports ...
import { configSchema } from './config'; // Assuming config schema from previous chapters
import { registerPlugins } from './plugins'; // Assuming plugin registration helper
import { setupRoutes } from './routes'; // Assuming route setup helper
import { errorHandler } from './utils/errorHandler'; // Assuming global error handler
import { AppError } from './utils/appError';
import { HTTPStatusCodes } from './utils/httpStatusCodes';
import { logger } from './utils/logger';
const build = async () => {
const app = fastify({
logger: logger, // Use our configured logger
disableRequestLogging: true, // Disable default Fastify logging as we use pino-http
});
// Register @fastify/env for configuration management
await app.register(fastifyEnv, {
confKey: 'config',
schema: configSchema,
dotenv: true,
data: process.env,
});
// Register security plugins
await app.register(fastifyHelmet);
await app.register(fastifyCors, {
origin: app.config.CORS_ORIGIN, // Use config for CORS origin
credentials: true,
});
await app.register(fastifyRateLimit, {
max: 100, // Max 100 requests per 1000ms (1s)
timeWindow: 1000,
});
// Register static file serving for public assets
app.register(fastifyStatic, {
root: path.resolve(__dirname, '../public'), // Serve files from the 'public' directory
prefix: '/static/', // Files will be accessible under /static/
decorateReply: false, // Prevents overwriting reply.sendFile if already decorated
// Set cache control headers for static assets
setHeaders: (res, path, stat) => {
if (path.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
} else {
res.setHeader('Cache-Control', 'public, max-age=31536000'); // Cache for 1 year
}
},
});
logger.info('Static assets serving from /public registered.');
// Register static file serving for uploaded files
app.register(fastifyStatic, {
root: path.resolve(__dirname, '../uploads'), // Serve files from the 'uploads' directory
prefix: '/uploads/', // Files will be accessible under /uploads/
decorateReply: false, // Prevents overwriting reply.sendFile if already decorated
// Security consideration:
// Ensure that direct directory listing is disabled in production environments.
// fastify-static disables this by default.
// Set appropriate cache control headers for uploaded content.
setHeaders: (res, path, stat) => {
res.setHeader('Cache-Control', 'public, max-age=31536000'); // Cache for 1 year
},
});
logger.info('Uploaded files serving from /uploads registered.');
// Register custom plugins (e.g., JWT, Multer)
await registerPlugins(app);
// Register routes
await setupRoutes(app);
// Register global error handler
app.setErrorHandler(errorHandler);
// Catch all for 404
app.setNotFoundHandler((request, reply) => {
logger.warn(`404 Not Found: ${request.method} ${request.url}`);
reply.code(HTTPStatusCodes.NOT_FOUND).send(new AppError('Not Found', HTTPStatusCodes.NOT_FOUND));
});
return app;
};
export { build };
Important changes in src/app.ts:
fastifyStaticregistration forpublic/:root: Points to ourpublicdirectory.prefix: '/static/': Means files inpublic/will be accessible under/static/. E.g.,public/index.htmlbecomeshttp://localhost:3000/static/index.html.setHeaders: We addCache-Controlheaders for performance. HTML files are set tono-cacheto ensure fresh content, while other assets are aggressively cached.
fastifyStaticregistration foruploads/:root: Points to ouruploadsdirectory.prefix: '/uploads/': Files inuploads/will be accessible under/uploads/. E.g., an uploaded fileprofilePicture-uuid.jpegbecomeshttp://localhost:3000/uploads/profilePicture-uuid.jpeg.- Security Note: While
fastify-staticdisables directory listing by default, always be cautious about what you expose. For truly sensitive files, consider serving them through an authenticated route that reads the file from disk (or cloud storage) and streams it after authorization checks, rather than direct static serving.
decorateReply: false: This is important when registeringfastify-staticmultiple times. It prevents conflicts ifreply.sendFileor similar decorators are added by multiple instances.
Finally, ensure src/plugins/uploadPlugin.ts and src/routes/uploadRoutes.ts are registered in your src/plugins/index.ts and src/routes/index.ts respectively.
Modify src/plugins/index.ts (add uploadPlugin):
// src/plugins/index.ts
import { FastifyInstance } from 'fastify';
import jwtAuthPlugin from './jwtAuthPlugin'; // From Chapter 5
import uploadPlugin from './uploadPlugin'; // NEW: Import upload plugin
export async function registerPlugins(app: FastifyInstance) {
await app.register(jwtAuthPlugin);
await app.register(uploadPlugin, { prefix: '/api/v1/uploads' }); // NEW: Register upload plugin
// Register other plugins here
}
Modify src/routes/index.ts (add uploadRoutes):
// src/routes/index.ts
import { FastifyInstance } from 'fastify';
import authRoutes from './authRoutes'; // From Chapter 5
import uploadRoutes from './uploadRoutes'; // NEW: Import upload routes
export async function setupRoutes(app: FastifyInstance) {
app.register(authRoutes, { prefix: '/api/v1/auth' });
app.register(uploadRoutes, { prefix: '/api/v1/uploads' }); // NEW: Register upload routes
// Register other routes here
}
4.5. Testing This Component
- Start your Fastify server:
npm run dev - Access Static Assets:
- Open your browser and navigate to
http://localhost:3000/static/index.html. You should see the “Welcome to our Static Content Server!” page. - Verify that
Cache-Controlheaders are correctly set using your browser’s developer tools.
- Open your browser and navigate to
- Test File Upload (using Postman or cURL):
- Prerequisite: You need an
access_tokenfrom a successful login (Chapter 5). - Method:
POST - URL:
http://localhost:3000/api/v1/uploads/profile-picture - Headers:
Authorization: Bearer <your_access_token>Content-Type: multipart/form-data(Postman handles this automatically when you selectform-data)
- Body: Select
form-data.- Add a key named
profilePicture. - Change its type from
TexttoFile. - Select an image file (JPEG, PNG, GIF, or WebP) from your computer.
- Add a key named
- Send Request.
- Expected Success: You should receive a
200 OKresponse with a JSON body like:{ "message": "Profile picture uploaded successfully.", "filePath": "/uploads/profilePicture-some-uuid.jpeg" } - Verify Upload: Check your
uploads/directory in your project root. A new file with a unique name should be present. - Access Uploaded File: Open your browser and navigate to
http://localhost:3000<filePath_from_response>. You should see your uploaded image.
- Prerequisite: You need an
- Test File Upload - Error Cases:
- Invalid File Type: Try uploading a
.txtor.pdffile. You should get a400 Bad Requestwith an error message like “Invalid file type. Only image/jpeg, image/png, image/gif, image/webp are allowed.” - File Too Large: Try uploading a file larger than 5MB. You should get a
400 Bad Requestwith an error message like “File too large. Maximum 5MB allowed.” - No File: Send the request without selecting a file. You should get a
400 Bad Requestwith an error message like “No file uploaded or file upload failed.” - Unauthenticated: Send the request without the
Authorizationheader. You should get a401 Unauthorizedresponse.
- Invalid File Type: Try uploading a
Production Considerations
While our current implementation works, several critical aspects need to be addressed for a production environment.
- Cloud Storage (AWS S3, GCS, Azure Blob Storage):
- Why: Local file storage is suitable for development but highly problematic for production. It doesn’t scale (files are tied to a single server instance), lacks durability (server failure means data loss), and complicates backups.
- Solution: Migrate to a cloud-based object storage service. Multer has storage engines for various cloud providers (e.g.,
multer-s3). This provides scalability, high availability, and integrates well with CDNs. - Impact: Our
uploadPlugin.tswould change to use a cloud storage engine, andfilePathwould become a URL to the cloud object.
- Image Optimization & Processing:
- Why: Raw uploaded images can be very large, impacting page load times and storage costs.
- Solution: Integrate image processing libraries (e.g.,
sharp,jimp) to resize, compress, or convert images to more efficient formats (like WebP) immediately after upload.
- Content Delivery Networks (CDNs):
- Why: Serving static and uploaded assets directly from your server can be slow for geographically dispersed users and consume server resources.
- Solution: Point your cloud storage bucket (or
uploads/directory if staying local) to a CDN (e.g., CloudFront, Cloudflare). CDNs cache content closer to users, improving performance and reducing server load.
- Advanced File Validation & Security Scanning:
- Why: MIME type validation is good, but malicious users can spoof MIME types. Viruses and other malware can be embedded in seemingly innocuous files.
- Solution:
- Magic Number Validation: Use libraries that read the first few bytes of a file (magic numbers) to determine its true file type, regardless of extension or reported MIME type.
- Antivirus Scanning: Integrate with an antivirus service (e.g., ClamAV, AWS Rekognition for content moderation) to scan uploaded files for malware before making them publicly accessible.
- Content-Type Sniffing Protection: Ensure your server sends
X-Content-Type-Options: nosniffheader for all served files to prevent browsers from trying to guess the MIME type, which can lead to XSS attacks.fastify-helmetgenerally handles this.
- Access Control for Uploaded Files:
- Why: Not all uploaded files should be publicly accessible. Profile pictures might be, but confidential documents or private media should not.
- Solution: For private files, do not serve them directly via
fastify-static. Instead, create a dedicated API endpoint (e.g.,GET /api/v1/files/:id) that performs authorization checks, then retrieves the file from storage (local or cloud) and streams it to the client. Pre-signed URLs from cloud storage are an excellent solution for temporary, authenticated access.
- Logging & Monitoring:
- Why: Detailed logs are crucial for debugging, auditing, and detecting suspicious activity.
- Solution: Log all upload attempts, including user ID, file metadata (original name, new name, size, MIME type), and the outcome (success/failure, reason for failure). Monitor storage usage and performance metrics.
- Error Handling:
- Ensure all possible Multer errors (file size, type, count, unexpected field) are caught and translated into meaningful API responses. Our current implementation does this well.
Code Review Checkpoint
At this point, you should have:
- Installed:
fastify-multer,multer,fastify-static,mime-types. - Created
uploads/andpublic/directories. - Created
src/utils/fileValidation.ts: Contains a reusable function for file type validation. - Created
src/plugins/uploadPlugin.ts: Configures Multer with disk storage, unique filename generation, file size limits, and robust file type filtering. It decoratesfastifywith theuploadinstance. - Created
src/routes/uploadRoutes.ts: Defines a POST endpoint for/api/v1/uploads/profile-picturethat usesfastify.upload.single()middleware, applies JWT authentication, and handles Multer-specific errors. - Modified
src/app.ts: Registeredfastify-statictwice – once for/static/(pointing topublic/) and once for/uploads/(pointing touploads/), including appropriate cache headers. - Modified
src/plugins/index.tsandsrc/routes/index.ts: IntegrateduploadPluginanduploadRoutesinto the application’s plugin and route registration system.
The application can now securely handle file uploads and serve both general static assets and user-uploaded content.
Common Issues & Solutions
- Issue:
ENOENT: no such file or directory, open 'uploads/...'- Cause: The
uploads/directory (or your configureddestination) does not exist when Multer tries to save a file. - Debugging: Check your project structure. Ensure the
uploadsdirectory is created at the root level (or whereverpath.resolve(__dirname, '../../uploads')points). - Solution: Manually create the
uploads/directory, or add a script/logic to ensure it exists on application start.
- Cause: The
- Issue:
MulterError: Unexpected fieldorMulterError: Bad Request- Cause: The field name in your
fastify.upload.single('fieldName')call does not match the name used in the client-side form data. - Debugging: Double-check the
nameattribute of your file input in the client (e.g.,<input type="file" name="profilePicture">) or the key name used in Postman/cURL’s form-data body. It must exactly match'profilePicture'infastify.upload.single('profilePicture'). - Solution: Correct the field name to match.
- Cause: The field name in your
- Issue:
401 Unauthorizedfor file upload, even with a token.- Cause: The
verifyAccessTokenmiddleware is failing, or the token is invalid/expired. - Debugging:
- Log the
Authorizationheader received by the server. - Check your JWT secret and expiration settings.
- Ensure the
verifyAccessTokenmiddleware is correctly placed before the Multer middleware in thepreHandlerarray.
- Log the
- Solution: Verify token validity, ensure correct secret, and check middleware order.
- Cause: The
- Issue:
400 Bad Requestwith “Invalid file type” or “File too large” errors.- Cause: Your file filter or size limits are rejecting the file.
- Debugging:
- For “Invalid file type”: Check
ALLOWED_IMAGE_MIME_TYPESinuploadPlugin.tsand the MIME type of the file you are uploading. - For “File too large”: Check
MAX_FILE_SIZE_BYTESinuploadPlugin.tsand the size of your uploaded file.
- For “Invalid file type”: Check
- Solution: Adjust limits/allowed types as needed, or ensure you’re uploading a valid file.
- Issue: Static files not loading or returning 404.
- Cause: Incorrect
rootpath orprefixforfastify-static. - Debugging:
- Verify
root: path.resolve(__dirname, '../public')points to the correctpublicdirectory. Useconsole.log(path.resolve(__dirname, '../public'))to see the resolved path. - Check the
prefixvalue and ensure your browser URL matches it (e.g.,http://localhost:3000/static/index.htmlforprefix: '/static/').
- Verify
- Solution: Correct the
rootandprefixpaths.
- Cause: Incorrect
Testing & Verification
To ensure everything is working as expected:
- Start your application:
npm run dev - Verify Static Assets:
- Open
http://localhost:3000/static/index.htmlin your browser. The page should load correctly. - Check developer tools -> Network tab for
index.htmland confirmCache-Control: no-cacheheaders.
- Open
- Verify File Upload:
- Obtain a valid JWT access token by logging in via
POST /api/v1/auth/login. - Use Postman or cURL to send a
POSTrequest tohttp://localhost:3000/api/v1/uploads/profile-picture. - Attach a small image file (e.g., JPG, PNG, < 5MB) using
form-datawith the field nameprofilePicture. - Include the
Authorization: Bearer <your_token>header. - Expect a
200 OKresponse withmessageandfilePath. - Verify the file appears in your
uploads/directory.
- Obtain a valid JWT access token by logging in via
- Verify Serving Uploaded File:
- Take the
filePathfrom the upload response (e.g.,/uploads/profilePicture-uuid.jpeg). - Open
http://localhost:3000<filePath>in your browser. The uploaded image should display. - Check developer tools -> Network tab for the image and confirm
Cache-Control: public, max-age=31536000headers.
- Take the
- Verify Error Handling:
- Attempt to upload a text file (
.txt) – should get400 Bad Request(Invalid file type). - Attempt to upload a very large file (> 5MB) – should get
400 Bad Request(File too large). - Attempt to upload without an
Authorizationheader – should get401 Unauthorized.
- Attempt to upload a text file (
If all these steps pass, your secure file upload and static asset serving mechanisms are correctly implemented!
Summary & Next Steps
In this chapter, we successfully implemented secure file upload functionality using fastify-multer and configured our Fastify application to serve static assets with fastify-static. We focused heavily on security best practices, including robust file type and size validation, unique filename generation, and proper error handling. We also discussed critical production considerations like cloud storage, image optimization, CDNs, and advanced security scanning, setting the stage for a truly production-ready media management system.
Having established a solid foundation for handling media, our next logical step is to persist application data. In Chapter 7: Database Integration (PostgreSQL & Prisma ORM), we will dive into integrating a relational database (PostgreSQL) with our Fastify application, leveraging Prisma ORM for type-safe and efficient database interactions. We’ll design our database schema, implement migrations, and build basic CRUD (Create, Read, Update, Delete) operations, connecting our backend to a powerful data store.