Déduplication efficace de fichiers avec SHA-256 et Node.js
Utilisez l’empreinte SHA-256 d’un fichier comme clé unique en base de données pour rejeter les téléversements répétés, même lorsque leurs noms de fichier diffèrent. Ce tutoriel construit un service Node.js local, envoie des fichiers avec cURL et vérifie que ses enregistrements de déduplication survivent à un redémarrage.
Comprendre la déduplication basée sur le contenu
Le serveur enregistre chaque fichier entrant sous un nom généré, fait passer ses octets en flux dans
SHA-256 et tente d’insérer l’empreinte dans SQLite. Une insertion réussie conserve le fichier. Une
insertion en conflit supprime la nouvelle copie et renvoie HTTP 409 Conflict.
La décision importante se prend dans une seule instruction de base de données : INSERT … ON CONFLICT(hash) DO NOTHING.
Le comportement UPSERT de SQLite permet à la clé unique d’arbitrer
les téléversements concurrents. Rechercher d’abord une empreinte pour l’insérer ensuite permettrait
à deux requêtes de constater toutes deux son absence. La clé primaire garantit déjà l’unicité ; un
second index de hachage est inutile.
Cette méthode détecte des octets identiques. Renommer un fichier ne change pas son empreinte, mais réencoder une image ou modifier ses métadonnées intégrées peut la changer. Elle ne trouve pas les images visuellement similaires.
Pourquoi pas MD5 ?
MD5 ne convient pas lorsque la résistance aux collisions compte, notamment lorsque quelqu’un pourrait délibérément envoyer des fichiers différents ayant la même empreinte. SHA-256 offre une résistance aux collisions, et non une garantie mathématique que des collisions ne peuvent pas exister. Cet exemple traite les empreintes identiques comme des doublons. Si votre application exige une vérification d’égalité exacte, comparez les octets stockés et entrants avant d’écarter un téléversement correspondant, et prévoyez un moyen distinct de stocker une collision.
Configurer le projet
Utilisez un shell Linux, avec cURL, Node.js 24.15.0 et Corepack avec Yarn 4.12.0 à
disposition. L’exemple fonctionne aussi avec Node.js 26.8.1. La
prise en charge intégrée de TypeScript de Node exécute le fichier
.ts enregistré sans étape de compilation ; elle ne vérifie pas ses types.
Collez ceci dans un terminal. Cela crée un nouveau projet et revient à votre répertoire d’origine.
Si file-deduplication existe déjà, choisissez un nouveau répertoire parent ; les
commandes refusent de le réutiliser.
(
mkdir file-deduplication &&
cd file-deduplication &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' 'enableScripts: true' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn add --exact express@5.2.1 multer@2.4.0 sqlite3@5.1.7
)
Attendez que l’installation réussisse avant de continuer. sqlite3 inclut une
liaison native ; si son binaire précompilé n’est pas disponible pour votre plateforme, son
guide d’installation décrit les prérequis de compilation. Les
versions ci-dessus utilisent SQLite 3.44.2 dans la configuration Linux testée. Le
yarn.lock local fait de cet exemple un projet indépendant, et
node-modules permet à node de résoudre directement ses
paquets. Les scripts d’installation sont activés pour la liaison SQLite native. Le dépôt
sqlite3 est désormais archivé : considérez donc ceci comme un exemple local
aux versions figées, et choisissez un pilote de base de données maintenu avant de l’adapter à un
nouveau service déployé.
Créer un gestionnaire de téléversement local
Enregistrez le bloc complet sous file-deduplication/server.ts. Ce service écoute sur
127.0.0.1 et n’a aucune authentification. Il accepte des octets de fichier
arbitraires, y compris des fichiers vides, et ne les traite ni ne les sert. Gardez-le en local ; un
filtrage MIME ne suffirait pas à établir qu’un téléversement est sûr.
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { rm } from 'node:fs/promises'
import express, { type ErrorRequestHandler } from 'express'
import multer from 'multer'
import sqlite3 from 'sqlite3'
async function hashFile(filePath: string): Promise<string> {
const hash = createHash('sha256')
for await (const chunk of createReadStream(filePath)) hash.update(chunk)
return hash.digest('hex')
}
async function storeFile(
db: sqlite3.Database,
filePath: string,
name: string,
size: number,
): Promise<{ hash: string; stored: boolean }> {
let stored = false
try {
const hash = await hashFile(filePath)
const changes = await new Promise<number>((resolve, reject) => {
db.run(
`INSERT INTO files (hash, original_name, file_path, size)
VALUES (?, ?, ?, ?) ON CONFLICT(hash) DO NOTHING`,
[hash, name, filePath, size],
function (error) {
if (error) reject(error)
else resolve(this.changes)
},
)
})
stored = changes === 1
return { hash, stored }
} finally {
// Finish cleanup before the route sends a duplicate or failure response.
if (!stored) await rm(filePath, { force: true })
}
}
async function main(): Promise<void> {
const port = Number(process.env.PORT ?? '3000')
if (!Number.isInteger(port) || port < 1 || port > 65535) {
console.error('PORT must be an integer from 1 to 65535')
process.exit(1)
}
const db = await new Promise<sqlite3.Database>((resolve, reject) => {
const database = new sqlite3.Database('deduplication.db', (error) => {
if (error) reject(error)
else resolve(database)
})
})
await new Promise<void>((resolve, reject) => {
db.exec(
`CREATE TABLE IF NOT EXISTS files (
hash TEXT NOT NULL PRIMARY KEY,
original_name TEXT NOT NULL,
file_path TEXT NOT NULL,
size INTEGER NOT NULL,
upload_date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
(error) => (error ? reject(error) : resolve()),
)
})
const app = express()
const upload = multer({
dest: 'uploads/',
limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 0 },
})
app.post('/upload', upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Send one file in the file field' })
const { path, originalname, size } = req.file
const { hash, stored } = await storeFile(db, path, originalname, size)
if (!stored) return res.status(409).json({ error: 'Duplicate file detected', hash })
return res.status(201).json({ message: 'File stored', hash, size })
})
app.get('/files', (_req, res, next) => {
db.all(
'SELECT original_name AS name, hash, size, upload_date FROM files ORDER BY hash',
(error, rows) => {
if (error) return next(error)
res.json(rows)
},
)
})
const handleError: ErrorRequestHandler = (error: unknown, _req, res, _next) => {
if (error instanceof multer.MulterError) {
const status = error.code === 'LIMIT_FILE_SIZE' ? 413 : 400
res.status(status).json({ error: 'Send one file of at most 10 MiB and no text fields' })
return
}
console.error('Request failed; check the database and upload directory')
res.status(500).json({ error: 'Could not complete the request' })
}
app.use(handleError)
await new Promise<void>((resolve, reject) => {
app.listen(port, '127.0.0.1', (error?: Error) => {
if (error) reject(error)
else resolve()
})
})
console.log(`Listening on http://127.0.0.1:${port}`)
}
main().catch((error: unknown) => {
const code = error instanceof Error && 'code' in error ? error.code : 'STARTUP_FAILED'
console.error(`Could not start server (${code}); check the port and storage permissions`)
process.exit(1)
})
Le stockage sur disque de Multer
attribue un nom de fichier généré au lieu d’utiliser le nom fourni par le client comme chemin.
SQLite conserve le premier nom accepté comme métadonnée. La fonction de rappel
function classique dans db.run est intentionnelle :
this.changes appartient à cette instruction terminée
et distingue une ligne insérée d’un conflit resté sans effet.
Démarrer le serveur
Depuis le même répertoire parent que celui où vous avez lancé la configuration, collez :
(
cd file-deduplication &&
node server.ts
)
Attendez Listening on http://127.0.0.1:3000. Si vous obtenez EADDRINUSE, arrêtez
votre processus d’écoute existant ou définissez PORT=3001 avant
node server.ts, puis utilisez ce port dans chaque requête ci-dessous. La fonction de
rappel de démarrage vérifie les erreurs, car
Express 5 y signale les échecs de liaison au port. Une erreur
d’autorisation sur la base de données ou le répertoire doit être corrigée avant que le serveur
puisse démarrer.
Téléverser, répéter et modifier les octets
Dans un second terminal, téléversez six octets, retour à la ligne compris :
printf 'hello\n' | curl -sS -i -F 'file=@-;filename=hello.txt' http://127.0.0.1:3000/upload
La réponse a le statut 201 Created et ce corps JSON :
{"message":"File stored","hash":"5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03","size":6}
Téléversez les mêmes octets sous un autre nom :
printf 'hello\n' | curl -sS -i -F 'file=@-;filename=renamed.txt' http://127.0.0.1:3000/upload
Attendez-vous à 409 Conflict, avec error défini à
Duplicate file detected et la même empreinte. La première copie stockée est conservée. Ces
commandes de téléversement omettent volontairement l’option -f de cURL
afin que vous puissiez inspecter la réponse 409 attendue ; un code de
sortie réussi de cURL ne signifie pas à lui seul que le serveur a accepté un fichier.
Envoyez maintenant des octets différents avec le premier nom de fichier, puis envoyez un fichier vide :
printf 'different\n' | curl -sS -i -F 'file=@-;filename=hello.txt' http://127.0.0.1:3000/upload
curl -sS -i -F 'file=@/dev/null;filename=empty.bin' http://127.0.0.1:3000/upload
Les deux renvoient 201, avec des tailles de 10
et 0. Répéter l’une ou l’autre requête renvoie
409. Il y a maintenant trois fichiers sous file-deduplication/uploads/
et trois enregistrements en base de données, même si deux fichiers acceptés portent le même nom
d’origine. Listez ces enregistrements avec :
curl -fsS http://127.0.0.1:3000/files
Une fois toutes les requêtes terminées, arrêtez le serveur avec Ctrl+C, redémarrez-le avec la
même commande et répétez le premier téléversement. Il renvoie toujours 409.
deduplication.db et uploads/ se trouvent tous deux dans le
répertoire du projet : gardez-les donc ensemble et redémarrez depuis ce répertoire. L’exemple ajoute
le nouveau contenu et conserve la première copie du contenu répété ; un redémarrage ne vide aucun
des deux stockages.
Lors de téléversements simultanés d’un même nouveau contenu, l’insertion unique admet une seule
requête avec 201. Les autres reçoivent 409
après la suppression de leurs fichiers supplémentaires. Les fichiers manquants, les noms de champ
incorrects, les fichiers supplémentaires et les champs texte reçoivent 400 ;
les fichiers de plus de 10 MiB reçoivent 413. Un échec de stockage
renvoie une erreur 500 générique : vérifiez donc le terminal du serveur
et le système de fichiers avant de réessayer.
Gérer efficacement les fichiers volumineux
L’API de hachage incrémental
permet à hashFile de lire des blocs au lieu de mettre tout le fichier en
mémoire tampon. Multer écrit d’abord le fichier téléversé complet sur disque, puis le hachage le
relit. La détection des doublons économise donc le stockage conservé, mais n’évite ni la bande
passante du téléversement ni l’utilisation temporaire du disque.
Chaque appel à hash.update() consomme du CPU sur le thread principal. La limite de
10 MiB par fichier borne la taille des entrées de cet exemple local ; elle ne borne ni le nombre de
requêtes simultanées ni l’utilisation totale du disque. Des charges de travail plus importantes
nécessitent des limites de concurrence et de stockage avant d’augmenter ce plafond.
Conseils de performance, de stockage et de sécurité
L’insertion SQLite est atomique, mais l’écriture du fichier et l’insertion en base de données ne forment pas une seule transaction. Un plantage entre les deux peut laisser un fichier non référencé. Supprimer ou corrompre un fichier stocké ne supprime pas son enregistrement d’empreinte : un téléversement ultérieur peut donc être rejeté alors que les octets stockés sont manquants. Le nettoyage peut aussi échouer si les autorisations du stockage changent. Cet exemple ne réconcilie pas ces cas, ne vérifie pas les fichiers stockés à chaque recherche et ne promet pas de durabilité en cas de coupure de courant.
Sauvegardez la base de données et les fichiers comme une paire cohérente pendant que le service est arrêté. Avant d’adapter cet exemple à des téléversements publics, ajoutez une authentification, des contrôles de propriété, des quotas et un processus de récupération qui réconcilie les enregistrements avec les fichiers. Une réponse de doublon globale peut révéler que quelqu’un d’autre a téléversé un contenu particulier ; choisissez délibérément la portée de la déduplication.
