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>
284 lines
15 KiB
Markdown
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.
|