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>
This commit is contained in:
2026-05-30 08:52:37 +02:00
parent 69c2042995
commit 4c616fa5d3
8 changed files with 316 additions and 142 deletions
+95 -48
View File
@@ -1,10 +1,15 @@
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
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
@@ -50,22 +55,14 @@ async def get_stats(session: AsyncSession = Depends(get_session)):
}
def _pg_env() -> dict:
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
@router.post("/backup")
async def create_backup():
backup_dir = settings.backup_path
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M")
filename = f"homehub_{timestamp}.dump"
filepath = backup_dir / filename
async def _pg_dump_to(path: Path) -> None:
env, url = _pg_env()
proc = await asyncio.create_subprocess_exec(
"pg_dump", "-Fc",
@@ -73,48 +70,16 @@ async def create_backup():
"-p", str(url.port or 5432),
"-U", url.username or "homehub",
"-d", (url.path or "/homehub").lstrip("/"),
"-f", str(filepath),
"-f", str(path),
env=env,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise HTTPException(500, f"Échec du backup : {stderr.decode()}")
stat = filepath.stat()
return {
"filename": filename,
"size": stat.st_size,
"created_at": datetime.now().isoformat(),
}
raise HTTPException(500, f"Échec du pg_dump : {stderr.decode()}")
@router.get("/backups")
async def list_backups():
backup_dir = settings.backup_path
if not backup_dir.exists():
return []
files = sorted(backup_dir.glob("*.dump"), key=lambda f: f.stat().st_mtime, reverse=True)
return [
{
"filename": f.name,
"size": f.stat().st_size,
"created_at": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
}
for f in files
]
@router.post("/restore/{filename}")
async def restore_backup(filename: str):
# Sécurité : interdit les chemins relatifs
if "/" in filename or ".." in filename or not filename.endswith(".dump"):
raise HTTPException(400, "Nom de fichier invalide")
filepath = settings.backup_path / filename
if not filepath.exists():
raise HTTPException(404, "Fichier introuvable")
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",
@@ -122,7 +87,7 @@ async def restore_backup(filename: str):
"-p", str(url.port or 5432),
"-U", url.username or "homehub",
"-d", (url.path or "/homehub").lstrip("/"),
str(filepath),
str(path),
env=env,
stderr=asyncio.subprocess.PIPE,
)
@@ -131,4 +96,86 @@ async def restore_backup(filename: str):
if proc.returncode not in (0, 1):
raise HTTPException(500, f"Échec de la restauration : {stderr.decode()}")
return {"message": "Restauration réussie"}
@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)