Phase 2.1 : preparation au deploiement public

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

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

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

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

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

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

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

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

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

View File

@@ -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"))]

View File

@@ -1,19 +1,48 @@
"""Endpoints de sante et de configuration publique."""
"""Endpoints de sante et de configuration publique.
Aucune limitation de debit n'est posee ici : le healthcheck du conteneur doit
rester fiable meme quand une instance subit un afflux de requetes.
"""
from __future__ import annotations
from 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,
}

View File

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