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)