Files
convert/docs/superpowers/specs/2026-07-28-file-converter-design.md
T
anthonyandClaude Sonnet 5 020fa199f8 Add design spec for file converter v1 (core + Images + Documents)
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>
2026-07-28 14:35:42 +02:00

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, puppeteer qui 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 :

  1. 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.
  2. Worker (process indépendant, lancé via SSH avec pm2, en dehors de Passenger) : boucle qui va chercher les jobs pending dans 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/ et outputs/.
  • 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.static sur uploads//outputs/.
  • Route unique GET /api/jobs/:id/download : vérifie status = done, lit output_path + output_mime_type + original_filename depuis la base, stream le fichier avec Content-Type: <output_mime_type> et Content-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 sharp en 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 → PDF et DOCX → PDF (via HTML intermédiaire) : puppeteer (Chromium headless téléchargé automatiquement par npm install, rendu HTML → impression PDF).
  • PDF → texte/HTML : pdfjs-dist (extraction de contenu textuel, pure JS confirmé, aucune dépendance native).
  • PDF → DOCX : pdfjs-dist pour extraire le texte, puis docx (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

  • sharp normalise n'importe quel format image supporté vers PNG, puis pdf-lib (pure JS, zéro dépendance native, confirmé) crée un PDF d'une page dimensionnée à l'image et y embedPng le 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 + leur target_format choisi) → réponse = liste de { id, status: 'pending' }, une ligne conversion_jobs créée par fichier.
  • Chaque carte de fichier poll indépendamment GET /api/jobs/:id (~1,5s d'intervalle, arrêt dès done/failed) : barre de progression indéterminée en pending/processing, bouton "Télécharger" (GET /api/jobs/:id/download) si done, message d'erreur (error_message) si failed.
  • 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 build en amont). Passenger gère démarrage/ redémarrage.
  • Worker (pm2) : process indépendant lancé via SSH (pm2 start worker.js --name convert-worker), puis pm2 save + pm2 startup pour survivre à un redémarrage — si pm2 startup s'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 lignes conversion_jobsexpires_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_MB via middleware d'upload, rejet avant écriture disque.
  • Rate limiting : express-rate-limit sur POST /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 en pending.
  • Validation stricte : source_format déclaré doit correspondre à input_mime_type détecté par magic bytes, sinon rejet HTTP 422 avant création du job.
  • Timeout de conversion par job (ex. 60s) : au-delà, failed avec error_message générique et error_log dé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/:id renvoie toujours un job bien formé avec status: '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.