Files
openmind/backend/app/cli.py
snoopy 9ede2c5e68 Phase 2.1.1 : durcissement avant exposition publique
Fail-fast sur la configuration. Une limite de debit illisible (10/hours,
10/banana, -1/hour) arrete desormais le demarrage avec un message nommant la
variable, au lieu de desactiver silencieusement la protection : seules les
valeurs 0, off, none, disabled et vide desactivent volontairement. Meme
traitement pour MAX_SHARED_MAPS, MAX_SHARE_SIZE_MB, DEFAULT_SHARE_EXPIRY_DAYS
et chaque entree de TRUSTED_PROXY_CIDRS.

X-Forwarded-For n'est plus cru sur la seule foi de TRUST_PROXY_HEADERS : la
connexion doit aussi provenir d'un reseau declare dans TRUSTED_PROXY_CIDRS
(ipaddress, IPv4/IPv6/CIDR). Activer la confiance sans declarer de reseau est
refuse au demarrage, faute de quoi la configuration semblerait active sans
l'etre. L'adresse retenue est normalisee : les differentes ecritures d'une meme
IPv6 ne donnent plus des compteurs distincts.

MAX_SHARED_MAPS : comptage et insertion serialises par un verrou de processus,
pour que deux creations simultanees ne franchissent pas ensemble la derniere
place. Documente comme valable pour un service applicatif unique.

Ajoute APP_BIND pour ne pas publier 8080 sur toutes les interfaces en
production, une option backup --keep pour la retention, et documente Nginx,
Traefik en override, cron et systemd.

Corrige un bug de rendu du build local : mon edition avait reecrit
docker-entrypoint.sh en CRLF, rendant le shebang introuvable dans le conteneur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:53:29 +02:00

374 lines
13 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 _apply_retention(destination: Path, keep: int) -> list[Path]:
"""Ne garde que les `keep` sauvegardes les plus recentes du meme dossier.
Le filtre est volontairement etroit : meme dossier, meme extension, et le
fichier qui vient d'etre ecrit est toujours conserve. Une commande de
sauvegarde ne doit pas devenir un effaceur de fichiers surprise.
"""
if keep <= 0:
return []
others = sorted(
(
path
for path in destination.parent.glob(f"*{destination.suffix}")
if path.is_file() and path != destination
),
key=lambda path: path.stat().st_mtime,
reverse=True,
)
# La sauvegarde qui vient d'etre ecrite compte comme la plus recente, et
# comme l'une des `keep` conservees : on ne peut donc pas finir avec keep+1.
ordered = [destination, *others]
removed = []
for path in ordered[keep:]:
path.unlink()
removed.append(path)
return removed
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)})")
for removed in _apply_retention(destination, args.keep):
print(f"Ancienne sauvegarde supprimee : {removed.name}")
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.add_argument(
"--keep",
type=int,
default=0,
help="Ne garder que les N sauvegardes les plus recentes du dossier (0 = tout garder)",
)
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())