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.

flowchart TD Client[Client Application] -->|HTTP POST /api/v1/uploads/profile-picture| FastifyServer[Fastify Server] FastifyServer -->|Uses fastify-multer| MulterPlugin[Multer Plugin] MulterPlugin -->|Validates & Stores File| LocalStorage[Local File System] LocalStorage -->|File Path Reference| Database[(Database)] Client -->|HTTP GET /uploads/:filename| FastifyServer FastifyServer -->|Uses fastify-static| StaticPlugin[Static Files Plugin] StaticPlugin -->|Serves File| LocalStorage Client -->|HTTP GET /static/index.html| FastifyServer StaticPlugin -->|Serves Public Asset| PublicDir[Public Directory]

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 a public/ 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 integrating multer.
  • multer: A Node.js middleware for handling multipart/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 use path.resolve to ensure a consistent path relative to our project root, placing uploads in the uploads/ directory.
    • filename: Generates a unique filename using randomUUID() and preserves the original file extension. This is crucial for security, preventing malicious users from uploading files with dangerous names (e.g., ../../../etc/passwd or .php).
  • multer instance:
    • storage: Uses the diskStorage configuration.
    • 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 call fileTypeValidator to check the MIME type against a list of ALLOWED_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 as fastify.upload in our routes.
  • fp(uploadPlugin, { name: 'uploadPlugin' }): Wraps our plugin with fastify-plugin to 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 via fastify.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 name profilePicture in the incoming form data. If successful, the file details will be available in request.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 appropriate AppError responses. 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:

  • fastifyStatic registration for public/:
    • root: Points to our public directory.
    • prefix: '/static/': Means files in public/ will be accessible under /static/. E.g., public/index.html becomes http://localhost:3000/static/index.html.
    • setHeaders: We add Cache-Control headers for performance. HTML files are set to no-cache to ensure fresh content, while other assets are aggressively cached.
  • fastifyStatic registration for uploads/:
    • root: Points to our uploads directory.
    • prefix: '/uploads/': Files in uploads/ will be accessible under /uploads/. E.g., an uploaded file profilePicture-uuid.jpeg becomes http://localhost:3000/uploads/profilePicture-uuid.jpeg.
    • Security Note: While fastify-static disables 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 registering fastify-static multiple times. It prevents conflicts if reply.sendFile or 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

  1. Start your Fastify server:
    npm run dev
    
  2. 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-Control headers are correctly set using your browser’s developer tools.
  3. Test File Upload (using Postman or cURL):
    • Prerequisite: You need an access_token from 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 select form-data)
    • Body: Select form-data.
      • Add a key named profilePicture.
      • Change its type from Text to File.
      • Select an image file (JPEG, PNG, GIF, or WebP) from your computer.
    • Send Request.
    • Expected Success: You should receive a 200 OK response 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.
  4. Test File Upload - Error Cases:
    • Invalid File Type: Try uploading a .txt or .pdf file. You should get a 400 Bad Request with 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 Request with 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 Request with an error message like “No file uploaded or file upload failed.”
    • Unauthenticated: Send the request without the Authorization header. You should get a 401 Unauthorized response.

Production Considerations

While our current implementation works, several critical aspects need to be addressed for a production environment.

  1. 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.ts would change to use a cloud storage engine, and filePath would become a URL to the cloud object.
  2. 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.
  3. 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.
  4. 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: nosniff header for all served files to prevent browsers from trying to guess the MIME type, which can lead to XSS attacks. fastify-helmet generally handles this.
  5. 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.
  6. 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.
  7. 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/ and public/ 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 decorates fastify with the upload instance.
  • Created src/routes/uploadRoutes.ts: Defines a POST endpoint for /api/v1/uploads/profile-picture that uses fastify.upload.single() middleware, applies JWT authentication, and handles Multer-specific errors.
  • Modified src/app.ts: Registered fastify-static twice – once for /static/ (pointing to public/) and once for /uploads/ (pointing to uploads/), including appropriate cache headers.
  • Modified src/plugins/index.ts and src/routes/index.ts: Integrated uploadPlugin and uploadRoutes into 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

  1. Issue: ENOENT: no such file or directory, open 'uploads/...'
    • Cause: The uploads/ directory (or your configured destination) does not exist when Multer tries to save a file.
    • Debugging: Check your project structure. Ensure the uploads directory is created at the root level (or wherever path.resolve(__dirname, '../../uploads') points).
    • Solution: Manually create the uploads/ directory, or add a script/logic to ensure it exists on application start.
  2. Issue: MulterError: Unexpected field or MulterError: 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 name attribute 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' in fastify.upload.single('profilePicture').
    • Solution: Correct the field name to match.
  3. Issue: 401 Unauthorized for file upload, even with a token.
    • Cause: The verifyAccessToken middleware is failing, or the token is invalid/expired.
    • Debugging:
      • Log the Authorization header received by the server.
      • Check your JWT secret and expiration settings.
      • Ensure the verifyAccessToken middleware is correctly placed before the Multer middleware in the preHandler array.
    • Solution: Verify token validity, ensure correct secret, and check middleware order.
  4. Issue: 400 Bad Request with “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_TYPES in uploadPlugin.ts and the MIME type of the file you are uploading.
      • For “File too large”: Check MAX_FILE_SIZE_BYTES in uploadPlugin.ts and the size of your uploaded file.
    • Solution: Adjust limits/allowed types as needed, or ensure you’re uploading a valid file.
  5. Issue: Static files not loading or returning 404.
    • Cause: Incorrect root path or prefix for fastify-static.
    • Debugging:
      • Verify root: path.resolve(__dirname, '../public') points to the correct public directory. Use console.log(path.resolve(__dirname, '../public')) to see the resolved path.
      • Check the prefix value and ensure your browser URL matches it (e.g., http://localhost:3000/static/index.html for prefix: '/static/').
    • Solution: Correct the root and prefix paths.

Testing & Verification

To ensure everything is working as expected:

  1. Start your application: npm run dev
  2. Verify Static Assets:
    • Open http://localhost:3000/static/index.html in your browser. The page should load correctly.
    • Check developer tools -> Network tab for index.html and confirm Cache-Control: no-cache headers.
  3. 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 POST request to http://localhost:3000/api/v1/uploads/profile-picture.
    • Attach a small image file (e.g., JPG, PNG, < 5MB) using form-data with the field name profilePicture.
    • Include the Authorization: Bearer <your_token> header.
    • Expect a 200 OK response with message and filePath.
    • Verify the file appears in your uploads/ directory.
  4. Verify Serving Uploaded File:
    • Take the filePath from 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=31536000 headers.
  5. Verify Error Handling:
    • Attempt to upload a text file (.txt) – should get 400 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 Authorization header – should get 401 Unauthorized.

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.