snoopy 55e5b359a1 Rendre le compose deployable depuis le depot
env_file rendait obligatoire un fichier non versionne : un clone neuf, ou une
stack Portainer construite depuis Git, echouait avec "env file .env not found".
La declaration etait de toute facon redondante, Compose lisant deja un .env
voisin pour resoudre les interpolations, et toutes les variables du .env.example
etant couvertes par environment: ou ports:.

Verifie : configuration resolue identique avec un .env present, a l'exception de
APP_PORT qui n'est plus injecte dans le conteneur. C'est une variable cote hote,
que le backend ne lit jamais et dont le port est fige dans le CMD.

Documente le deploiement par stack Portainer : variables d'environnement en
remplacement du .env, emplacement et proprietaire du dossier de donnees (uid
10001), mise a jour, administration par la console, et cas du proxy en Docker.

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

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.

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 :

curl http://localhost:8080/api/health

Reponse attendue : {"status":"ok"}

Etat du conteneur (le healthcheck Docker interroge /api/health) :

docker compose ps

Arreter :

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 :

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 :

docker compose stop
tar czf openmind-partages-$(date +%F).tar.gz data/
docker compose start

Restauration

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 :

{ "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 :

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 :

# 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 :

# /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
# /etc/systemd/system/openmind-backup.timer
[Unit]
Description=Sauvegarde quotidienne d'OpenMind

[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target
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 avec Portainer

OpenMind se deploie comme une stack Portainer construite directement depuis le depot Git. Aucun registre d'images n'est necessaire : Portainer clone le depot et lance le build multi-etapes.

1. Creer la stack

Stacks -> Add stack -> onglet Repository :

Champ Valeur
Name openmind
Repository URL l'URL de clone du depot
Repository reference refs/heads/main
Compose path docker-compose.yml
Authentication uniquement si le depot est prive

Le premier deploiement compile le frontend puis l'image Python : comptez plusieurs minutes. Les suivants reutilisent le cache de build.

2. Renseigner les variables

Le fichier .env n'est pas dans le depot : c'est normal, il contient la configuration de votre instance. Utilisez la section Environment variables de la stack, qui joue exactement le meme role. Toutes les variables ont une valeur par defaut : ne declarez que ce que vous changez.

Pour une instance publique derriere un reverse proxy :

Variable Valeur
APP_BIND 127.0.0.1 si le proxy tourne sur l'hote
TRUST_PROXY_HEADERS true
TRUSTED_PROXY_CIDRS le reseau d'ou arrivent les connexions du proxy
DEFAULT_SHARE_EXPIRY_DAYS 30
MAX_SHARED_MAPS 10000

Rappel : TRUST_PROXY_HEADERS=true sans TRUSTED_PROXY_CIDRS empeche le demarrage, volontairement. Si la stack ne demarre pas, la raison est dans les logs du conteneur, en clair.

3. Le dossier de donnees

Le compose monte ./data a cote du fichier compose, c'est-a-dire dans le repertoire de la stack cree par Portainer, typiquement :

/var/lib/docker/volumes/portainer_data/_data/compose/<id-de-la-stack>/data/

Ce dossier contient openmind.db et vos sauvegardes : c'est lui qu'il faut sauvegarder. Si vous preferez un emplacement stable et explicite, remplacez la ligne de volume par un chemin absolu de l'hote :

    volumes:
      - /srv/openmind/data:/app/data

Le conteneur tourne sous l'uid 10001. Si vous creez le dossier vous-meme, donnez-lui le bon proprietaire, sinon SQLite ne pourra pas ecrire :

sudo mkdir -p /srv/openmind/data
sudo chown -R 10001:10001 /srv/openmind/data

4. Mettre a jour

Stacks -> openmind -> Pull and redeploy, en cochant Re-pull image and redeploy pour reconstruire. Les migrations de schema s'appliquent seules au demarrage ; les partages existants sont conserves.

5. Administration

Portainer donne acces a la console du conteneur (Containers -> openmind -> Console, commande /bin/sh). Toutes les commandes d'administration y fonctionnent telles quelles :

python -m app.cli stats
python -m app.cli shares purge-expired --dry-run
python -m app.cli backup /app/data/backups/openmind.db --yes --keep 7

Pour automatiser sauvegarde et purge, gardez cron ou un timer systemd sur l'hote : docker exec openmind python -m app.cli ....

Si le proxy tourne aussi dans Docker

Retirez la publication de port et placez les deux conteneurs sur un reseau commun. Dans Portainer, ajoutez ces lignes a la stack :

services:
  openmind:
    ports: !reset []
    networks: [proxy]

networks:
  proxy:
    external: true

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 :

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 :

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) :

# 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 :

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.

{
  "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

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

cd frontend
npm install
npm run dev

Le serveur Vite ecoute sur http://localhost:5173 et proxyfie /api vers http://localhost:8080.

Autres commandes :

npm run build
npm run lint
npm run typecheck
npm run test

Regenerer les icones de la PWA :

python scripts/generate_icons.py

Tests et qualite

Backend :

cd backend
python -m pytest -q
ruff check .
ruff format --check .

Frontend :

cd frontend
npm run test
npm run lint
npm run typecheck

Sans installation locale de Node ou Python, tout peut tourner via Docker :

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"
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.

Description
Cartes mentales libres, local-first, sans compte et sans abonnement. React + FastAPI + SQLite, un seul conteneur Docker.
Readme MIT 524 KiB
Languages
TypeScript 77.5%
Python 21%
JavaScript 0.5%
CSS 0.5%
Dockerfile 0.3%