Files
mabibli/docs/architecture.md
T
Mathieu LimonierandClaude Opus 5 6a6d745af4 MaBibli 1.0.0
Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts,
scan de code-barres, consultation hors-ligne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:36:16 +02:00

284 lines
15 KiB
Markdown

# MaBibli, partie par partie
Ce que fait chaque morceau, la décision qui le tient, et le piège à ne pas rouvrir.
Pour installer, voir [installer.md](installer.md). Pour sortir une version,
[publier-une-version.md](publier-une-version.md). Le détail complet des décisions, avec
Le périmètre de la V1, les invariants à ne pas casser et les pistes encore ouvertes
sont dans `CLAUDE.md`.
| | |
|---|---|
| [La forme](#la-forme--trois-projets-un-seul-processus) | trois projets, un seul processus |
| [Le modèle](#le-modèle--trois-portées-et-tout-en-découle) | trois portées, et tout en découle |
| [Le serveur](#le-serveur--enrichir-une-fiche-depuis-un-code-barres) | enrichir une fiche depuis un code-barres |
| [Le client](#le-client--consulter-dabord-modifier-ensuite) | consulter d'abord, modifier ensuite |
| [Le hors-ligne](#le-hors-ligne--consultation-seule-et-neuf-instantanés) | consultation seule, neuf instantanés |
| [Le déploiement](#le-déploiement--sso-par-en-têtes-et-une-frontière-dure) | SSO par en-têtes, et une frontière dure |
| [La méthode](#la-méthode-qui-est-peut-être-le-vrai-acquis) | comment le projet tranche |
---
## La forme : trois projets, un seul processus
Le client Blazor WebAssembly **n'est pas un serveur**. Compilé, ce ne sont que des
fichiers statiques que l'API sert depuis son `wwwroot`. Un seul processus tourne donc
en production.
```
MaBibli.Client ─┐ ┌─ 10 368 lignes
MaBibli.Shared ─┼──► dotnet publish ──► un ├─ 3 100 (entités, DTO, ISBN, normalisation)
MaBibli.Api ─┘ (self-contained) service└─ 9 314
systemd · 127.0.0.1:PORT · nginx · SSOwat
```
`MaBibli.Shared` n'est pas un fourre-tout : il porte ce que **les deux côtés doivent
calculer à l'identique** — la normalisation du texte, le rapprochement d'auteurs, le
formatage des ISBN. Un écart entre client et serveur y deviendrait un bug invisible.
### ⚠️ Le piège du publish, résolu et à ne pas rouvrir
Les **empreintes WASM sont désactivées** (`WasmFingerprintAssets=false`), et il ne faut
pas les réactiver. Publier l'API réécrivait mal `index.html` : les placeholders restaient
littéraux, la racine répondait 200 et l'application restait blanche.
**Vérifier que `/` renvoie 200 ne prouve rien** — c'est ce qui a laissé passer le défaut
pendant trois phases. Il faut vérifier les scripts que `index.html` référence réellement.
---
## Le modèle : trois portées, et tout en découle
C'est la notion structurante du projet. Chaque donnée appartient à l'une des trois, et
s'y tenir décide de tout le reste — ce qu'on filtre, ce qu'on affiche, ce qu'on cache.
| Portée | Ce que c'est | Filtre-t-on dessus ? |
|---|---|---|
| **Commune** | Catalogue, auteurs, séries, revues, thèmes, **prêts** | **Jamais.** Un livre absent l'est pour tout le monde |
| **Personnelle** | Statut de lecture, liste d'envies, bibliographies masquées | **Toujours**, sur l'appelant, sans exception |
| **Trace** | `AjoutePar` | Jamais — c'est une information, pas une frontière |
Le contraste le plus instructif est celui du **statut de lecture** et du **prêt**. Deux
membres du foyer lisent le même exemplaire à leur rythme : le statut est personnel. Mais
un livre sorti l'est pour tous, et n'importe qui doit pouvoir noter son retour : le prêt
est commun. Les points d'entrée des prêts ne reçoivent donc **aucune identité** — pour
qu'on ne puisse pas s'en servir par inadvertance.
### Cinq tables qui refusent de fusionner
`Livre`, `Revue`, `Serie`, `LivreSouhaite`, `RevueSouhaitee`. Le même argument revient
quatre fois, et il porte sur un invariant, pas sur le confort :
> Logée dans `Livres`, une revue — ou un souhait — entrerait **mécaniquement** dans le
> catalogue, les compteurs, la détection de doublons, les séries et les bibliographies.
> Il faudrait ajouter « et qui n'est pas une revue » à **chaque** lecture.
> **Un invariant qu'on réécrit partout finit par être oublié quelque part.**
Le prix est réel et se paie là où les listes se rejoignent : affichage, export `.txt`,
export `.csv`, instantané hors-ligne, renormalisation. C'est là que le sujet se rate
s'il est bâclé.
### Deux astuces SQLite qui portent des règles métier
- **Index uniques partiels.** `UNIQUE(LivreId) WHERE DateRetour IS NULL` garantit un seul
prêt ouvert par livre, tout en laissant l'historique accumuler autant de prêts clos
qu'il veut. Même mécanique pour la place d'un tome dans une série, et pour l'ISSN.
- **Colonnes normalisées.** SQLite ne sait pas ignorer les accents — `NOCASE` ne couvre
que l'ASCII, et il n'y a pas d'`unaccent()`. La forme mise à plat est donc **stockée**,
et la recherche y compare un terme passé par exactement la même fonction.
⚠️ Toute écriture doit les recalculer.
### ⚠️ Deux NULL sont distincts pour SQLite
C'est le piège qui se repose **à chaque table personnelle**. `AuteurNormalise` et
`NumeroNormalise` sont `NOT NULL` avec un défaut vide : sinon « Médor, sans numéro »
s'ajouterait autant de fois qu'on cliquerait, l'index unique ne voyant que des valeurs
distinctes.
---
## Le serveur : enrichir une fiche depuis un code-barres
C'est la fonction qui justifiait un projet sur mesure — les deux solutions existantes
échouaient dessus, l'une par dépendance à Google Books, l'autre par un bug d'auteur non
récupéré.
```
EAN-13 lu ──► préfixe 977 ? ──oui──► périodique : ISSN, titre BnF, fiche revue
│non
BnF SRU · bib.isbn en ISBN-13 ──trouvé──► notice
│rien
BnF SRU · bib.isbn en ISBN-10 ──trouvé──► notice
│rien
OpenLibrary /isbn → /works → /authors ──► notice
│rien
saisie manuelle, toujours accessible
```
### ⚠️ Les deux formes d'ISBN ne sont pas une précaution
La BnF indexe l'ISBN **tel qu'imprimé**. Un livre d'avant 2007 ne porte qu'un ISBN-10 et
est donc **introuvable par son ISBN-13** — alors qu'un scanner lit toujours un EAN-13.
Mesuré : *Germinal* en poche donne 0 notice en 13, **3 en 10**. Sans la conversion, on
perd le fonds ancien, c'est-à-dire l'essentiel d'une bibliothèque constituée.
- **BnF d'abord, OpenLibrary ensuite**, et pas l'inverse : la collection est francophone,
et le dépôt légal garantit structurellement la meilleure couverture. OpenLibrary est
riche sur l'anglophone, lacunaire sur le français.
- **Google Books est écarté** — préférence explicite, et clé d'API obligatoire.
- **Les couvertures viennent toujours d'OpenLibrary**, quelle que soit la source des
métadonnées : le Dublin Core BnF n'en fournit aucune. Le `?default=false` est
indispensable — sans lui on reçoit un placeholder au lieu d'un 404 franc.
- ⚠️ **Ne jamais valider une URL de couverture par un HEAD** avant de l'afficher :
OpenLibrary rend des 502 intermittents, on supprimerait au hasard des couvertures
existantes.
### Le relais de couvertures est une surface SSRF
`GET /api/couvertures` existe parce que le cache hors-ligne lit les octets par `fetch()`,
ce qui exige du CORS — or une URL de couverture collée à la main n'en a pas. Le relais
rend l'image de même origine.
**Deux verrous, et aucun ne suffit seul.** L'URL doit **déjà être en base** (égalité
exacte), *et* la connexion ne s'ouvre que vers une adresse publiquement routable.
⚠️ Le garde agit dans `ConnectCallback`, **pas avant la requête** : résoudre le nom puis
laisser `HttpClient` résoudre à nouveau laisserait passer un **DNS rebinding**. C'est
aussi ce qui permet de **suivre** les redirections en sécurité — et il le faut,
`covers.openlibrary.org` en fait deux avant d'aboutir sur `archive.org`.
⚠️ **Tout refus répond 404**, sans distinguer les cas : autrement le point d'entrée serait
un **oracle** révélant quelles URL l'application connaît, et quelles adresses répondent
depuis le serveur.
---
## Le client : consulter d'abord, modifier ensuite
Une règle traverse les 24 écrans : **le mode est dans l'URL**, jamais dans un booléen
interne. `/livres/3` consulte, `/livres/3/edition` modifie. L'écran d'édition devient
partageable, et le bouton « retour » du navigateur une sortie naturelle.
### ⚠️ Le piège Blazor qui va avec
Les deux routes visent le même composant avec le même paramètre : passer de l'une à
l'autre ne change **aucun paramètre**, et le routeur ne redessine rien. L'URL changeait,
l'écran restait — alors qu'un chargement direct fonctionnait, ce qui masque le défaut.
D'où l'abonnement à `LocationChanged`, présent sur la fiche livre, la série et la revue.
### Ce qui reste manipulable sans entrer en édition
Deux exceptions assumées, parce qu'elles ne touchent pas à la *fiche* : le **statut de
lecture** (personnel, l'action la plus fréquente de l'application) et **prêter / marquer
rendu** (commun, un geste du quotidien). La **suppression**, elle, n'existe qu'en édition.
### Trois règles d'affichage qui reviennent partout
- **On n'affiche que ce qui a été choisi.** Le format n'apparaît que pour les ebooks
(un livre est physique par défaut), le type de document que s'il n'est pas
`NonPrecise`, les rôles d'auteurs qu'à partir de deux auteurs. Sans cela, chaque ligne
porterait « Non précisé ».
- **On grise, on ne masque jamais** — un faux négatif se voit et se corrige, une ligne
masquée à tort est invisible. Seule exception, explicite : le masquage manuel d'une
œuvre bibliographique, qui est personnel et compté à l'écran.
- **Hors-ligne, on désactive avec un motif, on ne masque pas.** Un bouton disparu est
indiscernable d'une fonction supprimée : l'utilisateur conclurait à une régression au
lieu d'apprendre que c'est le réseau.
### ⚠️ Deux pièges Blazor payés cher
**Une règle CSS scopée n'atteint pas ce que rend un composant enfant.** L'attribut de
portée `b-xxxx` n'est posé que sur le balisage du composant courant : `.menu-lien` ne
touchait pas les `<a>` rendus par `NavLink`, et le menu restait en liens bruts. Les
règles du menu vivent donc en feuille **globale** — la navigation est ce qui doit le
moins pouvoir tomber.
**Un composant qui écrit dans un objet prêté par son parent doit le prévenir.** Sinon
tout ce que le parent calcule à partir de cet objet — bouton désactivé, compteur,
message — reste figé sur l'état d'avant. Le symptôme trompe : on cherche une validation
fautive là où il n'y a qu'un rendu manquant.
---
## Le hors-ligne : consultation seule, et neuf instantanés
Le besoin réel est de consulter la bibliothèque **déjà enregistrée** sans réseau. Pas
d'écriture, pas de file d'attente, pas de résolution de conflits.
### ⚠️ « Les données sont déjà en local » est faux
Vrai au sens *serveur* seulement. Le code tourne dans le navigateur, SQLite vit sur le
serveur, et le service worker met en cache les **assets**, pas les réponses de l'API.
Sans travail explicite, l'application se lance hors-ligne et affiche **une liste vide**.
D'où **IndexedDB** plutôt que le cache du service worker : un cache de réponses HTTP ne
restituerait que les URL déjà visitées, donc *une recherche jamais tapée en ligne ne
rendrait rien*. Ici l'instantané porte le catalogue **entier**, et la recherche se refait
localement.
**Un instantané par vue**, jamais par requête : `catalogue`, `auteurs`, `prets-en-cours`,
`utilisateur`, `souhaits`, `series`, `revues`, `souhaits-revues`, `version`.
⚠️ **Toute vue nouvelle doit en recevoir un**, faute de quoi son écran est mort
hors-ligne — c'est exactement ce qui était arrivé à la liste d'envies, et le symptôme
(un 404 en plein écran) ne désignait pas la cause.
### ⚠️ `navigator.onLine` ne vaut que par sa négation
« Faux » est fiable, « vrai » ne prouve rien — portail captif, serveur arrêté, Wi-Fi sans
Internet. D'où deux notions : `EnLigne` (ce que l'interface affiche) et `TenterLeReseau`
(faut-il tenter un appel).
**Quand la panne vient du serveur, aucun événement `online` ne viendra jamais** : sans
cette distinction, l'application restait bloquée sur le cache jusqu'au rechargement.
Et **deux événements distincts**, sinon boucle infinie : un rechargement écrirait un
instantané → nouvel événement → nouveau rechargement.
### Le filtre existe en deux exemplaires, et c'est assumé
`FiltreLivres` (serveur, sur `IQueryable`, colonnes normalisées déjà calculées) et
`FiltreLivresLocal` (navigateur, sur des DTO qui n'en portent pas) **ne peuvent pas être
le même code**. Le garde-fou est un test qui **confronte les deux** sur le même jeu de
données et 17 jeux de critères.
⚠️ **Toute nouvelle dimension de filtre s'ajoute aux deux, et au test.** Une divergence
silencieuse serait pire qu'un cache absent : l'utilisateur conclurait que le livre n'est
pas dans sa bibliothèque.
---
## Le déploiement : SSO par en-têtes, et une frontière dure
MaBibli n'a **aucune connexion propre**. nginx authentifie via le portail YunoHost,
SSOwat injecte `YNH_USER`, l'application se contente de lire. OIDC a été écarté :
YunoHost n'en documente aucun pour les apps.
### ⚠️ L'écoute sur 127.0.0.1 est une frontière de sécurité, pas un réglage
L'application fait confiance à `YNH_USER` **parce que** SSOwat l'écrase à chaque requête
traversant nginx. Un service joignable directement permettrait à quiconque de forger
`YNH_USER` et de contourner tout le portail. Ne jamais élargir `ASPNETCORE_URLS`.
| Contrainte | Pourquoi elle ne se négocie pas |
|---|---|
| **Domaine entier**, pas de sous-chemin | `<base href="/">` et les empreintes du service worker sont figés **à la compilation**, et rien n'est compilé sur le serveur |
| **HTTPS** | Le scan caméra n'existe qu'en contexte sécurisé. Tester depuis une IP locale échouera *toujours* |
| **amd64 ou arm64** côté serveur | Chaque publication produit **les deux** archives, et le manifeste déclare les deux : YunoHost choisit d'après la machine. ⚠️ Déclarer une architecture sans téléverser son archive la promettrait pour rien, et l'échec surviendrait chez celui qui installe. La machine qui **compile**, elle, peut être de n'importe quelle architecture — le publish est croisé |
| **Dépôt public** | `ynh_setup_source` télécharge sans jeton, et un dépôt privé répond **404 — pas 403**, symptôme identique à « la release n'existe pas » |
### Deux pièges systemd invisibles hors d'un vrai serveur
- `Environment=` **découpe sur les espaces** : sans guillemets, la ligne définit deux
variables au lieu d'une, et le service meurt sur un `ArgumentException`.
- `ProtectHome=yes` masque `/home`, où vit le `data_dir` de YunoHost —
`ReadWritePaths` ne perce pas cette protection. Il faut `tmpfs` + `BindPaths`.
Ni l'un ni l'autre ne peut sortir d'un `dotnet run` : ils tiennent au gestionnaire de
services. C'est ce qui justifie de tester l'installation réelle, pas seulement le binaire.