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>
189 lines
5.1 KiB
Python
189 lines
5.1 KiB
Python
"""API REST de partage de cartes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Response, status
|
|
|
|
from app.api.deps import (
|
|
DbSession,
|
|
ManagementToken,
|
|
RateLimitCreate,
|
|
RateLimitRead,
|
|
RateLimitWrite,
|
|
SharingEnabled,
|
|
)
|
|
from app.schemas.share import (
|
|
ShareCreatedOut,
|
|
ShareCreateIn,
|
|
ShareExpiryIn,
|
|
ShareOwnerOut,
|
|
SharePublicOut,
|
|
ShareUpdatedOut,
|
|
)
|
|
from app.services import shares as share_service
|
|
|
|
router = APIRouter(prefix="/shares", tags=["shares"])
|
|
|
|
|
|
def _share_path(share_token: str) -> str:
|
|
return f"/s/{share_token}"
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
response_model=ShareCreatedOut,
|
|
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,
|
|
_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,
|
|
expires_in_days=None if payload.permanent else payload.expires_in_days,
|
|
expiry_explicit=explicit,
|
|
)
|
|
return ShareCreatedOut(
|
|
share_token=share.share_token,
|
|
management_token=management_token,
|
|
share_path=_share_path(share.share_token),
|
|
title=share.title,
|
|
created_at=share.created_at,
|
|
updated_at=share.updated_at,
|
|
expires_at=share.expires_at,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/{share_token}",
|
|
response_model=SharePublicOut,
|
|
summary="Lire une carte partagee (public, lecture seule)",
|
|
)
|
|
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,
|
|
title=share.title,
|
|
map_version=share.map_version,
|
|
map=share_service.load_map_data(share),
|
|
created_at=share.created_at,
|
|
updated_at=share.updated_at,
|
|
expires_at=share.expires_at,
|
|
)
|
|
|
|
|
|
@router.put(
|
|
"/{share_token}",
|
|
response_model=ShareUpdatedOut,
|
|
summary="Mettre a jour le snapshot partage (necessite le jeton de gestion)",
|
|
)
|
|
def update_share(
|
|
share_token: str,
|
|
payload: ShareCreateIn,
|
|
session: DbSession,
|
|
management_token: ManagementToken,
|
|
_sharing: SharingEnabled,
|
|
_rate: RateLimitWrite,
|
|
) -> ShareUpdatedOut:
|
|
share = share_service.update_share(
|
|
session,
|
|
share_token,
|
|
management_token=management_token,
|
|
title=payload.title,
|
|
document=payload.map,
|
|
)
|
|
return ShareUpdatedOut(
|
|
share_token=share.share_token,
|
|
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,
|
|
)
|
|
|
|
|
|
@router.delete(
|
|
"/{share_token}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
response_class=Response,
|
|
summary="Supprimer le partage (necessite le jeton de gestion)",
|
|
)
|
|
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)
|