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:
+95
-48
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user