feat: export Markdown notes (ARQ/Redis) + backup/restore BDD — v0.5.2
- 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>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
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"}
|
||||
Reference in New Issue
Block a user