From 020fa199f8b5b97a955055f2aa8db89396eb9e27 Mon Sep 17 00:00:00 2001 From: Anthony GAEREMYNCK <1@anthony.sh> Date: Tue, 28 Jul 2026 14:35:42 +0200 Subject: [PATCH] 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 --- .../specs/2026-07-28-file-converter-design.md | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-28-file-converter-design.md diff --git a/docs/superpowers/specs/2026-07-28-file-converter-design.md b/docs/superpowers/specs/2026-07-28-file-converter-design.md new file mode 100644 index 0000000..f62ebaa --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-file-converter-design.md @@ -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 `.` | +| `output_path` | `VARCHAR(255)` | nom de fichier relatif sous `outputs/`, format `.`, 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 `.` 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: ` et `Content-Disposition: attachment; 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 +``` + +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.