Phase 2.1 : preparation au deploiement public

Rend une instance exposee sur Internet exploitable sans la transformer en
infrastructure : le produit reste local-first, sans compte et sans telemetrie.

Protection du service : limitation de debit en memoire par client et par
famille d'endpoint (health et ready jamais limites), interrupteur
SHARING_ENABLED, capacite maximale de partages, taille propre aux partages.
X-Forwarded-For n'est cru que si TRUST_PROXY_HEADERS l'autorise, et on lit alors
la derniere valeur, celle ajoutee par le proxy.

Expiration facultative des partages (24 h a 90 jours, ou jamais) : un lien
expire repond 410 et la page publique le dit, sans autre detail. Le proprietaire
peut prolonger ; aucune purge automatique.

Protection des liens non repertories : robots.txt, X-Robots-Tag et meta robots
sur /s/ et /api/, Referrer-Policy no-referrer sur tout le site, redaction des
jetons dans les journaux d'acces, share_token porte a 128 bits (les anciens
restent valides).

Schema gere par Alembic, applique au demarrage. Une base creee avant Alembic est
estampillee puis migree : verifie sur la base reelle de developpement, 4
partages inchanges et expires_at NULL.

Administration par CLI locale au conteneur (stats, liste, suppression, purge,
sauvegarde a chaud via l'API SQLite, restauration prudente) : aucune interface
/admin exposee.

Le jeton de gestion peut enfin etre exporte dans un fichier .openmind-share puis
reimporte depuis un autre navigateur, apres verification aupres du serveur. Il
n'est toujours jamais ajoute a un export .mmap.

Ajoute une page /privacy dont le texte est centralise, et corrige un bug trouve
en observant les logs : Alembic, lance dans le processus applicatif,
reappliquait la configuration de logs d'alembic.ini et desactivait les loggers
existants, ce qui faisait disparaitre les journaux d'acces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 09:22:17 +02:00
parent e3c530348e
commit 49870dcd56
51 changed files with 3341 additions and 110 deletions

283
README.md
View File

@@ -127,6 +127,24 @@ Toutes definies dans `.env` (modele : `.env.example`). Aucun secret n'est requis
| `CORS_ORIGINS` | vide | Origines externes autorisees, separees par des virgules |
| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
Exposition publique :
| Variable | Defaut | Role |
|---|---|---|
| `SHARING_ENABLED` | `true` | `false` coupe entierement le service de partage ; les cartes locales continuent de fonctionner |
| `SHARE_CREATE_RATE_LIMIT` | `10/hour` | Limite de creation de partages, par client |
| `SHARE_READ_RATE_LIMIT` | `120/minute` | Limite de lecture d'un partage, par client |
| `SHARE_WRITE_RATE_LIMIT` | `60/hour` | Limite des mises a jour, changements d'expiration et suppressions |
| `MAX_SHARED_MAPS` | `10000` | Nombre total de partages heberges. `0` = illimite |
| `MAX_SHARE_SIZE_MB` | `0` | Taille maximale d'un partage. `0` reprend `MAX_MAP_SIZE_MB` |
| `DEFAULT_SHARE_EXPIRY_DAYS` | `30` | Duree de vie par defaut d'un nouveau partage. `0` = permanent |
| `ALLOW_PERMANENT_SHARES` | `true` | Autorise l'option "Jamais" dans le choix d'expiration |
| `TRUST_PROXY_HEADERS` | `false` | Croire `X-Forwarded-For`. **A n'activer que derriere un reverse proxy de confiance** |
Format des limites de debit : `<nombre>/<fenetre>`, ou la fenetre vaut `second`,
`minute`, `hour` ou `day`. Une valeur vide, `0` ou `off` desactive la limite
concernee.
Le nom du produit est centralise : cote backend via `APP_NAME`, cote frontend
dans `frontend/src/config/app.ts`. Le frontend recupere le nom a l'execution via
`GET /api/config` : changer `APP_NAME` suffit, sans recompiler.
@@ -145,14 +163,47 @@ Deux stockages **totalement distincts** :
Le dossier `./data` est monte en **bind mount** (aucun volume Docker nomme) :
les fichiers sont donc directement lisibles depuis l'hote.
### Sauvegarde a chaud
SQLite fonctionne en mode WAL : les ecritures recentes vivent dans
`openmind.db-wal`, et un simple `cp openmind.db` peut donc produire une copie
incoherente. La commande de sauvegarde utilise l'API de sauvegarde de SQLite,
qui produit une base complete **pendant que l'application tourne** :
```bash
docker compose exec openmind python -m app.cli backup /app/data/backups/openmind-$(date +%F).db
```
Le fichier obtenu se trouve sur l'hote dans `./data/backups/`. C'est une base
SQLite ordinaire : elle s'ouvre avec n'importe quel outil, sans OpenMind.
A froid, une archive du dossier reste valable :
```bash
docker compose stop
tar czf openmind-partages-$(date +%F).tar.gz data/
docker compose start
```
SQLite fonctionne en mode WAL : `openmind.db`, `openmind.db-wal` et
`openmind.db-shm` doivent etre sauvegardes ensemble.
### Restauration
```bash
docker compose exec openmind python -m app.cli restore /app/data/backups/openmind-2026-09-10.db --yes
docker compose restart
```
La commande verifie d'abord que le fichier est bien une base OpenMind, place une
copie de securite de la base actuelle dans `./data/backups/pre-restore-*.db`,
puis ecrit le contenu restaure. Le redemarrage n'est pas indispensable mais reste
recommande. Pour une restauration sans aucun risque, arretez le conteneur, copiez
le fichier a la place de `openmind.db` (en supprimant les `-wal` / `-shm`
associes), puis redemarrez.
> **Ne modifiez pas `openmind.db` depuis l'hote pendant que le conteneur tourne.**
> Les fichiers `-wal` / `-shm` de SQLite sont partages entre les processus qui
> ont la base ouverte : une ecriture concurrente depuis l'hote, a travers un bind
> mount, peut etre perdue ou desynchroniser l'application du fichier. Passez par
> la CLI dans le conteneur, ou arretez le conteneur.
Redemarrer le serveur n'a **aucun impact** sur les cartes privees locales.
@@ -168,7 +219,7 @@ Deux secrets distincts sont generes cote serveur avec le module Python
| Jeton | Entropie | Ou il vit | A quoi il sert |
|---|---|---|---|
| `share_token` | `secrets.token_urlsafe(12)`, environ 96 bits | dans l'URL publique `/s/<token>` | **lecture seule** |
| `share_token` | `secrets.token_urlsafe(16)`, 128 bits | dans l'URL publique `/s/<token>` | **lecture seule** |
| `management_token` | `secrets.token_urlsafe(32)`, environ 256 bits | **uniquement** dans l'IndexedDB du proprietaire | modification et suppression |
Regles appliquees :
@@ -182,6 +233,55 @@ Regles appliquees :
supprimer un partage.
- Le lien est **non repertorie** : il n'apparait dans aucune liste publique,
mais toute personne qui le connait peut consulter la carte.
- Les jetons emis avant le passage a 128 bits restent valides : la recherche se
fait par valeur exacte, sans contrainte de longueur.
Protection du lien :
- `robots.txt` interdit `/s/` et `/api/`, et les reponses de ces chemins portent
`X-Robots-Tag: noindex, nofollow, noarchive`. La page publique ajoute la balise
`<meta name="robots">` correspondante. Ce n'est pas une mesure de securite :
c'est le jeton qui autorise la lecture.
- `Referrer-Policy: no-referrer` est applique a tout le site. Un lien externe
clique depuis une carte partagee ne transmet donc pas l'URL `/s/<token>` au
site de destination. Les liens des noeuds s'ouvrent avec
`rel="noopener noreferrer nofollow"`.
- Les journaux applicatifs remplacent les jetons par `[REDACTED]` dans les
chemins `/s/...` et `/api/shares/...`, et designent un partage par une
empreinte courte non reversible. Les autres routes restent lisibles pour le
diagnostic. **Un reverse proxy place devant l'instance a ses propres journaux**,
qui ne dependent pas d'OpenMind : configurez-les separement si vous ne voulez
pas y voir apparaitre les jetons.
### Expiration
Un partage peut expirer automatiquement. La duree est choisie a la creation
(24 heures, 7 jours, 30 jours, 90 jours, ou jamais) et modifiable ensuite depuis
le dialogue de partage. `DEFAULT_SHARE_EXPIRY_DAYS` fixe la valeur par defaut de
l'instance, `ALLOW_PERMANENT_SHARES=false` retire l'option "Jamais".
- Un partage expire repond `410 Gone` et la page publique affiche *Ce partage a
expire*, sans autre detail.
- Il n'est pas supprime pour autant : son proprietaire peut le prolonger, et
l'administrateur le purger.
- Les partages crees avant l'introduction de l'expiration ont `expires_at = NULL`
et restent donc valides indefiniment.
### Recuperer la main sur un partage
Le jeton de gestion vit uniquement dans l'IndexedDB du navigateur d'origine.
Depuis le dialogue de partage, **Exporter la cle de gestion** produit un petit
fichier `.openmind-share` :
```json
{ "version": 1, "shareToken": "...", "managementToken": "...", "title": "..." }
```
Depuis un autre navigateur, ouvrez la carte correspondante puis **Importer une
cle de gestion** : la cle est verifiee aupres du serveur concerne avant d'etre
enregistree localement. Ce fichier contient un secret : il n'est jamais ajoute
automatiquement a un export `.mmap`, et son export est toujours une action
explicite.
Cycle de vie :
@@ -203,15 +303,146 @@ serveur.
|---|---|---|---|
| `GET` | `/api/health` | - | Etat du service |
| `GET` | `/api/config` | - | Nom de l'application et limites |
| `GET` | `/api/ready` | - | Le service peut-il repondre (acces SQLite verifie) |
| `POST` | `/api/shares` | - | Creer un partage |
| `GET` | `/api/shares/{share_token}` | - | Lire une carte partagee |
| `PUT` | `/api/shares/{share_token}` | `X-Management-Token` | Mettre a jour |
| `PUT` | `/api/shares/{share_token}` | `X-Management-Token` | Mettre a jour le contenu |
| `PATCH` | `/api/shares/{share_token}` | `X-Management-Token` | Modifier l'expiration |
| `POST` | `/api/shares/{share_token}/verify` | `X-Management-Token` | Verifier une cle de gestion |
| `DELETE` | `/api/shares/{share_token}` | `X-Management-Token` | Supprimer |
`/api/health` et `/api/ready` ne sont soumis a aucune limitation de debit : le
healthcheck du conteneur reste fiable meme sous charge. Toutes les routes de
partage repondent `503` quand `SHARING_ENABLED=false`, et `429` avec un en-tete
`Retry-After` quand une limite est atteinte.
Documentation interactive : `http://localhost:8080/api/docs`.
---
## Administration d'une instance
Il n'existe volontairement **aucune interface `/admin` exposee sur Internet**.
L'administration passe par une CLI locale au conteneur, donc reservee a qui a
deja un acces machine :
```bash
docker compose exec openmind python -m app.cli stats
docker compose exec openmind python -m app.cli shares list --limit 20
docker compose exec openmind python -m app.cli shares list --expired
docker compose exec openmind python -m app.cli shares delete <share-token> --yes
docker compose exec openmind python -m app.cli shares purge-expired --dry-run
docker compose exec openmind python -m app.cli shares purge-expired --yes
docker compose exec openmind python -m app.cli backup /app/data/backups/openmind.db
docker compose exec openmind python -m app.cli restore /app/data/backups/openmind.db --yes
```
| Commande | Role |
|---|---|
| `stats` | Nombre de partages (total, actifs, expires), capacite, taille de la base, plus ancien et plus recent |
| `shares list` | Listage. Options `--limit`, `--active`, `--expired` |
| `shares delete <token>` | Suppression administrative, sans jeton de gestion. Demande confirmation sauf `--yes` |
| `shares purge-expired` | Supprime physiquement les partages expires. `--dry-run` affiche sans rien supprimer |
| `backup <chemin>` | Sauvegarde a chaud via l'API SQLite. `--yes` ecrase un fichier existant |
| `restore <chemin>` | Restauration prudente : verification, copie de securite, puis remplacement |
Aucune commande n'affiche le jeton de gestion, son hash, ni le contenu des
cartes. Les jetons de partage y sont masques (`sbcY...BCyw`) : suffisant pour
identifier une ligne, insuffisant pour ouvrir le lien.
---
## Deploiement derriere un reverse proxy
Le conteneur ecoute simplement sur `8080` en HTTP. Le TLS est termine par le
reverse proxy ; OpenMind n'embarque ni certificat ni Traefik, et aucune solution
n'est imposee.
Points a respecter :
1. Transmettre l'hote et le protocole d'origine.
2. Activer `TRUST_PROXY_HEADERS=true` **uniquement** dans ce cas : la limitation
de debit lit alors la derniere valeur de `X-Forwarded-For`, celle ajoutee par
votre proxy. Laisse a `false`, un client direct pourrait forger cet en-tete et
changer d'identite a chaque requete.
3. Ne pas mettre en cache `/api/`.
Aucune variable `PUBLIC_BASE_URL` n'est necessaire : le serveur ne fabrique
jamais d'URL absolue (il renvoie un chemin relatif `/s/<token>`), et le lien
affiche est construit dans le navigateur a partir de `window.location.origin`.
Un utilisateur sur `https://mind.example.com` obtient donc un lien sur ce
domaine, sans configuration.
Exemple Nginx :
```nginx
server {
listen 443 ssl;
server_name mind.example.com;
# certificats geres par votre outillage habituel
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m; # >= MAX_MAP_SIZE_MB
}
}
```
Exemple Traefik (labels sur le service `openmind` du `docker-compose.yml`) :
```yaml
labels:
- traefik.enable=true
- traefik.http.routers.openmind.rule=Host(`mind.example.com`)
- traefik.http.routers.openmind.entrypoints=websecure
- traefik.http.routers.openmind.tls.certresolver=letsencrypt
- traefik.http.services.openmind.loadbalancer.server.port=8080
```
Les journaux du reverse proxy contiennent les URL demandees, jetons de partage
compris. Si cela vous derange, configurez leur format ou leur retention : c'est
independant d'OpenMind.
---
## Migrations de schema
Le schema SQLite est gere par **Alembic**. Les migrations sont appliquees
automatiquement au demarrage du conteneur, avant qu'uvicorn ne serve la moindre
requete :
```
docker compose up -d -> docker-entrypoint.sh -> alembic upgrade head -> uvicorn
```
Trois cas, tous pris en charge sans intervention :
| Etat de depart | Ce qui se passe |
|---|---|
| Base absente | Toutes les revisions s'appliquent |
| Base creee avant Alembic (`create_all`) | La revision initiale est **estampillee** (aucune donnee touchee), puis les suivantes s'appliquent |
| Base deja a jour | Rien a faire |
Manuellement :
```bash
docker compose exec openmind python -m app.db.migrations # applique head
docker compose exec openmind alembic current # revision courante
docker compose exec openmind alembic history # historique
```
| Revision | Apport |
|---|---|
| `0001_initial_schema` | Table `shared_maps` telle qu'elle existait avant Alembic |
| `0002_share_expiry` | Colonne `expires_at` nullable et son index |
---
## Fonctionnalites
**Tableau de bord** : creation, import, recherche, date de derniere
@@ -547,6 +778,13 @@ suppression d'un partage, refus avec un mauvais jeton de gestion, refus sans
jeton, partage inexistant, payload invalide, limites de taille et de noeuds,
non-divulgation du jeton de gestion, repli SPA, acceptation des documents v3 et
v4 (ordre des freres, cotes, relations) et refus d'une relation incoherente.
S'y ajoutent, pour l'exposition publique : limitation de debit (creation,
lecture, exemption du healthcheck, desactivation), coupure du partage, capacite
maximale, taille propre aux partages, expiration et prolongation, verification
d'une cle de gestion, resolution de l'adresse client derriere un proxy,
en-tetes d'indexation et de referrer, redaction des jetons dans les journaux,
migrations Alembic sur base neuve et sur base d'avant Alembic, et l'ensemble des
commandes de la CLI d'administration.
Les tests frontend couvrent : stockage IndexedDB, serialisation et validation
d'import, migrations v1 -> v4, non-mutation et idempotence de la normalisation,
@@ -573,23 +811,26 @@ modeles, assainissement des URL.
├── backend/
│ ├── requirements.txt
│ ├── pyproject.toml # configuration ruff et pytest
│ ├── alembic.ini # configuration Alembic (URL prise dans DATABASE_URL)
│ ├── migrations/ # revisions de schema
│ ├── app/
│ │ ├── main.py # assemblage uniquement
│ │ ├── cli.py # administration locale : stats, partages, sauvegarde
│ │ ├── config.py
│ │ ├── logging_config.py
│ │ ├── middleware.py # en-tetes de securite, limite de taille
│ │ ├── logging_config.py # format des logs et redaction des jetons
│ │ ├── middleware.py # en-tetes de securite, cache, limite de taille
│ │ ├── web.py # service des fichiers statiques du frontend
│ │ ├── api/ # routes health, config, shares
│ │ ├── db/ # moteur, session, initialisation du schema
│ │ ├── api/ # routes health, ready, config, shares
│ │ ├── db/ # moteur, session, migrations
│ │ ├── models/ # SharedMap (SQLAlchemy)
│ │ ├── schemas/ # validation Pydantic
│ │ └── services/ # jetons, logique de partage, erreurs metier
│ │ └── services/ # jetons, partage, limitation de debit, adresse client
│ └── tests/
└── frontend/
├── index.html
├── public/ # manifest PWA, service worker, icones
└── src/
├── config/app.ts # nom du produit et constantes
├── config/ # nom du produit, constantes, texte de confidentialite
├── types/map.ts # modele metier versionne
├── db/ # schema Dexie
├── services/ # depots, serialisation, client API
@@ -600,6 +841,7 @@ modeles, assainissement des URL.
│ ├── editor/ # canvas, noeud, barre d'outils, panneaux, layout, bilateral, relations, themes
│ ├── templates/ # galerie et definitions des modeles
│ ├── maps/ # tableau de bord
│ ├── privacy/ # page /privacy
│ ├── export/ # PNG, SVG, PDF, impression
│ └── sharing/ # dialogue de partage, page publique
├── utils/
@@ -666,9 +908,24 @@ modeles, assainissement des URL.
racines suivantes ne sont pas recentrees individuellement.
- **Types de noeuds reserves** : `note`, `image`, `group`, `link` et `task` sont
acceptes par le format, mais la V1 ne produit que `root` et `standard`.
- **Migrations de schema SQLite** : la V1 initialise le schema avec
`create_all`, une seule table. Alembic n'est pas utilise ; il faudra
l'introduire au premier changement de schema.
- **Limitation de debit en memoire** : les compteurs vivent dans le processus.
Un redemarrage du conteneur les remet a zero, et plusieurs workers uvicorn
auraient chacun les leurs. C'est un garde-fou contre la saturation triviale
d'une instance publique, pas un quota strict. Un reverse proxy peut ajouter sa
propre limitation si le besoin est plus exigeant.
- **Partage expire et information** : un lien expire repond `410` et non `404`,
ce qui revele qu'un partage a existe a cette adresse. C'est assume : sans cela,
la page publique ne pourrait pas expliquer la difference entre un lien expire
et un lien erronne. Rien d'autre n'est divulgue.
- **Purge des partages expires** : elle n'est pas automatique. Un partage expire
cesse d'etre lisible mais reste en base jusqu'a un
`shares purge-expired`, ce qui laisse a son proprietaire la possibilite de le
prolonger. Planifiez la commande si vous voulez liberer l'espace regulierement.
- **Ecriture concurrente sur la base SQLite** : ne modifiez pas `openmind.db`
depuis l'hote pendant que le conteneur tourne. Les fichiers `-wal` / `-shm`
sont partages entre processus ; a travers un bind mount, une ecriture externe
peut etre perdue ou desynchroniser l'application du fichier. Utilisez la CLI
dans le conteneur, ou arretez le conteneur.
- **Guides d'alignement** : ils apparaissent pendant un deplacement lorsqu'un
bord ou un centre coincide (a 6 px pres) avec celui d'un autre noeud. Ils sont
purement visuels : ils n'aimantent pas le noeud. Pour aimanter, activez