Covers architecture, MariaDB job model, conversion registry pattern, o2switch deployment (Passenger + pm2 worker + cron cleanup), and anti-abuse/error-handling strategy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
12 KiB
File Converter — Design (v1: noyau + Images + Documents)
Contexte et objectif
Construire un convertisseur de fichiers en ligne façon Convertio.co (Audio, Vidéo, Images, Documents, Présentations, Polices, Ebook, Archives), en Node.js, déployé sur un hébergement mutualisé o2switch avec accès SSH utilisateur non-root.
Contrainte structurante : aucune installation de binaire système (pas d'apt-get, pas de compilation, pas de LibreOffice/ImageMagick/ffmpeg installés manuellement au niveau système). Sont en revanche acceptés :
- les packages npm 100% JS,
- les packages npm qui embarquent un binaire précompilé téléchargé automatiquement pendant
npm install(ex.@ffmpeg-installer/ffmpeg,puppeteerqui télécharge Chromium), - l'upload manuel d'un binaire statique via SSH si nécessaire (non utilisé en v1).
Vu l'ampleur du projet (8 familles de formats), la v1 se concentre sur le noyau commun (upload, file d'attente, stockage, nettoyage, API, front) + 2 familles : Images et Documents, pour valider l'architecture de bout en bout avant d'ajouter les 6 familles restantes en suivant le même patron.
Contraintes d'hébergement
- o2switch mutualisé, accès SSH non-root.
- Budget dimensionné : 20 process Node.js simultanés max (sur 80 possibles au total sur le compte), 12 Go RAM max (sur 48 Go).
- Base de données : MariaDB.
- Usage cible : outil public, anonyme (pas de compte utilisateur), trafic modéré à potentiellement significatif.
Vue d'ensemble de l'architecture
Deux process Node.js séparés :
- App web (gérée par Passenger via "Setup Node.js App" cPanel) : sert l'API JSON et le build statique du frontend React. Ne fait jamais de conversion elle-même — reste légère et réactive pour toutes les requêtes HTTP.
- Worker (process indépendant, lancé via SSH avec
pm2, en dehors de Passenger) : boucle qui va chercher les jobspendingdans MariaDB, exécute la conversion avec une concurrence bornée (WORKER_CONCURRENCY), écrit le résultat sur disque, met à jour le statut.
Flux : upload (un ou plusieurs fichiers) → l'app web valide (taille, MIME réel) et enregistre
chaque fichier sur disque + une ligne pending par fichier en base → le worker détecte les jobs
pending, convertit, écrit le fichier de sortie, marque done/failed → le front (React) poll
GET /api/jobs/:id pour chaque job indépendamment → affiche le lien de téléchargement une fois
done. Un job expiré (> RETENTION_HOURS) est supprimé (ligne DB + fichiers disque) par une
tâche cron cPanel indépendante.
Ce découpage isole les conversions CPU-intensives du serveur web, ne nécessite aucun broker de messages (MariaDB sert de file d'attente), et convient au budget de ressources disponible.
Projet en ESM ("type": "module") de bout en bout, notamment parce que file-type (sniffing
MIME) est distribué en ESM pur.
Modèle de données (MariaDB)
Table conversion_jobs
| Colonne | Type | Notes |
|---|---|---|
id |
CHAR(36) |
UUID v4, clé primaire — c'est aussi l'identifiant utilisé dans les URLs publiques (statut, téléchargement). Non énumérable. |
status |
ENUM('pending','processing','done','failed') |
|
family |
VARCHAR(32) |
image, document, ... — sert surtout au regroupement côté UI, le registre de conversion n'a pas de frontière stricte entre familles (ex. image→pdf traverse deux familles). |
source_format |
VARCHAR(16) |
ex: docx, png |
target_format |
VARCHAR(16) |
ex: pdf, webp |
original_filename |
VARCHAR(255) |
nom d'origine du fichier, utilisé uniquement pour le header Content-Disposition au téléchargement |
input_path |
VARCHAR(255) |
nom de fichier relatif sous uploads/, format <uuid>.<ext> |
output_path |
VARCHAR(255) |
nom de fichier relatif sous outputs/, format <uuid>.<ext>, rempli une fois done |
input_mime_type |
VARCHAR(128) |
détecté par sniffing des magic bytes (file-type), jamais déduit de l'extension ou du Content-Type déclaré par le client |
output_mime_type |
VARCHAR(128) |
déduit d'une table statique target_format → MIME type, renseigné à la fin de la conversion |
error_message |
VARCHAR(255) |
message court, générique, sûr à afficher à l'utilisateur si failed |
error_log |
TEXT |
détail technique complet (stack trace, message d'exception de la lib de conversion) — jamais exposé par l'API, uniquement pour le debug via la base |
created_at / updated_at |
DATETIME |
|
expires_at |
DATETIME |
created_at + RETENTION_HOURS, utilisé par le nettoyage cron |
Stockage disque
STORAGE_DIR(variable.env), avec deux sous-dossiers :uploads/etoutputs/.- Fichiers nommés directement
<uuid>.<extension>dans chaque dossier (pas de sous-dossier par job, pas de nom original dans le chemin).
Accès aux fichiers
- Pas de
express.staticsuruploads//outputs/. - Route unique
GET /api/jobs/:id/download: vérifiestatus = done, litoutput_path+output_mime_type+original_filenamedepuis la base, stream le fichier avecContent-Type: <output_mime_type>etContent-Disposition: attachment; filename="<original_filename>". - L'UUID v4 (122 bits d'entropie) sert d'URL non énumérable ; combiné à l'absence de tout lien statique/indexé, c'est suffisant pour un usage anonyme sans compte.
Variables .env
STORAGE_DIR, DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, MAX_FILE_SIZE_MB (défaut 100),
RETENTION_HOURS (défaut 1), WORKER_POLL_INTERVAL_MS, WORKER_CONCURRENCY, PORT,
RATE_LIMIT_MAX_JOBS, RATE_LIMIT_WINDOW_MINUTES.
Pattern de conversion (registre par famille)
Chaque famille expose un module avec une interface commune :
convert({ inputPath, sourceFormat, targetFormat, outputPath }) → Promise<void>
Un registre central (converterRegistry.js) mappe chaque couple sourceFormat → targetFormat
vers la fonction de conversion correspondante, et expose la liste des couples valides (utilisée
par l'API pour valider une requête, et par le front pour ne proposer que les formats de sortie
possibles pour un fichier donné). Le worker n'a besoin de connaître que ce registre, pas les
détails de chaque lib.
Ajouter une famille plus tard (vidéo, audio, ...) ne touche ni le worker ni l'API : uniquement un nouveau module + son enregistrement dans le registre.
Famille Images (sharp)
- Formats en entrée et sortie : JPG, PNG, WEBP, GIF, TIFF, AVIF — tous nativement supportés par
sharp(confirmé : formats de sortie officiels = JPEG, PNG, WebP, GIF, AVIF, TIFF). - BMP : supporté par
sharpen entrée mais pas en sortie. En sortie, passer par une conversion PNG intermédiaire (sharp) puis encodage BMP via une petite lib pure JS dédiée (bmp-js). À confirmer si réellement nécessaire pour la v1 (non prioritaire).
Famille Documents
DOCX → HTML:mammoth(pure JS, confirmé).TXT/HTML → PDFetDOCX → PDF(via HTML intermédiaire) :puppeteer(Chromium headless téléchargé automatiquement parnpm install, rendu HTML → impression PDF).PDF → texte/HTML:pdfjs-dist(extraction de contenu textuel, pure JS confirmé, aucune dépendance native).PDF → DOCX:pdfjs-distpour extraire le texte, puisdocx(pure JS, zéro dépendance runtime, confirmé) pour reconstruire un document Word. Limite assumée pour la v1 : reconstruction best-effort (texte + structure de paragraphes basique) — mise en page complexe, tableaux et positionnement précis des images d'un PDF source ne sont pas fidèlement reproduits. Aucune lib pure-JS/npm-à-binaire-auto n'approche la fidélité d'un LibreOffice/Aspose pour cette conversion spécifique ; documenté comme limitation connue plutôt que résolu.
Conversion croisée : Image → PDF
sharpnormalise n'importe quel format image supporté vers PNG, puispdf-lib(pure JS, zéro dépendance native, confirmé) crée un PDF d'une page dimensionnée à l'image et yembedPngle résultat. Plus léger qu'un passage par Puppeteer pour ce cas.
Frontend (React SPA)
- Écran unique avec zone de drop/sélection multi-fichiers.
- Pour chaque fichier déposé : sélecteur de format cible, filtré selon le registre (n'affiche que les cibles valides pour le format source détecté).
- Bouton "Convertir" →
POST /api/jobs(multipart, tous les fichiers + leurtarget_formatchoisi) → réponse = liste de{ id, status: 'pending' }, une ligneconversion_jobscréée par fichier. - Chaque carte de fichier poll indépendamment
GET /api/jobs/:id(~1,5s d'intervalle, arrêt dèsdone/failed) : barre de progression indéterminée enpending/processing, bouton "Télécharger" (GET /api/jobs/:id/download) sidone, message d'erreur (error_message) sifailed. - Pas de compte/session : le state (liste des jobs en cours) vit uniquement côté navigateur, perdu au rechargement de page — acceptable pour un usage anonyme.
- Validation côté client (taille, extension) pour feedback immédiat, ne remplace jamais la validation serveur (magic bytes) qui reste seule source de vérité.
Déploiement o2switch
- App web (Passenger) : "Setup Node.js App" cPanel, point d'entrée unique servant l'API
(
/api/*) et le build statique React (npm run builden amont). Passenger gère démarrage/ redémarrage. - Worker (pm2) : process indépendant lancé via SSH (
pm2 start worker.js --name convert-worker), puispm2 save+pm2 startuppour survivre à un redémarrage — sipm2 startups'avère impraticable sans root sur ce mutualisé, fallback sur un cron cPanel toutes les 5 min qui vérifie/relance le worker (à valider une fois en SSH selon ce que permet réellement l'environnement o2switch). - Nettoyage : tâche cron cPanel indépendante (toutes les 15-30 min), script Node
(
cleanup.js) qui supprime les lignesconversion_jobsoùexpires_at < NOW()ainsi que les fichiers disque correspondants. Volontairement séparé du worker pour rester robuste même si le worker est down. - Build/déploiement :
npm install(jamais--ignore-scripts, sinon Puppeteer ne télécharge pas Chromium) +npm run build(frontend) à chaque déploiement, avant redémarrage Passenger.
Anti-abus & limites
- Taille de fichier : limite dure
MAX_FILE_SIZE_MBvia middleware d'upload, rejet avant écriture disque. - Rate limiting :
express-rate-limitsurPOST /api/jobs, par IP. - Concurrence du worker bornée (
WORKER_CONCURRENCY) pour rester dans le budget CPU/RAM du mutualisé ; surplus de jobs attend simplement enpending. - Validation stricte :
source_formatdéclaré doit correspondre àinput_mime_typedétecté par magic bytes, sinon rejet HTTP 422 avant création du job. - Timeout de conversion par job (ex. 60s) : au-delà,
failedavecerror_messagegénérique eterror_logdétaillé — évite qu'un fichier pathologique bloque un slot du worker indéfiniment.
Gestion des erreurs
- Erreurs de validation (taille, format non supporté, mismatch MIME) → HTTP 4xx avant création du job.
- Erreurs de conversion (worker) → job
failed, jamais d'exception remontée au client HTTP ;GET /api/jobs/:idrenvoie toujours un job bien formé avecstatus: 'failed'+error_message. - Erreurs serveur inattendues (DB down, disque plein) → 500 générique côté API, détail complet loggé côté serveur.
Tests
- Unitaire : chaque convertisseur de famille testé isolément (fixtures → assertions sur le fichier de sortie : format valide, dimensions/contenu attendus).
- Intégration : registre (bon convertisseur choisi pour un couple donné, rejet propre pour un couple non supporté).
- API : upload → statut → téléchargement de bout en bout pour un cas simple par famille, plus les cas d'erreur (fichier trop gros, mismatch MIME, format inconnu).
- Worker et cron de nettoyage : plus difficiles à tester automatiquement de bout en bout (dépendent de MariaDB/disque réels) — testés manuellement en pré-prod ; la logique métier (sélection des jobs à traiter/expirer) couverte par des tests unitaires sur les requêtes SQL isolées.
Hors périmètre v1 (à ajouter ensuite, même patron)
Audio, Vidéo, Présentations, Polices, Ebook, Archives.