Files
home_hub/backend/app/api/admin.py
T
gilles 4c616fa5d3 fix(ui): bouton + laptop, overflow mobile, clôture semaine, backup complet
- TodosPage/ShoppingPage : bouton « + » visible en laptop (hidden lg:flex)
- ShoppingPage : renomme « Terminer » en « Clôturer la semaine », badge ⚠
  si semaine ISO dépassée, confirmation modale avec décompte non-cochés
- NotesPage : overflowWrap:anywhere sur titre/contenu/markdown (URLs longues
  qui débordaient hors de la tuile sur smartphone)
- index.css : overflow-x:hidden + max-width:100vw sur html/body (garde-fou global)
- admin.py : backup remplacé par archive .tar.gz (pg_dump + uploads/) streamée
  au navigateur ; restore via multipart upload avec extraction sécurisée
- admin.ts : downloadBackup() (blob trigger) + uploadAndRestore() avec progression XHR
- ConfigPage : refonte section backup avec boutons Télécharger/Restaurer
  et barre de progression upload

v0.5.11

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 08:52:37 +02:00

182 lines
6.3 KiB
Python

import asyncio
import os
import shutil
import tarfile
import tempfile
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_session
router = APIRouter()
def _dir_stats(path: Path) -> dict:
files = [f for f in path.rglob("*") if f.is_file() and not f.name.startswith(".")]
return {"count": len(files), "size_bytes": sum(f.stat().st_size for f in files)}
@router.get("/stats")
async def get_stats(session: AsyncSession = Depends(get_session)):
# Taille de la BDD
db_size = (await session.execute(text("SELECT pg_database_size(current_database())"))).scalar()
# Compteurs
notes_count = (await session.execute(text("SELECT COUNT(*) FROM notes.items"))).scalar()
todos_count = (await session.execute(text("SELECT COUNT(*) FROM todos.items"))).scalar()
lists_count = (await session.execute(text("SELECT COUNT(*) FROM shopping.lists"))).scalar()
# Médias sur le disque
uploads = settings.upload_path
photos = _dir_stats(uploads / "images" / "originals") if (uploads / "images" / "originals").exists() else {"count": 0, "size_bytes": 0}
audio = _dir_stats(uploads / "audio") if (uploads / "audio").exists() else {"count": 0, "size_bytes": 0}
video = _dir_stats(uploads / "videos") if (uploads / "videos").exists() else {"count": 0, "size_bytes": 0}
return {
"db_size_bytes": db_size,
"media": {
"photos": photos,
"audio": audio,
"video": video,
},
"counts": {
"notes": notes_count,
"todos": todos_count,
"shopping_lists": lists_count,
},
}
def _pg_env() -> tuple[dict, object]:
url = urlparse(settings.database_url.replace("+asyncpg", ""))
env = os.environ.copy()
env["PGPASSWORD"] = url.password or ""
return env, url
async def _pg_dump_to(path: Path) -> None:
env, url = _pg_env()
proc = await asyncio.create_subprocess_exec(
"pg_dump", "-Fc",
"-h", url.hostname or "db",
"-p", str(url.port or 5432),
"-U", url.username or "homehub",
"-d", (url.path or "/homehub").lstrip("/"),
"-f", str(path),
env=env,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise HTTPException(500, f"Échec du pg_dump : {stderr.decode()}")
async def _pg_restore_from(path: Path) -> None:
env, url = _pg_env()
proc = await asyncio.create_subprocess_exec(
"pg_restore", "--clean", "--if-exists", "--no-owner", "--no-privileges",
"-h", url.hostname or "db",
"-p", str(url.port or 5432),
"-U", url.username or "homehub",
"-d", (url.path or "/homehub").lstrip("/"),
str(path),
env=env,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
# pg_restore retourne 1 pour des warnings non-fatals (DROP IF EXISTS sur objets manquants)
if proc.returncode not in (0, 1):
raise HTTPException(500, f"Échec de la restauration : {stderr.decode()}")
@router.post("/backup")
async def download_backup():
"""Génère une archive .tar.gz contenant DB + médias, streamée au navigateur."""
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M")
archive_name = f"homehub_{timestamp}.tar.gz"
tmpdir = Path(tempfile.mkdtemp(prefix="homehub_backup_"))
dump_path = tmpdir / "db.dump"
archive_path = tmpdir / archive_name
await _pg_dump_to(dump_path)
def _build_archive() -> None:
with tarfile.open(archive_path, "w:gz") as tar:
tar.add(dump_path, arcname="db.dump")
uploads = settings.upload_path
if uploads.exists():
tar.add(uploads, arcname="uploads")
await asyncio.to_thread(_build_archive)
def _cleanup() -> None:
shutil.rmtree(tmpdir, ignore_errors=True)
return FileResponse(
archive_path,
media_type="application/gzip",
filename=archive_name,
background=BackgroundTask(_cleanup),
)
@router.post("/restore")
async def upload_and_restore(file: UploadFile = File(...)):
"""Restaure depuis une archive .tar.gz uploadée (DB + médias)."""
if not file.filename or not file.filename.endswith((".tar.gz", ".tgz")):
raise HTTPException(400, "Format attendu : .tar.gz")
tmpdir = Path(tempfile.mkdtemp(prefix="homehub_restore_"))
try:
archive_path = tmpdir / "upload.tar.gz"
with archive_path.open("wb") as f:
while chunk := await file.read(1024 * 1024):
f.write(chunk)
extract_dir = tmpdir / "extract"
extract_dir.mkdir()
def _extract() -> None:
with tarfile.open(archive_path, "r:gz") as tar:
# Sécurité : refuser les chemins absolus ou contenant ..
for member in tar.getmembers():
if member.name.startswith("/") or ".." in Path(member.name).parts:
raise HTTPException(400, f"Chemin invalide dans l'archive : {member.name}")
tar.extractall(extract_dir, filter="data")
await asyncio.to_thread(_extract)
dump_path = extract_dir / "db.dump"
if not dump_path.exists():
raise HTTPException(400, "Archive invalide : db.dump introuvable")
await _pg_restore_from(dump_path)
uploads_src = extract_dir / "uploads"
if uploads_src.exists():
uploads_dst = settings.upload_path
uploads_dst.mkdir(parents=True, exist_ok=True)
def _sync_media() -> None:
for item in uploads_src.rglob("*"):
if not item.is_file():
continue
rel = item.relative_to(uploads_src)
dest = uploads_dst / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, dest)
await asyncio.to_thread(_sync_media)
return {"message": "Restauration réussie"}
finally:
shutil.rmtree(tmpdir, ignore_errors=True)