feat: person excludes for auto-organize rules, backup & restore system
Add person exclude criteria to Immich auto-organize — assets containing excluded persons are filtered out after candidate gathering. Also adds full backup/restore system with export, import, scheduled backups, and retention management.
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
"""Configuration backup/restore API (admin only)."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..auth.dependencies import require_admin
|
||||
from ..config import settings as app_config
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import AppSetting, User
|
||||
from ..services.backup_schema import (
|
||||
ALL_CATEGORIES, BackupCategory, BackupFile, ConflictMode, SecretsMode,
|
||||
)
|
||||
from ..services.backup_service import (
|
||||
cleanup_old_backups, export_backup, import_backup, list_backup_files,
|
||||
validate_backup,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/backup", tags=["backup"])
|
||||
|
||||
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
|
||||
def _backup_dir():
|
||||
return app_config.data_dir / "backups"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/export")
|
||||
async def export_config(
|
||||
secrets_mode: SecretsMode = Query(default=SecretsMode.EXCLUDE),
|
||||
categories: str = Query(default=""),
|
||||
user: User = Depends(require_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Export configuration as a downloadable JSON file."""
|
||||
cats = None
|
||||
if categories:
|
||||
try:
|
||||
cats = [BackupCategory(c.strip()) for c in categories.split(",") if c.strip()]
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid category: {e}")
|
||||
|
||||
backup = await export_backup(session, user.id, cats, secrets_mode)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
|
||||
filename = f"notify-bridge-backup-{ts}.json"
|
||||
|
||||
return JSONResponse(
|
||||
content=backup.model_dump(),
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/validate")
|
||||
async def validate_config(
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(require_admin),
|
||||
):
|
||||
"""Validate a backup file without importing."""
|
||||
content = await file.read()
|
||||
if len(content) > MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
||||
|
||||
try:
|
||||
raw = json.loads(content)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
|
||||
|
||||
result = validate_backup(raw)
|
||||
return result.model_dump()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/import")
|
||||
async def import_config(
|
||||
file: UploadFile = File(...),
|
||||
conflict_mode: ConflictMode = Query(default=ConflictMode.SKIP),
|
||||
user: User = Depends(require_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Import configuration from a backup file."""
|
||||
content = await file.read()
|
||||
if len(content) > MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
||||
|
||||
try:
|
||||
raw = json.loads(content)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
|
||||
|
||||
# Validate first
|
||||
validation = validate_backup(raw)
|
||||
if not validation.valid:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid backup: {'; '.join(validation.errors)}")
|
||||
|
||||
backup = BackupFile.model_validate(raw)
|
||||
result = await import_backup(session, user.id, backup, conflict_mode)
|
||||
return result.model_dump()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scheduled backup settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BACKUP_SETTING_KEYS = {
|
||||
"backup_scheduled_enabled": "false",
|
||||
"backup_scheduled_interval_hours": "24",
|
||||
"backup_secrets_mode": "exclude",
|
||||
"backup_retention_count": "5",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/scheduled")
|
||||
async def get_scheduled_settings(
|
||||
user: User = Depends(require_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get scheduled backup settings."""
|
||||
result = {}
|
||||
for key, default in _BACKUP_SETTING_KEYS.items():
|
||||
row = await session.get(AppSetting, key)
|
||||
result[key] = row.value if row and row.value else default
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/scheduled")
|
||||
async def update_scheduled_settings(
|
||||
body: dict,
|
||||
user: User = Depends(require_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update scheduled backup settings and reschedule."""
|
||||
for key, default in _BACKUP_SETTING_KEYS.items():
|
||||
value = body.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
row = await session.get(AppSetting, key)
|
||||
if row:
|
||||
row.value = str(value)
|
||||
else:
|
||||
row = AppSetting(key=key, value=str(value))
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
|
||||
# Reschedule backup job
|
||||
from ..services.scheduler import schedule_backup
|
||||
enabled = body.get("backup_scheduled_enabled", "false") == "true"
|
||||
interval_hours = int(body.get("backup_scheduled_interval_hours", "24"))
|
||||
if enabled:
|
||||
await schedule_backup(interval_hours)
|
||||
else:
|
||||
from ..services.scheduler import unschedule_backup
|
||||
await unschedule_backup()
|
||||
|
||||
# Return updated settings
|
||||
result = {}
|
||||
for key, default in _BACKUP_SETTING_KEYS.items():
|
||||
row = await session.get(AppSetting, key)
|
||||
result[key] = row.value if row and row.value else default
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup file management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/files")
|
||||
async def get_backup_files(
|
||||
user: User = Depends(require_admin),
|
||||
):
|
||||
"""List saved backup files."""
|
||||
return list_backup_files(_backup_dir())
|
||||
|
||||
|
||||
@router.get("/files/{filename}")
|
||||
async def download_backup_file(
|
||||
filename: str,
|
||||
user: User = Depends(require_admin),
|
||||
):
|
||||
"""Download a specific backup file."""
|
||||
filepath = _backup_dir() / filename
|
||||
if not filepath.is_file() or not filename.startswith("backup-"):
|
||||
raise HTTPException(status_code=404, detail="Backup file not found")
|
||||
|
||||
try:
|
||||
content = json.loads(filepath.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to read backup: {e}")
|
||||
|
||||
return JSONResponse(
|
||||
content=content,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/files/{filename}")
|
||||
async def delete_backup_file(
|
||||
filename: str,
|
||||
user: User = Depends(require_admin),
|
||||
):
|
||||
"""Delete a specific backup file."""
|
||||
filepath = _backup_dir() / filename
|
||||
if not filepath.is_file() or not filename.startswith("backup-"):
|
||||
raise HTTPException(status_code=404, detail="Backup file not found")
|
||||
|
||||
filepath.unlink()
|
||||
return {"deleted": filename}
|
||||
Reference in New Issue
Block a user