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