Tema
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 WebPMiddleware 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í.