Files
openmind/README.md
snoopy 9ede2c5e68 Phase 2.1.1 : durcissement avant exposition publique
Fail-fast sur la configuration. Une limite de debit illisible (10/hours,
10/banana, -1/hour) arrete desormais le demarrage avec un message nommant la
variable, au lieu de desactiver silencieusement la protection : seules les
valeurs 0, off, none, disabled et vide desactivent volontairement. Meme
traitement pour MAX_SHARED_MAPS, MAX_SHARE_SIZE_MB, DEFAULT_SHARE_EXPIRY_DAYS
et chaque entree de TRUSTED_PROXY_CIDRS.

X-Forwarded-For n'est plus cru sur la seule foi de TRUST_PROXY_HEADERS : la
connexion doit aussi provenir d'un reseau declare dans TRUSTED_PROXY_CIDRS
(ipaddress, IPv4/IPv6/CIDR). Activer la confiance sans declarer de reseau est
refuse au demarrage, faute de quoi la configuration semblerait active sans
l'etre. L'adresse retenue est normalisee : les differentes ecritures d'une meme
IPv6 ne donnent plus des compteurs distincts.

MAX_SHARED_MAPS : comptage et insertion serialises par un verrou de processus,
pour que deux creations simultanees ne franchissent pas ensemble la derniere
place. Documente comme valable pour un service applicatif unique.

Ajoute APP_BIND pour ne pas publier 8080 sur toutes les interfaces en
production, une option backup --keep pour la retention, et documente Nginx,
Traefik en override, cron et systemd.

Corrige un bug de rendu du build local : mon edition avait reecrit
docker-entrypoint.sh en CRLF, rendant le shebang introuvable dans le conteneur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:53:29 +02:00

1119 lines
46 KiB
Markdown

# OpenMind
**Cartes mentales libres, local-first, sans compte et sans abonnement.**
OpenMind est une alternative gratuite et open source aux services de mind mapping
qui placent l'export, les themes ou le nombre de cartes derriere un abonnement.
Toutes les fonctionnalites sont disponibles, sans exception et sans compte.
- Aucune limitation artificielle, aucun paiement, aucun "premium".
- Aucun compte obligatoire : il n'y a d'ailleurs aucun systeme de comptes.
- Aucune telemetrie, aucun tracker, aucune publicite.
- Vos cartes vous appartiennent : elles restent dans votre navigateur.
- Export PDF, PNG, SVG et impression : gratuits.
- Un partage par lien, uniquement si vous le demandez explicitement.
- Un seul conteneur Docker a demarrer.
---
## Philosophie local-first
Une carte que vous creez n'est **jamais** envoyee au serveur.
Elle est enregistree dans l'**IndexedDB de votre navigateur**, automatiquement,
apres une courte periode d'inactivite. Le backend n'existe que pour une seule
chose : generer un lien de partage lorsque vous cliquez explicitement sur
**Partager**.
Consequences concretes :
- Fermer le navigateur, redemarrer le PC ou redemarrer le conteneur ne fait
perdre aucune carte locale.
- Une fois l'application chargee une premiere fois, elle continue de fonctionner
sans reseau (PWA) : creation, edition, export, impression.
- Vider les donnees du site, ou changer de navigateur/machine, fait disparaitre
les cartes locales. **Exportez vos cartes en `.mmap`** pour en conserver une
sauvegarde portable.
---
## Demarrage rapide
Prerequis : Docker et Docker Compose.
```bash
git clone <url-du-depot>
cd Mind-Mapping
cp .env.example .env
docker compose up -d --build
```
Puis ouvrez :
```
http://localhost:8080
```
Verifier que le service repond :
```bash
curl http://localhost:8080/api/health
```
Reponse attendue : `{"status":"ok"}`
Etat du conteneur (le healthcheck Docker interroge `/api/health`) :
```bash
docker compose ps
```
Arreter :
```bash
docker compose down
```
---
## Architecture
Un seul service applicatif. FastAPI sert a la fois l'API et les fichiers
statiques du frontend compile.
```
Navigateur
|
+-- React / TypeScript (Vite, Tailwind, React Flow, Zustand)
|
+-- IndexedDB (Dexie)
| +-- cartes privees/locales <-- ne quittent jamais le navigateur
|
+-- API, uniquement pour les fonctions serveur (partage)
|
v
FastAPI (+ fichiers statiques du frontend)
|
v
SQLite
|
+-- cartes explicitement partagees
```
Choix structurants :
| Sujet | Choix | Pourquoi |
|---|---|---|
| Hierarchie | `parentId` fait autorite, les liens en sont derives | un seul invariant a maintenir, pas de desynchronisation |
| Ordre des freres | `node.order`, renumerote a chaque modification | une source par preoccupation : la relation d'un cote, la position de l'autre |
| Modele | types metier propres, adaptes vers React Flow a l'affichage | le format de fichier ne depend pas de la bibliotheque de rendu |
| Historique | instantanes `{nodes, edges}`, groupes par action | un glisser-deposer = une seule annulation |
| Auto-layout | dagre, declenche uniquement a la demande | les deplacements manuels ne sont jamais ecrases |
| Versionnement | champ `version` + point d'extension de migration | le format peut evoluer sans casser les fichiers existants |
---
## Variables d'environnement
Toutes definies dans `.env` (modele : `.env.example`). Aucun secret n'est requis.
| Variable | Defaut | Role |
|---|---|---|
| `APP_NAME` | `OpenMind` | Nom du produit, affiche dans l'interface et l'onglet |
| `APP_PORT` | `8080` | Port publie sur l'hote (le conteneur ecoute toujours 8080) |
| `APP_BIND` | `0.0.0.0` | Interface de publication. `127.0.0.1` en production derriere un proxy local |
| `DATABASE_URL` | `sqlite:////app/data/openmind.db` | Base des cartes partagees |
| `MAX_MAP_SIZE_MB` | `10` | Taille maximale d'une carte envoyee au serveur |
| `MAX_NODES` | `5000` | Nombre maximal de noeuds par carte |
| `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** |
| `TRUSTED_PROXY_CIDRS` | vide | Reseaux depuis lesquels `X-Forwarded-For` est accepte. Obligatoire si `TRUST_PROXY_HEADERS=true` |
Format des limites de debit : `<nombre>/<fenetre>`, ou la fenetre vaut `second`,
`minute`, `hour` ou `day`.
**La configuration est validee au demarrage.** Desactiver une limite se fait
avec `0`, `off`, `none`, `disabled` ou une valeur vide. Toute autre valeur
illisible arrete l'application avec un message nommant la variable :
```
Configuration invalide :
SHARE_CREATE_RATE_LIMIT : '10/hours' n'est pas une limite de debit valide :
fenetre 'hours' inconnue. Fenetres acceptees : day, hour, minute, second.
```
Une faute de frappe n'est pas une desactivation volontaire : sans ce controle,
une instance publique tournerait sans protection en croyant en avoir une. Sont
validees de la meme facon : `MAX_SHARED_MAPS >= 0`, `MAX_SHARE_SIZE_MB >= 0`
(`0` = reprend `MAX_MAP_SIZE_MB`), `MAX_MAP_SIZE_MB > 0`,
`DEFAULT_SHARE_EXPIRY_DAYS >= 0`, et chaque entree de `TRUSTED_PROXY_CIDRS`.
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.
---
## Donnees et sauvegarde
Deux stockages **totalement distincts** :
| Donnee | Ou | Sauvegarde |
|---|---|---|
| Cartes privees | IndexedDB du navigateur | Export `.mmap` depuis le tableau de bord ou l'editeur |
| Cartes partagees | SQLite, dans `./data/openmind.db` | Copie du dossier `./data` |
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
```
### 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.
---
## Fonctionnement du partage
Une carte n'est envoyee au serveur que lorsque vous cliquez sur **Partager**,
apres une explication et une confirmation.
Deux secrets distincts sont generes cote serveur avec le module Python
`secrets` :
| Jeton | Entropie | Ou il vit | A quoi il sert |
|---|---|---|---|
| `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 :
- Le `management_token` n'apparait **jamais** dans une URL, jamais dans un export
`.mmap`, jamais dans les logs.
- Il est transmis dans l'en-tete `X-Management-Token` pour `PUT` et `DELETE`.
- Seul son **hash SHA-256** est stocke en base ; la comparaison est faite en
temps constant avec `secrets.compare_digest`.
- Connaitre le `share_token` ne permet donc **ni** de modifier **ni** de
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 :
1. **Partager** : un instantane de la carte est copie sur le serveur, un lien est
renvoye, par exemple `http://localhost:8080/s/a8F3xP9k`.
2. **Mettre a jour le partage** : remplace l'instantane par la version actuelle.
Tant que vous ne le faites pas, le lien public montre l'ancienne version.
3. **Supprimer le partage** : efface l'instantane serveur. Le lien affiche alors
une page propre indiquant que le partage n'existe plus. La carte locale n'est
pas affectee.
Si vous supprimez une carte locale, sa reference de partage locale part avec
elle : supprimez d'abord le partage si vous voulez aussi retirer la copie
serveur.
### API
| Methode | Route | Auth | Role |
|---|---|---|---|
| `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 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 |
`backup` accepte `--keep N` : apres ecriture, seules les `N` sauvegardes les
plus recentes du meme dossier sont conservees, la nouvelle comprise. Sans
`--keep`, rien n'est jamais supprime.
### Automatiser la purge et la sauvegarde
OpenMind n'embarque volontairement **aucun planificateur** : ces commandes
s'appellent depuis l'outillage de la machine.
Avec cron, une entree quotidienne :
```cron
# Sauvegarde a 3h15, sept jours de retention
15 3 * * * cd /srv/openmind && docker compose exec -T openmind python -m app.cli backup /app/data/backups/openmind-$(date +\%F).db --yes --keep 7
# Purge des partages expires a 3h30
30 3 * * * cd /srv/openmind && docker compose exec -T openmind python -m app.cli shares purge-expired --yes
```
Dans un fichier cron, `%` doit etre echappe : d'ou `\%F`.
Avec systemd, un service et son minuteur :
```ini
# /etc/systemd/system/openmind-backup.service
[Unit]
Description=Sauvegarde OpenMind
[Service]
Type=oneshot
WorkingDirectory=/srv/openmind
ExecStart=/usr/bin/docker compose exec -T openmind python -m app.cli backup /app/data/backups/openmind-daily.db --yes --keep 7
ExecStart=/usr/bin/docker compose exec -T openmind python -m app.cli shares purge-expired --yes
```
```ini
# /etc/systemd/system/openmind-backup.timer
[Unit]
Description=Sauvegarde quotidienne d'OpenMind
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
```
```bash
systemctl enable --now openmind-backup.timer
```
Les fichiers produits arrivent dans `./data/backups/` sur l'hote : copiez-les
hors de la machine si vous voulez une vraie sauvegarde.
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. **Ne pas publier 8080 sur Internet.** Posez `APP_BIND=127.0.0.1` dans `.env`
si le proxy tourne sur l'hote : OpenMind n'est alors joignable que par lui.
Si le proxy tourne dans Docker, retirez entierement la section `ports` et
placez les deux conteneurs sur un reseau Docker commun.
3. Activer `TRUST_PROXY_HEADERS=true` **et** renseigner `TRUSTED_PROXY_CIDRS`
avec les reseaux d'ou viennent les connexions du proxy. Les deux vont
ensemble : activer la confiance sans declarer de reseau arrete le demarrage.
4. Ne pas mettre en cache `/api/`.
### Comment l'adresse du client est determinee
`X-Forwarded-For` est un en-tete que n'importe qui peut ecrire. OpenMind ne le
croit qu'a deux conditions cumulees :
| `TRUST_PROXY_HEADERS` | Connexion venant d'un reseau de `TRUSTED_PROXY_CIDRS` | Adresse retenue |
|---|---|---|
| `false` | peu importe | adresse reelle de la socket |
| `true` | non | adresse reelle de la socket |
| `true` | oui | derniere valeur de `X-Forwarded-For`, sinon `X-Real-IP`, sinon la socket |
OpenMind suppose **un reverse proxy frontal de confiance**. Un proxy
correctement configure (`$proxy_add_x_forwarded_for` chez Nginx) ajoute a la fin
de la chaine l'adresse qu'il a reellement vue : c'est donc la derniere valeur
qui fait foi, les precedentes pouvant avoir ete forgees par le client. Aucun
mecanisme multi-sauts n'est prevu ; avec plusieurs proxies en cascade, declarez
uniquement celui qui est directement devant OpenMind.
L'adresse retenue est normalisee avec le module standard `ipaddress` : les
differentes ecritures d'une meme IPv6 (`2001:db8::1`,
`2001:0db8:0000:...:0001`, `2001:DB8::1`) donnent une seule cle de limitation,
et il ne suffit donc pas de reecrire son adresse autrement pour repartir avec un
compteur neuf.
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
}
}
```
Cote OpenMind, dans `.env` :
```ini
APP_BIND=127.0.0.1
TRUST_PROXY_HEADERS=true
TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128
```
Exemple Traefik (labels sur le service `openmind` du `docker-compose.yml`) :
```yaml
# docker-compose.override.yml — Traefik reste hors du projet.
services:
openmind:
# Plus aucun port publie : seul Traefik joint le conteneur.
ports: !reset []
networks: [proxy]
environment:
TRUST_PROXY_HEADERS: "true"
TRUSTED_PROXY_CIDRS: "172.16.0.0/12" # reseau du conteneur Traefik
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- 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
networks:
proxy:
external: true
```
Traefik transmet `X-Forwarded-For`, `X-Forwarded-Proto` et `X-Real-IP` par
defaut. Adaptez `TRUSTED_PROXY_CIDRS` au sous-reseau reel du reseau `proxy`
(`docker network inspect proxy`).
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
modification, renommage, duplication, export, suppression avec confirmation.
Une carte d'exemple est creee au tout premier lancement.
**Editeur** : canvas infini, zoom, deplacement, zoom-to-fit, minimap, grille
optionnelle, creation/suppression/deplacement de noeuds, selection multiple,
connexion entre noeuds avec detection de boucle, edition du texte au
double-clic, undo/redo, copier/coller, duplication, sauvegarde automatique.
**Mind mapping** : noeud racine et noeuds standard, branches droites, courbes ou
Bezier, branches repliables avec compteur d'elements masques.
**Auto-layout**, uniquement sur demande : mind map droite, mind map gauche,
mind map bilaterale, arbre horizontal, arbre vertical. L'espacement est
configurable (distance parent/enfant et espacement entre branches), avec les
presets Compact, Normal et Aere. Choisir une disposition ou modifier
l'espacement ne deplace rien : c'est *Reorganiser* qui applique, ce qui protege
les positions placees a la main.
**Mind map bilaterale** : la racine reste au centre, les branches de niveau 1 se
repartissent a gauche et a droite, et tous les descendants heritent du cote de
leur branche. La repartition automatique equilibre les deux cotes selon la
taille des sous-arbres ; le cote se change ensuite a la main (*Placer la branche
a gauche / a droite*).
**Relations secondaires** : une connexion visuelle entre deux noeuds
quelconques, independante de la hierarchie. Elle ne modifie ni le parent, ni
l'ordre, ni le repli, et l'auto-layout l'ignore totalement. Libelle libre,
trace (Bezier, courbe, droite), couleur, epaisseur, trait plein ou pointille,
fleches aux deux extremites. Une relation dont une extremite est masquee par un
repli disparait de l'affichage et revient au deploiement.
**Modeles** : galerie locale de huit modeles (carte vide, brainstorming, gestion
de projet, architecture IT, reunion, SWOT, plan d'etude, planification
personnelle). Choisir un modele en cree une copie : l'original n'est jamais
modifie, et la carte obtenue est une carte comme les autres.
**Markdown** : import d'un fichier `.md` ou d'un texte colle, comme nouvelle
carte depuis le tableau de bord ou comme sous-arbre d'un noeud existant. Les
titres et les listes construisent la hierarchie ; les paragraphes et les blocs
de code rejoignent la note du noeud, en texte brut. Export inverse en `.md` :
contenu et hierarchie uniquement, sans positions ni couleurs, avec un
avertissement si la carte contient des relations secondaires.
**Proprietes du noeud**, regroupees en sections repliables (Contenu,
Typographie, Apparence, Mise en page, Branche) : texte, icone, URL, notes ;
police, taille, gras, italique, alignement horizontal et vertical, interligne ;
couleurs de fond/texte/bordure, forme (rectangle, arrondi, capsule, cercle),
ombre ; taille automatique ou personnalisee, largeur, hauteur, padding
horizontal et vertical, retour a la ligne, largeur maximale du texte ; style,
epaisseur et couleur de la branche. Un reglage s'applique a toute la selection,
avec un etat *Mixte* quand les valeurs different.
**Creation rapide** : *Ajouter plusieurs enfants* (une ligne = un noeud, avec
apercu), collage d'une liste multi-ligne directement sur un noeud, import d'une
arborescence indentee (espaces ou tabulations, puces retirees, indentation
incoherente signalee), ajout avant / apres un frere.
**Hierarchie** : ordre explicite des freres (monter, descendre, placer en
premier / en dernier), remontee d'un niveau, *Deplacer vers...* avec recherche
du nouveau parent, suppression d'un noeud en conservant ses enfants.
**Branches** : selectionner, dupliquer, copier / coller comme enfant, deplacer
sur le canvas avec ses descendants, replier / developper une branche, tout
replier, tout developper, replier au-dela d'un niveau donne.
**Mise en page visuelle** : redimensionnement des noeuds a la souris (une seule
annulation par redimensionnement), taille automatique qui epouse le contenu,
alignement et distribution d'une multi-selection, aimantation sur la grille,
guides d'alignement pendant le deplacement, copier / coller / reinitialiser le
style (Ctrl+Maj+C, Ctrl+Maj+V), menu contextuel par clic droit. Le style couvre
les couleurs, la forme, la typographie, la mise en page, la presentation de
l'icone et la branche ; l'icone elle-meme, comme le texte, la note et l'URL,
appartient au contenu du noeud et n'est jamais remplacee par un collage.
**Bibliotheque d'icones** : emojis et icones integrees (Lucide), avec position
(gauche, droite, au-dessus), taille et espacement configurables.
**Themes** : Default, Minimal, Dark, Midnight, Ocean, Forest, Pastel,
Monochrome, Blueprint. Plus un mode clair/sombre pour l'interface. Changer de
theme ne supprime jamais une personnalisation explicite d'un noeud.
**Recherche dans la carte** : resultats listes, navigation entre eux, centrage
et mise en evidence temporaire du noeud, depliage automatique des branches
repliees.
**Exports** : `.mmap` (format natif), Markdown (`.md`), PNG (x1, x2, fond transparent), SVG, PDF
(A4/A3, portrait/paysage, marges, adapter a une page ou taille reelle, fond,
titre, date), impression via `@media print`.
**PWA** : installable, et utilisable hors ligne pour les cartes locales une fois
l'application chargee.
---
## Raccourcis clavier
`Cmd` remplace `Ctrl` sur macOS.
| Raccourci | Action |
|---|---|
| `Tab` | Ajouter un noeud enfant |
| `Entree` | Ajouter un noeud frere |
| `Suppr` / `Retour arriere` | Supprimer la selection, hors saisie de texte |
| `F2` | Editer le texte du noeud selectionne |
| Double-clic | Editer le texte du noeud |
| `Ctrl+Z` | Annuler |
| `Ctrl+Y` ou `Ctrl+Shift+Z` | Retablir |
| `Ctrl+C` | Copier |
| `Ctrl+V` | Coller |
| `Ctrl+D` | Dupliquer |
| `Ctrl+Maj+C` | Copier le style du noeud (sans son icone) |
| `Ctrl+Maj+V` | Coller le style sur la selection |
| `Maj+Tab` | Remonter le noeud d'un niveau |
| `Maj+Entree` | Ajouter plusieurs enfants |
| `Alt+Haut` / `Alt+Bas` | Monter / descendre parmi les freres |
| `Alt` + glisser | Inverser le mode de deplacement de branche |
| `Ctrl+V` | Coller : noeuds OpenMind, sinon liste de texte en enfants |
| `Ctrl+A` | Tout selectionner |
| `Ctrl+S` | Sauvegarde explicite |
| `Ctrl+F` | Rechercher dans la carte |
| `F` | Adapter la carte a l'ecran |
| `Echap` | Quitter l'edition ou deselectionner |
---
## Format de fichier `.mmap`
Un `.mmap` est un fichier **JSON UTF-8** contenant le document complet de la
carte. Il ne contient jamais de jeton de partage.
Le format est en **version 3**.
| Version | Apport | Migration a l'import |
|---|---|---|
| v1 | format initial | `style.width` devient `layout.width` |
| v2 | mise en page des noeuds (`layout`, `iconStyle`, `branch`) | l'ordre des freres est materialise depuis leur position dans `nodes` |
| v3 | ordre explicite des freres (`node.order`) | aucune |
| v4 | cote des branches (`node.side`) et relations secondaires (`relations`) | aucune |
Les fichiers v1, v2 et v3 restent lisibles, et les cartes deja enregistrees dans
IndexedDB s'ouvrent sans modification. Le passage en v3 etait justifie : l'ordre
des freres fait partie de la semantique du document, et un lecteur v2
l'ignorerait en reordonnant silencieusement la carte. Les noeuds sont malgre
tout ecrits en parcours de profondeur, freres dans l'ordre, ce qui donne le bon
resultat meme a un lecteur qui ignore `order`.
Le passage en v4 l'est aussi : un lecteur v3 ignorerait `relations` et perdrait
donc une partie du document. La migration v3 -> v4 n'a en revanche rien a
inventer : aucun cote defini signifie "a repartir automatiquement", aucune
relation signifie "aucune". Une carte v3 s'affiche exactement comme avant.
Toute carte qui entre dans l'editeur passe par le meme point d'entree,
`normalizeMapDocument()`, quelle que soit sa provenance :
```
fichier .mmap ┐
IndexedDB ├─> validation -> migration de version -> normalisation -> modele courant
partage ┘
```
La normalisation est **non destructive** : le document fourni n'est jamais
modifie et rien n'est reecrit en base. Une carte v1 restee en IndexedDB s'ouvre
donc exactement comme avant, et n'est reenregistree au format courant que si
vous la modifiez.
```json
{
"version": 1,
"id": "uuid",
"name": "Architecture Homelab",
"createdAt": "2026-01-01T10:00:00.000Z",
"updatedAt": "2026-01-01T12:30:00.000Z",
"nodes": [
{
"id": "uuid",
"type": "root",
"text": "Infrastructure",
"position": { "x": 0, "y": 0 },
"parentId": null,
"order": 0,
"side": "right",
"collapsed": false,
"icon": "\ud83d\udda5\ufe0f",
"url": "https://exemple.fr",
"note": "Texte libre",
"style": {
"backgroundColor": "#eef2ff",
"shape": "rounded",
"textAlign": "center",
"verticalAlign": "middle",
"lineHeight": 1.35,
"fontFamily": "sans",
"shadow": "soft"
},
"layout": {
"widthMode": "fixed",
"width": 240,
"heightMode": "auto",
"paddingX": 14,
"paddingY": 8,
"wrap": true,
"maxTextWidth": 260
},
"iconStyle": { "kind": "emoji", "position": "left", "size": 16, "gap": 8 },
"branch": { "style": "bezier", "width": 2, "color": "#94a3b8" }
}
],
"edges": [{ "id": "e-a-b", "source": "a", "target": "b" }],
"relations": [
{
"id": "r-uuid",
"sourceId": "a",
"targetId": "b",
"label": "depend de",
"style": "bezier",
"color": "#a855f7",
"width": 2,
"dashed": true,
"markerEnd": "arrow"
}
],
"viewport": { "x": 0, "y": 0, "zoom": 1 },
"settings": {
"theme": "default",
"layout": "mindmap-right",
"branchStyle": "bezier",
"showGrid": true,
"layoutSpacing": { "parentGap": 100, "siblingGap": 50 },
"snapToGrid": false,
"snapGrid": 20,
"alignmentGuides": true,
"dragMovesBranch": false
}
}
```
Deux precisions sur ce document :
- `edges` est **derive** de `parentId` : il est recalcule a chaque modification
et n'est jamais une source de verite. `relations`, au contraire, est une
donnee a part entiere, que rien ne permet de deduire de la hierarchie.
- `side` n'a de sens que sur un enfant direct d'une racine. Tous ses descendants
heritent du cote de leur branche, ce qui evite d'entretenir la meme
information sur chaque noeud.
A l'import, le fichier est **valide et normalise** : structure, version, champs
essentiels, limite de noeuds, assainissement des URL (`javascript:` et `data:`
sont supprimes), rejet des styles invalides. Le contenu n'est jamais execute ni
interprete comme du HTML ; un fichier invalide produit un message clair.
Le champ `version` permet les migrations futures : le point d'extension se
trouve dans `frontend/src/services/mapSerialization.ts`, fonction `migrate`.
---
## Developpement
### Backend
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements-dev.txt
DATABASE_URL=sqlite:///./openmind-dev.db STATIC_DIR=./static uvicorn app.main:app --reload --port 8080
```
Sous Windows, activez l'environnement avec `.venv\Scripts\activate`.
### Frontend
```bash
cd frontend
npm install
npm run dev
```
Le serveur Vite ecoute sur `http://localhost:5173` et proxyfie `/api` vers
`http://localhost:8080`.
Autres commandes :
```bash
npm run build
npm run lint
npm run typecheck
npm run test
```
Regenerer les icones de la PWA :
```bash
python scripts/generate_icons.py
```
---
## Tests et qualite
Backend :
```bash
cd backend
python -m pytest -q
ruff check .
ruff format --check .
```
Frontend :
```bash
cd frontend
npm run test
npm run lint
npm run typecheck
```
Sans installation locale de Node ou Python, tout peut tourner via Docker :
```bash
docker run --rm -v "$PWD/backend:/work" -w /work python:3.12-slim sh -c "pip install -q -r requirements-dev.txt && ruff check . && python -m pytest -q"
```
```bash
docker run --rm -v "$PWD/frontend:/app" -w /app node:20-alpine sh -c "npm ci && npm run lint && npm run test && npm run build"
```
Les tests backend couvrent : healthcheck, creation, lecture, mise a jour et
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,
store Zustand (creation, suppression de branche, undo/redo, groupement du
glisser-deposer, copier/coller, duplication, detection de boucle, recherche,
auto-layout, lecture seule), repartition et heritage des cotes, auto-layout
bilateral, relations secondaires (creation, refus, style, repli, duplication,
independance vis-a-vis de la hierarchie), analyse et export Markdown, galerie de
modeles, assainissement des URL.
---
## Structure du projet
```
.
├── Dockerfile # build multi-stage : node -> python -> image finale
├── docker-compose.yml # un seul service, bind mount ./data
├── .env.example
├── README.md
├── scripts/
│ └── generate_icons.py # generation des icones PWA
├── data/ # bind mount : SQLite des partages
├── 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 # 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, ready, config, shares
│ │ ├── db/ # moteur, session, migrations
│ │ ├── models/ # SharedMap (SQLAlchemy)
│ │ ├── schemas/ # validation Pydantic
│ │ └── services/ # jetons, partage, limitation de debit, adresse client
│ └── tests/
└── frontend/
├── index.html
├── public/ # manifest PWA, service worker, icones
└── src/
├── config/ # nom du produit, constantes, texte de confidentialite
├── types/map.ts # modele metier versionne
├── db/ # schema Dexie
├── services/ # depots, serialisation, client API
├── stores/ # Zustand : editeur, interface, configuration
├── hooks/ # routeur minimal, debounce
├── components/ # briques d'interface reutilisables
├── features/
│ ├── 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/
└── styles/
```
---
## Securite
- Le texte d'un noeud est **toujours** traite comme du texte. Aucun
`dangerouslySetInnerHTML`, aucun HTML issu d'une carte n'est interprete.
- Les URL sont assainies : seuls `http`, `https` et `mailto` sont acceptes. Les
liens s'ouvrent avec `rel="noopener noreferrer nofollow"`.
- Validation Pydantic de tout ce qui entre dans l'API, avec limites de taille et
de nombre de noeuds ; un depassement produit un message clair, jamais un
plantage.
- En-tetes de securite : CSP stricte, `X-Content-Type-Options`,
`X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`,
`Cross-Origin-Opener-Policy`.
- CORS restrictif : aucune origine externe par defaut.
- Acces SQL exclusivement via l'ORM SQLAlchemy.
- Jetons cryptographiquement aleatoires, jeton de gestion stocke hashe et jamais
journalise.
- Le conteneur tourne avec un utilisateur non-root.
- Aucun secret dans le frontend.
---
## Limitations connues
- **Export SVG** : les noeuds sont encapsules dans un `foreignObject` (HTML
embarque). Le fichier reste vectoriel et se redimensionne sans perte, les
branches sont de vrais chemins SVG, mais certains editeurs vectoriels
n'exploitent pas le contenu HTML des noeuds. Preferez PNG ou PDF pour un usage
bureautique.
- **Exports image et PDF en arriere-plan** : la generation s'appuie sur une
frame d'animation du navigateur, qui n'est pas produite lorsque l'onglet est
masque. Gardez l'onglet au premier plan pendant un export ; sinon l'operation
est interrompue au bout de 45 secondes avec un message explicite.
- **PDF en taille reelle** : si la carte depasse la page choisie, elle est
reduite pour tenir entierement plutot que d'etre coupee ; un message le
signale.
- **Gestion d'un partage** : le jeton de gestion vit dans l'IndexedDB du
navigateur d'origine. Depuis un autre appareil, un partage ne peut etre ni mis
a jour ni supprime.
- **Dispositions reservees** : `organization` et `freeform` existent dans le
modele de donnees mais ne sont pas proposees dans l'interface.
- **Relations et export Markdown** : l'export `.md` decrit le contenu et la
hierarchie. Les relations secondaires n'y ont pas de representation fidele et
ne sont pas inventees ; un message le signale au moment de l'export.
- **Relations et minimap** : la minimap n'affiche que les noeuds. Les relations
y sont invisibles, comme les branches hierarchiques.
- **Doublon de relation** : deux relations identiques (meme source vers meme
cible) se superposeraient, la seconde serait donc invisible et impossible a
selectionner. Une seule est acceptee par couple ; le sens inverse, lui, reste
autorise.
- **Relation dupliquee** : dupliquer ou coller une branche reporte les relations
dont les deux extremites sont dans la branche. Une relation qui sort vers un
noeud exterieur n'est pas recreee : rien ne dit que la copie doit viser la
meme cible, et le deviner creerait un lien non demande.
- **Bilaterale et racines multiples** : le recalage des deux cotes se fait sur
la premiere racine. Une carte a plusieurs racines reste affichee, mais les
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`.
- **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.
- **Capacite maximale** : le comptage et l'insertion sont serialises par un
verrou de processus, ce qui empeche deux creations simultanees de franchir
ensemble la derniere place. Cette garantie vaut pour l'architecture livree :
un seul service applicatif devant un SQLite local. Avec plusieurs workers ou
plusieurs conteneurs, `MAX_SHARED_MAPS` redeviendrait un garde-fou approximatif
et non un quota transactionnel.
- **Un seul niveau de proxy** : OpenMind suppose un reverse proxy frontal de
confiance et lit la derniere valeur de `X-Forwarded-For`. Avec plusieurs
proxies en cascade, declarez uniquement celui qui est directement devant
l'application ; aucun mecanisme multi-sauts n'est prevu.
- **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
*Aimanter sur la grille*.
- **Alignement vertical** : il n'a d'effet visible que si le noeud est plus haut
que son texte, c'est-a-dire avec une hauteur fixe ou un padding vertical
important.
- **Cercle** : un noeud circulaire garde toujours un rapport 1:1. Modifier sa
largeur ajuste sa hauteur et inversement ; convertir un noeud rectangulaire en
cercle le ramene a un carre. Il n'est donc pas possible d'obtenir un ovale.
- **Plusieurs racines** : le modele autorise depuis la v1 une carte a plusieurs
racines, et certaines cartes existantes en dependent ; ce n'est donc pas
interdit. Une carte mentale classique n'en a qu'une, et l'interface pousse
dans ce sens : *Detacher* est une action explicite, et un noeud detache
devient une racine a part entiere. Un noeud ne peut jamais devenir son propre
parent ni celui d'un de ses ancetres.
- **Reordonner ne deplace pas les noeuds sur le canvas** : l'ordre est logique.
Il se voit immediatement dans l'ordre des liens et dans le resultat de
*Reorganiser* ; les positions libres, elles, restent celles choisies par
l'utilisateur.
- **Deplacement sur une tres grande carte** : sur une carte de ~180 noeuds, une
frame de glisser-deposer coute environ 60 a 80 ms car React Flow recalcule
l'ensemble des noeuds controles. Le deplacement reste utilisable mais moins
fluide que sur une carte de taille courante.
- **Mobile** : consultation et edition basique fonctionnent, mais l'interface
est concue pour desktop et tablette.
- **Aucun compte utilisateur** : c'est voulu. Pas de connexion, pas de
collaboration temps reel, pas de synchronisation cloud.
---
## Licences des dependances
Toutes les dependances sont sous licence libre permissive, compatible avec une
application publique et gratuite. Aucune bibliotheque a licence commerciale
n'est utilisee.
| Dependance | Licence |
|---|---|
| React, React DOM | MIT |
| `@xyflow/react` (React Flow) | MIT |
| Zustand | MIT |
| Dexie | Apache-2.0 |
| dagre | MIT |
| html-to-image | MIT |
| jsPDF | MIT |
| lucide-react | ISC |
| Vite, Tailwind CSS, TypeScript, ESLint, Vitest | MIT |
| FastAPI, Starlette, Pydantic, SQLAlchemy, Uvicorn | MIT / BSD |
---
## Licence
OpenMind est distribue sous licence **MIT** : voir le fichier [LICENSE](LICENSE).