Phase 2.1 : preparation au deploiement public

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

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

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

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

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

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

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

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

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

View File

@@ -17,6 +17,37 @@ DATABASE_URL=sqlite:////app/data/openmind.db
MAX_MAP_SIZE_MB=10 MAX_MAP_SIZE_MB=10
MAX_NODES=5000 MAX_NODES=5000
# ---------------------------------------------------------------------------
# Exposition publique
# ---------------------------------------------------------------------------
# false = instance entierement privee : aucune API de partage, bouton masque.
# Les cartes locales continuent de fonctionner normalement.
SHARING_ENABLED=true
# Limites de debit, au format <nombre>/<fenetre> (second, minute, hour, day).
# Vide ou 0 desactive la limite. Les compteurs sont en memoire : ils repartent
# de zero au redemarrage du conteneur.
SHARE_CREATE_RATE_LIMIT=10/hour
SHARE_READ_RATE_LIMIT=120/minute
SHARE_WRITE_RATE_LIMIT=60/hour
# Nombre total de partages heberges. 0 = illimite.
MAX_SHARED_MAPS=10000
# Taille maximale d'un partage. 0 = reprend MAX_MAP_SIZE_MB.
MAX_SHARE_SIZE_MB=5
# Duree de vie par defaut d'un nouveau partage, en jours. 0 = permanent.
DEFAULT_SHARE_EXPIRY_DAYS=30
# Autorise l'option "Jamais" lors du choix de l'expiration.
ALLOW_PERMANENT_SHARES=true
# A activer UNIQUEMENT derriere un reverse proxy de confiance : sinon un client
# direct pourrait falsifier X-Forwarded-For et contourner les limites de debit.
TRUST_PROXY_HEADERS=false
# Origines autorisees en CORS, separees par des virgules. # Origines autorisees en CORS, separees par des virgules.
# Vide = aucune origine externe (l'interface est servie par ce meme serveur). # Vide = aucune origine externe (l'interface est servie par ce meme serveur).
CORS_ORIGINS= CORS_ORIGINS=

View File

@@ -50,8 +50,13 @@ WORKDIR /app
COPY --from=backend-deps /opt/python /opt/python COPY --from=backend-deps /opt/python /opt/python
COPY backend/app ./app COPY backend/app ./app
COPY backend/alembic.ini ./alembic.ini
COPY backend/migrations ./migrations
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
COPY --from=frontend-build /build/dist ./static COPY --from=frontend-build /build/dist ./static
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
RUN mkdir -p /app/data && chown -R openmind:openmind /app RUN mkdir -p /app/data && chown -R openmind:openmind /app
USER openmind USER openmind
@@ -60,4 +65,6 @@ EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/api/health || exit 1 CMD curl -fsS http://127.0.0.1:8080/api/health || exit 1
# Les migrations sont appliquees par l'entrypoint, avant le demarrage d'uvicorn.
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers"]

283
README.md
View File

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

42
backend/alembic.ini Normal file
View File

@@ -0,0 +1,42 @@
# Configuration Alembic d'OpenMind.
# L'URL de la base n'est PAS ecrite ici : elle vient de DATABASE_URL, via
# migrations/env.py, afin qu'il n'existe qu'une seule source de configuration.
[alembic]
script_location = migrations
prepend_sys_path = .
version_path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stdout,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -4,12 +4,14 @@ from __future__ import annotations
from typing import Annotated from typing import Annotated
from fastapi import Depends, Header from fastapi import Depends, Header, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.db.session import get_db from app.db.session import get_db
from app.services.errors import ShareForbiddenError from app.services.client_ip import resolve_client_ip
from app.services.errors import RateLimitedError, ShareForbiddenError, SharingDisabledError
from app.services.rate_limit import limiter, parse_rate
MANAGEMENT_TOKEN_HEADER = "X-Management-Token" MANAGEMENT_TOKEN_HEADER = "X-Management-Token"
@@ -27,3 +29,41 @@ def require_management_token(
ManagementToken = Annotated[str, Depends(require_management_token)] ManagementToken = Annotated[str, Depends(require_management_token)]
def require_sharing_enabled(settings: AppSettings) -> None:
"""Coupe toute l'API de partage quand l'instance est privee.
Les cartes locales continuent de fonctionner : seul le service serveur est
indisponible, et il le dit clairement.
"""
if not settings.sharing_enabled:
raise SharingDisabledError()
SharingEnabled = Annotated[None, Depends(require_sharing_enabled)]
def _rate_limit(bucket: str, spec_name: str): # type: ignore[no-untyped-def]
"""Fabrique une dependance de limitation pour une famille d'endpoints.
La limitation est posee sur les routes concernees, jamais en middleware
global : /api/health et /api/ready ne peuvent donc pas etre bloques, ce qui
garantit que le healthcheck du conteneur reste fiable sous charge.
"""
def dependency(request: Request, settings: AppSettings) -> None:
rule = parse_rate(getattr(settings, spec_name))
if rule is None:
return
client = resolve_client_ip(request, trust_proxy_headers=settings.trust_proxy_headers)
retry_after = limiter.check(f"{bucket}:{client}", rule)
if retry_after is not None:
raise RateLimitedError(retry_after)
return dependency
RateLimitCreate = Annotated[None, Depends(_rate_limit("share:create", "share_create_rate_limit"))]
RateLimitRead = Annotated[None, Depends(_rate_limit("share:read", "share_read_rate_limit"))]
RateLimitWrite = Annotated[None, Depends(_rate_limit("share:write", "share_write_rate_limit"))]

View File

@@ -1,19 +1,48 @@
"""Endpoints de sante et de configuration publique.""" """Endpoints de sante et de configuration publique.
Aucune limitation de debit n'est posee ici : le healthcheck du conteneur doit
rester fiable meme quand une instance subit un afflux de requetes.
"""
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter import logging
from app.api.deps import AppSettings from fastapi import APIRouter
from fastapi.responses import JSONResponse
from sqlalchemy import text
from app.api.deps import AppSettings, DbSession
logger = logging.getLogger(__name__)
router = APIRouter(tags=["system"]) router = APIRouter(tags=["system"])
# Durees proposees dans l'interface. `None` = permanent.
SHARE_EXPIRY_OPTIONS: list[int | None] = [1, 7, 30, 90, None]
@router.get("/health", summary="Etat du service") @router.get("/health", summary="Etat du service")
def health() -> dict[str, str]: def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@router.get("/ready", summary="Le service peut-il repondre : base de donnees joignable")
def ready(session: DbSession): # type: ignore[no-untyped-def]
"""Complete /health : verifie que SQLite repond reellement.
Utile derriere un orchestrateur qui doit distinguer "processus vivant" de
"capable de servir", par exemple quand le volume de donnees est absent ou
en lecture seule.
"""
try:
session.execute(text("SELECT 1"))
except Exception:
logger.exception("Base de donnees injoignable")
return JSONResponse(status_code=503, content={"status": "unavailable"})
return {"status": "ready"}
@router.get("/config", summary="Configuration publique exposee au frontend") @router.get("/config", summary="Configuration publique exposee au frontend")
def public_config(settings: AppSettings) -> dict[str, object]: def public_config(settings: AppSettings) -> dict[str, object]:
"""Aucun secret ici : uniquement ce dont l'interface a besoin.""" """Aucun secret ici : uniquement ce dont l'interface a besoin."""
@@ -21,5 +50,9 @@ def public_config(settings: AppSettings) -> dict[str, object]:
"appName": settings.app_name, "appName": settings.app_name,
"maxNodes": settings.max_nodes, "maxNodes": settings.max_nodes,
"maxMapSizeMb": settings.max_map_size_mb, "maxMapSizeMb": settings.max_map_size_mb,
"sharingEnabled": True, "maxShareSizeMb": settings.max_share_size_mb_effective,
"sharingEnabled": settings.sharing_enabled,
"allowPermanentShares": settings.allow_permanent_shares,
"defaultShareExpiryDays": settings.default_share_expiry_days,
"shareExpiryOptions": SHARE_EXPIRY_OPTIONS,
} }

View File

@@ -4,10 +4,19 @@ from __future__ import annotations
from fastapi import APIRouter, Response, status from fastapi import APIRouter, Response, status
from app.api.deps import DbSession, ManagementToken from app.api.deps import (
DbSession,
ManagementToken,
RateLimitCreate,
RateLimitRead,
RateLimitWrite,
SharingEnabled,
)
from app.schemas.share import ( from app.schemas.share import (
ShareCreatedOut, ShareCreatedOut,
ShareCreateIn, ShareCreateIn,
ShareExpiryIn,
ShareOwnerOut,
SharePublicOut, SharePublicOut,
ShareUpdatedOut, ShareUpdatedOut,
) )
@@ -26,9 +35,21 @@ def _share_path(share_token: str) -> str:
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
summary="Publier un snapshot de carte et obtenir un lien non repertorie", summary="Publier un snapshot de carte et obtenir un lien non repertorie",
) )
def create_share(payload: ShareCreateIn, session: DbSession) -> ShareCreatedOut: def create_share(
payload: ShareCreateIn,
session: DbSession,
_sharing: SharingEnabled,
_rate: RateLimitCreate,
) -> ShareCreatedOut:
# `permanent` distingue "aucune preference" (defaut de l'instance) de
# "explicitement sans expiration", que le JSON seul ne permet pas de separer.
explicit = payload.permanent or payload.expires_in_days is not None
share, management_token = share_service.create_share( share, management_token = share_service.create_share(
session, title=payload.title, document=payload.map session,
title=payload.title,
document=payload.map,
expires_in_days=None if payload.permanent else payload.expires_in_days,
expiry_explicit=explicit,
) )
return ShareCreatedOut( return ShareCreatedOut(
share_token=share.share_token, share_token=share.share_token,
@@ -37,6 +58,7 @@ def create_share(payload: ShareCreateIn, session: DbSession) -> ShareCreatedOut:
title=share.title, title=share.title,
created_at=share.created_at, created_at=share.created_at,
updated_at=share.updated_at, updated_at=share.updated_at,
expires_at=share.expires_at,
) )
@@ -45,7 +67,12 @@ def create_share(payload: ShareCreateIn, session: DbSession) -> ShareCreatedOut:
response_model=SharePublicOut, response_model=SharePublicOut,
summary="Lire une carte partagee (public, lecture seule)", summary="Lire une carte partagee (public, lecture seule)",
) )
def read_share(share_token: str, session: DbSession) -> SharePublicOut: def read_share(
share_token: str,
session: DbSession,
_sharing: SharingEnabled,
_rate: RateLimitRead,
) -> SharePublicOut:
share = share_service.get_share(session, share_token) share = share_service.get_share(session, share_token)
return SharePublicOut( return SharePublicOut(
share_token=share.share_token, share_token=share.share_token,
@@ -54,6 +81,7 @@ def read_share(share_token: str, session: DbSession) -> SharePublicOut:
map=share_service.load_map_data(share), map=share_service.load_map_data(share),
created_at=share.created_at, created_at=share.created_at,
updated_at=share.updated_at, updated_at=share.updated_at,
expires_at=share.expires_at,
) )
@@ -67,6 +95,8 @@ def update_share(
payload: ShareCreateIn, payload: ShareCreateIn,
session: DbSession, session: DbSession,
management_token: ManagementToken, management_token: ManagementToken,
_sharing: SharingEnabled,
_rate: RateLimitWrite,
) -> ShareUpdatedOut: ) -> ShareUpdatedOut:
share = share_service.update_share( share = share_service.update_share(
session, session,
@@ -80,6 +110,64 @@ def update_share(
share_path=_share_path(share.share_token), share_path=_share_path(share.share_token),
title=share.title, title=share.title,
updated_at=share.updated_at, updated_at=share.updated_at,
expires_at=share.expires_at,
)
@router.patch(
"/{share_token}",
response_model=ShareOwnerOut,
summary="Modifier l'expiration du partage (necessite le jeton de gestion)",
)
def update_expiry(
share_token: str,
payload: ShareExpiryIn,
session: DbSession,
management_token: ManagementToken,
_sharing: SharingEnabled,
_rate: RateLimitWrite,
) -> ShareOwnerOut:
share = share_service.set_expiry(
session,
share_token,
management_token=management_token,
expires_in_days=None if payload.permanent else payload.expires_in_days,
)
return ShareOwnerOut(
share_token=share.share_token,
title=share.title,
created_at=share.created_at,
updated_at=share.updated_at,
expires_at=share.expires_at,
)
@router.post(
"/{share_token}/verify",
response_model=ShareOwnerOut,
summary="Verifier qu'une cle de gestion correspond a ce partage",
)
def verify_share(
share_token: str,
session: DbSession,
management_token: ManagementToken,
_sharing: SharingEnabled,
_rate: RateLimitWrite,
) -> ShareOwnerOut:
"""Permet de reimporter une cle de gestion depuis un autre navigateur.
Ne renvoie aucune donnee de carte : seulement de quoi reconstituer la fiche
de partage locale.
"""
share = share_service.verify_management_token(
session, share_token, management_token=management_token
)
return ShareOwnerOut(
share_token=share.share_token,
title=share.title,
created_at=share.created_at,
updated_at=share.updated_at,
expires_at=share.expires_at,
) )
@@ -93,6 +181,8 @@ def delete_share(
share_token: str, share_token: str,
session: DbSession, session: DbSession,
management_token: ManagementToken, management_token: ManagementToken,
_sharing: SharingEnabled,
_rate: RateLimitWrite,
) -> Response: ) -> Response:
share_service.delete_share(session, share_token, management_token=management_token) share_service.delete_share(session, share_token, management_token=management_token)
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)

334
backend/app/cli.py Normal file
View File

@@ -0,0 +1,334 @@
"""Administration locale d'une instance OpenMind.
Volontairement une CLI et non une interface web : il n'existe aucune page
/admin exposee sur Internet, et ces commandes ne s'utilisent qu'avec un acces
au conteneur.
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 delete <share-token>
docker compose exec openmind python -m app.cli shares purge-expired --dry-run
docker compose exec openmind python -m app.cli backup /app/data/backups/openmind.db
Aucune commande n'affiche ni le jeton de gestion, ni son hash, ni le contenu
des cartes.
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
from sqlalchemy import func, select
from app.config import get_settings
from app.db.session import SessionLocal
from app.models.share import SharedMap, utcnow
_SQLITE_PREFIX = "sqlite:///"
def database_path() -> Path | None:
"""Chemin du fichier SQLite, ou None si la base n'est pas un fichier."""
url = get_settings().database_url
if not url.startswith(_SQLITE_PREFIX):
return None
raw = url[len(_SQLITE_PREFIX) :]
if not raw or raw == ":memory:":
return None
return Path(raw)
def _format_size(num_bytes: int) -> str:
value = float(num_bytes)
for unit in ("o", "Ko", "Mo", "Go"):
if value < 1024 or unit == "Go":
return f"{value:.1f} {unit}" if unit != "o" else f"{int(value)} {unit}"
value /= 1024
return f"{value:.1f} Go"
def _format_date(moment: datetime | None) -> str:
if moment is None:
return "jamais"
return moment.strftime("%Y-%m-%d %H:%M")
def _mask(token: str) -> str:
"""N'affiche jamais un jeton en entier : il autorise la lecture."""
if len(token) <= 8:
return f"{token[:2]}...{token[-2:]}"
return f"{token[:4]}...{token[-4:]}"
def _confirm(question: str, assume_yes: bool) -> bool:
if assume_yes:
return True
if not sys.stdin.isatty():
print("Confirmation impossible (entree non interactive). Utilisez --yes.")
return False
answer = input(f"{question} [o/N] ").strip().lower()
return answer in {"o", "oui", "y", "yes"}
def cmd_stats(_args: argparse.Namespace) -> int:
now = utcnow()
with SessionLocal() as session:
total = session.execute(select(func.count()).select_from(SharedMap)).scalar_one()
expired = session.execute(
select(func.count())
.select_from(SharedMap)
.where(SharedMap.expires_at.is_not(None), SharedMap.expires_at <= now)
).scalar_one()
oldest = session.execute(
select(SharedMap.created_at).order_by(SharedMap.created_at.asc()).limit(1)
).scalar_one_or_none()
newest = session.execute(
select(SharedMap.created_at).order_by(SharedMap.created_at.desc()).limit(1)
).scalar_one_or_none()
settings = get_settings()
path = database_path()
size = 0
if path and path.exists():
size = path.stat().st_size
for suffix in ("-wal", "-shm"):
side = path.with_name(path.name + suffix)
if side.exists():
size += side.stat().st_size
limit = settings.max_shared_maps
print(f"Partages : {total}")
print(f" actifs : {total - expired}")
print(f" expires : {expired}")
print(f"Capacite : {'illimitee' if limit <= 0 else limit}")
print(f"Partage active : {'oui' if settings.sharing_enabled else 'non'}")
print(f"Base SQLite : {path or 'non applicable'}")
print(f"Taille (WAL comprise) : {_format_size(size)}")
print(f"Plus ancien : {_format_date(oldest)}")
print(f"Plus recent : {_format_date(newest)}")
return 0
def cmd_shares_list(args: argparse.Namespace) -> int:
now = utcnow()
stmt = select(SharedMap).order_by(SharedMap.created_at.desc())
if args.expired:
stmt = stmt.where(SharedMap.expires_at.is_not(None), SharedMap.expires_at <= now)
elif args.active:
stmt = stmt.where((SharedMap.expires_at.is_(None)) | (SharedMap.expires_at > now))
stmt = stmt.limit(args.limit)
with SessionLocal() as session:
rows = list(session.execute(stmt).scalars())
if not rows:
print("Aucun partage.")
return 0
header = f"{'JETON':<14} {'TITRE':<28} {'CREE':<17} {'MAJ':<17} {'EXPIRE':<17} {'NOEUDS':>7}"
print(header)
print("-" * len(header))
for share in rows:
# `parentId` apparait exactement une fois par noeud, contrairement a
# `id` que portent aussi les liens et les relations.
nodes = share.map_data.count('"parentId"')
title = (share.title or "(sans titre)")[:27]
marker = " (expire)" if share.is_expired(now=now) else ""
print(
f"{_mask(share.share_token):<14} {title:<28} "
f"{_format_date(share.created_at):<17} {_format_date(share.updated_at):<17} "
f"{_format_date(share.expires_at) + marker:<17} {nodes:>7}"
)
return 0
def cmd_shares_delete(args: argparse.Namespace) -> int:
"""Suppression administrative : n'utilise pas le jeton de gestion.
Elle est reservee a qui a deja un acces au conteneur, donc a la base.
"""
with SessionLocal() as session:
share = session.execute(
select(SharedMap).where(SharedMap.share_token == args.share_token)
).scalar_one_or_none()
if share is None:
print("Aucun partage ne correspond a ce jeton.")
return 1
print(f"Partage : {share.title or '(sans titre)'} ({_mask(share.share_token)})")
print(f" cree le : {_format_date(share.created_at)}")
print(f" expire le : {_format_date(share.expires_at)}")
if not _confirm("Supprimer definitivement ce partage ?", args.yes):
print("Annule.")
return 1
session.delete(share)
session.commit()
print("1 partage supprime.")
return 0
def cmd_shares_purge(args: argparse.Namespace) -> int:
now = utcnow()
with SessionLocal() as session:
rows = list(
session.execute(
select(SharedMap).where(
SharedMap.expires_at.is_not(None), SharedMap.expires_at <= now
)
).scalars()
)
if not rows:
print("0 partage(s) supprime(s) : aucun partage expire.")
return 0
print(f"{len(rows)} partage(s) expire(s) :")
for share in rows:
print(
f" {_mask(share.share_token)} {(share.title or '(sans titre)')[:40]}"
f" expire le {_format_date(share.expires_at)}"
)
if args.dry_run:
print("Mode simulation : rien n'a ete supprime.")
return 0
if not _confirm(f"Supprimer ces {len(rows)} partage(s) ?", args.yes):
print("Annule.")
return 1
for share in rows:
session.delete(share)
session.commit()
print(f"{len(rows)} partage(s) supprime(s).")
return 0
def _sqlite_backup(source: Path, destination: Path) -> None:
"""Copie coherente via l'API de sauvegarde SQLite.
Un simple `cp` du fichier n'est pas sur en mode WAL : les ecritures
recentes vivent dans le fichier -wal, et la copie obtenue peut etre
incoherente. L'API backup, elle, produit une base complete et utilisable
pendant que l'application continue de tourner.
"""
destination.parent.mkdir(parents=True, exist_ok=True)
with (
sqlite3.connect(f"file:{source}?mode=ro", uri=True) as origin,
sqlite3.connect(destination) as copy,
):
origin.backup(copy)
def cmd_backup(args: argparse.Namespace) -> int:
source = database_path()
if source is None or not source.exists():
print("Aucun fichier SQLite a sauvegarder.")
return 1
destination = Path(args.destination)
if destination.exists() and not args.yes:
print(f"{destination} existe deja. Utilisez --yes pour l'ecraser.")
return 1
_sqlite_backup(source, destination)
print(f"Sauvegarde ecrite : {destination} ({_format_size(destination.stat().st_size)})")
return 0
def cmd_restore(args: argparse.Namespace) -> int:
"""Restauration prudente : verification, copie de securite, puis remplacement.
Le remplacement passe lui aussi par l'API backup de SQLite, qui ecrit dans
la base cible sous son propre verrou plutot que d'echanger le fichier sous
les pieds d'un processus qui l'a ouvert. Arreter le conteneur avant reste
la facon la plus sure de proceder.
"""
source = Path(args.source)
target = database_path()
if target is None:
print("La base configuree n'est pas un fichier SQLite.")
return 1
if not source.exists():
print(f"Fichier introuvable : {source}")
return 1
try:
with sqlite3.connect(f"file:{source}?mode=ro", uri=True) as check:
tables = {row[0] for row in check.execute("SELECT name FROM sqlite_master")}
except sqlite3.DatabaseError:
print("Ce fichier n'est pas une base SQLite lisible.")
return 1
if "shared_maps" not in tables:
print("Cette base ne contient pas de table shared_maps : restauration refusee.")
return 1
print(f"Source : {source}")
print(f"Destination : {target}")
if not _confirm("Remplacer le contenu de la base actuelle ?", args.yes):
print("Annule.")
return 1
if target.exists():
safety = target.parent / "backups" / f"pre-restore-{utcnow():%Y%m%d-%H%M%S}.db"
_sqlite_backup(target, safety)
print(f"Sauvegarde de securite : {safety}")
_sqlite_backup(source, target)
print("Restauration terminee. Redemarrez le conteneur pour repartir proprement.")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="python -m app.cli",
description="Administration locale d'OpenMind (partages, sauvegarde).",
)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("stats", help="Compteurs et taille de la base").set_defaults(func=cmd_stats)
shares = sub.add_parser("shares", help="Gestion des partages")
shares_sub = shares.add_subparsers(dest="shares_command", required=True)
listing = shares_sub.add_parser("list", help="Lister les partages")
listing.add_argument("--limit", type=int, default=50, help="Nombre maximum de lignes")
group = listing.add_mutually_exclusive_group()
group.add_argument("--expired", action="store_true", help="Uniquement les partages expires")
group.add_argument("--active", action="store_true", help="Uniquement les partages actifs")
listing.set_defaults(func=cmd_shares_list)
delete = shares_sub.add_parser("delete", help="Supprimer un partage")
delete.add_argument("share_token", help="Jeton public du partage")
delete.add_argument("--yes", action="store_true", help="Ne pas demander confirmation")
delete.set_defaults(func=cmd_shares_delete)
purge = shares_sub.add_parser("purge-expired", help="Supprimer les partages expires")
purge.add_argument("--dry-run", action="store_true", help="Afficher sans supprimer")
purge.add_argument("--yes", action="store_true", help="Ne pas demander confirmation")
purge.set_defaults(func=cmd_shares_purge)
backup = sub.add_parser("backup", help="Sauvegarder la base pendant que l'app tourne")
backup.add_argument("destination", help="Chemin du fichier de sauvegarde")
backup.add_argument("--yes", action="store_true", help="Ecraser un fichier existant")
backup.set_defaults(func=cmd_backup)
restore = sub.add_parser("restore", help="Restaurer une sauvegarde (prudent)")
restore.add_argument("source", help="Fichier de sauvegarde a restaurer")
restore.add_argument("--yes", action="store_true", help="Ne pas demander confirmation")
restore.set_defaults(func=cmd_restore)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
return int(args.func(args))
if __name__ == "__main__": # pragma: no cover - point d'entree
raise SystemExit(main())

View File

@@ -28,6 +28,32 @@ class Settings(BaseSettings):
max_nodes: int = Field(default=5000, gt=0) max_nodes: int = Field(default=5000, gt=0)
max_title_length: int = Field(default=200, gt=0) max_title_length: int = Field(default=200, gt=0)
# --- Exploitation publique -------------------------------------------
# Instance entierement privee : le partage serveur peut etre coupe sans
# rien changer au fonctionnement local des cartes.
sharing_enabled: bool = True
# Limites de debit, au format "<nombre>/<fenetre>" : 10/hour, 120/minute,
# 5/second. Une valeur vide ou "0" desactive la limite concernee.
share_create_rate_limit: str = "10/hour"
share_read_rate_limit: str = "120/minute"
share_write_rate_limit: str = "60/hour"
# Nombre total de partages heberges. 0 = illimite.
max_shared_maps: int = Field(default=10000, ge=0)
# Taille maximale d'un partage. 0 = on retombe sur max_map_size_mb.
max_share_size_mb: float = Field(default=0.0, ge=0)
# Duree de vie par defaut d'un nouveau partage, en jours. 0 = permanent.
default_share_expiry_days: int = Field(default=30, ge=0)
# Autorise l'option "Jamais" dans l'interface et via l'API.
allow_permanent_shares: bool = True
# A n'activer que derriere un reverse proxy de confiance : sinon un client
# direct pourrait falsifier son adresse et contourner les limites de debit.
trust_proxy_headers: bool = False
# Liste d'origines autorisees, separees par des virgules. # Liste d'origines autorisees, separees par des virgules.
# Vide = aucune origine externe (l'app est servie par ce meme serveur). # Vide = aucune origine externe (l'app est servie par ce meme serveur).
cors_origins: str = "" cors_origins: str = ""
@@ -38,6 +64,17 @@ class Settings(BaseSettings):
def max_map_size_bytes(self) -> int: def max_map_size_bytes(self) -> int:
return int(self.max_map_size_mb * 1024 * 1024) return int(self.max_map_size_mb * 1024 * 1024)
@property
def max_share_size_bytes(self) -> int:
"""Limite propre aux partages, bornee par la limite HTTP globale."""
if self.max_share_size_mb <= 0:
return self.max_map_size_bytes
return min(int(self.max_share_size_mb * 1024 * 1024), self.max_map_size_bytes)
@property
def max_share_size_mb_effective(self) -> float:
return self.max_share_size_bytes / (1024 * 1024)
@property @property
def cors_origin_list(self) -> list[str]: def cors_origin_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]

View File

@@ -1,8 +1,8 @@
"""Initialisation du schema SQLite. """Mise a niveau du schema SQLite au demarrage.
La V1 n'utilise pas Alembic : le schema tient dans une seule table et Depuis la Phase 2.1, le schema est gere par Alembic : `init_db` se contente de
`create_all` suffit. Toute evolution future du schema devra soit passer par declencher les migrations. Il n'existe volontairement aucun mecanisme de
Alembic, soit par une migration explicite ajoutee ici (voir README). migration maison en parallele.
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,16 +11,12 @@ import logging
from sqlalchemy import Engine from sqlalchemy import Engine
from app.db.base import Base from app.db.migrations import run_migrations
from app.db.session import engine as default_engine from app.db.session import engine as default_engine
from app.models import share as _share_model # noqa: F401 (enregistre la table) from app.models import share as _share_model # noqa: F401 (enregistre la table)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SCHEMA_VERSION = 1
def init_db(engine: Engine | None = None) -> None: def init_db(engine: Engine | None = None) -> None:
target = engine or default_engine run_migrations(engine or default_engine)
Base.metadata.create_all(bind=target)
logger.info("Schema SQLite initialise (version %s)", SCHEMA_VERSION)

View File

@@ -0,0 +1,67 @@
"""Application des migrations Alembic au demarrage.
Deux situations coexistent et doivent toutes deux fonctionner sans
intervention :
* **installation neuve** : la base est vide, toutes les revisions s'appliquent ;
* **base existante** creee avant Alembic par `create_all` : la table est deja
la, mais `alembic_version` n'existe pas. On estampille alors la revision
initiale (aucune modification de donnees) avant de derouler la suite.
Aucune operation destructive n'est effectuee : la seule migration de cette
phase ajoute une colonne nullable.
"""
from __future__ import annotations
import logging
from pathlib import Path
from alembic import command
from alembic.config import Config
from sqlalchemy import inspect
from app.config import get_settings
from app.db.session import engine as default_engine
logger = logging.getLogger(__name__)
BACKEND_ROOT = Path(__file__).resolve().parents[2]
ALEMBIC_INI = BACKEND_ROOT / "alembic.ini"
INITIAL_REVISION = "0001_initial_schema"
MAIN_TABLE = "shared_maps"
def build_config(database_url: str | None = None, *, configure_logger: bool = False) -> Config:
config = Config(str(ALEMBIC_INI))
config.set_main_option("script_location", str(BACKEND_ROOT / "migrations"))
config.set_main_option("sqlalchemy.url", database_url or get_settings().database_url)
# Les migrations tournent dans le processus applicatif : elles ne doivent pas
# reconfigurer les logs deja en place.
config.attributes["configure_logger"] = configure_logger
return config
def run_migrations(engine=None, database_url: str | None = None) -> None: # type: ignore[no-untyped-def]
target = engine or default_engine
config = build_config(database_url)
with target.connect() as connection:
tables = set(inspect(connection).get_table_names())
legacy = MAIN_TABLE in tables and "alembic_version" not in tables
if legacy:
# Base d'avant Alembic : on declare l'etat initial deja atteint plutot
# que de rejouer une creation de table qui echouerait.
logger.info("Base existante detectee : estampillage de %s", INITIAL_REVISION)
command.stamp(config, INITIAL_REVISION)
command.upgrade(config, "head")
logger.info("Migrations appliquees (head)")
if __name__ == "__main__": # pragma: no cover - appele par l'entrypoint Docker
import sys
logging.basicConfig(level="INFO", format="%(levelname)-8s | %(message)s", stream=sys.stdout)
run_migrations()

View File

@@ -44,6 +44,10 @@ def create_db_engine(database_url: str | None = None) -> Engine:
# WAL : meilleure resistance aux arrets brutaux du conteneur. # WAL : meilleure resistance aux arrets brutaux du conteneur.
cursor.execute("PRAGMA journal_mode=WAL") cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON") cursor.execute("PRAGMA foreign_keys=ON")
# Attend au lieu d'echouer immediatement si une ecriture concurrente
# tient le verrou : une sauvegarde en cours ne doit pas produire
# d'erreur "database is locked" cote utilisateur.
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close() cursor.close()
return engine return engine

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import sys import sys
_LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s" _LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
@@ -11,6 +12,32 @@ _DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
# Champs qui ne doivent jamais apparaitre dans les logs. # Champs qui ne doivent jamais apparaitre dans les logs.
REDACTED_KEYS = frozenset({"management_token", "managementToken", "authorization"}) REDACTED_KEYS = frozenset({"management_token", "managementToken", "authorization"})
# Un share token est une capacite de lecture : le laisser dans les logs d'acces
# revient a y ecrire le lien complet. On le remplace, sans toucher au reste du
# chemin pour que les autres routes restent diagnosticables.
_TOKEN_PATHS = re.compile(r"(?P<prefix>/(?:s|api/shares)/)(?P<token>[A-Za-z0-9_-]{6,})")
REDACTED_TOKEN = "[REDACTED]"
def redact_tokens(text: str) -> str:
return _TOKEN_PATHS.sub(lambda m: f"{m.group('prefix')}{REDACTED_TOKEN}", text)
class TokenRedactingFilter(logging.Filter):
"""Retire les jetons de partage des messages avant ecriture.
Applique aux logs d'acces uvicorn, ou le chemin complet est journalise.
"""
def filter(self, record: logging.LogRecord) -> bool:
if isinstance(record.args, tuple):
record.args = tuple(
redact_tokens(arg) if isinstance(arg, str) else arg for arg in record.args
)
if isinstance(record.msg, str):
record.msg = redact_tokens(record.msg)
return True
def configure_logging(level: str = "INFO") -> None: def configure_logging(level: str = "INFO") -> None:
handler = logging.StreamHandler(sys.stdout) handler = logging.StreamHandler(sys.stdout)
@@ -21,11 +48,16 @@ def configure_logging(level: str = "INFO") -> None:
root.addHandler(handler) root.addHandler(handler)
root.setLevel(level.upper()) root.setLevel(level.upper())
redactor = TokenRedactingFilter()
handler.addFilter(redactor)
# Uvicorn possede ses propres handlers : on les aligne sur les notres. # Uvicorn possede ses propres handlers : on les aligne sur les notres.
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"): for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
logger = logging.getLogger(name) logger = logging.getLogger(name)
logger.handlers.clear() logger.handlers.clear()
logger.propagate = True logger.propagate = True
logger.filters.clear()
logger.addFilter(redactor)
def safe_extra(data: dict[str, object]) -> dict[str, object]: def safe_extra(data: dict[str, object]) -> dict[str, object]:

View File

@@ -80,7 +80,11 @@ def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(ShareError) @app.exception_handler(ShareError)
async def _share_error(_request: Request, exc: ShareError) -> JSONResponse: async def _share_error(_request: Request, exc: ShareError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.message}) return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.message},
headers=exc.headers or None,
)
@app.exception_handler(RequestValidationError) @app.exception_handler(RequestValidationError)
async def _validation_error(_request: Request, exc: RequestValidationError) -> JSONResponse: async def _validation_error(_request: Request, exc: RequestValidationError) -> JSONResponse:

View File

@@ -35,11 +35,39 @@ SECURITY_HEADERS = {
} }
# Chemins qui ne doivent jamais etre indexes : ils sont derriere un lien non
# repertorie, ou ne sont pas des pages.
NOINDEX_PREFIXES = ("/s/", "/api/")
ROBOTS_TAG = "noindex, nofollow, noarchive"
class SecurityHeadersMiddleware(BaseHTTPMiddleware): class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""En-tetes de securite communs.
`Referrer-Policy: no-referrer` est applique a tout le site, pas seulement
aux pages de partage : une URL /s/<token> est une capacite de lecture, et
elle ne doit jamais partir dans l'en-tete Referer d'un site externe sur
lequel le lecteur cliquerait.
"""
async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def] async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
response: Response = await call_next(request) response: Response = await call_next(request)
for header, value in SECURITY_HEADERS.items(): for header, value in SECURITY_HEADERS.items():
response.headers.setdefault(header, value) response.headers.setdefault(header, value)
path = request.url.path
if path.startswith("/assets/"):
# Les fichiers produits par Vite portent un hash de contenu dans leur
# nom : ils ne changent jamais sous une meme URL. index.html et sw.js,
# eux, restent en no-cache (voir web.py) pour qu'une mise a jour de
# l'application soit prise en compte au rechargement suivant.
response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable")
if path.startswith(NOINDEX_PREFIXES):
# robots.txt reste une simple convention : c'est le jeton qui
# autorise la lecture. Cet en-tete evite seulement qu'un lien
# partage se retrouve dans un index public.
response.headers.setdefault("X-Robots-Tag", ROBOTS_TAG)
return response return response

View File

@@ -37,5 +37,21 @@ class SharedMap(Base):
DateTime(timezone=True), default=utcnow, onupdate=utcnow DateTime(timezone=True), default=utcnow, onupdate=utcnow
) )
# NULL = partage permanent. Les partages crees avant l'introduction de
# l'expiration sont migres a NULL et restent donc valides.
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None, index=True
)
def is_expired(self, *, now: datetime | None = None) -> bool:
if self.expires_at is None:
return False
moment = now or utcnow()
deadline = self.expires_at
# SQLite rend des datetime naifs : on les considere en UTC.
if deadline.tzinfo is None:
deadline = deadline.replace(tzinfo=UTC)
return deadline <= moment
def __repr__(self) -> str: # pragma: no cover - aide au debug uniquement def __repr__(self) -> str: # pragma: no cover - aide au debug uniquement
return f"<SharedMap id={self.id} share_token={self.share_token!r}>" return f"<SharedMap id={self.id} share_token={self.share_token!r}>"

View File

@@ -16,6 +16,19 @@ class ShareCreateIn(BaseModel):
title: str = Field(default="", max_length=255) title: str = Field(default="", max_length=255)
map: MapDocumentIn map: MapDocumentIn
# Absent = valeur par defaut de l'instance ; null explicite = permanent.
expires_in_days: int | None = Field(default=None, ge=1, le=3650)
# Distingue "champ absent" de "null explicite", que Pydantic ne separe pas.
permanent: bool = False
class ShareExpiryIn(BaseModel):
"""Corps de PATCH /api/shares/{token} : uniquement l'expiration."""
model_config = ConfigDict(extra="forbid")
expires_in_days: int | None = Field(default=None, ge=1, le=3650)
permanent: bool = False
class ShareCreatedOut(BaseModel): class ShareCreatedOut(BaseModel):
@@ -31,6 +44,7 @@ class ShareCreatedOut(BaseModel):
title: str title: str
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
expires_at: datetime | None = None
class ShareUpdatedOut(BaseModel): class ShareUpdatedOut(BaseModel):
@@ -38,6 +52,17 @@ class ShareUpdatedOut(BaseModel):
share_path: str share_path: str
title: str title: str
updated_at: datetime updated_at: datetime
expires_at: datetime | None = None
class ShareOwnerOut(BaseModel):
"""Vue reservee au detenteur du jeton de gestion. Aucune donnee de carte."""
share_token: str
title: str
created_at: datetime
updated_at: datetime
expires_at: datetime | None = None
class SharePublicOut(BaseModel): class SharePublicOut(BaseModel):
@@ -49,3 +74,4 @@ class SharePublicOut(BaseModel):
map: dict map: dict
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
expires_at: datetime | None = None

View File

@@ -0,0 +1,39 @@
"""Identification du client pour la limitation de debit.
Le point sensible : `X-Forwarded-For` est un en-tete que n'importe quel client
peut ecrire. Le croire sans condition permettrait a un client direct de changer
d'identite a chaque requete et de contourner toute limite.
La confiance est donc explicite et desactivee par defaut
(`TRUST_PROXY_HEADERS`). Quand elle est activee, on lit la DERNIERE valeur de
`X-Forwarded-For` : un reverse proxy correctement configure ajoute l'adresse
qu'il a reellement vue a la fin de la liste, tandis que les valeurs precedentes
peuvent avoir ete forgees par le client. Cette lecture suppose un seul proxy
directement devant OpenMind, ce qui correspond au deploiement documente.
"""
from __future__ import annotations
from starlette.requests import Request
FORWARDED_FOR = "x-forwarded-for"
REAL_IP = "x-real-ip"
UNKNOWN_CLIENT = "unknown"
def resolve_client_ip(request: Request, *, trust_proxy_headers: bool) -> str:
if trust_proxy_headers:
forwarded = request.headers.get(FORWARDED_FOR)
if forwarded:
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if parts:
return parts[-1]
real_ip = request.headers.get(REAL_IP)
if real_ip and real_ip.strip():
return real_ip.strip()
client = request.client
if client and client.host:
return client.host
return UNKNOWN_CLIENT

View File

@@ -17,6 +17,8 @@ class ShareError(Exception):
super().__init__(message or self.message) super().__init__(message or self.message)
if message: if message:
self.message = message self.message = message
# En-tetes additionnels de la reponse (Retry-After, par exemple).
self.headers: dict[str, str] = {}
class ShareNotFoundError(ShareError): class ShareNotFoundError(ShareError):
@@ -32,3 +34,40 @@ class ShareForbiddenError(ShareError):
class SharePayloadTooLargeError(ShareError): class SharePayloadTooLargeError(ShareError):
status_code = 413 status_code = 413
message = "La carte depasse la taille maximale autorisee." message = "La carte depasse la taille maximale autorisee."
class ShareExpiredError(ShareError):
"""Le partage a existe mais sa date d'expiration est passee.
Statut distinct du 404 : c'est ce qui permet a la page publique d'afficher
"Ce partage a expire" plutot qu'un message d'absence. Rien d'autre n'est
revele : ni le titre, ni le contenu, ni la date de creation.
"""
status_code = 410
message = "Ce partage a expire."
class SharingDisabledError(ShareError):
status_code = 503
message = "Le partage est desactive sur cette instance."
class ShareCapacityError(ShareError):
status_code = 503
message = "Cette instance a atteint sa capacite maximale de partages."
class PermanentShareNotAllowedError(ShareError):
status_code = 400
message = "Cette instance n'autorise pas les partages sans expiration."
class RateLimitedError(ShareError):
status_code = 429
message = "Trop de requetes. Reessayez dans un instant."
def __init__(self, retry_after: int, message: str | None = None) -> None:
super().__init__(message)
self.retry_after = retry_after
self.headers["Retry-After"] = str(retry_after)

View File

@@ -0,0 +1,124 @@
"""Limitation de debit en memoire, par client et par famille d'endpoint.
Volontairement sans dependance externe ni Redis : OpenMind n'a qu'un seul
service applicatif, et l'objectif de cette limitation est d'empecher qu'une
instance publique soit triviale a saturer, pas de construire un quota
distribue.
Consequence assumee et documentee : les compteurs vivent dans le processus.
Un redemarrage du conteneur les remet a zero, et plusieurs workers uvicorn
auraient chacun les leurs. C'est acceptable pour l'usage vise.
"""
from __future__ import annotations
import threading
import time
from collections import deque
from dataclasses import dataclass
_WINDOW_SECONDS = {
"second": 1,
"sec": 1,
"s": 1,
"minute": 60,
"min": 60,
"m": 60,
"hour": 3600,
"h": 3600,
"day": 86400,
"d": 86400,
}
# Garde-fou memoire : au-dela, les entrees les plus anciennes sont oubliees.
MAX_TRACKED_CLIENTS = 20000
@dataclass(frozen=True)
class RateRule:
"""Nombre d'appels autorises sur une fenetre glissante."""
limit: int
window_seconds: int
def describe(self) -> str:
return f"{self.limit} requete(s) par {self.window_seconds} seconde(s)"
def parse_rate(spec: str) -> RateRule | None:
"""Lit une regle "10/hour". Retourne None si la limite est desactivee.
Une valeur vide, "0", ou une syntaxe non reconnue desactive la limite :
une configuration erronee ne doit jamais rendre l'instance inutilisable.
"""
text = (spec or "").strip().lower()
if not text or text in {"0", "none", "off", "disabled"}:
return None
number, _, unit = text.partition("/")
try:
limit = int(number.strip())
except ValueError:
return None
if limit <= 0:
return None
window = _WINDOW_SECONDS.get(unit.strip() or "minute")
if window is None:
return None
return RateRule(limit=limit, window_seconds=window)
class RateLimiter:
"""Fenetre glissante par cle, protegee par un verrou."""
def __init__(self) -> None:
self._hits: dict[str, deque[float]] = {}
self._lock = threading.Lock()
def check(self, key: str, rule: RateRule | None, *, now: float | None = None) -> int | None:
"""Enregistre un appel.
Retourne `None` si l'appel est autorise, sinon le nombre de secondes a
attendre avant le prochain essai (valeur de l'en-tete Retry-After).
"""
if rule is None:
return None
moment = time.monotonic() if now is None else now
floor = moment - rule.window_seconds
with self._lock:
hits = self._hits.get(key)
if hits is None:
if len(self._hits) >= MAX_TRACKED_CLIENTS:
self._forget_oldest_locked(floor)
hits = deque()
self._hits[key] = hits
while hits and hits[0] <= floor:
hits.popleft()
if len(hits) >= rule.limit:
wait = rule.window_seconds - (moment - hits[0])
return max(1, int(wait) + 1)
hits.append(moment)
return None
def _forget_oldest_locked(self, floor: float) -> None:
"""Nettoie les cles dont toutes les traces sont hors fenetre."""
stale = [key for key, hits in self._hits.items() if not hits or hits[-1] <= floor]
for key in stale:
del self._hits[key]
if not stale:
# Aucune cle expiree : on repart de zero plutot que de grossir sans fin.
self._hits.clear()
def reset(self) -> None:
"""Remet tous les compteurs a zero. Utilise par les tests."""
with self._lock:
self._hits.clear()
limiter = RateLimiter()

View File

@@ -2,16 +2,21 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import logging import logging
from datetime import timedelta
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import get_settings from app.config import get_settings
from app.models.share import SharedMap, utcnow from app.models.share import SharedMap, utcnow
from app.schemas.map import MapDocumentIn from app.schemas.map import MapDocumentIn
from app.services.errors import ( from app.services.errors import (
PermanentShareNotAllowedError,
ShareCapacityError,
ShareExpiredError,
ShareForbiddenError, ShareForbiddenError,
ShareNotFoundError, ShareNotFoundError,
SharePayloadTooLargeError, SharePayloadTooLargeError,
@@ -25,6 +30,20 @@ from app.services.tokens import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Duree maximale acceptee pour une expiration explicite (10 ans).
MAX_EXPIRY_DAYS = 3650
def share_ref(share_token: str) -> str:
"""Reference courte et non reversible d'un partage, pour les journaux.
Le share token est une capacite de lecture : le recopier dans les logs
reviendrait a y ecrire le lien complet. On journalise donc une empreinte
qui permet de correler des lignes entre elles sans permettre d'ouvrir la
carte.
"""
return hashlib.sha256(share_token.encode("utf-8")).hexdigest()[:12]
def serialize_map(document: MapDocumentIn) -> str: def serialize_map(document: MapDocumentIn) -> str:
"""Serialise le document carte en JSON compact et deterministe.""" """Serialise le document carte en JSON compact et deterministe."""
@@ -36,15 +55,52 @@ def serialize_map(document: MapDocumentIn) -> str:
def _guard_payload_size(payload: str) -> None: def _guard_payload_size(payload: str) -> None:
"""Limite propre aux partages, distincte de la limite HTTP globale."""
settings = get_settings() settings = get_settings()
size = len(payload.encode("utf-8")) size = len(payload.encode("utf-8"))
if size > settings.max_map_size_bytes: if size > settings.max_share_size_bytes:
raise SharePayloadTooLargeError( raise SharePayloadTooLargeError(
f"La carte fait {size / (1024 * 1024):.1f} Mo, " f"La carte fait {size / (1024 * 1024):.1f} Mo, "
f"la limite est de {settings.max_map_size_mb} Mo." f"la limite de partage est de {settings.max_share_size_mb_effective:.0f} Mo."
) )
def _guard_capacity(session: Session) -> None:
"""Refuse une creation quand l'instance est pleine.
Aucun partage existant n'est supprime pour faire de la place : une
suppression silencieuse ferait disparaitre le lien de quelqu'un d'autre.
La mise a jour d'un partage existant reste possible.
"""
limit = get_settings().max_shared_maps
if limit <= 0:
return
total = session.execute(select(func.count()).select_from(SharedMap)).scalar_one()
if total >= limit:
raise ShareCapacityError()
def resolve_expiry(expires_in_days: int | None, *, explicit: bool): # type: ignore[no-untyped-def]
"""Traduit une duree en date d'expiration absolue.
`explicit=False` signifie que le client n'a rien demande : on applique
alors la valeur par defaut de l'instance.
"""
settings = get_settings()
if not explicit:
days = settings.default_share_expiry_days
return None if days <= 0 else utcnow() + timedelta(days=days)
if expires_in_days is None:
if not settings.allow_permanent_shares:
raise PermanentShareNotAllowedError()
return None
days = max(1, min(int(expires_in_days), MAX_EXPIRY_DAYS))
return utcnow() + timedelta(days=days)
def _get_by_token(session: Session, share_token: str) -> SharedMap: def _get_by_token(session: Session, share_token: str) -> SharedMap:
stmt = select(SharedMap).where(SharedMap.share_token == share_token) stmt = select(SharedMap).where(SharedMap.share_token == share_token)
share = session.execute(stmt).scalar_one_or_none() share = session.execute(stmt).scalar_one_or_none()
@@ -53,13 +109,29 @@ def _get_by_token(session: Session, share_token: str) -> SharedMap:
return share return share
def create_share(session: Session, *, title: str, document: MapDocumentIn) -> tuple[SharedMap, str]: def _get_live(session: Session, share_token: str) -> SharedMap:
"""Comme `_get_by_token`, mais un partage expire est considere indisponible."""
share = _get_by_token(session, share_token)
if share.is_expired():
raise ShareExpiredError()
return share
def create_share(
session: Session,
*,
title: str,
document: MapDocumentIn,
expires_in_days: int | None = None,
expiry_explicit: bool = False,
) -> tuple[SharedMap, str]:
"""Cree un partage et retourne (entite, management_token en clair). """Cree un partage et retourne (entite, management_token en clair).
Le management token en clair n'est renvoye qu'ici, une seule fois. Le management token en clair n'est renvoye qu'ici, une seule fois.
""" """
payload = serialize_map(document) payload = serialize_map(document)
_guard_payload_size(payload) _guard_payload_size(payload)
_guard_capacity(session)
management_token = generate_management_token() management_token = generate_management_token()
share = SharedMap( share = SharedMap(
@@ -68,17 +140,60 @@ def create_share(session: Session, *, title: str, document: MapDocumentIn) -> tu
title=(title or document.name or "Carte sans titre")[: get_settings().max_title_length], title=(title or document.name or "Carte sans titre")[: get_settings().max_title_length],
map_version=document.version, map_version=document.version,
map_data=payload, map_data=payload,
expires_at=resolve_expiry(expires_in_days, explicit=expiry_explicit),
) )
session.add(share) session.add(share)
session.commit() session.commit()
session.refresh(share) session.refresh(share)
logger.info("Partage cree share_token=%s nodes=%s", share.share_token, len(document.nodes)) logger.info(
"Partage cree share=%s noeuds=%s", share_ref(share.share_token), len(document.nodes)
)
return share, management_token return share, management_token
def get_share(session: Session, share_token: str) -> SharedMap: def get_share(session: Session, share_token: str) -> SharedMap:
return _get_by_token(session, share_token) return _get_live(session, share_token)
def set_expiry(
session: Session,
share_token: str,
*,
management_token: str,
expires_in_days: int | None,
) -> SharedMap:
"""Change la date d'expiration. Reserve au detenteur du jeton de gestion.
Volontairement applicable a un partage deja expire : c'est ce qui permet a
son proprietaire de le reactiver plutot que de devoir tout republier.
"""
share = _get_by_token(session, share_token)
if not verify_token(management_token, share.management_token_hash):
logger.warning("Changement d'expiration refuse share=%s", share_ref(share_token))
raise ShareForbiddenError()
share.expires_at = resolve_expiry(expires_in_days, explicit=True)
share.updated_at = utcnow()
session.commit()
session.refresh(share)
logger.info("Expiration mise a jour share=%s", share_ref(share_token))
return share
def verify_management_token(
session: Session, share_token: str, *, management_token: str
) -> SharedMap:
"""Verifie qu'un jeton de gestion correspond bien a ce partage.
Sert a la reimportation d'une cle de gestion depuis un autre navigateur.
Ne renvoie aucune donnee de carte.
"""
share = _get_by_token(session, share_token)
if not verify_token(management_token, share.management_token_hash):
logger.warning("Verification de cle refusee share=%s", share_ref(share_token))
raise ShareForbiddenError("Cette cle de gestion ne correspond pas a ce partage.")
return share
def load_map_data(share: SharedMap) -> dict: def load_map_data(share: SharedMap) -> dict:
@@ -86,7 +201,7 @@ def load_map_data(share: SharedMap) -> dict:
try: try:
data = json.loads(share.map_data) data = json.loads(share.map_data)
except json.JSONDecodeError: # pragma: no cover - donnee corrompue except json.JSONDecodeError: # pragma: no cover - donnee corrompue
logger.exception("Snapshot illisible pour share_token=%s", share.share_token) logger.exception("Snapshot illisible pour share=%s", share_ref(share.share_token))
raise ShareNotFoundError("Le contenu de ce partage est illisible.") from None raise ShareNotFoundError("Le contenu de ce partage est illisible.") from None
if not isinstance(data, dict): # pragma: no cover - donnee corrompue if not isinstance(data, dict): # pragma: no cover - donnee corrompue
raise ShareNotFoundError("Le contenu de ce partage est illisible.") raise ShareNotFoundError("Le contenu de ce partage est illisible.")
@@ -103,7 +218,7 @@ def update_share(
) -> SharedMap: ) -> SharedMap:
share = _get_by_token(session, share_token) share = _get_by_token(session, share_token)
if not verify_token(management_token, share.management_token_hash): if not verify_token(management_token, share.management_token_hash):
logger.warning("Tentative de mise a jour refusee share_token=%s", share_token) logger.warning("Tentative de mise a jour refusee share=%s", share_ref(share_token))
raise ShareForbiddenError() raise ShareForbiddenError()
payload = serialize_map(document) payload = serialize_map(document)
@@ -116,16 +231,16 @@ def update_share(
session.commit() session.commit()
session.refresh(share) session.refresh(share)
logger.info("Partage mis a jour share_token=%s", share_token) logger.info("Partage mis a jour share=%s", share_ref(share_token))
return share return share
def delete_share(session: Session, share_token: str, *, management_token: str) -> None: def delete_share(session: Session, share_token: str, *, management_token: str) -> None:
share = _get_by_token(session, share_token) share = _get_by_token(session, share_token)
if not verify_token(management_token, share.management_token_hash): if not verify_token(management_token, share.management_token_hash):
logger.warning("Tentative de suppression refusee share_token=%s", share_token) logger.warning("Tentative de suppression refusee share=%s", share_ref(share_token))
raise ShareForbiddenError() raise ShareForbiddenError()
session.delete(share) session.delete(share)
session.commit() session.commit()
logger.info("Partage supprime share_token=%s", share_token) logger.info("Partage supprime share=%s", share_ref(share_token))

View File

@@ -14,8 +14,10 @@ from __future__ import annotations
import hashlib import hashlib
import secrets import secrets
# 12 octets -> 16 caracteres url-safe, ~96 bits d'entropie : non enumerable. # 16 octets -> 22 caracteres url-safe, 128 bits d'entropie.
SHARE_TOKEN_BYTES = 12 # Les jetons plus courts emis avant ce reglage restent valides : la colonne
# accepte n'importe quelle longueur et la recherche se fait par valeur exacte.
SHARE_TOKEN_BYTES = 16
# 32 octets -> ~256 bits d'entropie pour le secret de gestion. # 32 octets -> ~256 bits d'entropie pour le secret de gestion.
MANAGEMENT_TOKEN_BYTES = 32 MANAGEMENT_TOKEN_BYTES = 32

69
backend/migrations/env.py Normal file
View File

@@ -0,0 +1,69 @@
"""Environnement Alembic d'OpenMind.
L'URL de la base vient toujours de la configuration applicative
(`DATABASE_URL`) : il n'y a pas de seconde source de verite dans alembic.ini.
"""
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.config import get_settings
from app.db.base import Base
from app.models import share as _share_model # noqa: F401 (enregistre la table)
config = context.config
# `fileConfig` reconfigure tout le systeme de logs et desactive les loggers
# existants. C'est le comportement voulu quand Alembic est lance en ligne de
# commande, mais destructeur quand les migrations tournent dans le processus de
# l'application : les logs d'acces d'uvicorn disparaitraient. L'appelant
# embarque pose donc `configure_logger = False`.
if config.config_file_name is not None and config.attributes.get("configure_logger", True):
fileConfig(config.config_file_name, disable_existing_loggers=False)
# L'appelant peut imposer une URL (tests, outillage). Sinon, on prend celle de
# la configuration applicative : il n'y a jamais d'URL ecrite dans alembic.ini.
if not config.get_main_option("sqlalchemy.url", None):
config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
# SQLite ne sait pas modifier une colonne en place : le mode "batch"
# recree proprement la table quand c'est necessaire.
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,42 @@
"""Schema initial : table shared_maps telle qu'elle existait avant Alembic.
Cette revision decrit l'etat produit par `Base.metadata.create_all` dans les
versions precedentes d'OpenMind. Sur une base existante, elle n'est jamais
executee : la table est deja la, et le demarrage se contente d'estampiller
cette revision (voir `app/db/migrations.py`). Elle sert donc uniquement aux
installations neuves.
Revision ID: 0001_initial_schema
Revises:
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0001_initial_schema"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"shared_maps",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("share_token", sa.String(length=64), nullable=False),
sa.Column("management_token_hash", sa.String(length=64), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("map_version", sa.Integer(), nullable=False),
sa.Column("map_data", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_shared_maps_share_token", "shared_maps", ["share_token"], unique=True)
def downgrade() -> None:
op.drop_index("ix_shared_maps_share_token", table_name="shared_maps")
op.drop_table("shared_maps")

View File

@@ -0,0 +1,33 @@
"""Ajoute l'expiration facultative des partages.
Migration volontairement additive : une seule colonne nullable et son index.
Aucune ligne n'est reecrite, aucune table n'est recreee, aucune donnee n'est
perdue. Les partages existants recoivent `expires_at = NULL`, c'est-a-dire
"pas d'expiration", et restent donc valides exactement comme avant.
Revision ID: 0002_share_expiry
Revises: 0001_initial_schema
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0002_share_expiry"
down_revision = "0001_initial_schema"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"shared_maps",
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_shared_maps_expires_at", "shared_maps", ["expires_at"])
def downgrade() -> None:
op.drop_index("ix_shared_maps_expires_at", table_name="shared_maps")
op.drop_column("shared_maps", "expires_at")

View File

@@ -1,5 +1,6 @@
fastapi>=0.115,<0.117 fastapi>=0.115,<0.117
uvicorn[standard]>=0.32,<0.36 uvicorn[standard]>=0.32,<0.36
SQLAlchemy>=2.0.36,<2.1 SQLAlchemy>=2.0.36,<2.1
alembic>=1.14,<2
pydantic>=2.9,<3 pydantic>=2.9,<3
pydantic-settings>=2.6,<3 pydantic-settings>=2.6,<3

View File

@@ -15,24 +15,60 @@ os.environ["STATIC_DIR"] = f"{_TMP_DIR}/static"
os.environ["APP_NAME"] = "OpenMind" os.environ["APP_NAME"] = "OpenMind"
os.environ["MAX_NODES"] = "5000" os.environ["MAX_NODES"] = "5000"
os.environ["MAX_MAP_SIZE_MB"] = "10" os.environ["MAX_MAP_SIZE_MB"] = "10"
# Les limites de debit sont desactivees par defaut dans les tests : chaque test
# qui les concerne les active explicitement via la fixture `configure`.
os.environ["SHARE_CREATE_RATE_LIMIT"] = "0"
os.environ["SHARE_READ_RATE_LIMIT"] = "0"
os.environ["SHARE_WRITE_RATE_LIMIT"] = "0"
from fastapi.testclient import TestClient # noqa: E402 from fastapi.testclient import TestClient # noqa: E402
from sqlalchemy import text # noqa: E402
from app.config import get_settings # noqa: E402 from app.config import get_settings # noqa: E402
from app.db.base import Base # noqa: E402 from app.db.base import Base # noqa: E402
from app.db.migrations import run_migrations # noqa: E402
from app.db.session import engine # noqa: E402 from app.db.session import engine # noqa: E402
from app.main import app # noqa: E402 from app.main import app # noqa: E402
from app.services.rate_limit import limiter # noqa: E402
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _fresh_database() -> Iterator[None]: def _fresh_database() -> Iterator[None]:
"""Base neuve avant chaque test, construite par les vraies migrations.
On passe deliberement par Alembic plutot que par `create_all` : le schema
teste est exactement celui qu'obtiendra une installation reelle.
"""
get_settings.cache_clear() get_settings.cache_clear()
Base.metadata.drop_all(bind=engine) Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine) with engine.begin() as connection:
connection.execute(text("DROP TABLE IF EXISTS alembic_version"))
run_migrations(engine)
# Les compteurs de debit vivent dans le processus : sans cette remise a
# zero, les tests se limiteraient les uns les autres.
limiter.reset()
yield yield
get_settings.cache_clear() get_settings.cache_clear()
@pytest.fixture()
def configure(monkeypatch: pytest.MonkeyPatch): # type: ignore[no-untyped-def]
"""Surcharge des reglages le temps d'un test.
Les valeurs sont posees dans l'environnement puis le cache de `get_settings`
est vide, exactement comme au demarrage de l'application.
"""
def _apply(**values: object) -> None:
for key, value in values.items():
monkeypatch.setenv(key.upper(), str(value))
get_settings.cache_clear()
yield _apply
get_settings.cache_clear()
@pytest.fixture() @pytest.fixture()
def client() -> Iterator[TestClient]: def client() -> Iterator[TestClient]:
with TestClient(app) as test_client: with TestClient(app) as test_client:

194
backend/tests/test_cli.py Normal file
View File

@@ -0,0 +1,194 @@
"""CLI d'administration : compteurs, listage, suppression, purge, sauvegarde."""
from __future__ import annotations
import sqlite3
from datetime import timedelta
from pathlib import Path
from fastapi.testclient import TestClient
from sqlalchemy import select
from app import cli
from app.db.session import SessionLocal
from app.models.share import SharedMap, utcnow
from tests.conftest import build_map
def _create(client: TestClient, title: str = "Carte") -> dict:
response = client.post("/api/shares", json={"title": title, "map": build_map()})
assert response.status_code == 201, response.text
return response.json()
def _expire(share_token: str) -> None:
with SessionLocal() as session:
share = session.execute(
select(SharedMap).where(SharedMap.share_token == share_token)
).scalar_one()
share.expires_at = utcnow() - timedelta(days=1)
session.commit()
def _count() -> int:
with SessionLocal() as session:
return len(session.execute(select(SharedMap)).scalars().all())
def test_stats_reports_counts_without_secrets(client: TestClient, capsys) -> None:
created = _create(client, "Infrastructure")
_expire(_create(client, "Ancienne")["share_token"])
assert cli.main(["stats"]) == 0
out = capsys.readouterr().out
assert "Partages : 2" in out
assert "actifs : 1" in out
assert "expires : 1" in out
assert "Taille" in out
# Ni jeton, ni hash, ni contenu de carte.
assert created["management_token"] not in out
assert created["share_token"] not in out
assert "map_data" not in out
def test_shares_list_masks_tokens(client: TestClient, capsys) -> None:
created = _create(client, "Infrastructure")
assert cli.main(["shares", "list"]) == 0
out = capsys.readouterr().out
assert "Infrastructure" in out
assert created["share_token"] not in out
assert created["management_token"] not in out
# Le jeton est identifiable sans etre utilisable.
assert created["share_token"][:4] in out
def test_shares_list_filters(client: TestClient, capsys) -> None:
active = _create(client, "Active")
expired = _create(client, "Expiree")
_expire(expired["share_token"])
cli.main(["shares", "list", "--active"])
out = capsys.readouterr().out
assert "Active" in out and "Expiree" not in out
cli.main(["shares", "list", "--expired"])
out = capsys.readouterr().out
assert "Expiree" in out and "Active" not in out
assert active["share_token"] not in out
def test_shares_list_limit(client: TestClient, capsys) -> None:
for index in range(5):
_create(client, f"Carte {index}")
cli.main(["shares", "list", "--limit", "2"])
body = capsys.readouterr().out.strip().splitlines()
# En-tete, separateur, puis deux lignes.
assert len(body) == 4
def test_shares_delete_requires_confirmation_flag(client: TestClient, capsys) -> None:
created = _create(client)
# Entree non interactive et pas de --yes : rien n'est supprime.
assert cli.main(["shares", "delete", created["share_token"]]) == 1
assert _count() == 1
assert cli.main(["shares", "delete", created["share_token"], "--yes"]) == 0
assert "1 partage supprime." in capsys.readouterr().out
assert _count() == 0
def test_shares_delete_unknown_token(client: TestClient, capsys) -> None:
assert cli.main(["shares", "delete", "inexistant", "--yes"]) == 1
assert "Aucun partage" in capsys.readouterr().out
def test_purge_expired_dry_run_changes_nothing(client: TestClient, capsys) -> None:
_create(client, "Active")
_expire(_create(client, "Expiree")["share_token"])
assert cli.main(["shares", "purge-expired", "--dry-run"]) == 0
out = capsys.readouterr().out
assert "1 partage(s) expire(s)" in out
assert "simulation" in out
assert _count() == 2
def test_purge_expired_removes_only_expired(client: TestClient, capsys) -> None:
_create(client, "Active")
_expire(_create(client, "Expiree")["share_token"])
assert cli.main(["shares", "purge-expired", "--yes"]) == 0
assert "1 partage(s) supprime(s)." in capsys.readouterr().out
with SessionLocal() as session:
remaining = session.execute(select(SharedMap.title)).scalars().all()
assert remaining == ["Active"]
def test_purge_without_expired_shares(client: TestClient, capsys) -> None:
_create(client)
assert cli.main(["shares", "purge-expired", "--yes"]) == 0
assert "0 partage(s) supprime(s)" in capsys.readouterr().out
def test_backup_produces_a_usable_database(client: TestClient, tmp_path: Path, capsys) -> None:
_create(client, "Infrastructure")
destination = tmp_path / "backups" / "openmind.db"
assert cli.main(["backup", str(destination)]) == 0
assert "Sauvegarde ecrite" in capsys.readouterr().out
assert destination.exists()
# La copie contient bien les donnees et se relit sans l'application.
connection = sqlite3.connect(destination)
try:
titles = [row[0] for row in connection.execute("SELECT title FROM shared_maps")]
finally:
connection.close()
assert titles == ["Infrastructure"]
def test_backup_refuses_to_overwrite_without_flag(client: TestClient, tmp_path: Path) -> None:
_create(client)
destination = tmp_path / "openmind.db"
assert cli.main(["backup", str(destination)]) == 0
assert cli.main(["backup", str(destination)]) == 1
assert cli.main(["backup", str(destination), "--yes"]) == 0
def test_restore_refuses_a_foreign_file(client: TestClient, tmp_path: Path, capsys) -> None:
intruder = tmp_path / "autre.db"
sqlite3.connect(intruder).execute("CREATE TABLE autre (id INTEGER)")
assert cli.main(["restore", str(intruder), "--yes"]) == 1
assert "shared_maps" in capsys.readouterr().out
# La base courante est intacte.
assert _count() == 0
def test_restore_puts_back_a_backup(client: TestClient, tmp_path: Path, capsys) -> None:
_create(client, "Avant sauvegarde")
backup = tmp_path / "snapshot.db"
cli.main(["backup", str(backup)])
capsys.readouterr()
# On modifie la base apres la sauvegarde.
with SessionLocal() as session:
for share in session.execute(select(SharedMap)).scalars():
session.delete(share)
session.commit()
assert _count() == 0
assert cli.main(["restore", str(backup), "--yes"]) == 0
out = capsys.readouterr().out
assert "Sauvegarde de securite" in out
assert "Restauration terminee" in out
with SessionLocal() as session:
titles = session.execute(select(SharedMap.title)).scalars().all()
assert titles == ["Avant sauvegarde"]

View File

@@ -0,0 +1,52 @@
"""Identification du client : X-Forwarded-For n'est cru que sur demande."""
from __future__ import annotations
from starlette.datastructures import Headers
from starlette.requests import Request
from app.services.client_ip import resolve_client_ip
def _request(headers: dict[str, str], peer: str | None = "203.0.113.9") -> Request:
scope = {
"type": "http",
"method": "GET",
"path": "/api/shares",
"headers": Headers(headers).raw,
"client": (peer, 12345) if peer else None,
}
return Request(scope)
def test_proxy_headers_are_ignored_by_default() -> None:
request = _request({"x-forwarded-for": "1.2.3.4"})
assert resolve_client_ip(request, trust_proxy_headers=False) == "203.0.113.9"
def test_forged_header_cannot_change_identity() -> None:
"""Sans confiance explicite, un client direct ne peut pas se demultiplier."""
seen = {
resolve_client_ip(_request({"x-forwarded-for": f"10.0.0.{i}"}), trust_proxy_headers=False)
for i in range(20)
}
assert seen == {"203.0.113.9"}
def test_trusted_proxy_uses_the_last_forwarded_value() -> None:
# Le client a forge "9.9.9.9" ; le proxy a ajoute l'adresse reellement vue.
request = _request({"x-forwarded-for": "9.9.9.9, 198.51.100.7"})
assert resolve_client_ip(request, trust_proxy_headers=True) == "198.51.100.7"
def test_trusted_proxy_falls_back_to_real_ip() -> None:
request = _request({"x-real-ip": "198.51.100.8"})
assert resolve_client_ip(request, trust_proxy_headers=True) == "198.51.100.8"
def test_falls_back_to_peer_without_headers() -> None:
assert resolve_client_ip(_request({}), trust_proxy_headers=True) == "203.0.113.9"
def test_unknown_client_is_handled() -> None:
assert resolve_client_ip(_request({}, peer=None), trust_proxy_headers=False) == "unknown"

View File

@@ -0,0 +1,147 @@
"""Expiration des partages : creation, lecture, prolongation."""
from __future__ import annotations
from datetime import timedelta
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.share import SharedMap, utcnow
from tests.conftest import build_map
def _create(client: TestClient, **extra: object) -> dict:
body: dict[str, object] = {"title": "Carte", "map": build_map()}
body.update(extra)
response = client.post("/api/shares", json=body)
assert response.status_code == 201, response.text
return response.json()
def _force_expiry(share_token: str, *, delta: timedelta) -> None:
"""Deplace la date d'expiration pour ne pas attendre en test."""
with SessionLocal() as session:
share = session.execute(
select(SharedMap).where(SharedMap.share_token == share_token)
).scalar_one()
share.expires_at = utcnow() + delta
session.commit()
def test_default_expiry_is_applied(client: TestClient, configure) -> None:
configure(default_share_expiry_days=30)
body = _create(client)
assert body["expires_at"] is not None
def test_zero_default_means_permanent(client: TestClient, configure) -> None:
configure(default_share_expiry_days=0)
assert _create(client)["expires_at"] is None
def test_explicit_duration_is_used(client: TestClient, configure) -> None:
configure(default_share_expiry_days=30)
body = _create(client, expires_in_days=7)
assert body["expires_at"] is not None
public = client.get("/api/shares/" + body["share_token"]).json()
assert public["expires_at"] == body["expires_at"]
def test_permanent_can_be_requested_when_allowed(client: TestClient, configure) -> None:
configure(default_share_expiry_days=30, allow_permanent_shares=True)
assert _create(client, permanent=True)["expires_at"] is None
def test_permanent_is_refused_when_forbidden(client: TestClient, configure) -> None:
configure(allow_permanent_shares=False)
response = client.post(
"/api/shares", json={"title": "x", "map": build_map(), "permanent": True}
)
assert response.status_code == 400
assert "expiration" in response.json()["detail"].lower()
def test_expired_share_is_gone(client: TestClient, configure) -> None:
configure(default_share_expiry_days=1)
body = _create(client)
_force_expiry(body["share_token"], delta=timedelta(seconds=-1))
response = client.get("/api/shares/" + body["share_token"])
assert response.status_code == 410
assert response.json()["detail"] == "Ce partage a expire."
assert "map" not in response.json()
def test_expired_share_is_not_deleted_automatically(client: TestClient, configure) -> None:
configure(default_share_expiry_days=1)
body = _create(client)
_force_expiry(body["share_token"], delta=timedelta(seconds=-1))
client.get("/api/shares/" + body["share_token"])
with SessionLocal() as session:
assert session.execute(select(SharedMap)).scalars().all()
def test_owner_can_extend_expiry(client: TestClient, configure) -> None:
configure(default_share_expiry_days=1)
body = _create(client)
_force_expiry(body["share_token"], delta=timedelta(seconds=-1))
response = client.patch(
"/api/shares/" + body["share_token"],
json={"expires_in_days": 30},
headers={"X-Management-Token": body["management_token"]},
)
assert response.status_code == 200
assert response.json()["expires_at"] is not None
assert client.get("/api/shares/" + body["share_token"]).status_code == 200
def test_owner_can_make_share_permanent(client: TestClient, configure) -> None:
configure(default_share_expiry_days=30, allow_permanent_shares=True)
body = _create(client)
response = client.patch(
"/api/shares/" + body["share_token"],
json={"permanent": True},
headers={"X-Management-Token": body["management_token"]},
)
assert response.status_code == 200
assert response.json()["expires_at"] is None
def test_public_link_alone_cannot_change_expiry(client: TestClient) -> None:
body = _create(client)
without = client.patch("/api/shares/" + body["share_token"], json={"expires_in_days": 90})
assert without.status_code == 403
wrong = client.patch(
"/api/shares/" + body["share_token"],
json={"expires_in_days": 90},
headers={"X-Management-Token": "mauvais-jeton"},
)
assert wrong.status_code == 403
def test_management_key_can_be_verified(client: TestClient) -> None:
body = _create(client)
ok = client.post(
f"/api/shares/{body['share_token']}/verify",
headers={"X-Management-Token": body["management_token"]},
)
assert ok.status_code == 200
payload = ok.json()
assert payload["title"] == body["title"]
assert "map" not in payload
assert "management_token" not in payload
ko = client.post(
f"/api/shares/{body['share_token']}/verify",
headers={"X-Management-Token": "mauvaise-cle"},
)
assert ko.status_code == 403

View File

@@ -0,0 +1,150 @@
"""Protection des liens non repertories : indexation, referrer, journaux."""
from __future__ import annotations
import logging
from fastapi.testclient import TestClient
from app.logging_config import TokenRedactingFilter, redact_tokens
from app.services.tokens import SHARE_TOKEN_BYTES, generate_share_token
from tests.conftest import build_map
def test_robots_txt_disallows_shares(client: TestClient, tmp_path) -> None:
"""Le fichier livre au frontend interdit /s/ et /api/."""
robots = (
__import__("pathlib").Path(__file__).resolve().parents[2]
/ "frontend"
/ "public"
/ "robots.txt"
)
content = robots.read_text(encoding="utf-8")
assert "Disallow: /s/" in content
assert "Disallow: /api/" in content
def test_share_routes_are_marked_noindex(client: TestClient) -> None:
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
public_page = client.get(f"/s/{created['share_token']}")
assert public_page.headers["X-Robots-Tag"] == "noindex, nofollow, noarchive"
api = client.get(f"/api/shares/{created['share_token']}")
assert api.headers["X-Robots-Tag"] == "noindex, nofollow, noarchive"
def test_dashboard_is_not_marked_noindex(client: TestClient) -> None:
assert "X-Robots-Tag" not in client.get("/").headers
def test_referrer_policy_is_no_referrer(client: TestClient) -> None:
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
for path in ("/", f"/s/{created['share_token']}", "/api/health"):
assert client.get(path).headers["Referrer-Policy"] == "no-referrer"
def test_security_headers_are_still_present(client: TestClient) -> None:
headers = client.get("/").headers
assert headers["X-Content-Type-Options"] == "nosniff"
assert "frame-ancestors 'none'" in headers["Content-Security-Policy"]
assert headers["X-Frame-Options"] == "DENY"
assert "geolocation=()" in headers["Permissions-Policy"]
def test_share_tokens_have_at_least_128_bits() -> None:
assert SHARE_TOKEN_BYTES * 8 >= 128
token = generate_share_token()
assert len(token) >= 22
assert len({generate_share_token() for _ in range(200)}) == 200
def test_token_redaction_in_paths() -> None:
assert redact_tokens("GET /api/shares/a8F3xP9kQwZ2 HTTP/1.1") == (
"GET /api/shares/[REDACTED] HTTP/1.1"
)
assert redact_tokens("GET /s/OZGrnetO4RPbqPtS HTTP/1.1") == "GET /s/[REDACTED] HTTP/1.1"
# Les autres routes restent lisibles pour le diagnostic.
assert redact_tokens("GET /api/health HTTP/1.1") == "GET /api/health HTTP/1.1"
assert redact_tokens("GET /api/config HTTP/1.1") == "GET /api/config HTTP/1.1"
def test_redacting_filter_rewrites_arguments() -> None:
record = logging.LogRecord(
name="uvicorn.access",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg='%s - "%s" %d',
args=("1.2.3.4", "GET /api/shares/SECRETTOKEN12 HTTP/1.1", 200),
exc_info=None,
)
TokenRedactingFilter().filter(record)
assert "SECRETTOKEN12" not in record.getMessage()
assert "[REDACTED]" in record.getMessage()
def test_management_token_never_appears_in_logs(client: TestClient, caplog) -> None:
with caplog.at_level(logging.DEBUG):
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
token = created["management_token"]
client.put(
f"/api/shares/{created['share_token']}",
json={"title": "y", "map": build_map()},
headers={"X-Management-Token": token},
)
client.put(
f"/api/shares/{created['share_token']}",
json={"title": "z", "map": build_map()},
headers={"X-Management-Token": "mauvais"},
)
# `httpx`, utilise par le client de test, journalise l'URL qu'il appelle :
# c'est un log de l'outillage de test, pas de l'application. On verifie donc
# uniquement ce qu'OpenMind ecrit lui-meme.
logged = "\n".join(
record.getMessage()
for record in caplog.records
if not record.name.startswith(("httpx", "httpcore"))
)
assert token not in logged
assert created["share_token"] not in logged
# Les messages applicatifs designent un partage par une empreinte courte.
assert "share=" in logged
def test_application_logging_survives_migrations(client: TestClient, caplog) -> None:
"""Les migrations ne doivent pas desactiver les loggers de l'application.
Alembic reconfigure les logs quand il est lance en ligne de commande. Lance
dans le processus applicatif, il couperait les journaux d'acces d'uvicorn :
la configuration embarquee doit donc laisser les loggers en place.
"""
from app.db.migrations import run_migrations
from app.db.session import engine
access_logger = logging.getLogger("uvicorn.access")
run_migrations(engine)
assert access_logger.disabled is False
with caplog.at_level(logging.INFO):
access_logger.info('%s - "%s" %d', "1.2.3.4", "GET /api/health HTTP/1.1", 200)
assert any("GET /api/health" in record.getMessage() for record in caplog.records)
def test_entry_points_are_never_cached_long() -> None:
"""index.html, sw.js et le manifeste ne doivent jamais figer une version.
Sans cela, une mise a jour de l'application resterait invisible tant que le
navigateur garde l'ancienne page en cache.
"""
from app.web import _NO_CACHE_FILES
assert {"index.html", "sw.js", "manifest.webmanifest"} <= _NO_CACHE_FILES
def test_hashed_assets_get_a_long_cache(client: TestClient) -> None:
response = client.get("/assets/index-abc123.js")
# Le fichier n'existe pas dans l'environnement de test, mais l'en-tete de
# cache est pose par le middleware, avant le service du fichier.
assert "immutable" in response.headers.get("Cache-Control", "")

View File

@@ -0,0 +1,128 @@
"""Migrations Alembic : installation neuve et reprise d'une base existante."""
from __future__ import annotations
import sqlite3
from pathlib import Path
from sqlalchemy import create_engine, inspect, text
from app.db.migrations import run_migrations
# Schema exact d'avant l'introduction d'Alembic (aucune colonne expires_at).
LEGACY_SCHEMA = """
CREATE TABLE shared_maps (
id INTEGER NOT NULL,
share_token VARCHAR(64) NOT NULL,
management_token_hash VARCHAR(64) NOT NULL,
title VARCHAR(255) NOT NULL,
map_version INTEGER NOT NULL,
map_data TEXT NOT NULL,
created_at DATETIME,
updated_at DATETIME,
PRIMARY KEY (id)
);
CREATE UNIQUE INDEX ix_shared_maps_share_token ON shared_maps (share_token);
"""
LEGACY_ROWS = [
(1, "tokenA", "hashA", "Infrastructure", 3, '{"version":3,"nodes":[]}'),
(2, "tokenB", "hashB", "Projet", 4, '{"version":4,"nodes":[{"id":"a"}]}'),
(3, "tokenC", "hashC", "Reunion", 1, '{"version":1,"nodes":[]}'),
]
def _make_legacy_database(path: Path) -> None:
connection = sqlite3.connect(path)
try:
connection.executescript(LEGACY_SCHEMA)
connection.executemany(
"INSERT INTO shared_maps "
"(id, share_token, management_token_hash, title, map_version, map_data,"
" created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, '2026-01-01 10:00:00', '2026-01-02 11:00:00')",
LEGACY_ROWS,
)
connection.commit()
finally:
connection.close()
def _columns(engine) -> set[str]: # type: ignore[no-untyped-def]
with engine.connect() as connection:
return {column["name"] for column in inspect(connection).get_columns("shared_maps")}
def test_fresh_database_gets_the_full_schema(tmp_path: Path) -> None:
url = f"sqlite:///{tmp_path / 'fresh.db'}"
engine = create_engine(url, future=True)
run_migrations(engine, url)
assert "expires_at" in _columns(engine)
with engine.connect() as connection:
tables = set(inspect(connection).get_table_names())
assert {"shared_maps", "alembic_version"} <= tables
def test_migrations_are_idempotent(tmp_path: Path) -> None:
url = f"sqlite:///{tmp_path / 'twice.db'}"
engine = create_engine(url, future=True)
run_migrations(engine, url)
run_migrations(engine, url)
assert "expires_at" in _columns(engine)
def test_legacy_database_is_upgraded_without_data_loss(tmp_path: Path) -> None:
path = tmp_path / "legacy.db"
_make_legacy_database(path)
url = f"sqlite:///{path}"
engine = create_engine(url, future=True)
# Etat de depart : ancienne structure, sans table de version Alembic.
assert "expires_at" not in _columns(engine)
with engine.connect() as connection:
assert "alembic_version" not in set(inspect(connection).get_table_names())
run_migrations(engine, url)
assert "expires_at" in _columns(engine)
with engine.connect() as connection:
rows = connection.execute(
text(
"SELECT id, share_token, management_token_hash, title, map_version,"
" map_data, expires_at FROM shared_maps ORDER BY id"
)
).all()
assert len(rows) == len(LEGACY_ROWS)
for row, expected in zip(rows, LEGACY_ROWS, strict=True):
assert row.id == expected[0]
assert row.share_token == expected[1]
assert row.management_token_hash == expected[2]
assert row.title == expected[3]
assert row.map_version == expected[4]
assert row.map_data == expected[5]
# Tous les partages existants restent valides : aucune expiration.
assert row.expires_at is None
def test_legacy_database_keeps_working_after_upgrade(tmp_path: Path) -> None:
"""Apres migration, les anciennes lignes restent lisibles par l'ORM."""
path = tmp_path / "legacy2.db"
_make_legacy_database(path)
url = f"sqlite:///{path}"
engine = create_engine(url, future=True)
run_migrations(engine, url)
from sqlalchemy.orm import Session
from app.models.share import SharedMap
with Session(engine) as session:
share = session.query(SharedMap).filter_by(share_token="tokenB").one()
assert share.title == "Projet"
assert share.expires_at is None
assert share.is_expired() is False

View File

@@ -0,0 +1,157 @@
"""Protections d'une instance exposee publiquement.
Couvre la limitation de debit, la coupure du partage, la capacite maximale et
l'identification du client derriere un reverse proxy.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from app.services.rate_limit import RateLimiter, parse_rate
from tests.conftest import build_map
def _post(client: TestClient) -> int:
return client.post("/api/shares", json={"title": "x", "map": build_map()}).status_code
class TestRateRuleParsing:
def test_reads_usual_syntaxes(self) -> None:
assert parse_rate("10/hour") == parse_rate("10/h")
assert parse_rate("5/minute").limit == 5
assert parse_rate("5/minute").window_seconds == 60
assert parse_rate("2/second").window_seconds == 1
def test_missing_unit_defaults_to_minute(self) -> None:
assert parse_rate("3").window_seconds == 60
def test_disabled_values(self) -> None:
for spec in ("", "0", "off", "none", "-4/hour", "n'importe quoi"):
assert parse_rate(spec) is None
class TestRateLimiter:
def test_allows_then_blocks_within_window(self) -> None:
limiter = RateLimiter()
rule = parse_rate("2/minute")
assert limiter.check("a", rule, now=0.0) is None
assert limiter.check("a", rule, now=1.0) is None
retry = limiter.check("a", rule, now=2.0)
assert retry is not None and retry > 0
def test_window_slides(self) -> None:
limiter = RateLimiter()
rule = parse_rate("1/minute")
assert limiter.check("a", rule, now=0.0) is None
assert limiter.check("a", rule, now=10.0) is not None
assert limiter.check("a", rule, now=61.0) is None
def test_clients_are_independent(self) -> None:
limiter = RateLimiter()
rule = parse_rate("1/minute")
assert limiter.check("a", rule, now=0.0) is None
assert limiter.check("b", rule, now=0.0) is None
def test_share_creation_is_rate_limited(client: TestClient, configure) -> None:
configure(share_create_rate_limit="3/hour")
assert [_post(client) for _ in range(3)] == [201, 201, 201]
response = client.post("/api/shares", json={"title": "x", "map": build_map()})
assert response.status_code == 429
assert "Retry-After" in response.headers
assert int(response.headers["Retry-After"]) > 0
assert "detail" in response.json()
def test_health_and_ready_are_never_rate_limited(client: TestClient, configure) -> None:
configure(share_create_rate_limit="1/hour", share_read_rate_limit="1/hour")
client.post("/api/shares", json={"title": "x", "map": build_map()})
client.post("/api/shares", json={"title": "x", "map": build_map()})
for _ in range(30):
assert client.get("/api/health").status_code == 200
assert client.get("/api/ready").status_code == 200
def test_reading_a_share_is_rate_limited(client: TestClient, configure) -> None:
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
configure(share_read_rate_limit="2/minute")
token = created["share_token"]
assert client.get(f"/api/shares/{token}").status_code == 200
assert client.get(f"/api/shares/{token}").status_code == 200
assert client.get(f"/api/shares/{token}").status_code == 429
def test_rate_limit_can_be_disabled(client: TestClient, configure) -> None:
configure(share_create_rate_limit="0")
assert all(_post(client) == 201 for _ in range(12))
def test_sharing_disabled_blocks_the_whole_api(client: TestClient, configure) -> None:
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
configure(sharing_enabled=False)
expected = "Le partage est desactive sur cette instance."
assert client.post("/api/shares", json={"title": "x", "map": build_map()}).status_code == 503
assert client.get(f"/api/shares/{created['share_token']}").status_code == 503
assert (
client.put(
f"/api/shares/{created['share_token']}",
json={"title": "x", "map": build_map()},
headers={"X-Management-Token": created["management_token"]},
).status_code
== 503
)
assert (
client.delete(
f"/api/shares/{created['share_token']}",
headers={"X-Management-Token": created["management_token"]},
).status_code
== 503
)
assert (
client.post("/api/shares", json={"title": "x", "map": build_map()}).json()["detail"]
== expected
)
def test_sharing_disabled_is_announced_in_config(client: TestClient, configure) -> None:
configure(sharing_enabled=False)
assert client.get("/api/config").json()["sharingEnabled"] is False
def test_capacity_limit_refuses_new_shares(client: TestClient, configure) -> None:
configure(max_shared_maps=2)
first = client.post("/api/shares", json={"title": "1", "map": build_map()}).json()
assert client.post("/api/shares", json={"title": "2", "map": build_map()}).status_code == 201
refused = client.post("/api/shares", json={"title": "3", "map": build_map()})
assert refused.status_code == 503
assert refused.json()["detail"] == "Cette instance a atteint sa capacite maximale de partages."
# Les partages existants restent lisibles et modifiables.
assert client.get(f"/api/shares/{first['share_token']}").status_code == 200
assert (
client.put(
f"/api/shares/{first['share_token']}",
json={"title": "1 bis", "map": build_map()},
headers={"X-Management-Token": first["management_token"]},
).status_code
== 200
)
def test_capacity_zero_means_unlimited(client: TestClient, configure) -> None:
configure(max_shared_maps=0)
assert all(_post(client) == 201 for _ in range(6))
def test_share_specific_size_limit(client: TestClient, configure) -> None:
configure(max_share_size_mb=0.001)
response = client.post("/api/shares", json={"title": "x", "map": build_map(node_count=50)})
assert response.status_code == 413
assert "limite de partage" in response.json()["detail"]

View File

@@ -14,6 +14,15 @@ services:
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/openmind.db} DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/openmind.db}
MAX_MAP_SIZE_MB: ${MAX_MAP_SIZE_MB:-10} MAX_MAP_SIZE_MB: ${MAX_MAP_SIZE_MB:-10}
MAX_NODES: ${MAX_NODES:-5000} MAX_NODES: ${MAX_NODES:-5000}
SHARING_ENABLED: ${SHARING_ENABLED:-true}
SHARE_CREATE_RATE_LIMIT: ${SHARE_CREATE_RATE_LIMIT:-10/hour}
SHARE_READ_RATE_LIMIT: ${SHARE_READ_RATE_LIMIT:-120/minute}
SHARE_WRITE_RATE_LIMIT: ${SHARE_WRITE_RATE_LIMIT:-60/hour}
MAX_SHARED_MAPS: ${MAX_SHARED_MAPS:-10000}
MAX_SHARE_SIZE_MB: ${MAX_SHARE_SIZE_MB:-0}
DEFAULT_SHARE_EXPIRY_DAYS: ${DEFAULT_SHARE_EXPIRY_DAYS:-30}
ALLOW_PERMANENT_SHARES: ${ALLOW_PERMANENT_SHARES:-true}
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false}
CORS_ORIGINS: ${CORS_ORIGINS:-} CORS_ORIGINS: ${CORS_ORIGINS:-}
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
STATIC_DIR: /app/static STATIC_DIR: /app/static

12
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# Applique les migrations avant de demarrer FastAPI.
#
# Le script est idempotent : sur une base deja a jour, il ne fait rien. Sur une
# base creee avant l'introduction d'Alembic, il estampille la revision initiale
# puis applique les suivantes, sans jamais supprimer de donnees.
set -e
echo "Migrations de la base..."
python -m app.db.migrations
exec "$@"

View File

@@ -0,0 +1,7 @@
# Les liens de partage sont non repertories : ils ne doivent pas etre indexes.
# Ce fichier reste une convention respectee par les robots corrects ; ce qui
# autorise reellement la lecture d'une carte, c'est la connaissance du jeton.
User-agent: *
Disallow: /s/
Disallow: /api/
Allow: /

View File

@@ -4,6 +4,7 @@ import { Toasts } from '@/components/Toasts';
import { DashboardPage } from '@/features/maps/DashboardPage'; import { DashboardPage } from '@/features/maps/DashboardPage';
import { EditorPage } from '@/features/editor/EditorPage'; import { EditorPage } from '@/features/editor/EditorPage';
import { SharedMapPage } from '@/features/sharing/SharedMapPage'; import { SharedMapPage } from '@/features/sharing/SharedMapPage';
import { PrivacyPage } from '@/features/privacy/PrivacyPage';
import { useAppConfigStore } from '@/stores/appConfigStore'; import { useAppConfigStore } from '@/stores/appConfigStore';
import { navigate, useRoute } from '@/hooks/useRoute'; import { navigate, useRoute } from '@/hooks/useRoute';
@@ -36,6 +37,7 @@ export function App() {
{route.name === 'dashboard' ? <DashboardPage /> : null} {route.name === 'dashboard' ? <DashboardPage /> : null}
{route.name === 'editor' ? <EditorPage mapId={route.mapId} /> : null} {route.name === 'editor' ? <EditorPage mapId={route.mapId} /> : null}
{route.name === 'shared' ? <SharedMapPage token={route.token} /> : null} {route.name === 'shared' ? <SharedMapPage token={route.token} /> : null}
{route.name === 'privacy' ? <PrivacyPage /> : null}
{route.name === 'notFound' ? <NotFoundPage /> : null} {route.name === 'notFound' ? <NotFoundPage /> : null}
</main> </main>
<Toasts /> <Toasts />

View File

@@ -0,0 +1,94 @@
/**
* Texte de la page Confidentialite.
*
* Centralise ici pour qu'un exploitant puisse l'adapter a son instance sans
* fouiller dans les composants. Volontairement factuel : ce sont des faits
* verifiables sur le fonctionnement de l'application, pas un texte juridique
* invente.
*
* `{app}` est remplace par le nom de l'instance.
*/
export interface PrivacySection {
title: string;
paragraphs: string[];
bullets?: string[];
}
export const PRIVACY_INTRO =
"{app} fonctionne d'abord dans votre navigateur. Cette page decrit ce qui reste sur " +
'votre appareil, ce qui part vers le serveur, et a quel moment.';
export const PRIVACY_SECTIONS: PrivacySection[] = [
{
title: 'Vos cartes restent sur cet appareil',
paragraphs: [
"Les cartes que vous creez sont enregistrees dans l'IndexedDB de ce navigateur. " +
"Elles ne sont pas envoyees au serveur, qui n'en a donc aucune connaissance.",
'Consequence directe : vider les donnees du site, changer de navigateur ou de ' +
'machine vous fait perdre ces cartes. Exportez-les en .mmap pour en garder une ' +
'copie portable.',
],
},
{
title: 'Le partage est une action explicite',
paragraphs: [
"Quand vous creez un lien de partage, et seulement a ce moment, une copie de la " +
'carte est envoyee au serveur et enregistree dans sa base de donnees.',
],
bullets: [
'Le lien obtenu est non repertorie : il ne figure dans aucune liste publique.',
"Il n'est pas protege par un mot de passe : toute personne qui connait le lien " +
'peut consulter la carte.',
'La copie serveur reste figee tant que vous ne demandez pas sa mise a jour.',
"Elle est conservee jusqu'a son expiration, ou jusqu'a ce que vous la supprimiez.",
],
},
{
title: 'La cle de gestion',
paragraphs: [
'Mettre a jour ou supprimer un partage demande une cle de gestion, generee au ' +
'moment du partage. Le serveur ne stocke que son empreinte : il ne peut donc ' +
'pas la retrouver, et personne ne peut vous la redonner.',
"Elle reste dans ce navigateur. Vous pouvez l'exporter dans un fichier pour la " +
'conserver ailleurs, puis la reimporter depuis un autre navigateur.',
],
},
{
title: 'Aucun compte, aucune publicite, aucune telemetrie',
paragraphs: [
"{app} ne demande aucune inscription et ne cree aucun profil utilisateur. " +
"L'application n'affiche aucune publicite et n'envoie aucune statistique " +
"d'usage : il n'existe pas de code de mesure d'audience dans le projet.",
"Les seules requetes reseau sont celles vers cette instance : la configuration " +
"publique, et les partages que vous creez ou consultez. Aucune ressource n'est " +
'chargee depuis un service tiers.',
],
},
{
title: 'Journaux du serveur',
paragraphs: [
"Le serveur journalise les requetes qu'il recoit pour permettre son exploitation. " +
'Les jetons de partage y sont remplaces par [REDACTED], et la cle de gestion ' +
"n'y apparait jamais.",
"Un reverse proxy place devant cette instance possede ses propres journaux, qui " +
"ne dependent pas d'{app} : leur contenu releve de son exploitant.",
],
},
{
title: 'Reprendre la main',
paragraphs: [
'Vous pouvez a tout moment supprimer un partage depuis la carte correspondante : ' +
'le lien cesse alors de fonctionner et la copie serveur est effacee. Une carte ' +
'locale se supprime depuis le tableau de bord.',
],
},
];
/** Phrase affichee discretement sur le tableau de bord. */
export const LOCAL_FIRST_NOTE =
'Vos cartes restent sur cet appareil tant que vous ne les partagez pas.';
export function applyAppName(text: string, appName: string): string {
return text.split('{app}').join(appName);
}

View File

@@ -32,6 +32,7 @@ import {
resolveSpacing, resolveSpacing,
} from '@/features/editor/layout'; } from '@/features/editor/layout';
import { THEMES } from '@/features/editor/themes'; import { THEMES } from '@/features/editor/themes';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { selectCanRedo, selectCanUndo, useEditorStore } from '@/stores/editorStore'; import { selectCanRedo, selectCanUndo, useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { navigate } from '@/hooks/useRoute'; import { navigate } from '@/hooks/useRoute';
@@ -73,6 +74,9 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
const collapseToLevel = useEditorStore((state) => state.collapseToLevel); const collapseToLevel = useEditorStore((state) => state.collapseToLevel);
const balanceSides = useEditorStore((state) => state.balanceSides); const balanceSides = useEditorStore((state) => state.balanceSides);
const sharingEnabled = useAppConfigStore((state) => state.sharingEnabled);
const serverReachable = useAppConfigStore((state) => state.serverReachable);
const colorMode = useUiStore((state) => state.colorMode); const colorMode = useUiStore((state) => state.colorMode);
const toggleColorMode = useUiStore((state) => state.toggleColorMode); const toggleColorMode = useUiStore((state) => state.toggleColorMode);
const pushToast = useUiStore((state) => state.pushToast); const pushToast = useUiStore((state) => state.pushToast);
@@ -395,10 +399,24 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
<div className="mx-1 h-6 w-px bg-zinc-200 dark:bg-zinc-700" /> <div className="mx-1 h-6 w-px bg-zinc-200 dark:bg-zinc-700" />
<Button size="sm" onClick={onOpenShare}> {/*
<ShareIcon className="h-4 w-4" /> Instance privee (le serveur repond que le partage est coupe) : le
<span className="hidden md:inline">Partager</span> bouton disparait. Serveur injoignable : il reste visible mais inactif,
</Button> car l'etat du partage est simplement inconnu.
*/}
{sharingEnabled || !serverReachable ? (
<Button
size="sm"
onClick={onOpenShare}
disabled={!serverReachable}
title={
serverReachable ? undefined : 'Serveur de partage injoignable : travail local en cours'
}
>
<ShareIcon className="h-4 w-4" />
<span className="hidden md:inline">Partager</span>
</Button>
) : null}
<ExportMenu /> <ExportMenu />
</div> </div>
</header> </header>

View File

@@ -30,7 +30,8 @@ import {
import { MapFormatError, readMapFile, serializeMap } from '@/services/mapSerialization'; import { MapFormatError, readMapFile, serializeMap } from '@/services/mapSerialization';
import { useAppConfigStore } from '@/stores/appConfigStore'; import { useAppConfigStore } from '@/stores/appConfigStore';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { buildMapPath, navigate } from '@/hooks/useRoute'; import { LOCAL_FIRST_NOTE } from '@/config/privacy';
import { buildMapPath, navigate, PRIVACY_PATH } from '@/hooks/useRoute';
import { downloadBlob, toSafeFilename } from '@/utils/download'; import { downloadBlob, toSafeFilename } from '@/utils/download';
export function DashboardPage() { export function DashboardPage() {
@@ -157,6 +158,7 @@ export function DashboardPage() {
<p className="mt-1 max-w-xl text-sm text-zinc-500 dark:text-zinc-400"> <p className="mt-1 max-w-xl text-sm text-zinc-500 dark:text-zinc-400">
{APP_CONFIG.tagline} {APP_CONFIG.tagline}
</p> </p>
<p className="mt-1 text-xs text-zinc-400">{LOCAL_FIRST_NOTE}</p>
</div> </div>
<IconButton <IconButton
label={colorMode === 'dark' ? 'Passer en mode clair' : 'Passer en mode sombre'} label={colorMode === 'dark' ? 'Passer en mode clair' : 'Passer en mode sombre'}
@@ -283,7 +285,14 @@ export function DashboardPage() {
<footer className="mt-10 border-t border-zinc-200 pt-4 text-xs leading-relaxed text-zinc-500 dark:border-zinc-800 dark:text-zinc-400"> <footer className="mt-10 border-t border-zinc-200 pt-4 text-xs leading-relaxed text-zinc-500 dark:border-zinc-800 dark:text-zinc-400">
Vos cartes sont enregistrees <strong>dans ce navigateur</strong> (IndexedDB). Elles ne sont Vos cartes sont enregistrees <strong>dans ce navigateur</strong> (IndexedDB). Elles ne sont
envoyees au serveur que si vous creez explicitement un lien de partage. Exportez-les en{' '} envoyees au serveur que si vous creez explicitement un lien de partage. Exportez-les en{' '}
{APP_CONFIG.fileExtension} pour en conserver une sauvegarde portable. {APP_CONFIG.fileExtension} pour en conserver une sauvegarde portable.{' '}
<button
type="button"
className="underline underline-offset-2 hover:text-zinc-700 dark:hover:text-zinc-200"
onClick={() => navigate(PRIVACY_PATH)}
>
En savoir plus sur la confidentialite
</button>
</footer> </footer>
<TemplateGallery <TemplateGallery

View File

@@ -0,0 +1,58 @@
import { Button } from '@/components/Button';
import { ArrowLeftIcon } from '@/components/Icons';
import { PRIVACY_INTRO, PRIVACY_SECTIONS, applyAppName } from '@/config/privacy';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { navigate } from '@/hooks/useRoute';
/**
* Page /privacy.
*
* Le texte vit dans `config/privacy.ts` : cette page ne fait que le mettre en
* forme, pour qu'un exploitant puisse l'adapter a son instance en un seul
* endroit.
*/
export function PrivacyPage() {
const appName = useAppConfigStore((state) => state.name);
return (
<div className="mx-auto flex h-full w-full max-w-3xl flex-col overflow-y-auto px-5 py-8 sm:px-8">
<header className="mb-6">
<Button onClick={() => navigate('/')}>
<ArrowLeftIcon className="h-4 w-4" />
Retour
</Button>
<h1 className="mt-5 text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Confidentialite
</h1>
<p className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
{applyAppName(PRIVACY_INTRO, appName)}
</p>
</header>
<div className="space-y-7 pb-10">
{PRIVACY_SECTIONS.map((section) => (
<section key={section.title}>
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">
{section.title}
</h2>
{section.paragraphs.map((paragraph) => (
<p
key={paragraph.slice(0, 40)}
className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300"
>
{applyAppName(paragraph, appName)}
</p>
))}
{section.bullets ? (
<ul className="mt-2 list-inside list-disc space-y-1 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
{section.bullets.map((bullet) => (
<li key={bullet.slice(0, 40)}>{applyAppName(bullet, appName)}</li>
))}
</ul>
) : null}
</section>
))}
</div>
</div>
);
}

View File

@@ -1,19 +1,25 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '@/components/Button'; import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal'; import { Modal } from '@/components/Modal';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { CheckIcon, CopyIcon, SpinnerIcon } from '@/components/Icons'; import { CheckIcon, CopyIcon, DownloadIcon, ImportIcon, SpinnerIcon } from '@/components/Icons';
import { type ShareRecord } from '@/types/map'; import { type ShareRecord } from '@/types/map';
import { ShareApiError, createShare, deleteShare, updateShare } from '@/services/shareApi';
import { import {
deleteShareRecord, ShareApiError,
getShareRecord, createShare,
saveShareRecord, deleteShare,
} from '@/services/shareRepository'; setShareExpiry,
updateShare,
verifyManagementToken,
} from '@/services/shareApi';
import { ShareKeyError, SHARE_KEY_EXTENSION, readShareKeyFile, serializeShareKey } from '@/services/shareKey';
import { deleteShareRecord, getShareRecord, saveShareRecord } from '@/services/shareRepository';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { useEditorStore } from '@/stores/editorStore'; import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { buildShareUrl } from '@/hooks/useRoute'; import { buildShareUrl, navigate, PRIVACY_PATH } from '@/hooks/useRoute';
import { formatDateTime } from '@/utils/date'; import { formatDateTime } from '@/utils/date';
import { downloadBlob, toSafeFilename } from '@/utils/download';
export interface ShareDialogProps { export interface ShareDialogProps {
open: boolean; open: boolean;
@@ -21,15 +27,63 @@ export interface ShareDialogProps {
onShareStateChange?: (shared: boolean) => void; onShareStateChange?: (shared: boolean) => void;
} }
/** `null` represente l'option "Jamais". */
type ExpiryChoice = number | null;
function expiryLabel(days: ExpiryChoice): string {
if (days === null) return 'Jamais';
if (days === 1) return '24 heures';
if (days === 7) return '7 jours';
if (days === 30) return '30 jours';
if (days === 90) return '90 jours';
return `${days} jours`;
}
function serializeChoice(days: ExpiryChoice): string {
return days === null ? 'never' : String(days);
}
function parseChoice(value: string): ExpiryChoice {
return value === 'never' ? null : Number(value);
}
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Duree a afficher pour un partage existant.
*
* Le serveur ne stocke qu'une date absolue : la duree choisie a l'origine est
* perdue. On rapproche donc le temps restant de l'option la plus proche, la
* date exacte etant de toute facon rappelee juste en dessous du menu.
*/
function choiceForExpiry(expiresAt: string | null, options: ExpiryChoice[]): ExpiryChoice {
if (!expiresAt) return null;
const remaining = (new Date(expiresAt).getTime() - Date.now()) / DAY_MS;
const durations = options.filter((value): value is number => value !== null);
if (durations.length === 0) return null;
return durations.find((value) => value >= remaining) ?? (durations.at(-1) as number);
}
export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogProps) { export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogProps) {
const map = useEditorStore((state) => state.map); const map = useEditorStore((state) => state.map);
const pushToast = useUiStore((state) => state.pushToast); const pushToast = useUiStore((state) => state.pushToast);
const appName = useAppConfigStore((state) => state.name);
const sharingEnabled = useAppConfigStore((state) => state.sharingEnabled);
const allowPermanent = useAppConfigStore((state) => state.allowPermanentShares);
const defaultExpiryDays = useAppConfigStore((state) => state.defaultShareExpiryDays);
const expiryOptions = useAppConfigStore((state) => state.shareExpiryOptions);
const [record, setRecord] = useState<ShareRecord | null>(null); const [record, setRecord] = useState<ShareRecord | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [choice, setChoice] = useState<ExpiryChoice>(null);
const keyInputRef = useRef<HTMLInputElement>(null);
// Les options refusees par l'instance ne sont pas proposees.
const options = expiryOptions.filter((value) => value !== null || allowPermanent);
const refresh = useCallback(async (mapId: string) => { const refresh = useCallback(async (mapId: string) => {
setLoading(true); setLoading(true);
@@ -43,16 +97,32 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
void refresh(map.id); void refresh(map.id);
}, [open, map, refresh]); }, [open, map, refresh]);
useEffect(() => {
if (!open) return;
if (record) {
setChoice(choiceForExpiry(record.expiresAt ?? null, options));
return;
}
setChoice(defaultExpiryDays > 0 ? defaultExpiryDays : allowPermanent ? null : 30);
// `options` est recalcule a chaque rendu : le comparer relancerait l'effet en boucle.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, record, defaultExpiryDays, allowPermanent]);
const handleError = (error: unknown, fallback: string) => { const handleError = (error: unknown, fallback: string) => {
pushToast(error instanceof ShareApiError ? error.message : fallback, 'error'); pushToast(error instanceof ShareApiError ? error.message : fallback, 'error');
}; };
const persist = async (next: ShareRecord) => {
await saveShareRecord(next);
setRecord(next);
};
const handleCreate = async () => { const handleCreate = async () => {
if (!map || busy) return; if (!map || busy) return;
setBusy(true); setBusy(true);
try { try {
const response = await createShare(map, map.name); const response = await createShare(map, map.name, choice);
const created: ShareRecord = { await persist({
mapId: map.id, mapId: map.id,
shareToken: response.shareToken, shareToken: response.shareToken,
managementToken: response.managementToken, managementToken: response.managementToken,
@@ -60,9 +130,8 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
title: response.title, title: response.title,
createdAt: response.createdAt, createdAt: response.createdAt,
updatedAt: response.updatedAt, updatedAt: response.updatedAt,
}; expiresAt: response.expiresAt,
await saveShareRecord(created); });
setRecord(created);
onShareStateChange?.(true); onShareStateChange?.(true);
pushToast('Lien de partage cree.', 'success'); pushToast('Lien de partage cree.', 'success');
} catch (error) { } catch (error) {
@@ -76,15 +145,13 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
if (!map || !record || busy) return; if (!map || !record || busy) return;
setBusy(true); setBusy(true);
try { try {
const response = await updateShare( const response = await updateShare(record.shareToken, record.managementToken, map, map.name);
record.shareToken, await persist({
record.managementToken, ...record,
map, title: map.name,
map.name, updatedAt: response.updatedAt,
); expiresAt: response.expiresAt,
const updated = { ...record, title: map.name, updatedAt: response.updatedAt }; });
await saveShareRecord(updated);
setRecord(updated);
pushToast('Le lien public affiche maintenant la version actuelle.', 'success'); pushToast('Le lien public affiche maintenant la version actuelle.', 'success');
} catch (error) { } catch (error) {
handleError(error, "La mise a jour du partage n'a pas abouti."); handleError(error, "La mise a jour du partage n'a pas abouti.");
@@ -93,6 +160,24 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
} }
}; };
const handleExpiryChange = async (next: ExpiryChoice) => {
setChoice(next);
if (!record || busy) return;
setBusy(true);
try {
const response = await setShareExpiry(record.shareToken, record.managementToken, next);
await persist({ ...record, expiresAt: response.expiresAt, updatedAt: response.updatedAt });
pushToast(
next === null ? "Ce partage n'expire plus." : `Expiration fixee a ${expiryLabel(next)}.`,
'success',
);
} catch (error) {
handleError(error, "L'expiration n'a pas pu etre modifiee.");
} finally {
setBusy(false);
}
};
const handleDelete = async () => { const handleDelete = async () => {
if (!map || !record || busy) return; if (!map || !record || busy) return;
setConfirmDelete(false); setConfirmDelete(false);
@@ -116,9 +201,8 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
const handleCopy = async () => { const handleCopy = async () => {
if (!record) return; if (!record) return;
const url = buildShareUrl(record.shareToken);
try { try {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(buildShareUrl(record.shareToken));
setCopied(true); setCopied(true);
window.setTimeout(() => setCopied(false), 2000); window.setTimeout(() => setCopied(false), 2000);
} catch { } catch {
@@ -126,8 +210,55 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
} }
}; };
/** Sauvegarde volontaire du secret de gestion, hors du fichier .mmap. */
const handleExportKey = () => {
if (!record) return;
const blob = new Blob([serializeShareKey(record, window.location.origin)], {
type: 'application/json;charset=utf-8',
});
downloadBlob(blob, toSafeFilename(record.title || 'partage', SHARE_KEY_EXTENSION));
pushToast('Cle de gestion exportee. Conservez ce fichier en lieu sur.', 'success');
};
const handleImportKey = async (file: File) => {
if (!map || busy) return;
setBusy(true);
try {
const key = await readShareKeyFile(file);
// La cle n'est acceptee qu'apres verification par le serveur concerne.
const owner = await verifyManagementToken(key.shareToken, key.managementToken);
await persist({
mapId: map.id,
shareToken: owner.shareToken,
managementToken: key.managementToken,
sharePath: `/s/${owner.shareToken}`,
title: owner.title,
createdAt: owner.createdAt,
updatedAt: owner.updatedAt,
expiresAt: owner.expiresAt,
});
onShareStateChange?.(true);
pushToast('Cle de gestion importee : vous pouvez a nouveau gerer ce partage.', 'success');
} catch (error) {
if (error instanceof ShareKeyError) pushToast(error.message, 'error');
else handleError(error, "Cette cle de gestion n'a pas pu etre verifiee.");
} finally {
setBusy(false);
}
};
const shareUrl = record ? buildShareUrl(record.shareToken) : ''; const shareUrl = record ? buildShareUrl(record.shareToken) : '';
const disabledBody = (
<div className="space-y-3 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
<p>Le partage est desactive sur cette instance.</p>
<p className="text-zinc-500 dark:text-zinc-400">
Vos cartes continuent de fonctionner normalement : elles restent enregistrees dans ce
navigateur, et vous pouvez toujours les exporter en fichier.
</p>
</div>
);
return ( return (
<> <>
<Modal <Modal
@@ -135,13 +266,11 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
title={record ? 'Carte partagee' : 'Partager cette carte'} title={record ? 'Carte partagee' : 'Partager cette carte'}
onClose={onClose} onClose={onClose}
footer={ footer={
record ? ( !sharingEnabled ? (
<Button onClick={onClose}>Fermer</Button>
) : record ? (
<> <>
<Button <Button variant="danger" onClick={() => setConfirmDelete(true)} disabled={busy}>
variant="danger"
onClick={() => setConfirmDelete(true)}
disabled={busy}
>
Supprimer le partage Supprimer le partage
</Button> </Button>
<Button variant="primary" onClick={() => void handleUpdate()} disabled={busy}> <Button variant="primary" onClick={() => void handleUpdate()} disabled={busy}>
@@ -162,12 +291,23 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
) )
} }
> >
{loading ? ( {!sharingEnabled ? (
disabledBody
) : loading ? (
<p className="text-sm text-zinc-500">Chargement...</p> <p className="text-sm text-zinc-500">Chargement...</p>
) : record ? ( ) : record ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap gap-1.5">
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
Partage non repertorie
</span>
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
Lecture seule
</span>
</div>
<div> <div>
<span className="field-label">Lien public (lecture seule)</span> <span className="field-label">Lien public</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
readOnly readOnly
@@ -183,6 +323,30 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
</div> </div>
</div> </div>
<div>
<label className="field-label" htmlFor="share-expiry">
Expiration
</label>
<select
id="share-expiry"
className="field-input"
disabled={busy}
value={serializeChoice(choice)}
onChange={(event) => void handleExpiryChange(parseChoice(event.target.value))}
>
{options.map((value) => (
<option key={serializeChoice(value)} value={serializeChoice(value)}>
{expiryLabel(value)}
</option>
))}
</select>
<p className="mt-1 text-[11px] text-zinc-400">
{record.expiresAt
? `Le lien cessera de fonctionner le ${formatDateTime(record.expiresAt)}.`
: "Ce partage n'expire pas automatiquement."}
</p>
</div>
<dl className="rounded-lg border border-zinc-100 bg-zinc-50 p-3 text-xs dark:border-zinc-800 dark:bg-zinc-900/60"> <dl className="rounded-lg border border-zinc-100 bg-zinc-50 p-3 text-xs dark:border-zinc-800 dark:bg-zinc-900/60">
<div className="flex justify-between gap-4 py-0.5"> <div className="flex justify-between gap-4 py-0.5">
<dt className="text-zinc-500">Publie le</dt> <dt className="text-zinc-500">Publie le</dt>
@@ -198,29 +362,93 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
</div> </div>
</dl> </dl>
<div>
<Button size="sm" onClick={handleExportKey} disabled={busy}>
<DownloadIcon className="h-3.5 w-3.5" />
Exporter la cle de gestion
</Button>
<p className="mt-1 text-[11px] leading-snug text-zinc-400">
Ce fichier contient le secret qui permet de mettre a jour ou de supprimer ce
partage. Il n&apos;est jamais inclus dans un export {'.mmap'}. Conservez-le si vous
comptez gerer ce partage depuis un autre navigateur.
</p>
</div>
<p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400"> <p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
Le lien est <strong>non repertorie</strong> : il n&apos;apparait dans aucune liste <strong>Toute personne disposant de ce lien peut consulter cette carte.</strong> Les
publique, mais toute personne qui le connait peut consulter la carte. Les
modifications faites ici ne sont visibles publiquement qu&apos;apres un modifications faites ici ne sont visibles publiquement qu&apos;apres un
<em> Mettre a jour le partage</em>. <em> Mettre a jour le partage</em>.
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-3 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300"> <div className="space-y-4 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
<p> <p>
Cette carte est actuellement <strong>uniquement dans votre navigateur</strong>. Pour Cette carte est actuellement <strong>uniquement dans votre navigateur</strong>. Pour
generer un lien, une copie de la carte sera envoyee au serveur et stockee dans sa base generer un lien, une copie de la carte sera envoyee au serveur et stockee dans sa base
de donnees. de donnees.
</p> </p>
<div>
<label className="field-label" htmlFor="share-expiry-new">
Expiration
</label>
<select
id="share-expiry-new"
className="field-input"
disabled={busy}
value={serializeChoice(choice)}
onChange={(event) => setChoice(parseChoice(event.target.value))}
>
{options.map((value) => (
<option key={serializeChoice(value)} value={serializeChoice(value)}>
{expiryLabel(value)}
</option>
))}
</select>
</div>
<ul className="list-inside list-disc space-y-1 text-zinc-500 dark:text-zinc-400"> <ul className="list-inside list-disc space-y-1 text-zinc-500 dark:text-zinc-400">
<li>Le lien obtenu est non repertorie et en lecture seule.</li> <li>Le lien obtenu est non repertorie et en lecture seule.</li>
<li>Aucun compte n&apos;est necessaire pour le consulter.</li> <li>Toute personne disposant de ce lien peut consulter cette carte.</li>
<li>Les cartes non partagees restent uniquement dans votre navigateur.</li>
<li>Vous pourrez mettre a jour ou supprimer ce partage a tout moment.</li> <li>Vous pourrez mettre a jour ou supprimer ce partage a tout moment.</li>
<li>
Le secret permettant de le modifier reste dans ce navigateur : depuis un autre
appareil, vous ne pourrez pas gerer ce partage.
</li>
</ul> </ul>
<div className="border-t border-zinc-100 pt-3 dark:border-zinc-800">
<Button size="sm" onClick={() => keyInputRef.current?.click()} disabled={busy}>
<ImportIcon className="h-3.5 w-3.5" />
Importer une cle de gestion
</Button>
<input
ref={keyInputRef}
type="file"
accept={`${SHARE_KEY_EXTENSION},application/json`}
className="sr-only"
aria-label="Importer une cle de gestion"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) void handleImportKey(file);
}}
/>
<p className="mt-1 text-[11px] leading-snug text-zinc-400">
Cette carte a deja ete partagee depuis un autre navigateur ? Reimportez le fichier{' '}
{SHARE_KEY_EXTENSION} exporte a l&apos;epoque pour reprendre la main dessus.
</p>
</div>
<p className="text-[11px] text-zinc-400">
<button
type="button"
className="underline underline-offset-2 hover:text-zinc-600 dark:hover:text-zinc-300"
onClick={() => {
onClose();
navigate(PRIVACY_PATH);
}}
>
Comment {appName} traite vos donnees
</button>
</p>
</div> </div>
)} )}
</Modal> </Modal>

View File

@@ -18,8 +18,8 @@ import { MapFormatError } from '@/services/mapSerialization';
import { useAppConfigStore } from '@/stores/appConfigStore'; import { useAppConfigStore } from '@/stores/appConfigStore';
import { useEditorStore } from '@/stores/editorStore'; import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { navigate } from '@/hooks/useRoute'; import { navigate, PRIVACY_PATH } from '@/hooks/useRoute';
import { formatDateTime } from '@/utils/date'; import { formatDate, formatDateTime } from '@/utils/date';
export interface SharedMapPageProps { export interface SharedMapPageProps {
token: string; token: string;
@@ -27,8 +27,25 @@ export interface SharedMapPageProps {
type Status = type Status =
| { kind: 'loading' } | { kind: 'loading' }
| { kind: 'ready'; title: string; updatedAt: string } | { kind: 'ready'; title: string; updatedAt: string; expiresAt: string | null }
| { kind: 'error'; message: string }; | { kind: 'error'; message: string; expired?: boolean };
/**
* Un lien de partage est une capacite de lecture : il ne doit pas se retrouver
* dans un index public. L'en-tete X-Robots-Tag est pose par le serveur ; cette
* balise couvre le cas d'un robot qui ne lirait que le document.
*/
function useNoIndex(): void {
useEffect(() => {
const meta = document.createElement('meta');
meta.name = 'robots';
meta.content = 'noindex, nofollow, noarchive';
document.head.appendChild(meta);
return () => {
meta.remove();
};
}, []);
}
function SharedMapContent({ token }: SharedMapPageProps) { function SharedMapContent({ token }: SharedMapPageProps) {
const appName = useAppConfigStore((state) => state.name); const appName = useAppConfigStore((state) => state.name);
@@ -40,6 +57,7 @@ function SharedMapContent({ token }: SharedMapPageProps) {
const { zoomIn, zoomOut } = useReactFlow(); const { zoomIn, zoomOut } = useReactFlow();
const [status, setStatus] = useState<Status>({ kind: 'loading' }); const [status, setStatus] = useState<Status>({ kind: 'loading' });
useNoIndex();
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -49,7 +67,12 @@ function SharedMapContent({ token }: SharedMapPageProps) {
.then((response) => { .then((response) => {
if (cancelled) return; if (cancelled) return;
loadMap(response.map, { readOnly: true }); loadMap(response.map, { readOnly: true });
setStatus({ kind: 'ready', title: response.title, updatedAt: response.updatedAt }); setStatus({
kind: 'ready',
title: response.title,
updatedAt: response.updatedAt,
expiresAt: response.expiresAt,
});
document.title = `${response.title} - ${appName}`; document.title = `${response.title} - ${appName}`;
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
@@ -58,7 +81,8 @@ function SharedMapContent({ token }: SharedMapPageProps) {
error instanceof ShareApiError || error instanceof MapFormatError error instanceof ShareApiError || error instanceof MapFormatError
? error.message ? error.message
: 'Cette carte partagee est introuvable.'; : 'Cette carte partagee est introuvable.';
setStatus({ kind: 'error', message }); const expired = error instanceof ShareApiError && error.status === 410;
setStatus({ kind: 'error', message, expired });
}); });
return () => { return () => {
@@ -82,7 +106,9 @@ function SharedMapContent({ token }: SharedMapPageProps) {
<MapIcon className="h-10 w-10 text-zinc-300 dark:text-zinc-600" /> <MapIcon className="h-10 w-10 text-zinc-300 dark:text-zinc-600" />
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">{status.message}</h1> <h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">{status.message}</h1>
<p className="max-w-md text-sm text-zinc-500 dark:text-zinc-400"> <p className="max-w-md text-sm text-zinc-500 dark:text-zinc-400">
Le lien a peut-etre ete supprime par son auteur, ou l&apos;adresse est incorrecte. {status.expired
? "Son auteur avait fixe une date de fin. Lui seul peut le prolonger ou le republier."
: "Le lien a peut-etre ete supprime par son auteur, ou l'adresse est incorrecte."}
</p> </p>
<Button variant="primary" onClick={() => navigate('/')}> <Button variant="primary" onClick={() => navigate('/')}>
Ouvrir {appName} Ouvrir {appName}
@@ -100,7 +126,8 @@ function SharedMapContent({ token }: SharedMapPageProps) {
{status.title} {status.title}
</h1> </h1>
<p className="truncate text-[11px] text-zinc-400"> <p className="truncate text-[11px] text-zinc-400">
Carte partagee - lecture seule - mise a jour le {formatDateTime(status.updatedAt)} Carte partagee en lecture seule - mise a jour le {formatDateTime(status.updatedAt)}
{status.expiresAt ? ` - expire le ${formatDate(status.expiresAt)}` : ''}
</p> </p>
</div> </div>
@@ -122,8 +149,11 @@ function SharedMapContent({ token }: SharedMapPageProps) {
<MindMapCanvas readOnly showMiniMap={false} /> <MindMapCanvas readOnly showMiniMap={false} />
</div> </div>
<footer className="no-print flex h-8 shrink-0 items-center justify-center border-t border-zinc-200 bg-white text-[11px] text-zinc-400 dark:border-zinc-800 dark:bg-zinc-900"> <footer className="no-print flex h-8 shrink-0 items-center justify-center gap-2 border-t border-zinc-200 bg-white text-[11px] text-zinc-400 dark:border-zinc-800 dark:bg-zinc-900">
Carte partagee via {appName} - aucune inscription necessaire <span>Carte partagee via {appName} - aucune inscription necessaire</span>
<a href={PRIVACY_PATH} className="underline underline-offset-2 hover:text-zinc-600">
Confidentialite
</a>
</footer> </footer>
</div> </div>
); );

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { PRIVACY_PATH, parseRoute } from '@/hooks/useRoute';
describe('routes', () => {
it('reconnait le tableau de bord', () => {
expect(parseRoute('/')).toEqual({ name: 'dashboard' });
expect(parseRoute('')).toEqual({ name: 'dashboard' });
});
it('reconnait la page de confidentialite', () => {
expect(parseRoute(PRIVACY_PATH)).toEqual({ name: 'privacy' });
expect(parseRoute('/privacy/')).toEqual({ name: 'privacy' });
});
it('reconnait une carte locale', () => {
expect(parseRoute('/map/abc-123')).toEqual({ name: 'editor', mapId: 'abc-123' });
});
it('reconnait un partage', () => {
expect(parseRoute('/s/OZGrnetO4RPbqPtS')).toEqual({
name: 'shared',
token: 'OZGrnetO4RPbqPtS',
});
});
it('rejette le reste', () => {
expect(parseRoute('/inconnu')).toEqual({ name: 'notFound' });
expect(parseRoute('/s/')).toEqual({ name: 'notFound' });
});
});

View File

@@ -5,14 +5,18 @@ export type Route =
| { name: 'dashboard' } | { name: 'dashboard' }
| { name: 'editor'; mapId: string } | { name: 'editor'; mapId: string }
| { name: 'shared'; token: string } | { name: 'shared'; token: string }
| { name: 'privacy' }
| { name: 'notFound' }; | { name: 'notFound' };
const NAVIGATION_EVENT = 'openmind:navigate'; const NAVIGATION_EVENT = 'openmind:navigate';
export const PRIVACY_PATH = '/privacy';
export function parseRoute(pathname: string): Route { export function parseRoute(pathname: string): Route {
const clean = pathname.replace(/\/+$/, '') || '/'; const clean = pathname.replace(/\/+$/, '') || '/';
if (clean === '/' || clean === '') return { name: 'dashboard' }; if (clean === '/' || clean === '') return { name: 'dashboard' };
if (clean === PRIVACY_PATH) return { name: 'privacy' };
const editorMatch = clean.match(/^\/map\/([^/]+)$/); const editorMatch = clean.match(/^\/map\/([^/]+)$/);
if (editorMatch?.[1]) return { name: 'editor', mapId: decodeURIComponent(editorMatch[1]) }; if (editorMatch?.[1]) return { name: 'editor', mapId: decodeURIComponent(editorMatch[1]) };

View File

@@ -19,6 +19,7 @@ export interface ShareCreatedResponse {
title: string; title: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
expiresAt: string | null;
} }
export interface SharedMapResponse { export interface SharedMapResponse {
@@ -27,6 +28,29 @@ export interface SharedMapResponse {
map: MindMap; map: MindMap;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
expiresAt: string | null;
}
/** Fiche d'un partage sans aucune donnee de carte, reservee au proprietaire. */
export interface ShareOwnerResponse {
shareToken: string;
title: string;
createdAt: string;
updatedAt: string;
expiresAt: string | null;
}
/**
* Duree de vie demandee pour un partage.
* `null` = sans expiration ; `undefined` = laisser l'instance decider.
*/
export type ShareExpiry = number | null | undefined;
function expiryBody(expiry: ShareExpiry): Record<string, unknown> {
if (expiry === undefined) return {};
// Le serveur distingue "aucune preference" de "explicitement permanent" :
// un `null` JSON seul ne permettrait pas de faire la difference.
return expiry === null ? { permanent: true } : { expires_in_days: expiry };
} }
const MANAGEMENT_TOKEN_HEADER = 'X-Management-Token'; const MANAGEMENT_TOKEN_HEADER = 'X-Management-Token';
@@ -34,8 +58,15 @@ const MANAGEMENT_TOKEN_HEADER = 'X-Management-Token';
function messageForStatus(status: number, serverDetail?: string): string { function messageForStatus(status: number, serverDetail?: string): string {
if (status === 404) return "Ce partage n'existe plus."; if (status === 404) return "Ce partage n'existe plus.";
if (status === 403) return "Vous n'avez pas le droit de modifier ce partage depuis ce navigateur."; if (status === 403) return "Vous n'avez pas le droit de modifier ce partage depuis ce navigateur.";
if (status === 410) return serverDetail || 'Ce partage a expire.';
if (status === 413) return serverDetail || 'Cette carte est trop volumineuse pour etre partagee.'; if (status === 413) return serverDetail || 'Cette carte est trop volumineuse pour etre partagee.';
if (status === 422) return serverDetail || "Cette carte n'a pas pu etre validee par le serveur."; if (status === 422) return serverDetail || "Cette carte n'a pas pu etre validee par le serveur.";
if (status === 429) {
return serverDetail || 'Trop de requetes envoyees recemment. Reessayez dans un moment.';
}
// 503 porte un message explicite (partage desactive, instance pleine) :
// il est plus utile a l'utilisateur que le message generique des 5xx.
if (status === 503) return serverDetail || 'Le service de partage est indisponible.';
if (status >= 500) return 'Le serveur de partage est momentanement indisponible.'; if (status >= 500) return 'Le serveur de partage est momentanement indisponible.';
return serverDetail || 'Le partage a echoue. Reessayez dans un instant.'; return serverDetail || 'Le partage a echoue. Reessayez dans un instant.';
} }
@@ -88,6 +119,7 @@ interface RawShareCreated {
title: string; title: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
expires_at: string | null;
} }
interface RawSharePublic { interface RawSharePublic {
@@ -97,13 +129,36 @@ interface RawSharePublic {
map: unknown; map: unknown;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
expires_at: string | null;
}
interface RawShareOwner {
share_token: string;
title: string;
created_at: string;
updated_at: string;
expires_at: string | null;
}
function toOwner(raw: RawShareOwner): ShareOwnerResponse {
return {
shareToken: raw.share_token,
title: raw.title,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
expiresAt: raw.expires_at ?? null,
};
} }
/** Publie un snapshot de la carte. Appele uniquement sur action explicite. */ /** Publie un snapshot de la carte. Appele uniquement sur action explicite. */
export async function createShare(map: MindMap, title: string): Promise<ShareCreatedResponse> { export async function createShare(
map: MindMap,
title: string,
expiry: ShareExpiry = undefined,
): Promise<ShareCreatedResponse> {
const raw = await request<RawShareCreated>('/api/shares', { const raw = await request<RawShareCreated>('/api/shares', {
method: 'POST', method: 'POST',
body: JSON.stringify({ title, map }), body: JSON.stringify({ title, map, ...expiryBody(expiry) }),
}); });
return { return {
@@ -113,6 +168,7 @@ export async function createShare(map: MindMap, title: string): Promise<ShareCre
title: raw.title, title: raw.title,
createdAt: raw.created_at, createdAt: raw.created_at,
updatedAt: raw.updated_at, updatedAt: raw.updated_at,
expiresAt: raw.expires_at ?? null,
}; };
} }
@@ -121,8 +177,8 @@ export async function updateShare(
managementToken: string, managementToken: string,
map: MindMap, map: MindMap,
title: string, title: string,
): Promise<{ updatedAt: string }> { ): Promise<{ updatedAt: string; expiresAt: string | null }> {
const raw = await request<{ updated_at: string }>( const raw = await request<{ updated_at: string; expires_at: string | null }>(
`/api/shares/${encodeURIComponent(shareToken)}`, `/api/shares/${encodeURIComponent(shareToken)}`,
{ {
method: 'PUT', method: 'PUT',
@@ -130,7 +186,41 @@ export async function updateShare(
body: JSON.stringify({ title, map }), body: JSON.stringify({ title, map }),
}, },
); );
return { updatedAt: raw.updated_at }; return { updatedAt: raw.updated_at, expiresAt: raw.expires_at ?? null };
}
/** Change la date d'expiration d'un partage. Necessite la cle de gestion. */
export async function setShareExpiry(
shareToken: string,
managementToken: string,
expiry: number | null,
): Promise<ShareOwnerResponse> {
const raw = await request<RawShareOwner>(`/api/shares/${encodeURIComponent(shareToken)}`, {
method: 'PATCH',
headers: { [MANAGEMENT_TOKEN_HEADER]: managementToken },
body: JSON.stringify(expiryBody(expiry)),
});
return toOwner(raw);
}
/**
* Verifie qu'une cle de gestion correspond bien a ce partage.
*
* La cle n'est envoyee qu'a cette API, dans l'en-tete prevu pour elle, et
* jamais dans une URL. La reponse ne contient aucune donnee de carte.
*/
export async function verifyManagementToken(
shareToken: string,
managementToken: string,
): Promise<ShareOwnerResponse> {
const raw = await request<RawShareOwner>(
`/api/shares/${encodeURIComponent(shareToken)}/verify`,
{
method: 'POST',
headers: { [MANAGEMENT_TOKEN_HEADER]: managementToken },
},
);
return toOwner(raw);
} }
export async function deleteShare(shareToken: string, managementToken: string): Promise<void> { export async function deleteShare(shareToken: string, managementToken: string): Promise<void> {
@@ -149,6 +239,7 @@ export async function fetchSharedMap(shareToken: string): Promise<SharedMapRespo
map: normalizeMapDocument(raw.map, { source: 'share' }), map: normalizeMapDocument(raw.map, { source: 'share' }),
createdAt: raw.created_at, createdAt: raw.created_at,
updatedAt: raw.updated_at, updatedAt: raw.updated_at,
expiresAt: raw.expires_at ?? null,
}; };
} }
@@ -156,7 +247,12 @@ export interface PublicAppConfig {
appName: string; appName: string;
maxNodes: number; maxNodes: number;
maxMapSizeMb: number; maxMapSizeMb: number;
maxShareSizeMb: number;
sharingEnabled: boolean; sharingEnabled: boolean;
allowPermanentShares: boolean;
defaultShareExpiryDays: number;
/** Durees proposees dans l'interface ; `null` signifie "jamais". */
shareExpiryOptions: Array<number | null>;
} }
export async function fetchAppConfig(): Promise<PublicAppConfig> { export async function fetchAppConfig(): Promise<PublicAppConfig> {

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import { type ShareRecord } from '@/types/map';
import {
SHARE_KEY_VERSION,
ShareKeyError,
buildShareKeyFile,
parseShareKey,
serializeShareKey,
} from '@/services/shareKey';
function record(): ShareRecord {
return {
mapId: 'map-1',
shareToken: 'OZGrnetO4RPbqPtS',
managementToken: 'wYq3-Zx8_secret-de-gestion-tres-long-1234',
sharePath: '/s/OZGrnetO4RPbqPtS',
title: 'Infrastructure',
createdAt: '2026-09-01T10:00:00.000Z',
updatedAt: '2026-09-02T10:00:00.000Z',
expiresAt: null,
};
}
describe('export d une cle de gestion', () => {
it('contient les deux jetons et la version', () => {
const key = buildShareKeyFile(record(), 'https://mind.example.com');
expect(key.version).toBe(SHARE_KEY_VERSION);
expect(key.shareToken).toBe('OZGrnetO4RPbqPtS');
expect(key.managementToken).toBe('wYq3-Zx8_secret-de-gestion-tres-long-1234');
expect(key.title).toBe('Infrastructure');
expect(key.origin).toBe('https://mind.example.com');
});
it('ne contient aucune donnee de carte', () => {
const serialized = serializeShareKey(record());
const parsed = JSON.parse(serialized) as Record<string, unknown>;
expect(Object.keys(parsed).sort()).toEqual([
'exportedAt',
'managementToken',
'shareToken',
'title',
'version',
]);
expect(serialized).not.toContain('nodes');
expect(serialized).not.toContain('mapId');
});
it('produit un fichier relisable', () => {
const parsed = parseShareKey(JSON.parse(serializeShareKey(record())));
expect(parsed.shareToken).toBe(record().shareToken);
expect(parsed.managementToken).toBe(record().managementToken);
});
});
describe('validation stricte a l import', () => {
const valid = {
version: 1,
shareToken: 'OZGrnetO4RPbqPtS',
managementToken: 'wYq3-Zx8_secret-de-gestion-tres-long-1234',
};
it('accepte un fichier minimal', () => {
expect(parseShareKey(valid).shareToken).toBe('OZGrnetO4RPbqPtS');
});
it('refuse ce qui n est pas un objet', () => {
for (const value of [null, 42, 'texte', [], undefined]) {
expect(() => parseShareKey(value)).toThrow(ShareKeyError);
}
});
it('refuse un fichier sans version', () => {
expect(() => parseShareKey({ ...valid, version: undefined })).toThrow(ShareKeyError);
expect(() => parseShareKey({ ...valid, version: 'un' })).toThrow(ShareKeyError);
});
it('refuse une version future', () => {
expect(() => parseShareKey({ ...valid, version: 99 })).toThrow(
/version plus recente/,
);
});
it('refuse un jeton manquant ou mal forme', () => {
expect(() => parseShareKey({ ...valid, shareToken: '' })).toThrow(ShareKeyError);
expect(() => parseShareKey({ ...valid, managementToken: 'court' })).toThrow(ShareKeyError);
expect(() => parseShareKey({ ...valid, shareToken: 'avec espace et /' })).toThrow(
ShareKeyError,
);
expect(() => parseShareKey({ ...valid, managementToken: 42 })).toThrow(ShareKeyError);
});
it('ignore les champs superflus plutot que de les recopier', () => {
const parsed = parseShareKey({ ...valid, mechant: 'valeur', origin: 'https://ailleurs' });
expect(Object.keys(parsed).sort()).toEqual(['managementToken', 'shareToken', 'version']);
});
it('borne le titre importe', () => {
const parsed = parseShareKey({ ...valid, title: 'x'.repeat(500) });
expect(parsed.title).toHaveLength(200);
});
});

View File

@@ -0,0 +1,108 @@
import { type ShareRecord } from '@/types/map';
/**
* Cle de gestion d'un partage, exportable et reimportable.
*
* Le probleme resolu : le secret qui permet de mettre a jour ou de supprimer un
* partage vit uniquement dans l'IndexedDB du navigateur d'origine. Changer de
* machine, ou perdre son profil, revenait a perdre la main sur son propre lien.
*
* La reponse reste sans compte : un petit fichier que le proprietaire range ou
* il veut. L'export est une action explicite, et ce secret n'est jamais ajoute
* automatiquement a un fichier `.mmap`.
*/
export const SHARE_KEY_VERSION = 1;
export const SHARE_KEY_EXTENSION = '.openmind-share';
export interface ShareKeyFile {
version: number;
shareToken: string;
managementToken: string;
title?: string;
/** Informatif : d'ou vient ce fichier. Jamais utilise pour appeler quoi que ce soit. */
origin?: string;
exportedAt?: string;
}
/** Erreur de lecture d'un fichier de cle, deja formulee pour l'utilisateur. */
export class ShareKeyError extends Error {
constructor(message: string) {
super(message);
this.name = 'ShareKeyError';
}
}
const MAX_FILE_SIZE = 64 * 1024;
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{8,256}$/;
export function buildShareKeyFile(record: ShareRecord, origin?: string): ShareKeyFile {
return {
version: SHARE_KEY_VERSION,
shareToken: record.shareToken,
managementToken: record.managementToken,
...(record.title ? { title: record.title } : {}),
...(origin ? { origin } : {}),
exportedAt: new Date().toISOString(),
};
}
export function serializeShareKey(record: ShareRecord, origin?: string): string {
return JSON.stringify(buildShareKeyFile(record, origin), null, 2);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Valide strictement un fichier de cle.
*
* Aucune tolerance : ce fichier porte un secret, et une structure approximative
* signalerait plutot une erreur de manipulation qu'un fichier a rattraper.
*/
export function parseShareKey(raw: unknown): ShareKeyFile {
if (!isRecord(raw)) {
throw new ShareKeyError("Ce fichier n'est pas une cle de gestion valide.");
}
if (typeof raw.version !== 'number' || !Number.isFinite(raw.version) || raw.version < 1) {
throw new ShareKeyError("Ce fichier ne precise aucune version de cle de gestion.");
}
if (raw.version > SHARE_KEY_VERSION) {
throw new ShareKeyError(
"Cette cle a ete produite par une version plus recente d'OpenMind.",
);
}
const shareToken = typeof raw.shareToken === 'string' ? raw.shareToken.trim() : '';
const managementToken =
typeof raw.managementToken === 'string' ? raw.managementToken.trim() : '';
if (!TOKEN_PATTERN.test(shareToken) || !TOKEN_PATTERN.test(managementToken)) {
throw new ShareKeyError('Cette cle de gestion est incomplete ou mal formee.');
}
const title = typeof raw.title === 'string' ? raw.title.slice(0, 200) : undefined;
return {
version: SHARE_KEY_VERSION,
shareToken,
managementToken,
...(title ? { title } : {}),
};
}
/** Lit un fichier `.openmind-share` choisi par l'utilisateur. */
export async function readShareKeyFile(file: File): Promise<ShareKeyFile> {
if (file.size > MAX_FILE_SIZE) {
throw new ShareKeyError("Ce fichier est trop volumineux pour etre une cle de gestion.");
}
let raw: unknown;
try {
raw = JSON.parse(await file.text());
} catch {
throw new ShareKeyError("Ce fichier n'est pas une cle de gestion valide (JSON illisible).");
}
return parseShareKey(raw);
}

View File

@@ -6,11 +6,23 @@ interface AppConfigState {
name: string; name: string;
maxNodes: number; maxNodes: number;
maxMapSizeMb: number; maxMapSizeMb: number;
maxShareSizeMb: number;
/**
* Le partage serveur est-il ouvert sur cette instance ?
* Faux tant que le backend n'a pas repondu : mieux vaut ne pas proposer une
* action qui echouerait que l'inverse.
*/
sharingEnabled: boolean;
allowPermanentShares: boolean;
defaultShareExpiryDays: number;
shareExpiryOptions: Array<number | null>;
/** Faux tant que le backend n'a pas repondu : l'app reste utilisable hors ligne. */ /** Faux tant que le backend n'a pas repondu : l'app reste utilisable hors ligne. */
serverReachable: boolean; serverReachable: boolean;
load: () => Promise<void>; load: () => Promise<void>;
} }
const DEFAULT_EXPIRY_OPTIONS: Array<number | null> = [1, 7, 30, 90, null];
/** /**
* Le nom du produit vient de la configuration serveur (APP_NAME), avec repli * Le nom du produit vient de la configuration serveur (APP_NAME), avec repli
* sur `APP_CONFIG.name`. Aucun composant ne doit ecrire le nom en dur. * sur `APP_CONFIG.name`. Aucun composant ne doit ecrire le nom en dur.
@@ -19,6 +31,11 @@ export const useAppConfigStore = create<AppConfigState>((set) => ({
name: APP_CONFIG.name, name: APP_CONFIG.name,
maxNodes: APP_CONFIG.maxNodes, maxNodes: APP_CONFIG.maxNodes,
maxMapSizeMb: APP_CONFIG.maxMapSizeMb, maxMapSizeMb: APP_CONFIG.maxMapSizeMb,
maxShareSizeMb: APP_CONFIG.maxMapSizeMb,
sharingEnabled: false,
allowPermanentShares: true,
defaultShareExpiryDays: 30,
shareExpiryOptions: DEFAULT_EXPIRY_OPTIONS,
serverReachable: false, serverReachable: false,
load: async () => { load: async () => {
@@ -28,6 +45,14 @@ export const useAppConfigStore = create<AppConfigState>((set) => ({
name: config.appName || APP_CONFIG.name, name: config.appName || APP_CONFIG.name,
maxNodes: config.maxNodes || APP_CONFIG.maxNodes, maxNodes: config.maxNodes || APP_CONFIG.maxNodes,
maxMapSizeMb: config.maxMapSizeMb || APP_CONFIG.maxMapSizeMb, maxMapSizeMb: config.maxMapSizeMb || APP_CONFIG.maxMapSizeMb,
maxShareSizeMb: config.maxShareSizeMb || config.maxMapSizeMb || APP_CONFIG.maxMapSizeMb,
sharingEnabled: config.sharingEnabled !== false,
allowPermanentShares: config.allowPermanentShares !== false,
defaultShareExpiryDays: config.defaultShareExpiryDays ?? 30,
shareExpiryOptions:
Array.isArray(config.shareExpiryOptions) && config.shareExpiryOptions.length > 0
? config.shareExpiryOptions
: DEFAULT_EXPIRY_OPTIONS,
serverReachable: true, serverReachable: true,
}); });
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
@@ -35,7 +60,8 @@ export const useAppConfigStore = create<AppConfigState>((set) => ({
} }
} catch { } catch {
// Hors ligne : on garde les valeurs par defaut, sans bloquer l'interface. // Hors ligne : on garde les valeurs par defaut, sans bloquer l'interface.
set({ serverReachable: false }); // Le partage reste presente comme indisponible plutot que d'echouer au clic.
set({ serverReachable: false, sharingEnabled: false });
} }
}, },
})); }));

View File

@@ -288,4 +288,6 @@ export interface ShareRecord {
title: string; title: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
/** Date d'expiration ISO, ou `null` pour un partage sans expiration. */
expiresAt?: string | null;
} }