All files / src/middleware multer.middleware.ts

73.95% Statements 142/192
75% Branches 36/48
80.95% Functions 17/21
75% Lines 105/140

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 1412x 2x 2x 2x 2x 2x 2x 2x 2x   2x 36x 36x 36x 2x 2x 36x 2x 2x 2x 36x 36x 34x 34x 34x 2x 2x 2x 2x 2x 2x 2x 2x 36x 109x 2x 28x 7x   10x 10x 2x 2x 2x 9x 2x 8x 2x 2x 2x 2x 2x       23x 3x   3x 8x 3x 3x 3x         58x   44x 44x       8x 46x 46x 46x           36x         59x 55x     8x 8x 4x 4x 4x 4x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 36x 109x 1x     109x 102x     109x 104x     109x       2x 8x 8x 36x 202x 2x 8x 8x 8x 199x    
// src/middleware/multer.middleware.ts
import multer from 'multer';
import path from 'path';
import fs from 'node:fs/promises';
import { Request, Response, NextFunction } from 'express';
import { UserProfile } from '../types';
import { sanitizeFilename } from '../utils/stringUtils';
import { ValidationError } from '../services/db/errors.db';
import { logger } from '../services/logger.server';

export const flyerStoragePath =
  process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
export const avatarStoragePath = path.join(process.cwd(), 'public', 'uploads', 'avatars');
export const receiptStoragePath = path.join(
  process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com',
  'receipts',
);
 
// Ensure directories exist at startup
(async () => {
  try {
    await fs.mkdir(flyerStoragePath, { recursive: true });
    await fs.mkdir(avatarStoragePath, { recursive: true });
    await fs.mkdir(receiptStoragePath, { recursive: true });
    logger.info('Ensured multer storage directories exist.');
  } catch (error) {
    const err = error instanceof Error ? error : new Error(String(error));
    logger.error({ error: err }, 'Failed to create multer storage directories on startup.');
  }
})();
 
type StorageType = 'flyer' | 'avatar' | 'receipt';
 
const getStorageConfig = (type: StorageType) => {
  switch (type) {
    case 'avatar':
      return multer.diskStorage({
        destination: (req, file, cb) => cb(null, avatarStoragePath),
        filename: (req, file, cb) => {
          const user = req.user as UserProfile | undefined;
          if (!user) {
            // This should ideally not happen if auth middleware runs first.
            return cb(new Error('User not authenticated for avatar upload'), '');
          }
          if (process.env.NODE_ENV === 'test') {
            // Use a predictable filename for test avatars for easy cleanup.
            return cb(null, `test-avatar${path.extname(file.originalname) || '.png'}`);
          }
          const uniqueSuffix = `${user.user.user_id}-${Date.now()}${path.extname(
            file.originalname,
          )}`;
          cb(null, uniqueSuffix);
        },
      });
    case 'receipt':
      return multer.diskStorage({
        destination: (req, file, cb) => cb(null, receiptStoragePath),
        filename: (req, file, cb) => {
          const user = req.user as UserProfile | undefined;
          const userId = user?.user.user_id || 'anonymous';
          const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
          const sanitizedOriginalName = sanitizeFilename(file.originalname);
          cb(null, `receipt-${userId}-${uniqueSuffix}-${sanitizedOriginalName}`);
        },
      });
    case 'flyer':
    default:
      return multer.diskStorage({
        destination: (req, file, cb) => {
          console.error('[MULTER DEBUG] Flyer storage destination:', flyerStoragePath);
          cb(null, flyerStoragePath);
        },
        filename: (req, file, cb) => {
          // Use unique filenames in ALL environments to prevent race conditions
          // between concurrent test runs or uploads.
          const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
          const sanitizedOriginalName = sanitizeFilename(file.originalname);
          cb(null, `${file.fieldname}-${uniqueSuffix}-${sanitizedOriginalName}`);
        },
      });
  }
};

const imageFileFilter = (
  req: Request,
  file: Express.Multer.File,
  cb: multer.FileFilterCallback,
) => {
  if (file.mimetype.startsWith('image/')) {
    cb(null, true);
  } else {
    // Reject the file with a specific error that can be caught by a middleware.
    const validationIssue = {
      path: ['file', file.fieldname],
      message: 'Only image files are allowed!',
    };
    const err = new ValidationError([validationIssue], 'Only image files are allowed!');
    cb(err as Error); // Cast to Error to satisfy multer's type, though ValidationError extends Error.
  }
};
 
interface MulterOptions {
  storageType: StorageType;
  fileSize?: number;
  fileFilter?: 'image';
}
 
/**
 * Creates a configured multer instance for file uploads.
 * @param options - Configuration for storage type, file size, and file filter.
 * @returns A multer instance.
 */
export const createUploadMiddleware = (options: MulterOptions) => {
  const multerOptions: multer.Options = {
    storage: getStorageConfig(options.storageType),
  };

  if (options.fileSize) {
    multerOptions.limits = { fileSize: options.fileSize };
  }

  if (options.fileFilter === 'image') {
    multerOptions.fileFilter = imageFileFilter;
  }

  return multer(multerOptions);
};

/**
 * A general error handler for multer. Place this after all routes using multer in your router file.
 * It catches errors from `fileFilter` and other multer issues (e.g., file size limits).
 */
export const handleMulterError = (err: Error, req: Request, res: Response, next: NextFunction) => {
  if (err instanceof multer.MulterError) {
    // A Multer error occurred when uploading (e.g., file too large).
    return res.status(400).json({ message: `File upload error: ${err.message}` });
  }
  // If it's not a multer error, pass it on.
  next(err);
};