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:
31
.env.example
31
.env.example
@@ -17,6 +17,37 @@ DATABASE_URL=sqlite:////app/data/openmind.db
|
||||
MAX_MAP_SIZE_MB=10
|
||||
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.
|
||||
# Vide = aucune origine externe (l'interface est servie par ce meme serveur).
|
||||
CORS_ORIGINS=
|
||||
|
||||
@@ -50,8 +50,13 @@ WORKDIR /app
|
||||
|
||||
COPY --from=backend-deps /opt/python /opt/python
|
||||
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
|
||||
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
RUN mkdir -p /app/data && chown -R openmind:openmind /app
|
||||
|
||||
USER openmind
|
||||
@@ -60,4 +65,6 @@ EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
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"]
|
||||
|
||||
283
README.md
283
README.md
@@ -127,6 +127,24 @@ Toutes definies dans `.env` (modele : `.env.example`). Aucun secret n'est requis
|
||||
| `CORS_ORIGINS` | vide | Origines externes autorisees, separees par des virgules |
|
||||
| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
||||
|
||||
Exposition publique :
|
||||
|
||||
| Variable | Defaut | Role |
|
||||
|---|---|---|
|
||||
| `SHARING_ENABLED` | `true` | `false` coupe entierement le service de partage ; les cartes locales continuent de fonctionner |
|
||||
| `SHARE_CREATE_RATE_LIMIT` | `10/hour` | Limite de creation de partages, par client |
|
||||
| `SHARE_READ_RATE_LIMIT` | `120/minute` | Limite de lecture d'un partage, par client |
|
||||
| `SHARE_WRITE_RATE_LIMIT` | `60/hour` | Limite des mises a jour, changements d'expiration et suppressions |
|
||||
| `MAX_SHARED_MAPS` | `10000` | Nombre total de partages heberges. `0` = illimite |
|
||||
| `MAX_SHARE_SIZE_MB` | `0` | Taille maximale d'un partage. `0` reprend `MAX_MAP_SIZE_MB` |
|
||||
| `DEFAULT_SHARE_EXPIRY_DAYS` | `30` | Duree de vie par defaut d'un nouveau partage. `0` = permanent |
|
||||
| `ALLOW_PERMANENT_SHARES` | `true` | Autorise l'option "Jamais" dans le choix d'expiration |
|
||||
| `TRUST_PROXY_HEADERS` | `false` | Croire `X-Forwarded-For`. **A n'activer que derriere un reverse proxy de confiance** |
|
||||
|
||||
Format des limites de debit : `<nombre>/<fenetre>`, ou la fenetre vaut `second`,
|
||||
`minute`, `hour` ou `day`. Une valeur vide, `0` ou `off` desactive la limite
|
||||
concernee.
|
||||
|
||||
Le nom du produit est centralise : cote backend via `APP_NAME`, cote frontend
|
||||
dans `frontend/src/config/app.ts`. Le frontend recupere le nom a l'execution via
|
||||
`GET /api/config` : changer `APP_NAME` suffit, sans recompiler.
|
||||
@@ -145,14 +163,47 @@ Deux stockages **totalement distincts** :
|
||||
Le dossier `./data` est monte en **bind mount** (aucun volume Docker nomme) :
|
||||
les fichiers sont donc directement lisibles depuis l'hote.
|
||||
|
||||
### Sauvegarde a chaud
|
||||
|
||||
SQLite fonctionne en mode WAL : les ecritures recentes vivent dans
|
||||
`openmind.db-wal`, et un simple `cp openmind.db` peut donc produire une copie
|
||||
incoherente. La commande de sauvegarde utilise l'API de sauvegarde de SQLite,
|
||||
qui produit une base complete **pendant que l'application tourne** :
|
||||
|
||||
```bash
|
||||
docker compose exec openmind python -m app.cli backup /app/data/backups/openmind-$(date +%F).db
|
||||
```
|
||||
|
||||
Le fichier obtenu se trouve sur l'hote dans `./data/backups/`. C'est une base
|
||||
SQLite ordinaire : elle s'ouvre avec n'importe quel outil, sans OpenMind.
|
||||
|
||||
A froid, une archive du dossier reste valable :
|
||||
|
||||
```bash
|
||||
docker compose stop
|
||||
tar czf openmind-partages-$(date +%F).tar.gz data/
|
||||
docker compose start
|
||||
```
|
||||
|
||||
SQLite fonctionne en mode WAL : `openmind.db`, `openmind.db-wal` et
|
||||
`openmind.db-shm` doivent etre sauvegardes ensemble.
|
||||
### Restauration
|
||||
|
||||
```bash
|
||||
docker compose exec openmind python -m app.cli restore /app/data/backups/openmind-2026-09-10.db --yes
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
La commande verifie d'abord que le fichier est bien une base OpenMind, place une
|
||||
copie de securite de la base actuelle dans `./data/backups/pre-restore-*.db`,
|
||||
puis ecrit le contenu restaure. Le redemarrage n'est pas indispensable mais reste
|
||||
recommande. Pour une restauration sans aucun risque, arretez le conteneur, copiez
|
||||
le fichier a la place de `openmind.db` (en supprimant les `-wal` / `-shm`
|
||||
associes), puis redemarrez.
|
||||
|
||||
> **Ne modifiez pas `openmind.db` depuis l'hote pendant que le conteneur tourne.**
|
||||
> Les fichiers `-wal` / `-shm` de SQLite sont partages entre les processus qui
|
||||
> ont la base ouverte : une ecriture concurrente depuis l'hote, a travers un bind
|
||||
> mount, peut etre perdue ou desynchroniser l'application du fichier. Passez par
|
||||
> la CLI dans le conteneur, ou arretez le conteneur.
|
||||
|
||||
Redemarrer le serveur n'a **aucun impact** sur les cartes privees locales.
|
||||
|
||||
@@ -168,7 +219,7 @@ Deux secrets distincts sont generes cote serveur avec le module Python
|
||||
|
||||
| Jeton | Entropie | Ou il vit | A quoi il sert |
|
||||
|---|---|---|---|
|
||||
| `share_token` | `secrets.token_urlsafe(12)`, environ 96 bits | dans l'URL publique `/s/<token>` | **lecture seule** |
|
||||
| `share_token` | `secrets.token_urlsafe(16)`, 128 bits | dans l'URL publique `/s/<token>` | **lecture seule** |
|
||||
| `management_token` | `secrets.token_urlsafe(32)`, environ 256 bits | **uniquement** dans l'IndexedDB du proprietaire | modification et suppression |
|
||||
|
||||
Regles appliquees :
|
||||
@@ -182,6 +233,55 @@ Regles appliquees :
|
||||
supprimer un partage.
|
||||
- Le lien est **non repertorie** : il n'apparait dans aucune liste publique,
|
||||
mais toute personne qui le connait peut consulter la carte.
|
||||
- Les jetons emis avant le passage a 128 bits restent valides : la recherche se
|
||||
fait par valeur exacte, sans contrainte de longueur.
|
||||
|
||||
Protection du lien :
|
||||
|
||||
- `robots.txt` interdit `/s/` et `/api/`, et les reponses de ces chemins portent
|
||||
`X-Robots-Tag: noindex, nofollow, noarchive`. La page publique ajoute la balise
|
||||
`<meta name="robots">` correspondante. Ce n'est pas une mesure de securite :
|
||||
c'est le jeton qui autorise la lecture.
|
||||
- `Referrer-Policy: no-referrer` est applique a tout le site. Un lien externe
|
||||
clique depuis une carte partagee ne transmet donc pas l'URL `/s/<token>` au
|
||||
site de destination. Les liens des noeuds s'ouvrent avec
|
||||
`rel="noopener noreferrer nofollow"`.
|
||||
- Les journaux applicatifs remplacent les jetons par `[REDACTED]` dans les
|
||||
chemins `/s/...` et `/api/shares/...`, et designent un partage par une
|
||||
empreinte courte non reversible. Les autres routes restent lisibles pour le
|
||||
diagnostic. **Un reverse proxy place devant l'instance a ses propres journaux**,
|
||||
qui ne dependent pas d'OpenMind : configurez-les separement si vous ne voulez
|
||||
pas y voir apparaitre les jetons.
|
||||
|
||||
### Expiration
|
||||
|
||||
Un partage peut expirer automatiquement. La duree est choisie a la creation
|
||||
(24 heures, 7 jours, 30 jours, 90 jours, ou jamais) et modifiable ensuite depuis
|
||||
le dialogue de partage. `DEFAULT_SHARE_EXPIRY_DAYS` fixe la valeur par defaut de
|
||||
l'instance, `ALLOW_PERMANENT_SHARES=false` retire l'option "Jamais".
|
||||
|
||||
- Un partage expire repond `410 Gone` et la page publique affiche *Ce partage a
|
||||
expire*, sans autre detail.
|
||||
- Il n'est pas supprime pour autant : son proprietaire peut le prolonger, et
|
||||
l'administrateur le purger.
|
||||
- Les partages crees avant l'introduction de l'expiration ont `expires_at = NULL`
|
||||
et restent donc valides indefiniment.
|
||||
|
||||
### Recuperer la main sur un partage
|
||||
|
||||
Le jeton de gestion vit uniquement dans l'IndexedDB du navigateur d'origine.
|
||||
Depuis le dialogue de partage, **Exporter la cle de gestion** produit un petit
|
||||
fichier `.openmind-share` :
|
||||
|
||||
```json
|
||||
{ "version": 1, "shareToken": "...", "managementToken": "...", "title": "..." }
|
||||
```
|
||||
|
||||
Depuis un autre navigateur, ouvrez la carte correspondante puis **Importer une
|
||||
cle de gestion** : la cle est verifiee aupres du serveur concerne avant d'etre
|
||||
enregistree localement. Ce fichier contient un secret : il n'est jamais ajoute
|
||||
automatiquement a un export `.mmap`, et son export est toujours une action
|
||||
explicite.
|
||||
|
||||
Cycle de vie :
|
||||
|
||||
@@ -203,15 +303,146 @@ serveur.
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/health` | - | Etat du service |
|
||||
| `GET` | `/api/config` | - | Nom de l'application et limites |
|
||||
| `GET` | `/api/ready` | - | Le service peut-il repondre (acces SQLite verifie) |
|
||||
| `POST` | `/api/shares` | - | Creer un partage |
|
||||
| `GET` | `/api/shares/{share_token}` | - | Lire une carte partagee |
|
||||
| `PUT` | `/api/shares/{share_token}` | `X-Management-Token` | Mettre a jour |
|
||||
| `PUT` | `/api/shares/{share_token}` | `X-Management-Token` | Mettre a jour le contenu |
|
||||
| `PATCH` | `/api/shares/{share_token}` | `X-Management-Token` | Modifier l'expiration |
|
||||
| `POST` | `/api/shares/{share_token}/verify` | `X-Management-Token` | Verifier une cle de gestion |
|
||||
| `DELETE` | `/api/shares/{share_token}` | `X-Management-Token` | Supprimer |
|
||||
|
||||
`/api/health` et `/api/ready` ne sont soumis a aucune limitation de debit : le
|
||||
healthcheck du conteneur reste fiable meme sous charge. Toutes les routes de
|
||||
partage repondent `503` quand `SHARING_ENABLED=false`, et `429` avec un en-tete
|
||||
`Retry-After` quand une limite est atteinte.
|
||||
|
||||
Documentation interactive : `http://localhost:8080/api/docs`.
|
||||
|
||||
---
|
||||
|
||||
## Administration d'une instance
|
||||
|
||||
Il n'existe volontairement **aucune interface `/admin` exposee sur Internet**.
|
||||
L'administration passe par une CLI locale au conteneur, donc reservee a qui a
|
||||
deja un acces machine :
|
||||
|
||||
```bash
|
||||
docker compose exec openmind python -m app.cli stats
|
||||
docker compose exec openmind python -m app.cli shares list --limit 20
|
||||
docker compose exec openmind python -m app.cli shares list --expired
|
||||
docker compose exec openmind python -m app.cli shares delete <share-token> --yes
|
||||
docker compose exec openmind python -m app.cli shares purge-expired --dry-run
|
||||
docker compose exec openmind python -m app.cli shares purge-expired --yes
|
||||
docker compose exec openmind python -m app.cli backup /app/data/backups/openmind.db
|
||||
docker compose exec openmind python -m app.cli restore /app/data/backups/openmind.db --yes
|
||||
```
|
||||
|
||||
| Commande | Role |
|
||||
|---|---|
|
||||
| `stats` | Nombre de partages (total, actifs, expires), capacite, taille de la base, plus ancien et plus recent |
|
||||
| `shares list` | Listage. Options `--limit`, `--active`, `--expired` |
|
||||
| `shares delete <token>` | Suppression administrative, sans jeton de gestion. Demande confirmation sauf `--yes` |
|
||||
| `shares purge-expired` | Supprime physiquement les partages expires. `--dry-run` affiche sans rien supprimer |
|
||||
| `backup <chemin>` | Sauvegarde a chaud via l'API SQLite. `--yes` ecrase un fichier existant |
|
||||
| `restore <chemin>` | Restauration prudente : verification, copie de securite, puis remplacement |
|
||||
|
||||
Aucune commande n'affiche le jeton de gestion, son hash, ni le contenu des
|
||||
cartes. Les jetons de partage y sont masques (`sbcY...BCyw`) : suffisant pour
|
||||
identifier une ligne, insuffisant pour ouvrir le lien.
|
||||
|
||||
---
|
||||
|
||||
## Deploiement derriere un reverse proxy
|
||||
|
||||
Le conteneur ecoute simplement sur `8080` en HTTP. Le TLS est termine par le
|
||||
reverse proxy ; OpenMind n'embarque ni certificat ni Traefik, et aucune solution
|
||||
n'est imposee.
|
||||
|
||||
Points a respecter :
|
||||
|
||||
1. Transmettre l'hote et le protocole d'origine.
|
||||
2. Activer `TRUST_PROXY_HEADERS=true` **uniquement** dans ce cas : la limitation
|
||||
de debit lit alors la derniere valeur de `X-Forwarded-For`, celle ajoutee par
|
||||
votre proxy. Laisse a `false`, un client direct pourrait forger cet en-tete et
|
||||
changer d'identite a chaque requete.
|
||||
3. Ne pas mettre en cache `/api/`.
|
||||
|
||||
Aucune variable `PUBLIC_BASE_URL` n'est necessaire : le serveur ne fabrique
|
||||
jamais d'URL absolue (il renvoie un chemin relatif `/s/<token>`), et le lien
|
||||
affiche est construit dans le navigateur a partir de `window.location.origin`.
|
||||
Un utilisateur sur `https://mind.example.com` obtient donc un lien sur ce
|
||||
domaine, sans configuration.
|
||||
|
||||
Exemple Nginx :
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name mind.example.com;
|
||||
|
||||
# certificats geres par votre outillage habituel
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 12m; # >= MAX_MAP_SIZE_MB
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Exemple Traefik (labels sur le service `openmind` du `docker-compose.yml`) :
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.openmind.rule=Host(`mind.example.com`)
|
||||
- traefik.http.routers.openmind.entrypoints=websecure
|
||||
- traefik.http.routers.openmind.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.openmind.loadbalancer.server.port=8080
|
||||
```
|
||||
|
||||
Les journaux du reverse proxy contiennent les URL demandees, jetons de partage
|
||||
compris. Si cela vous derange, configurez leur format ou leur retention : c'est
|
||||
independant d'OpenMind.
|
||||
|
||||
---
|
||||
|
||||
## Migrations de schema
|
||||
|
||||
Le schema SQLite est gere par **Alembic**. Les migrations sont appliquees
|
||||
automatiquement au demarrage du conteneur, avant qu'uvicorn ne serve la moindre
|
||||
requete :
|
||||
|
||||
```
|
||||
docker compose up -d -> docker-entrypoint.sh -> alembic upgrade head -> uvicorn
|
||||
```
|
||||
|
||||
Trois cas, tous pris en charge sans intervention :
|
||||
|
||||
| Etat de depart | Ce qui se passe |
|
||||
|---|---|
|
||||
| Base absente | Toutes les revisions s'appliquent |
|
||||
| Base creee avant Alembic (`create_all`) | La revision initiale est **estampillee** (aucune donnee touchee), puis les suivantes s'appliquent |
|
||||
| Base deja a jour | Rien a faire |
|
||||
|
||||
Manuellement :
|
||||
|
||||
```bash
|
||||
docker compose exec openmind python -m app.db.migrations # applique head
|
||||
docker compose exec openmind alembic current # revision courante
|
||||
docker compose exec openmind alembic history # historique
|
||||
```
|
||||
|
||||
| Revision | Apport |
|
||||
|---|---|
|
||||
| `0001_initial_schema` | Table `shared_maps` telle qu'elle existait avant Alembic |
|
||||
| `0002_share_expiry` | Colonne `expires_at` nullable et son index |
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnalites
|
||||
|
||||
**Tableau de bord** : creation, import, recherche, date de derniere
|
||||
@@ -547,6 +778,13 @@ suppression d'un partage, refus avec un mauvais jeton de gestion, refus sans
|
||||
jeton, partage inexistant, payload invalide, limites de taille et de noeuds,
|
||||
non-divulgation du jeton de gestion, repli SPA, acceptation des documents v3 et
|
||||
v4 (ordre des freres, cotes, relations) et refus d'une relation incoherente.
|
||||
S'y ajoutent, pour l'exposition publique : limitation de debit (creation,
|
||||
lecture, exemption du healthcheck, desactivation), coupure du partage, capacite
|
||||
maximale, taille propre aux partages, expiration et prolongation, verification
|
||||
d'une cle de gestion, resolution de l'adresse client derriere un proxy,
|
||||
en-tetes d'indexation et de referrer, redaction des jetons dans les journaux,
|
||||
migrations Alembic sur base neuve et sur base d'avant Alembic, et l'ensemble des
|
||||
commandes de la CLI d'administration.
|
||||
|
||||
Les tests frontend couvrent : stockage IndexedDB, serialisation et validation
|
||||
d'import, migrations v1 -> v4, non-mutation et idempotence de la normalisation,
|
||||
@@ -573,23 +811,26 @@ modeles, assainissement des URL.
|
||||
├── backend/
|
||||
│ ├── requirements.txt
|
||||
│ ├── pyproject.toml # configuration ruff et pytest
|
||||
│ ├── alembic.ini # configuration Alembic (URL prise dans DATABASE_URL)
|
||||
│ ├── migrations/ # revisions de schema
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # assemblage uniquement
|
||||
│ │ ├── cli.py # administration locale : stats, partages, sauvegarde
|
||||
│ │ ├── config.py
|
||||
│ │ ├── logging_config.py
|
||||
│ │ ├── middleware.py # en-tetes de securite, limite de taille
|
||||
│ │ ├── logging_config.py # format des logs et redaction des jetons
|
||||
│ │ ├── middleware.py # en-tetes de securite, cache, limite de taille
|
||||
│ │ ├── web.py # service des fichiers statiques du frontend
|
||||
│ │ ├── api/ # routes health, config, shares
|
||||
│ │ ├── db/ # moteur, session, initialisation du schema
|
||||
│ │ ├── api/ # routes health, ready, config, shares
|
||||
│ │ ├── db/ # moteur, session, migrations
|
||||
│ │ ├── models/ # SharedMap (SQLAlchemy)
|
||||
│ │ ├── schemas/ # validation Pydantic
|
||||
│ │ └── services/ # jetons, logique de partage, erreurs metier
|
||||
│ │ └── services/ # jetons, partage, limitation de debit, adresse client
|
||||
│ └── tests/
|
||||
└── frontend/
|
||||
├── index.html
|
||||
├── public/ # manifest PWA, service worker, icones
|
||||
└── src/
|
||||
├── config/app.ts # nom du produit et constantes
|
||||
├── config/ # nom du produit, constantes, texte de confidentialite
|
||||
├── types/map.ts # modele metier versionne
|
||||
├── db/ # schema Dexie
|
||||
├── services/ # depots, serialisation, client API
|
||||
@@ -600,6 +841,7 @@ modeles, assainissement des URL.
|
||||
│ ├── editor/ # canvas, noeud, barre d'outils, panneaux, layout, bilateral, relations, themes
|
||||
│ ├── templates/ # galerie et definitions des modeles
|
||||
│ ├── maps/ # tableau de bord
|
||||
│ ├── privacy/ # page /privacy
|
||||
│ ├── export/ # PNG, SVG, PDF, impression
|
||||
│ └── sharing/ # dialogue de partage, page publique
|
||||
├── utils/
|
||||
@@ -666,9 +908,24 @@ modeles, assainissement des URL.
|
||||
racines suivantes ne sont pas recentrees individuellement.
|
||||
- **Types de noeuds reserves** : `note`, `image`, `group`, `link` et `task` sont
|
||||
acceptes par le format, mais la V1 ne produit que `root` et `standard`.
|
||||
- **Migrations de schema SQLite** : la V1 initialise le schema avec
|
||||
`create_all`, une seule table. Alembic n'est pas utilise ; il faudra
|
||||
l'introduire au premier changement de schema.
|
||||
- **Limitation de debit en memoire** : les compteurs vivent dans le processus.
|
||||
Un redemarrage du conteneur les remet a zero, et plusieurs workers uvicorn
|
||||
auraient chacun les leurs. C'est un garde-fou contre la saturation triviale
|
||||
d'une instance publique, pas un quota strict. Un reverse proxy peut ajouter sa
|
||||
propre limitation si le besoin est plus exigeant.
|
||||
- **Partage expire et information** : un lien expire repond `410` et non `404`,
|
||||
ce qui revele qu'un partage a existe a cette adresse. C'est assume : sans cela,
|
||||
la page publique ne pourrait pas expliquer la difference entre un lien expire
|
||||
et un lien erronne. Rien d'autre n'est divulgue.
|
||||
- **Purge des partages expires** : elle n'est pas automatique. Un partage expire
|
||||
cesse d'etre lisible mais reste en base jusqu'a un
|
||||
`shares purge-expired`, ce qui laisse a son proprietaire la possibilite de le
|
||||
prolonger. Planifiez la commande si vous voulez liberer l'espace regulierement.
|
||||
- **Ecriture concurrente sur la base SQLite** : ne modifiez pas `openmind.db`
|
||||
depuis l'hote pendant que le conteneur tourne. Les fichiers `-wal` / `-shm`
|
||||
sont partages entre processus ; a travers un bind mount, une ecriture externe
|
||||
peut etre perdue ou desynchroniser l'application du fichier. Utilisez la CLI
|
||||
dans le conteneur, ou arretez le conteneur.
|
||||
- **Guides d'alignement** : ils apparaissent pendant un deplacement lorsqu'un
|
||||
bord ou un centre coincide (a 6 px pres) avec celui d'un autre noeud. Ils sont
|
||||
purement visuels : ils n'aimantent pas le noeud. Pour aimanter, activez
|
||||
|
||||
42
backend/alembic.ini
Normal file
42
backend/alembic.ini
Normal 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
|
||||
@@ -4,12 +4,14 @@ from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header
|
||||
from fastapi import Depends, Header, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
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"
|
||||
|
||||
@@ -27,3 +29,41 @@ def 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"))]
|
||||
|
||||
@@ -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 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"])
|
||||
|
||||
# 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")
|
||||
def health() -> dict[str, str]:
|
||||
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")
|
||||
def public_config(settings: AppSettings) -> dict[str, object]:
|
||||
"""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,
|
||||
"maxNodes": settings.max_nodes,
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -4,10 +4,19 @@ from __future__ import annotations
|
||||
|
||||
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 (
|
||||
ShareCreatedOut,
|
||||
ShareCreateIn,
|
||||
ShareExpiryIn,
|
||||
ShareOwnerOut,
|
||||
SharePublicOut,
|
||||
ShareUpdatedOut,
|
||||
)
|
||||
@@ -26,9 +35,21 @@ def _share_path(share_token: str) -> str:
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
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(
|
||||
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(
|
||||
share_token=share.share_token,
|
||||
@@ -37,6 +58,7 @@ def create_share(payload: ShareCreateIn, session: DbSession) -> ShareCreatedOut:
|
||||
title=share.title,
|
||||
created_at=share.created_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,
|
||||
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)
|
||||
return SharePublicOut(
|
||||
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),
|
||||
created_at=share.created_at,
|
||||
updated_at=share.updated_at,
|
||||
expires_at=share.expires_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -67,6 +95,8 @@ def update_share(
|
||||
payload: ShareCreateIn,
|
||||
session: DbSession,
|
||||
management_token: ManagementToken,
|
||||
_sharing: SharingEnabled,
|
||||
_rate: RateLimitWrite,
|
||||
) -> ShareUpdatedOut:
|
||||
share = share_service.update_share(
|
||||
session,
|
||||
@@ -80,6 +110,64 @@ def update_share(
|
||||
share_path=_share_path(share.share_token),
|
||||
title=share.title,
|
||||
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,
|
||||
session: DbSession,
|
||||
management_token: ManagementToken,
|
||||
_sharing: SharingEnabled,
|
||||
_rate: RateLimitWrite,
|
||||
) -> Response:
|
||||
share_service.delete_share(session, share_token, management_token=management_token)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
334
backend/app/cli.py
Normal file
334
backend/app/cli.py
Normal 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())
|
||||
@@ -28,6 +28,32 @@ class Settings(BaseSettings):
|
||||
max_nodes: int = Field(default=5000, 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.
|
||||
# Vide = aucune origine externe (l'app est servie par ce meme serveur).
|
||||
cors_origins: str = ""
|
||||
@@ -38,6 +64,17 @@ class Settings(BaseSettings):
|
||||
def max_map_size_bytes(self) -> int:
|
||||
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
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||
|
||||
@@ -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
|
||||
`create_all` suffit. Toute evolution future du schema devra soit passer par
|
||||
Alembic, soit par une migration explicite ajoutee ici (voir README).
|
||||
Depuis la Phase 2.1, le schema est gere par Alembic : `init_db` se contente de
|
||||
declencher les migrations. Il n'existe volontairement aucun mecanisme de
|
||||
migration maison en parallele.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,16 +11,12 @@ import logging
|
||||
|
||||
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.models import share as _share_model # noqa: F401 (enregistre la table)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def init_db(engine: Engine | None = None) -> None:
|
||||
target = engine or default_engine
|
||||
Base.metadata.create_all(bind=target)
|
||||
logger.info("Schema SQLite initialise (version %s)", SCHEMA_VERSION)
|
||||
run_migrations(engine or default_engine)
|
||||
|
||||
67
backend/app/db/migrations.py
Normal file
67
backend/app/db/migrations.py
Normal 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()
|
||||
@@ -44,6 +44,10 @@ def create_db_engine(database_url: str | None = None) -> Engine:
|
||||
# WAL : meilleure resistance aux arrets brutaux du conteneur.
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
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()
|
||||
|
||||
return engine
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
|
||||
_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.
|
||||
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:
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
@@ -21,11 +48,16 @@ def configure_logging(level: str = "INFO") -> None:
|
||||
root.addHandler(handler)
|
||||
root.setLevel(level.upper())
|
||||
|
||||
redactor = TokenRedactingFilter()
|
||||
handler.addFilter(redactor)
|
||||
|
||||
# Uvicorn possede ses propres handlers : on les aligne sur les notres.
|
||||
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||
logger = logging.getLogger(name)
|
||||
logger.handlers.clear()
|
||||
logger.propagate = True
|
||||
logger.filters.clear()
|
||||
logger.addFilter(redactor)
|
||||
|
||||
|
||||
def safe_extra(data: dict[str, object]) -> dict[str, object]:
|
||||
|
||||
@@ -80,7 +80,11 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
|
||||
@app.exception_handler(ShareError)
|
||||
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)
|
||||
async def _validation_error(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
|
||||
@@ -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):
|
||||
"""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]
|
||||
response: Response = await call_next(request)
|
||||
for header, value in SECURITY_HEADERS.items():
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -37,5 +37,21 @@ class SharedMap(Base):
|
||||
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
|
||||
return f"<SharedMap id={self.id} share_token={self.share_token!r}>"
|
||||
|
||||
@@ -16,6 +16,19 @@ class ShareCreateIn(BaseModel):
|
||||
|
||||
title: str = Field(default="", max_length=255)
|
||||
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):
|
||||
@@ -31,6 +44,7 @@ class ShareCreatedOut(BaseModel):
|
||||
title: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class ShareUpdatedOut(BaseModel):
|
||||
@@ -38,6 +52,17 @@ class ShareUpdatedOut(BaseModel):
|
||||
share_path: str
|
||||
title: str
|
||||
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):
|
||||
@@ -49,3 +74,4 @@ class SharePublicOut(BaseModel):
|
||||
map: dict
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
expires_at: datetime | None = None
|
||||
|
||||
39
backend/app/services/client_ip.py
Normal file
39
backend/app/services/client_ip.py
Normal 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
|
||||
@@ -17,6 +17,8 @@ class ShareError(Exception):
|
||||
super().__init__(message or self.message)
|
||||
if message:
|
||||
self.message = message
|
||||
# En-tetes additionnels de la reponse (Retry-After, par exemple).
|
||||
self.headers: dict[str, str] = {}
|
||||
|
||||
|
||||
class ShareNotFoundError(ShareError):
|
||||
@@ -32,3 +34,40 @@ class ShareForbiddenError(ShareError):
|
||||
class SharePayloadTooLargeError(ShareError):
|
||||
status_code = 413
|
||||
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)
|
||||
|
||||
124
backend/app/services/rate_limit.py
Normal file
124
backend/app/services/rate_limit.py
Normal 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()
|
||||
@@ -2,16 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.share import SharedMap, utcnow
|
||||
from app.schemas.map import MapDocumentIn
|
||||
from app.services.errors import (
|
||||
PermanentShareNotAllowedError,
|
||||
ShareCapacityError,
|
||||
ShareExpiredError,
|
||||
ShareForbiddenError,
|
||||
ShareNotFoundError,
|
||||
SharePayloadTooLargeError,
|
||||
@@ -25,6 +30,20 @@ from app.services.tokens import (
|
||||
|
||||
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:
|
||||
"""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:
|
||||
"""Limite propre aux partages, distincte de la limite HTTP globale."""
|
||||
settings = get_settings()
|
||||
size = len(payload.encode("utf-8"))
|
||||
if size > settings.max_map_size_bytes:
|
||||
if size > settings.max_share_size_bytes:
|
||||
raise SharePayloadTooLargeError(
|
||||
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:
|
||||
stmt = select(SharedMap).where(SharedMap.share_token == share_token)
|
||||
share = session.execute(stmt).scalar_one_or_none()
|
||||
@@ -53,13 +109,29 @@ def _get_by_token(session: Session, share_token: str) -> SharedMap:
|
||||
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).
|
||||
|
||||
Le management token en clair n'est renvoye qu'ici, une seule fois.
|
||||
"""
|
||||
payload = serialize_map(document)
|
||||
_guard_payload_size(payload)
|
||||
_guard_capacity(session)
|
||||
|
||||
management_token = generate_management_token()
|
||||
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],
|
||||
map_version=document.version,
|
||||
map_data=payload,
|
||||
expires_at=resolve_expiry(expires_in_days, explicit=expiry_explicit),
|
||||
)
|
||||
session.add(share)
|
||||
session.commit()
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
@@ -86,7 +201,7 @@ def load_map_data(share: SharedMap) -> dict:
|
||||
try:
|
||||
data = json.loads(share.map_data)
|
||||
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
|
||||
if not isinstance(data, dict): # pragma: no cover - donnee corrompue
|
||||
raise ShareNotFoundError("Le contenu de ce partage est illisible.")
|
||||
@@ -103,7 +218,7 @@ def update_share(
|
||||
) -> SharedMap:
|
||||
share = _get_by_token(session, share_token)
|
||||
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()
|
||||
|
||||
payload = serialize_map(document)
|
||||
@@ -116,16 +231,16 @@ def update_share(
|
||||
session.commit()
|
||||
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
|
||||
|
||||
|
||||
def delete_share(session: Session, share_token: str, *, management_token: str) -> None:
|
||||
share = _get_by_token(session, share_token)
|
||||
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()
|
||||
|
||||
session.delete(share)
|
||||
session.commit()
|
||||
logger.info("Partage supprime share_token=%s", share_token)
|
||||
logger.info("Partage supprime share=%s", share_ref(share_token))
|
||||
|
||||
@@ -14,8 +14,10 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
# 12 octets -> 16 caracteres url-safe, ~96 bits d'entropie : non enumerable.
|
||||
SHARE_TOKEN_BYTES = 12
|
||||
# 16 octets -> 22 caracteres url-safe, 128 bits d'entropie.
|
||||
# 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.
|
||||
MANAGEMENT_TOKEN_BYTES = 32
|
||||
|
||||
|
||||
69
backend/migrations/env.py
Normal file
69
backend/migrations/env.py
Normal 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()
|
||||
25
backend/migrations/script.py.mako
Normal file
25
backend/migrations/script.py.mako
Normal 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"}
|
||||
42
backend/migrations/versions/0001_initial_schema.py
Normal file
42
backend/migrations/versions/0001_initial_schema.py
Normal 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")
|
||||
33
backend/migrations/versions/0002_share_expiry.py
Normal file
33
backend/migrations/versions/0002_share_expiry.py
Normal 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")
|
||||
@@ -1,5 +1,6 @@
|
||||
fastapi>=0.115,<0.117
|
||||
uvicorn[standard]>=0.32,<0.36
|
||||
SQLAlchemy>=2.0.36,<2.1
|
||||
alembic>=1.14,<2
|
||||
pydantic>=2.9,<3
|
||||
pydantic-settings>=2.6,<3
|
||||
|
||||
@@ -15,24 +15,60 @@ os.environ["STATIC_DIR"] = f"{_TMP_DIR}/static"
|
||||
os.environ["APP_NAME"] = "OpenMind"
|
||||
os.environ["MAX_NODES"] = "5000"
|
||||
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 sqlalchemy import text # noqa: E402
|
||||
|
||||
from app.config import get_settings # 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.main import app # noqa: E402
|
||||
from app.services.rate_limit import limiter # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
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()
|
||||
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
|
||||
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()
|
||||
def client() -> Iterator[TestClient]:
|
||||
with TestClient(app) as test_client:
|
||||
|
||||
194
backend/tests/test_cli.py
Normal file
194
backend/tests/test_cli.py
Normal 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"]
|
||||
52
backend/tests/test_client_ip.py
Normal file
52
backend/tests/test_client_ip.py
Normal 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"
|
||||
147
backend/tests/test_expiry.py
Normal file
147
backend/tests/test_expiry.py
Normal 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
|
||||
150
backend/tests/test_link_privacy.py
Normal file
150
backend/tests/test_link_privacy.py
Normal 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", "")
|
||||
128
backend/tests/test_migrations.py
Normal file
128
backend/tests/test_migrations.py
Normal 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
|
||||
157
backend/tests/test_public_exposure.py
Normal file
157
backend/tests/test_public_exposure.py
Normal 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"]
|
||||
@@ -14,6 +14,15 @@ services:
|
||||
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/openmind.db}
|
||||
MAX_MAP_SIZE_MB: ${MAX_MAP_SIZE_MB:-10}
|
||||
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:-}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
STATIC_DIR: /app/static
|
||||
|
||||
12
docker-entrypoint.sh
Normal file
12
docker-entrypoint.sh
Normal 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 "$@"
|
||||
7
frontend/public/robots.txt
Normal file
7
frontend/public/robots.txt
Normal 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: /
|
||||
@@ -4,6 +4,7 @@ import { Toasts } from '@/components/Toasts';
|
||||
import { DashboardPage } from '@/features/maps/DashboardPage';
|
||||
import { EditorPage } from '@/features/editor/EditorPage';
|
||||
import { SharedMapPage } from '@/features/sharing/SharedMapPage';
|
||||
import { PrivacyPage } from '@/features/privacy/PrivacyPage';
|
||||
import { useAppConfigStore } from '@/stores/appConfigStore';
|
||||
import { navigate, useRoute } from '@/hooks/useRoute';
|
||||
|
||||
@@ -36,6 +37,7 @@ export function App() {
|
||||
{route.name === 'dashboard' ? <DashboardPage /> : null}
|
||||
{route.name === 'editor' ? <EditorPage mapId={route.mapId} /> : null}
|
||||
{route.name === 'shared' ? <SharedMapPage token={route.token} /> : null}
|
||||
{route.name === 'privacy' ? <PrivacyPage /> : null}
|
||||
{route.name === 'notFound' ? <NotFoundPage /> : null}
|
||||
</main>
|
||||
<Toasts />
|
||||
|
||||
94
frontend/src/config/privacy.ts
Normal file
94
frontend/src/config/privacy.ts
Normal 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);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
resolveSpacing,
|
||||
} from '@/features/editor/layout';
|
||||
import { THEMES } from '@/features/editor/themes';
|
||||
import { useAppConfigStore } from '@/stores/appConfigStore';
|
||||
import { selectCanRedo, selectCanUndo, useEditorStore } from '@/stores/editorStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { navigate } from '@/hooks/useRoute';
|
||||
@@ -73,6 +74,9 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
|
||||
const collapseToLevel = useEditorStore((state) => state.collapseToLevel);
|
||||
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 toggleColorMode = useUiStore((state) => state.toggleColorMode);
|
||||
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" />
|
||||
|
||||
<Button size="sm" onClick={onOpenShare}>
|
||||
<ShareIcon className="h-4 w-4" />
|
||||
<span className="hidden md:inline">Partager</span>
|
||||
</Button>
|
||||
{/*
|
||||
Instance privee (le serveur repond que le partage est coupe) : le
|
||||
bouton disparait. Serveur injoignable : il reste visible mais inactif,
|
||||
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 />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
import { MapFormatError, readMapFile, serializeMap } from '@/services/mapSerialization';
|
||||
import { useAppConfigStore } from '@/stores/appConfigStore';
|
||||
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';
|
||||
|
||||
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">
|
||||
{APP_CONFIG.tagline}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-zinc-400">{LOCAL_FIRST_NOTE}</p>
|
||||
</div>
|
||||
<IconButton
|
||||
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">
|
||||
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{' '}
|
||||
{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>
|
||||
|
||||
<TemplateGallery
|
||||
|
||||
58
frontend/src/features/privacy/PrivacyPage.tsx
Normal file
58
frontend/src/features/privacy/PrivacyPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/Button';
|
||||
import { Modal } from '@/components/Modal';
|
||||
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 { ShareApiError, createShare, deleteShare, updateShare } from '@/services/shareApi';
|
||||
import {
|
||||
deleteShareRecord,
|
||||
getShareRecord,
|
||||
saveShareRecord,
|
||||
} from '@/services/shareRepository';
|
||||
ShareApiError,
|
||||
createShare,
|
||||
deleteShare,
|
||||
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 { useUiStore } from '@/stores/uiStore';
|
||||
import { buildShareUrl } from '@/hooks/useRoute';
|
||||
import { buildShareUrl, navigate, PRIVACY_PATH } from '@/hooks/useRoute';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import { downloadBlob, toSafeFilename } from '@/utils/download';
|
||||
|
||||
export interface ShareDialogProps {
|
||||
open: boolean;
|
||||
@@ -21,15 +27,63 @@ export interface ShareDialogProps {
|
||||
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) {
|
||||
const map = useEditorStore((state) => state.map);
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [copied, setCopied] = 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) => {
|
||||
setLoading(true);
|
||||
@@ -43,16 +97,32 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
|
||||
void refresh(map.id);
|
||||
}, [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) => {
|
||||
pushToast(error instanceof ShareApiError ? error.message : fallback, 'error');
|
||||
};
|
||||
|
||||
const persist = async (next: ShareRecord) => {
|
||||
await saveShareRecord(next);
|
||||
setRecord(next);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!map || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await createShare(map, map.name);
|
||||
const created: ShareRecord = {
|
||||
const response = await createShare(map, map.name, choice);
|
||||
await persist({
|
||||
mapId: map.id,
|
||||
shareToken: response.shareToken,
|
||||
managementToken: response.managementToken,
|
||||
@@ -60,9 +130,8 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
|
||||
title: response.title,
|
||||
createdAt: response.createdAt,
|
||||
updatedAt: response.updatedAt,
|
||||
};
|
||||
await saveShareRecord(created);
|
||||
setRecord(created);
|
||||
expiresAt: response.expiresAt,
|
||||
});
|
||||
onShareStateChange?.(true);
|
||||
pushToast('Lien de partage cree.', 'success');
|
||||
} catch (error) {
|
||||
@@ -76,15 +145,13 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
|
||||
if (!map || !record || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await updateShare(
|
||||
record.shareToken,
|
||||
record.managementToken,
|
||||
map,
|
||||
map.name,
|
||||
);
|
||||
const updated = { ...record, title: map.name, updatedAt: response.updatedAt };
|
||||
await saveShareRecord(updated);
|
||||
setRecord(updated);
|
||||
const response = await updateShare(record.shareToken, record.managementToken, map, map.name);
|
||||
await persist({
|
||||
...record,
|
||||
title: map.name,
|
||||
updatedAt: response.updatedAt,
|
||||
expiresAt: response.expiresAt,
|
||||
});
|
||||
pushToast('Le lien public affiche maintenant la version actuelle.', 'success');
|
||||
} catch (error) {
|
||||
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 () => {
|
||||
if (!map || !record || busy) return;
|
||||
setConfirmDelete(false);
|
||||
@@ -116,9 +201,8 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!record) return;
|
||||
const url = buildShareUrl(record.shareToken);
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
await navigator.clipboard.writeText(buildShareUrl(record.shareToken));
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} 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 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 (
|
||||
<>
|
||||
<Modal
|
||||
@@ -135,13 +266,11 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
|
||||
title={record ? 'Carte partagee' : 'Partager cette carte'}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
record ? (
|
||||
!sharingEnabled ? (
|
||||
<Button onClick={onClose}>Fermer</Button>
|
||||
) : record ? (
|
||||
<>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={busy}
|
||||
>
|
||||
<Button variant="danger" onClick={() => setConfirmDelete(true)} disabled={busy}>
|
||||
Supprimer le partage
|
||||
</Button>
|
||||
<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>
|
||||
) : record ? (
|
||||
<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>
|
||||
<span className="field-label">Lien public (lecture seule)</span>
|
||||
<span className="field-label">Lien public</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
readOnly
|
||||
@@ -183,6 +323,30 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
|
||||
</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">
|
||||
<div className="flex justify-between gap-4 py-0.5">
|
||||
<dt className="text-zinc-500">Publie le</dt>
|
||||
@@ -198,29 +362,93 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
|
||||
</div>
|
||||
</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'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">
|
||||
Le lien est <strong>non repertorie</strong> : il n'apparait dans aucune liste
|
||||
publique, mais toute personne qui le connait peut consulter la carte. Les
|
||||
<strong>Toute personne disposant de ce lien peut consulter cette carte.</strong> Les
|
||||
modifications faites ici ne sont visibles publiquement qu'apres un
|
||||
<em> Mettre a jour le partage</em>.
|
||||
</p>
|
||||
</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>
|
||||
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
|
||||
de donnees.
|
||||
</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">
|
||||
<li>Le lien obtenu est non repertorie et en lecture seule.</li>
|
||||
<li>Aucun compte n'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>
|
||||
Le secret permettant de le modifier reste dans ce navigateur : depuis un autre
|
||||
appareil, vous ne pourrez pas gerer ce partage.
|
||||
</li>
|
||||
</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'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>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -18,8 +18,8 @@ import { MapFormatError } from '@/services/mapSerialization';
|
||||
import { useAppConfigStore } from '@/stores/appConfigStore';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { navigate } from '@/hooks/useRoute';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import { navigate, PRIVACY_PATH } from '@/hooks/useRoute';
|
||||
import { formatDate, formatDateTime } from '@/utils/date';
|
||||
|
||||
export interface SharedMapPageProps {
|
||||
token: string;
|
||||
@@ -27,8 +27,25 @@ export interface SharedMapPageProps {
|
||||
|
||||
type Status =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'ready'; title: string; updatedAt: string }
|
||||
| { kind: 'error'; message: string };
|
||||
| { kind: 'ready'; title: string; updatedAt: string; expiresAt: string | null }
|
||||
| { 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) {
|
||||
const appName = useAppConfigStore((state) => state.name);
|
||||
@@ -40,6 +57,7 @@ function SharedMapContent({ token }: SharedMapPageProps) {
|
||||
|
||||
const { zoomIn, zoomOut } = useReactFlow();
|
||||
const [status, setStatus] = useState<Status>({ kind: 'loading' });
|
||||
useNoIndex();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -49,7 +67,12 @@ function SharedMapContent({ token }: SharedMapPageProps) {
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
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}`;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -58,7 +81,8 @@ function SharedMapContent({ token }: SharedMapPageProps) {
|
||||
error instanceof ShareApiError || error instanceof MapFormatError
|
||||
? error.message
|
||||
: 'Cette carte partagee est introuvable.';
|
||||
setStatus({ kind: 'error', message });
|
||||
const expired = error instanceof ShareApiError && error.status === 410;
|
||||
setStatus({ kind: 'error', message, expired });
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -82,7 +106,9 @@ function SharedMapContent({ token }: SharedMapPageProps) {
|
||||
<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>
|
||||
<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'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>
|
||||
<Button variant="primary" onClick={() => navigate('/')}>
|
||||
Ouvrir {appName}
|
||||
@@ -100,7 +126,8 @@ function SharedMapContent({ token }: SharedMapPageProps) {
|
||||
{status.title}
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -122,8 +149,11 @@ function SharedMapContent({ token }: SharedMapPageProps) {
|
||||
<MindMapCanvas readOnly showMiniMap={false} />
|
||||
</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">
|
||||
Carte partagee via {appName} - aucune inscription necessaire
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
30
frontend/src/hooks/useRoute.test.ts
Normal file
30
frontend/src/hooks/useRoute.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
@@ -5,14 +5,18 @@ export type Route =
|
||||
| { name: 'dashboard' }
|
||||
| { name: 'editor'; mapId: string }
|
||||
| { name: 'shared'; token: string }
|
||||
| { name: 'privacy' }
|
||||
| { name: 'notFound' };
|
||||
|
||||
const NAVIGATION_EVENT = 'openmind:navigate';
|
||||
|
||||
export const PRIVACY_PATH = '/privacy';
|
||||
|
||||
export function parseRoute(pathname: string): Route {
|
||||
const clean = pathname.replace(/\/+$/, '') || '/';
|
||||
|
||||
if (clean === '/' || clean === '') return { name: 'dashboard' };
|
||||
if (clean === PRIVACY_PATH) return { name: 'privacy' };
|
||||
|
||||
const editorMatch = clean.match(/^\/map\/([^/]+)$/);
|
||||
if (editorMatch?.[1]) return { name: 'editor', mapId: decodeURIComponent(editorMatch[1]) };
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface ShareCreatedResponse {
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface SharedMapResponse {
|
||||
@@ -27,6 +28,29 @@ export interface SharedMapResponse {
|
||||
map: MindMap;
|
||||
createdAt: 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';
|
||||
@@ -34,8 +58,15 @@ const MANAGEMENT_TOKEN_HEADER = 'X-Management-Token';
|
||||
function messageForStatus(status: number, serverDetail?: string): string {
|
||||
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 === 410) return serverDetail || 'Ce partage a expire.';
|
||||
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 === 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.';
|
||||
return serverDetail || 'Le partage a echoue. Reessayez dans un instant.';
|
||||
}
|
||||
@@ -88,6 +119,7 @@ interface RawShareCreated {
|
||||
title: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
interface RawSharePublic {
|
||||
@@ -97,13 +129,36 @@ interface RawSharePublic {
|
||||
map: unknown;
|
||||
created_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. */
|
||||
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', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, map }),
|
||||
body: JSON.stringify({ title, map, ...expiryBody(expiry) }),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -113,6 +168,7 @@ export async function createShare(map: MindMap, title: string): Promise<ShareCre
|
||||
title: raw.title,
|
||||
createdAt: raw.created_at,
|
||||
updatedAt: raw.updated_at,
|
||||
expiresAt: raw.expires_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,8 +177,8 @@ export async function updateShare(
|
||||
managementToken: string,
|
||||
map: MindMap,
|
||||
title: string,
|
||||
): Promise<{ updatedAt: string }> {
|
||||
const raw = await request<{ updated_at: string }>(
|
||||
): Promise<{ updatedAt: string; expiresAt: string | null }> {
|
||||
const raw = await request<{ updated_at: string; expires_at: string | null }>(
|
||||
`/api/shares/${encodeURIComponent(shareToken)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
@@ -130,7 +186,41 @@ export async function updateShare(
|
||||
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> {
|
||||
@@ -149,6 +239,7 @@ export async function fetchSharedMap(shareToken: string): Promise<SharedMapRespo
|
||||
map: normalizeMapDocument(raw.map, { source: 'share' }),
|
||||
createdAt: raw.created_at,
|
||||
updatedAt: raw.updated_at,
|
||||
expiresAt: raw.expires_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -156,7 +247,12 @@ export interface PublicAppConfig {
|
||||
appName: string;
|
||||
maxNodes: number;
|
||||
maxMapSizeMb: number;
|
||||
maxShareSizeMb: number;
|
||||
sharingEnabled: boolean;
|
||||
allowPermanentShares: boolean;
|
||||
defaultShareExpiryDays: number;
|
||||
/** Durees proposees dans l'interface ; `null` signifie "jamais". */
|
||||
shareExpiryOptions: Array<number | null>;
|
||||
}
|
||||
|
||||
export async function fetchAppConfig(): Promise<PublicAppConfig> {
|
||||
|
||||
101
frontend/src/services/shareKey.test.ts
Normal file
101
frontend/src/services/shareKey.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
108
frontend/src/services/shareKey.ts
Normal file
108
frontend/src/services/shareKey.ts
Normal 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);
|
||||
}
|
||||
@@ -6,11 +6,23 @@ interface AppConfigState {
|
||||
name: string;
|
||||
maxNodes: 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. */
|
||||
serverReachable: boolean;
|
||||
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
|
||||
* 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,
|
||||
maxNodes: APP_CONFIG.maxNodes,
|
||||
maxMapSizeMb: APP_CONFIG.maxMapSizeMb,
|
||||
maxShareSizeMb: APP_CONFIG.maxMapSizeMb,
|
||||
sharingEnabled: false,
|
||||
allowPermanentShares: true,
|
||||
defaultShareExpiryDays: 30,
|
||||
shareExpiryOptions: DEFAULT_EXPIRY_OPTIONS,
|
||||
serverReachable: false,
|
||||
|
||||
load: async () => {
|
||||
@@ -28,6 +45,14 @@ export const useAppConfigStore = create<AppConfigState>((set) => ({
|
||||
name: config.appName || APP_CONFIG.name,
|
||||
maxNodes: config.maxNodes || APP_CONFIG.maxNodes,
|
||||
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,
|
||||
});
|
||||
if (typeof document !== 'undefined') {
|
||||
@@ -35,7 +60,8 @@ export const useAppConfigStore = create<AppConfigState>((set) => ({
|
||||
}
|
||||
} catch {
|
||||
// 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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -288,4 +288,6 @@ export interface ShareRecord {
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Date d'expiration ISO, ou `null` pour un partage sans expiration. */
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user