Skip to content

Subida de archivos con multer ​

multer procesa formularios multipart/form-data (los que llevan archivos). Este snippet configura subidas seguras: límite de tamaño, sólo tipos permitidos, nombres aleatorios, varias imágenes a la vez y, opcionalmente, redimensionado con sharp.

Instalación ​

bash
npm install multer
npm install sharp   # opcional: redimensionar y convertir a WebP

Middleware reutilizable ​

js
import multer from 'multer';
import path from 'node:path';
import crypto from 'node:crypto';
import fs from 'node:fs';
import { AppError } from '../utils/AppError.js';

const CARPETA = path.resolve('uploads');
fs.mkdirSync(CARPETA, { recursive: true });

const TIPOS = {
  imagen: { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp' },
  documento: { 'application/pdf': '.pdf' },
};

/**
 * Crea un middleware de multer.
 * @param {'imagen'|'documento'} tipo
 * @param {number} maxMB tamaño máximo por archivo
 */
export function crearSubida(tipo = 'imagen', maxMB = 5, { enMemoria = false } = {}) {
  const permitidos = TIPOS[tipo];

  const storage = enMemoria
    ? multer.memoryStorage() // útil para procesar con sharp o subir a S3/Cloudinary
    : multer.diskStorage({
        destination: (req, file, cb) => cb(null, CARPETA),
        filename: (req, file, cb) => {
          // Nombre aleatorio + extensión según el MIME (no según el nombre original)
          cb(null, crypto.randomUUID() + permitidos[file.mimetype]);
        },
      });

  return multer({
    storage,
    limits: { fileSize: maxMB * 1024 * 1024, files: 10 },
    fileFilter: (req, file, cb) => {
      if (permitidos[file.mimetype]) return cb(null, true);
      cb(new AppError(`Tipo de archivo no permitido. Usa: ${Object.values(permitidos).join(', ')}`, 415));
    },
  });
}

export const subirImagen = crearSubida('imagen', 5);
export const subirImagenMemoria = crearSubida('imagen', 8, { enMemoria: true });
export const subirPdf = crearSubida('documento', 10);

El MIME también se puede falsificar

file.mimetype viene de lo que declara el navegador. Para máxima seguridad con imágenes, procesa el archivo con sharp (abajo): si no es una imagen real, sharp lanza error y el archivo se descarta.

Rutas de ejemplo ​

js
import { Router } from 'express';
import fs from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import crypto from 'node:crypto';
import { subirImagen, subirImagenMemoria, subirPdf } from '../middlewares/subida.js';
import { asyncHandler } from '../utils/asyncHandler.js';
import { AppError } from '../utils/AppError.js';

const router = Router();

// 1) Una imagen: campo "foto"
router.post('/avatar', subirImagen.single('foto'), (req, res) => {
  if (!req.file) throw AppError.badRequest('Selecciona una imagen');
  res.status(201).json({
    archivo: req.file.filename,
    url: `/uploads/${req.file.filename}`,
    tamano: req.file.size,
  });
});

// 2) Varias imágenes: campo "fotos" (máx. 6)
router.post('/galeria', subirImagen.array('fotos', 6), (req, res) => {
  const urls = (req.files ?? []).map((f) => `/uploads/${f.filename}`);
  res.status(201).json({ urls });
});

// 3) Campos mixtos: portada + documento PDF
router.post('/producto', subirImagen.fields([{ name: 'portada', maxCount: 1 }]), (req, res) => {
  const portada = req.files?.portada?.[0];
  res.json({ nombre: req.body.nombre, portada: portada && `/uploads/${portada.filename}` });
});

// 4) Imagen optimizada con sharp: redimensiona a 1200px y convierte a WebP
router.post('/optimizada', subirImagenMemoria.single('foto'), asyncHandler(async (req, res) => {
  if (!req.file) throw AppError.badRequest('Selecciona una imagen');

  const nombre = `${crypto.randomUUID()}.webp`;
  const destino = path.resolve('uploads', nombre);
  try {
    await sharp(req.file.buffer)
      .rotate()                                     // corrige orientación EXIF
      .resize({ width: 1200, height: 1200, fit: 'inside', withoutEnlargement: true })
      .webp({ quality: 80 })
      .toFile(destino);                             // los metadatos EXIF se eliminan

    // Miniatura cuadrada
    await sharp(req.file.buffer).rotate().resize(300, 300, { fit: 'cover' })
      .webp({ quality: 75 }).toFile(path.resolve('uploads', `thumb_${nombre}`));
  } catch {
    throw AppError.badRequest('El archivo no es una imagen válida');
  }

  res.status(201).json({ url: `/uploads/${nombre}`, miniatura: `/uploads/thumb_${nombre}` });
}));

// 5) PDF
router.post('/cv', subirPdf.single('cv'), (req, res) => {
  res.status(201).json({ archivo: req.file?.filename });
});

// Eliminar (valida el nombre para evitar "../../")
router.delete('/:nombre', asyncHandler(async (req, res) => {
  const nombre = path.basename(req.params.nombre);
  if (!/^[\w-]+\.(jpg|png|webp|pdf)$/.test(nombre)) throw AppError.badRequest('Nombre no válido');
  await fs.unlink(path.resolve('uploads', nombre)).catch(() => { throw AppError.noEncontrado(); });
  res.status(204).end();
}));

export default router;

Servir los archivos subidos ​

js
// src/app.js
import archivosRouter from './routes/archivos.routes.js';

app.use('/uploads', express.static('uploads', {
  maxAge: '30d',
  setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'),
}));
app.use('/api/archivos', archivosRouter);

Enviar desde el frontend ​

html
<form id="form-foto">
  <input type="file" name="foto" accept="image/*" required>
  <button>Subir</button>
  <img id="vista" alt="" style="max-width:200px">
</form>
js
document.getElementById('form-foto').addEventListener('submit', async (e) => {
  e.preventDefault();
  const datos = new FormData(e.target); // ¡no pongas Content-Type a mano!
  const res = await fetch('/api/archivos/optimizada', { method: 'POST', body: datos });
  const json = await res.json();
  if (!res.ok) return alert(json.error);
  document.getElementById('vista').src = json.url;
});

Error común

Poner headers: { 'Content-Type': 'multipart/form-data' } en el fetch. El navegador necesita añadir el boundary automáticamente; si lo pones tú, el servidor no puede leer el archivo. Con FormData no definas Content-Type.

En producción

Los servidores tipo Render o Railway borran el disco en cada despliegue. Para archivos permanentes usa almacenamiento externo (Cloudinary, Amazon S3, Cloudflare R2) con memoryStorage() y sube el buffer desde ahí.

Academia Web — aprende, copia y construye.