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>
This commit is contained in:
2026-07-28 14:35:42 +02:00
co-authored by Claude Sonnet 5
commit 020fa199f8
@@ -0,0 +1,213 @@
# 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_jobs` où `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_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.