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:
@@ -4,12 +4,14 @@ from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header
|
||||
from fastapi import Depends, Header, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db.session import get_db
|
||||
from app.services.errors import ShareForbiddenError
|
||||
from app.services.client_ip import resolve_client_ip
|
||||
from app.services.errors import RateLimitedError, ShareForbiddenError, SharingDisabledError
|
||||
from app.services.rate_limit import limiter, parse_rate
|
||||
|
||||
MANAGEMENT_TOKEN_HEADER = "X-Management-Token"
|
||||
|
||||
@@ -27,3 +29,41 @@ def require_management_token(
|
||||
|
||||
|
||||
ManagementToken = Annotated[str, Depends(require_management_token)]
|
||||
|
||||
|
||||
def require_sharing_enabled(settings: AppSettings) -> None:
|
||||
"""Coupe toute l'API de partage quand l'instance est privee.
|
||||
|
||||
Les cartes locales continuent de fonctionner : seul le service serveur est
|
||||
indisponible, et il le dit clairement.
|
||||
"""
|
||||
if not settings.sharing_enabled:
|
||||
raise SharingDisabledError()
|
||||
|
||||
|
||||
SharingEnabled = Annotated[None, Depends(require_sharing_enabled)]
|
||||
|
||||
|
||||
def _rate_limit(bucket: str, spec_name: str): # type: ignore[no-untyped-def]
|
||||
"""Fabrique une dependance de limitation pour une famille d'endpoints.
|
||||
|
||||
La limitation est posee sur les routes concernees, jamais en middleware
|
||||
global : /api/health et /api/ready ne peuvent donc pas etre bloques, ce qui
|
||||
garantit que le healthcheck du conteneur reste fiable sous charge.
|
||||
"""
|
||||
|
||||
def dependency(request: Request, settings: AppSettings) -> None:
|
||||
rule = parse_rate(getattr(settings, spec_name))
|
||||
if rule is None:
|
||||
return
|
||||
client = resolve_client_ip(request, trust_proxy_headers=settings.trust_proxy_headers)
|
||||
retry_after = limiter.check(f"{bucket}:{client}", rule)
|
||||
if retry_after is not None:
|
||||
raise RateLimitedError(retry_after)
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
RateLimitCreate = Annotated[None, Depends(_rate_limit("share:create", "share_create_rate_limit"))]
|
||||
RateLimitRead = Annotated[None, Depends(_rate_limit("share:read", "share_read_rate_limit"))]
|
||||
RateLimitWrite = Annotated[None, Depends(_rate_limit("share:write", "share_write_rate_limit"))]
|
||||
|
||||
@@ -1,19 +1,48 @@
|
||||
"""Endpoints de sante et de configuration publique."""
|
||||
"""Endpoints de sante et de configuration publique.
|
||||
|
||||
Aucune limitation de debit n'est posee ici : le healthcheck du conteneur doit
|
||||
rester fiable meme quand une instance subit un afflux de requetes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
import logging
|
||||
|
||||
from app.api.deps import AppSettings
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.api.deps import AppSettings, DbSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["system"])
|
||||
|
||||
# Durees proposees dans l'interface. `None` = permanent.
|
||||
SHARE_EXPIRY_OPTIONS: list[int | None] = [1, 7, 30, 90, None]
|
||||
|
||||
|
||||
@router.get("/health", summary="Etat du service")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/ready", summary="Le service peut-il repondre : base de donnees joignable")
|
||||
def ready(session: DbSession): # type: ignore[no-untyped-def]
|
||||
"""Complete /health : verifie que SQLite repond reellement.
|
||||
|
||||
Utile derriere un orchestrateur qui doit distinguer "processus vivant" de
|
||||
"capable de servir", par exemple quand le volume de donnees est absent ou
|
||||
en lecture seule.
|
||||
"""
|
||||
try:
|
||||
session.execute(text("SELECT 1"))
|
||||
except Exception:
|
||||
logger.exception("Base de donnees injoignable")
|
||||
return JSONResponse(status_code=503, content={"status": "unavailable"})
|
||||
return {"status": "ready"}
|
||||
|
||||
|
||||
@router.get("/config", summary="Configuration publique exposee au frontend")
|
||||
def public_config(settings: AppSettings) -> dict[str, object]:
|
||||
"""Aucun secret ici : uniquement ce dont l'interface a besoin."""
|
||||
@@ -21,5 +50,9 @@ def public_config(settings: AppSettings) -> dict[str, object]:
|
||||
"appName": settings.app_name,
|
||||
"maxNodes": settings.max_nodes,
|
||||
"maxMapSizeMb": settings.max_map_size_mb,
|
||||
"sharingEnabled": True,
|
||||
"maxShareSizeMb": settings.max_share_size_mb_effective,
|
||||
"sharingEnabled": settings.sharing_enabled,
|
||||
"allowPermanentShares": settings.allow_permanent_shares,
|
||||
"defaultShareExpiryDays": settings.default_share_expiry_days,
|
||||
"shareExpiryOptions": SHARE_EXPIRY_OPTIONS,
|
||||
}
|
||||
|
||||
@@ -4,10 +4,19 @@ from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Response, status
|
||||
|
||||
from app.api.deps import DbSession, ManagementToken
|
||||
from app.api.deps import (
|
||||
DbSession,
|
||||
ManagementToken,
|
||||
RateLimitCreate,
|
||||
RateLimitRead,
|
||||
RateLimitWrite,
|
||||
SharingEnabled,
|
||||
)
|
||||
from app.schemas.share import (
|
||||
ShareCreatedOut,
|
||||
ShareCreateIn,
|
||||
ShareExpiryIn,
|
||||
ShareOwnerOut,
|
||||
SharePublicOut,
|
||||
ShareUpdatedOut,
|
||||
)
|
||||
@@ -26,9 +35,21 @@ def _share_path(share_token: str) -> str:
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Publier un snapshot de carte et obtenir un lien non repertorie",
|
||||
)
|
||||
def create_share(payload: ShareCreateIn, session: DbSession) -> ShareCreatedOut:
|
||||
def create_share(
|
||||
payload: ShareCreateIn,
|
||||
session: DbSession,
|
||||
_sharing: SharingEnabled,
|
||||
_rate: RateLimitCreate,
|
||||
) -> ShareCreatedOut:
|
||||
# `permanent` distingue "aucune preference" (defaut de l'instance) de
|
||||
# "explicitement sans expiration", que le JSON seul ne permet pas de separer.
|
||||
explicit = payload.permanent or payload.expires_in_days is not None
|
||||
share, management_token = share_service.create_share(
|
||||
session, title=payload.title, document=payload.map
|
||||
session,
|
||||
title=payload.title,
|
||||
document=payload.map,
|
||||
expires_in_days=None if payload.permanent else payload.expires_in_days,
|
||||
expiry_explicit=explicit,
|
||||
)
|
||||
return ShareCreatedOut(
|
||||
share_token=share.share_token,
|
||||
@@ -37,6 +58,7 @@ def create_share(payload: ShareCreateIn, session: DbSession) -> ShareCreatedOut:
|
||||
title=share.title,
|
||||
created_at=share.created_at,
|
||||
updated_at=share.updated_at,
|
||||
expires_at=share.expires_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -45,7 +67,12 @@ def create_share(payload: ShareCreateIn, session: DbSession) -> ShareCreatedOut:
|
||||
response_model=SharePublicOut,
|
||||
summary="Lire une carte partagee (public, lecture seule)",
|
||||
)
|
||||
def read_share(share_token: str, session: DbSession) -> SharePublicOut:
|
||||
def read_share(
|
||||
share_token: str,
|
||||
session: DbSession,
|
||||
_sharing: SharingEnabled,
|
||||
_rate: RateLimitRead,
|
||||
) -> SharePublicOut:
|
||||
share = share_service.get_share(session, share_token)
|
||||
return SharePublicOut(
|
||||
share_token=share.share_token,
|
||||
@@ -54,6 +81,7 @@ def read_share(share_token: str, session: DbSession) -> SharePublicOut:
|
||||
map=share_service.load_map_data(share),
|
||||
created_at=share.created_at,
|
||||
updated_at=share.updated_at,
|
||||
expires_at=share.expires_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -67,6 +95,8 @@ def update_share(
|
||||
payload: ShareCreateIn,
|
||||
session: DbSession,
|
||||
management_token: ManagementToken,
|
||||
_sharing: SharingEnabled,
|
||||
_rate: RateLimitWrite,
|
||||
) -> ShareUpdatedOut:
|
||||
share = share_service.update_share(
|
||||
session,
|
||||
@@ -80,6 +110,64 @@ def update_share(
|
||||
share_path=_share_path(share.share_token),
|
||||
title=share.title,
|
||||
updated_at=share.updated_at,
|
||||
expires_at=share.expires_at,
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{share_token}",
|
||||
response_model=ShareOwnerOut,
|
||||
summary="Modifier l'expiration du partage (necessite le jeton de gestion)",
|
||||
)
|
||||
def update_expiry(
|
||||
share_token: str,
|
||||
payload: ShareExpiryIn,
|
||||
session: DbSession,
|
||||
management_token: ManagementToken,
|
||||
_sharing: SharingEnabled,
|
||||
_rate: RateLimitWrite,
|
||||
) -> ShareOwnerOut:
|
||||
share = share_service.set_expiry(
|
||||
session,
|
||||
share_token,
|
||||
management_token=management_token,
|
||||
expires_in_days=None if payload.permanent else payload.expires_in_days,
|
||||
)
|
||||
return ShareOwnerOut(
|
||||
share_token=share.share_token,
|
||||
title=share.title,
|
||||
created_at=share.created_at,
|
||||
updated_at=share.updated_at,
|
||||
expires_at=share.expires_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{share_token}/verify",
|
||||
response_model=ShareOwnerOut,
|
||||
summary="Verifier qu'une cle de gestion correspond a ce partage",
|
||||
)
|
||||
def verify_share(
|
||||
share_token: str,
|
||||
session: DbSession,
|
||||
management_token: ManagementToken,
|
||||
_sharing: SharingEnabled,
|
||||
_rate: RateLimitWrite,
|
||||
) -> ShareOwnerOut:
|
||||
"""Permet de reimporter une cle de gestion depuis un autre navigateur.
|
||||
|
||||
Ne renvoie aucune donnee de carte : seulement de quoi reconstituer la fiche
|
||||
de partage locale.
|
||||
"""
|
||||
share = share_service.verify_management_token(
|
||||
session, share_token, management_token=management_token
|
||||
)
|
||||
return ShareOwnerOut(
|
||||
share_token=share.share_token,
|
||||
title=share.title,
|
||||
created_at=share.created_at,
|
||||
updated_at=share.updated_at,
|
||||
expires_at=share.expires_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -93,6 +181,8 @@ def delete_share(
|
||||
share_token: str,
|
||||
session: DbSession,
|
||||
management_token: ManagementToken,
|
||||
_sharing: SharingEnabled,
|
||||
_rate: RateLimitWrite,
|
||||
) -> Response:
|
||||
share_service.delete_share(session, share_token, management_token=management_token)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
Reference in New Issue
Block a user