Deduplicación eficiente de archivos con SHA-256 y Node.js
Los archivos duplicados pueden convertirse rápidamente en un dolor de cabeza en las aplicaciones modernas: consumen almacenamiento y ralentizan las consultas. En este DevTip crearemos un pequeño servidor de Node.js que rechaza las subidas duplicadas calculando un hash SHA-256 para cada archivo entrante y comparándolo con los que ya se han visto.
¿Por qué no MD5?
MD5 solía ser el algoritmo preferido para comprobaciones rápidas de integridad, pero ya no se considera resistente a colisiones. Para un sistema de deduplicación normalmente no necesitas toda la fuerza criptográfica, pero elegir un hash roto significa aceptar un riesgo (pequeño, pero real) de que dos archivos diferentes produzcan el mismo digest. SHA-256 sigue siendo rápido, viene incluido con Node.js y elimina esa preocupación, así que es el que usaremos a lo largo del artículo.
Entender la deduplicación basada en contenido
La deduplicación basada en contenido, una forma de direccionamiento por contenido, identifica archivos idénticos por su contenido binario en lugar de por sus nombres de archivo o metadatos. El flujo de trabajo es sencillo:
- Recibe una subida.
- Pasa los bytes en streaming por una función de hash.
- Busca ese hash en un almacén de datos.
- Rechaza, reutiliza o guarda el archivo según si el hash ya existe.
Como los hashes tienen una longitud fija (256 bits para SHA-256), puedes usarlos como claves primarias ligeras sin importar el tamaño del archivo original.
Configurar el proyecto
mkdir file-deduplication && cd $_
npm init -y
npm install express@^4.21.2 multer@^2.3.0 sqlite3@^5.1.7
Reduciremos al mínimo las dependencias adicionales: express para el enrutamiento, multer para el análisis
multipart y sqlite3 para una pequeña base de datos persistente. path y crypto vienen incluidos con Node.js.
Crear un manejador de subidas local
Esta demostración local no tiene autenticación ni aislamiento por usuario. Enlázala a localhost y añade ambos antes de desplegar un servicio de subidas. Los tipos MIME proporcionados por el cliente son solo un filtro preliminar; valida el contenido real de los archivos antes de procesar o servir las subidas.
// index.js
const express = require('express')
const multer = require('multer')
const path = require('path')
const fs = require('fs')
const crypto = require('crypto')
const sqlite3 = require('sqlite3').verbose()
const app = express()
/* -------------------------------------------------------------------------- */
/* Database */
/* -------------------------------------------------------------------------- */
const db = new sqlite3.Database('deduplication.db')
const createFilesTable = `CREATE TABLE IF NOT EXISTS files (
hash TEXT PRIMARY KEY,
original_name TEXT,
filename TEXT,
file_path TEXT,
size INTEGER,
upload_date TEXT
)`
/* -------------------------------------------------------------------------- */
/* Multer configuration */
/* -------------------------------------------------------------------------- */
const storage = multer.diskStorage({
destination(req, file, cb) {
const dir = 'uploads'
fs.mkdirSync(dir, { recursive: true })
cb(null, dir)
},
filename(req, file, cb) {
const unique = Date.now() + '-' + Math.round(Math.random() * 1e9)
cb(null, `${file.fieldname}-${unique}${path.extname(file.originalname)}`)
},
})
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
const upload = multer({
storage,
limits: { fileSize: MAX_SIZE, files: 1 },
fileFilter(req, file, cb) {
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf']
cb(null, allowed.includes(file.mimetype))
},
})
/* -------------------------------------------------------------------------- */
/* Helper: stream hashing */
/* -------------------------------------------------------------------------- */
function hashFile(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256')
const stream = fs.createReadStream(filePath)
stream.on('data', (chunk) => hash.update(chunk))
stream.on('end', () => resolve(hash.digest('hex')))
stream.on('error', reject)
})
}
function removeUpload(filePath) {
fs.unlink(filePath, (error) => {
if (error && error.code !== 'ENOENT') console.error('Upload cleanup failed:', error.code)
})
}
/* -------------------------------------------------------------------------- */
/* Routes */
/* -------------------------------------------------------------------------- */
app.post('/upload', (req, res) => {
upload.single('file')(req, res, async (err) => {
if (err instanceof multer.MulterError) {
return res.status(400).json({ error: 'Upload rejected by size or file-count limits' })
}
if (err) {
return res.status(500).json({ error: 'Upload failed' })
}
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' })
}
const { path: filePath, originalname, filename, size } = req.file
try {
const digest = await hashFile(filePath)
// The unique key arbitrates concurrent identical uploads atomically.
db.run(
`INSERT INTO files (hash, original_name, filename, file_path, size, upload_date)
VALUES (?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(hash) DO NOTHING`,
[digest, originalname, filename, filePath, size],
function (insertErr) {
if (insertErr) {
removeUpload(filePath)
return res.status(500).json({ error: 'Could not store file metadata' })
}
if (this.changes === 0) {
removeUpload(filePath)
return res.status(409).json({ error: 'Duplicate file detected', hash: digest })
}
return res.json({ message: 'File uploaded successfully', hash: digest, size })
},
)
} catch (hashErr) {
removeUpload(filePath)
res.status(500).json({ error: 'Could not hash the uploaded file' })
}
})
})
app.get('/files', (req, res) => {
db.all('SELECT original_name as name, hash, size, upload_date FROM files', (err, rows) => {
if (err) return res.status(500).json({ error: 'Could not list files' })
res.json(rows)
})
})
/* -------------------------------------------------------------------------- */
/* Start server */
/* -------------------------------------------------------------------------- */
const PORT = process.env.PORT || 3000
db.exec(createFilesTable, (error) => {
if (error) {
console.error('Could not initialize the database')
db.close()
process.exitCode = 1
return
}
app.listen(PORT, '127.0.0.1', () => {
console.log(`Server listening on http://localhost:${PORT}`)
})
})
Gestionar archivos grandes de forma eficiente
hashFile transmite los datos en streaming y mantiene acotado su búfer de trabajo. Cada llamada a hash.update()
sigue usando CPU de forma síncrona, mientras que el paquete sqlite3 realiza el trabajo de base de
datos de forma asíncrona. Para cargas de trabajo del orden de gigabytes, considera usar workers en
segundo plano y limitar las subidas concurrentes. El ejemplo limita deliberadamente cada subida a
10 MB.
Consejos de rendimiento, almacenamiento y seguridad
- Añade un índice en la columna
hashpara acelerar las búsquedas cuando migres a PostgreSQL u otra base de datos completa. - Mantén tu directorio de subidas fuera de la raíz web y haz copias de seguridad de él por separado de tu base de datos de metadatos.
- Almacena en caché los hashes usados recientemente en Redis cuando tu carga de trabajo implique muchos archivos casi idénticos (por ejemplo, avatares de usuario).
- Sanea los nombres de archivo para evitar vulnerabilidades de path traversal si usas nombres proporcionados por el usuario directamente en operaciones del sistema de archivos (aunque nuestro ejemplo usa un nombre de archivo generado).
- Actualiza las dependencias con regularidad para parchear vulnerabilidades conocidas.
Conclusión
Un enfoque de deduplicación basado en hashes es compacto, preciso y fácil de acoplar a cualquier endpoint de subida existente. Al pasar a SHA-256 y a la lectura de archivos en streaming, obtienes resistencia a colisiones y un uso de memoria constante con solo un puñado de líneas de código.
Si prefieres delegar por completo el cálculo de hashes, nuestro Robot 🤖 /file/hash del servicio de Catalogación de medios puede generar sumas de verificación SHA-256 (y muchas otras) por ti, sin necesidad de mantener servidores.
