diff --git a/.env.example b/.env.example index fdba4e7..af8a75d 100644 --- a/.env.example +++ b/.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 / (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= diff --git a/Dockerfile b/Dockerfile index b51af1e..5f86cb7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index d9cccde..c12e899 100644 --- a/README.md +++ b/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 : `/`, 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/` | **lecture seule** | +| `share_token` | `secrets.token_urlsafe(16)`, 128 bits | dans l'URL publique `/s/` | **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 + `` 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/` 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 --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 ` | 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 ` | Sauvegarde a chaud via l'API SQLite. `--yes` ecrase un fichier existant | +| `restore ` | 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/`), 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 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..8e0d843 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 3d2f4b2..235f2d8 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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"))] diff --git a/backend/app/api/health.py b/backend/app/api/health.py index 84ecfe7..17f818b 100644 --- a/backend/app/api/health.py +++ b/backend/app/api/health.py @@ -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, } diff --git a/backend/app/api/shares.py b/backend/app/api/shares.py index 5698bba..6cfdea4 100644 --- a/backend/app/api/shares.py +++ b/backend/app/api/shares.py @@ -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) diff --git a/backend/app/cli.py b/backend/app/cli.py new file mode 100644 index 0000000..a9014e6 --- /dev/null +++ b/backend/app/cli.py @@ -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 + 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()) diff --git a/backend/app/config.py b/backend/app/config.py index 85ea49d..9e12f0a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 "/" : 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()] diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index 9368722..89ee083 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -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) diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py new file mode 100644 index 0000000..ba43982 --- /dev/null +++ b/backend/app/db/migrations.py @@ -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() diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 0323b13..7357c19 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -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 diff --git a/backend/app/logging_config.py b/backend/app/logging_config.py index 93a42e9..c5f63a7 100644 --- a/backend/app/logging_config.py +++ b/backend/app/logging_config.py @@ -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/(?:s|api/shares)/)(?P[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]: diff --git a/backend/app/main.py b/backend/app/main.py index cfb7777..7c6939b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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: diff --git a/backend/app/middleware.py b/backend/app/middleware.py index f45d82d..6ed9056 100644 --- a/backend/app/middleware.py +++ b/backend/app/middleware.py @@ -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/ 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 diff --git a/backend/app/models/share.py b/backend/app/models/share.py index 8b71e41..3d0febd 100644 --- a/backend/app/models/share.py +++ b/backend/app/models/share.py @@ -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"" diff --git a/backend/app/schemas/share.py b/backend/app/schemas/share.py index 1aabdbb..851440a 100644 --- a/backend/app/schemas/share.py +++ b/backend/app/schemas/share.py @@ -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 diff --git a/backend/app/services/client_ip.py b/backend/app/services/client_ip.py new file mode 100644 index 0000000..1a5a94b --- /dev/null +++ b/backend/app/services/client_ip.py @@ -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 diff --git a/backend/app/services/errors.py b/backend/app/services/errors.py index 8c15338..1847c60 100644 --- a/backend/app/services/errors.py +++ b/backend/app/services/errors.py @@ -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) diff --git a/backend/app/services/rate_limit.py b/backend/app/services/rate_limit.py new file mode 100644 index 0000000..c8fd1c9 --- /dev/null +++ b/backend/app/services/rate_limit.py @@ -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() diff --git a/backend/app/services/shares.py b/backend/app/services/shares.py index 7124acd..bf762ca 100644 --- a/backend/app/services/shares.py +++ b/backend/app/services/shares.py @@ -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)) diff --git a/backend/app/services/tokens.py b/backend/app/services/tokens.py index 83bbb33..7d284a5 100644 --- a/backend/app/services/tokens.py +++ b/backend/app/services/tokens.py @@ -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 diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..714c099 --- /dev/null +++ b/backend/migrations/env.py @@ -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() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..85855bd --- /dev/null +++ b/backend/migrations/script.py.mako @@ -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"} diff --git a/backend/migrations/versions/0001_initial_schema.py b/backend/migrations/versions/0001_initial_schema.py new file mode 100644 index 0000000..bc79316 --- /dev/null +++ b/backend/migrations/versions/0001_initial_schema.py @@ -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") diff --git a/backend/migrations/versions/0002_share_expiry.py b/backend/migrations/versions/0002_share_expiry.py new file mode 100644 index 0000000..dd1ada1 --- /dev/null +++ b/backend/migrations/versions/0002_share_expiry.py @@ -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") diff --git a/backend/requirements.txt b/backend/requirements.txt index fc7146a..900aaaf 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index e495d56..0a9ee50 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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: diff --git a/backend/tests/test_cli.py b/backend/tests/test_cli.py new file mode 100644 index 0000000..f96af9a --- /dev/null +++ b/backend/tests/test_cli.py @@ -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"] diff --git a/backend/tests/test_client_ip.py b/backend/tests/test_client_ip.py new file mode 100644 index 0000000..d4613ca --- /dev/null +++ b/backend/tests/test_client_ip.py @@ -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" diff --git a/backend/tests/test_expiry.py b/backend/tests/test_expiry.py new file mode 100644 index 0000000..6e4e671 --- /dev/null +++ b/backend/tests/test_expiry.py @@ -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 diff --git a/backend/tests/test_link_privacy.py b/backend/tests/test_link_privacy.py new file mode 100644 index 0000000..ecedcee --- /dev/null +++ b/backend/tests/test_link_privacy.py @@ -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", "") diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py new file mode 100644 index 0000000..5fd1418 --- /dev/null +++ b/backend/tests/test_migrations.py @@ -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 diff --git a/backend/tests/test_public_exposure.py b/backend/tests/test_public_exposure.py new file mode 100644 index 0000000..30d4bee --- /dev/null +++ b/backend/tests/test_public_exposure.py @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml index c5b5364..14759ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..77b86e6 --- /dev/null +++ b/docker-entrypoint.sh @@ -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 "$@" diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..537dd9e --- /dev/null +++ b/frontend/public/robots.txt @@ -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: / diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 676c7a1..fff68ba 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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' ? : null} {route.name === 'editor' ? : null} {route.name === 'shared' ? : null} + {route.name === 'privacy' ? : null} {route.name === 'notFound' ? : null} diff --git a/frontend/src/config/privacy.ts b/frontend/src/config/privacy.ts new file mode 100644 index 0000000..43a590f --- /dev/null +++ b/frontend/src/config/privacy.ts @@ -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); +} diff --git a/frontend/src/features/editor/EditorToolbar.tsx b/frontend/src/features/editor/EditorToolbar.tsx index c5f4ae3..93cdd33 100644 --- a/frontend/src/features/editor/EditorToolbar.tsx +++ b/frontend/src/features/editor/EditorToolbar.tsx @@ -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
- + {/* + 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 ? ( + + ) : null}
diff --git a/frontend/src/features/maps/DashboardPage.tsx b/frontend/src/features/maps/DashboardPage.tsx index 8d2162d..db21f1d 100644 --- a/frontend/src/features/maps/DashboardPage.tsx +++ b/frontend/src/features/maps/DashboardPage.tsx @@ -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() {

{APP_CONFIG.tagline}

+

{LOCAL_FIRST_NOTE}

Vos cartes sont enregistrees dans ce navigateur (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.{' '} + state.name); + + return ( +
+
+ +

+ Confidentialite +

+

+ {applyAppName(PRIVACY_INTRO, appName)} +

+
+ +
+ {PRIVACY_SECTIONS.map((section) => ( +
+

+ {section.title} +

+ {section.paragraphs.map((paragraph) => ( +

+ {applyAppName(paragraph, appName)} +

+ ))} + {section.bullets ? ( +
    + {section.bullets.map((bullet) => ( +
  • {applyAppName(bullet, appName)}
  • + ))} +
+ ) : null} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/features/sharing/ShareDialog.tsx b/frontend/src/features/sharing/ShareDialog.tsx index 2eede24..fa95dbe 100644 --- a/frontend/src/features/sharing/ShareDialog.tsx +++ b/frontend/src/features/sharing/ShareDialog.tsx @@ -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(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(null); + const keyInputRef = useRef(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 = ( +
+

Le partage est desactive sur cette instance.

+

+ Vos cartes continuent de fonctionner normalement : elles restent enregistrees dans ce + navigateur, et vous pouvez toujours les exporter en fichier. +

+
+ ); + return ( <> Fermer + ) : record ? ( <> - +

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

+ +

- Le lien est non repertorie : il n'apparait dans aucune liste - publique, mais toute personne qui le connait peut consulter la carte. Les + Toute personne disposant de ce lien peut consulter cette carte. Les modifications faites ici ne sont visibles publiquement qu'apres un Mettre a jour le partage.

) : ( -
+

Cette carte est actuellement uniquement dans votre navigateur. Pour generer un lien, une copie de la carte sera envoyee au serveur et stockee dans sa base de donnees.

+ +
+ + +
+
  • Le lien obtenu est non repertorie et en lecture seule.
  • -
  • Aucun compte n'est necessaire pour le consulter.
  • +
  • Toute personne disposant de ce lien peut consulter cette carte.
  • +
  • Les cartes non partagees restent uniquement dans votre navigateur.
  • Vous pourrez mettre a jour ou supprimer ce partage a tout moment.
  • -
  • - Le secret permettant de le modifier reste dans ce navigateur : depuis un autre - appareil, vous ne pourrez pas gerer ce partage. -
+ +
+ + { + const file = event.target.files?.[0]; + event.target.value = ''; + if (file) void handleImportKey(file); + }} + /> +

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

+
+ +

+ +

)} diff --git a/frontend/src/features/sharing/SharedMapPage.tsx b/frontend/src/features/sharing/SharedMapPage.tsx index b3adfe8..01f1b2c 100644 --- a/frontend/src/features/sharing/SharedMapPage.tsx +++ b/frontend/src/features/sharing/SharedMapPage.tsx @@ -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({ 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) {

{status.message}

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

@@ -122,8 +149,11 @@ function SharedMapContent({ token }: SharedMapPageProps) { -