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:
334
backend/app/cli.py
Normal file
334
backend/app/cli.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user