Skip to main content
The Portal Self-Service Backend uses Multer to handle multipart/form-data file uploads, with separate configurations for different modules and comprehensive security controls.

Architecture Overview

Multer Middleware

Disk storage with dynamic folder creation

File Validation

MIME type filtering and size limits

Unique Naming

Timestamp-based naming to prevent collisions

Storage Configuration

The system uses a factory function to create storage configurations for different modules:
// From uploadMiddleware.js:5-27
import multer from 'multer';
import path from 'path';
import fs from 'fs';

// Helper function for dynamic storage configurations
const createStorage = (folderName) => {
    // Safe absolute path for cPanel and local (e.g., /public/uploads/solicitudes)
    const uploadPath = path.join(process.cwd(), 'public', 'uploads', folderName);
    
    // Create folder if it doesn't exist (useful for first deployment)
    if (!fs.existsSync(uploadPath)) {
        fs.mkdirSync(uploadPath, { recursive: true });
    }
    
    return multer.diskStorage({
        destination: (req, file, cb) => {
            cb(null, uploadPath);
        },
        filename: (req, file, cb) => {
            // Unique name: timestamp + random number + original extension
            const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
            const ext = path.extname(file.originalname);
            const basename = path.basename(file.originalname, ext);
            cb(null, `${basename}-${uniqueSuffix}${ext}`);
        }
    });
};
Storage Path Structure:
/public
  /uploads
    /solicitudes    - Request attachments
    /comunicados    - Company announcements
    /documentos     - General documents

File Type Validation

The middleware includes MIME type filtering to allow only specific file types:
// From uploadMiddleware.js:29-51
const fileFilter = (req, file, cb) => {
    const allowedTypes = [
        // Documents
        'application/pdf',
        'application/msword', // .doc
        'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
        // Images
        'image/jpeg', // .jpg, .jpeg
        'image/png', // .png
        'image/webp',
        // Spreadsheets
        'application/vnd.ms-excel', // .xls
        'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
    ];
    
    if (allowedTypes.includes(file.mimetype)) {
        cb(null, true);
    } else {
        cb(
            new Error('Tipo de archivo no permitido. Solo se aceptan PDF y documentos Word.'), 
            false
        );
    }
};
Allowed File Types:
  • PDF documents (.pdf)
  • Word documents (.doc, .docx)
  • Images (.jpg, .jpeg, .png, .webp)
  • Excel spreadsheets (.xls, .xlsx)
Attempts to upload other file types will be rejected.

Upload Middleware Configurations

The system exports separate middleware instances for different modules:

Solicitud (Request) Uploads

// From uploadMiddleware.js:54-57
export const uploadSolicitud = multer({ 
    storage: createStorage('solicitudes'),
    limits: { fileSize: 10 * 1024 * 1024 } // 10MB
});
Used for attachments to employee requests (vacations, permissions, etc.).

Comunicado (Announcement) Uploads

// From uploadMiddleware.js:59-62
export const uploadComunicado = multer({ 
    storage: createStorage('comunicados'),
    limits: { fileSize: 5 * 1024 * 1024 } // 5MB
});
Used for company announcements and internal communications.

General Document Uploads

// From uploadMiddleware.js:64-67
export const uploadDocumento = multer({ 
    storage: createStorage('documentos'),
    limits: { fileSize: 20 * 1024 * 1024 } // 20MB
});
Used for general document management with a higher size limit.

Solicitudes

10MB limit for request attachments

Comunicados

5MB limit for announcements

Documentos

20MB limit for general documents

Usage in Routes

Single File Upload

import { uploadSolicitud } from '../middleware/uploadMiddleware.js';

// Upload single file with field name 'documento'
router.post(
    '/solicitudes',
    authenticate,
    uploadSolicitud.single('documento'),
    solicitudController.createSolicitud
);
The uploaded file will be available in the controller as:
export const createSolicitud = async (req, res) => {
    const { id_tipo_solicitud, fecha_inicio, fecha_fin, descripcion_solicitud } = req.body;
    
    // Access uploaded file
    const documento_url = req.file ? `/uploads/solicitudes/${req.file.filename}` : null;
    
    // Use documento_url when creating the request
    const nuevaSolicitud = await solicitudService.createSolicitud({
        // ... other fields
        documento_url
    });
    
    return res.status(201).json({ solicitud: nuevaSolicitud });
};

Multiple File Upload

// Upload multiple files (max 5) with field name 'documentos'
router.post(
    '/documentos/batch',
    authenticate,
    uploadDocumento.array('documentos', 5),
    documentoController.uploadMultiple
);
Access multiple files:
export const uploadMultiple = async (req, res) => {
    // req.files is an array of uploaded files
    const documentUrls = req.files.map(file => 
        `/uploads/documentos/${file.filename}`
    );
    
    return res.status(201).json({ 
        message: `${documentUrls.length} archivos subidos exitosamente`,
        urls: documentUrls 
    });
};

Multiple Fields

// Upload from multiple fields
router.post(
    '/perfil/update',
    authenticate,
    uploadDocumento.fields([
        { name: 'foto_perfil', maxCount: 1 },
        { name: 'documento_identidad', maxCount: 1 }
    ]),
    perfilController.updateProfile
);
export const updateProfile = async (req, res) => {
    // Access files by field name
    const fotoPerfil = req.files['foto_perfil'] ? req.files['foto_perfil'][0] : null;
    const documentoId = req.files['documento_identidad'] ? req.files['documento_identidad'][0] : null;
    
    const updateData = {
        img_perfil: fotoPerfil ? `/uploads/documentos/${fotoPerfil.filename}` : undefined,
        documento_identidad_url: documentoId ? `/uploads/documentos/${documentoId.filename}` : undefined
    };
    
    // Update profile...
};

Error Handling

File Size Exceeded

import multer from 'multer';

router.post('/upload', uploadSolicitud.single('file'), (error, req, res, next) => {
    if (error instanceof multer.MulterError) {
        if (error.code === 'LIMIT_FILE_SIZE') {
            return res.status(400).json({
                message: 'Archivo demasiado grande. Máximo 10MB permitido.'
            });
        }
        return res.status(400).json({ message: error.message });
    } else if (error) {
        return res.status(400).json({ message: error.message });
    }
    next();
});

Invalid File Type

The fileFilter function automatically rejects invalid file types:
// Error response for invalid file type
{
    "message": "Tipo de archivo no permitido. Solo se aceptan PDF y documentos Word."
}

Missing File

export const createSolicitud = async (req, res) => {
    // Check if file is required but not provided
    if (requiresDocument && !req.file) {
        return res.status(400).json({
            message: 'Este tipo de solicitud requiere un documento adjunto.'
        });
    }
    
    // Continue processing...
};

File Naming Convention

Uploaded files follow this naming pattern:
{original-basename}-{timestamp}-{random-number}.{extension}
Example:
certificado-medico-1709123456789-847362819.pdf
The combination of timestamp and random number ensures filename uniqueness and prevents overwriting existing files.

Security Considerations

MIME Type Validation

Only whitelisted file types are accepted

File Size Limits

Prevents resource exhaustion from large files

Unique Filenames

Prevents path traversal and overwrite attacks

Separate Directories

Isolates different file types by purpose

Additional Security Best Practices

Production Recommendations:
  1. Virus Scanning: Implement antivirus scanning for uploaded files
  2. CDN Integration: Serve files through a CDN with signed URLs
  3. Access Control: Validate user permissions before serving files
  4. Metadata Stripping: Remove EXIF data from images
  5. Content-Type Headers: Set proper Content-Type when serving files
  6. Rate Limiting: Limit upload requests per user per time period

Serving Uploaded Files

Static file serving for uploads:
// In your main app.js or server.js
import express from 'express';
import path from 'path';

const app = express();

// Serve uploaded files statically
app.use('/uploads', express.static(path.join(process.cwd(), 'public', 'uploads')));
With authentication middleware:
// Protected file serving
app.use('/uploads/solicitudes', authenticate, express.static(
    path.join(process.cwd(), 'public', 'uploads', 'solicitudes')
));

File Cleanup

Implement cleanup for rejected requests:
import fs from 'fs';
import path from 'path';

export const deleteUploadedFile = (filePath) => {
    const fullPath = path.join(process.cwd(), 'public', filePath);
    
    if (fs.existsSync(fullPath)) {
        fs.unlinkSync(fullPath);
        console.log(`Deleted file: ${filePath}`);
    }
};

// Usage in rejection handler
export const rechazarSolicitud = async (req, res) => {
    const solicitud = await solicitudService.getSolicitudById(id);
    
    // Delete associated document if request is rejected
    if (solicitud.documento_url) {
        deleteUploadedFile(solicitud.documento_url);
    }
    
    // Continue with rejection...
};

Frontend Integration

Using FormData

const uploadRequestDocument = async (solicitudData, file) => {
    const formData = new FormData();
    
    // Append text fields
    formData.append('id_tipo_solicitud', solicitudData.id_tipo_solicitud);
    formData.append('fecha_inicio', solicitudData.fecha_inicio);
    formData.append('fecha_fin', solicitudData.fecha_fin);
    formData.append('descripcion_solicitud', solicitudData.descripcion_solicitud);
    
    // Append file
    if (file) {
        formData.append('documento', file);
    }
    
    const response = await fetch('/api/solicitudes', {
        method: 'POST',
        headers: {
            'Authorization': `Bearer ${token}`
            // Don't set Content-Type - browser will set it with boundary
        },
        body: formData
    });
    
    return response.json();
};

React Example

import { useState } from 'react';

function SolicitudForm() {
    const [file, setFile] = useState(null);
    
    const handleSubmit = async (e) => {
        e.preventDefault();
        
        const formData = new FormData();
        formData.append('id_tipo_solicitud', 1);
        formData.append('fecha_inicio', '2026-03-15');
        formData.append('fecha_fin', '2026-03-20');
        formData.append('descripcion_solicitud', 'Vacaciones');
        
        if (file) {
            formData.append('documento', file);
        }
        
        const response = await fetch('/api/solicitudes', {
            method: 'POST',
            headers: {
                'Authorization': `Bearer ${token}`
            },
            body: formData
        });
        
        if (response.ok) {
            alert('Solicitud creada exitosamente');
        }
    };
    
    return (
        <form onSubmit={handleSubmit}>
            <input 
                type="file" 
                onChange={(e) => setFile(e.target.files[0])}
                accept=".pdf,.doc,.docx"
            />
            <button type="submit">Enviar Solicitud</button>
        </form>
    );
}

Request Workflow

Learn how file uploads integrate with the request lifecycle

API Reference

View the complete API documentation for file upload endpoints

Build docs developers (and LLMs) love