be0c8bceb6
- Volume data/ (bind mount ./data) remplace le volume uploads nommé
data/notes/ → .md auto-générés, data/uploads/ → médias, data/backup/ → dumps
- Service Redis (redis:7-alpine) + worker ARQ (backend-worker)
- notes_markdown.py : frontmatter YAML + contenu + pièces jointes (liens relatifs)
Nom : YYYY-MM-DD_slug-titre_shortid.md, rotation si titre modifié
- api/notes.py : publie export_note_markdown / remove_note_markdown sur Redis
après chaque create / update / delete / add_attachment / delete_attachment
- api/admin.py : POST /backup, GET /backups, POST /restore/{filename} (pg_dump/pg_restore)
- Backend Dockerfile : postgresql-client ; requirements : arq==0.26.1
- ConfigPage : section "Base de données" avec sauvegarde + liste + restauration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
96 lines
2.8 KiB
Python
96 lines
2.8 KiB
Python
import asyncio
|
|
import os
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
|
|
from app.core.config import settings
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _pg_env() -> dict:
|
|
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
|
|
|
|
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(filepath),
|
|
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(),
|
|
}
|
|
|
|
|
|
@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")
|
|
|
|
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(filepath),
|
|
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()}")
|
|
|
|
return {"message": "Restauration réussie"}
|