4c616fa5d3
- 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>
182 lines
6.3 KiB
Python
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)
|