Files
openmind/backend/app/cli.py
snoopy 49870dcd56 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>
2026-09-10 09:22:17 +02:00

335 lines
12 KiB
Python

"""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 <share-token>
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())