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:
@@ -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.
|
||||||
Reference in New Issue
Block a user