Merge pull request #507 from christianlouis/copilot/add-backup-and-restore-functionality
This commit is contained in:
@@ -8,6 +8,7 @@ from fastapi import APIRouter
|
||||
|
||||
from app.api.admin_users import router as admin_users_router
|
||||
from app.api.azure import router as azure_router
|
||||
from app.api.backup import router as backup_router
|
||||
from app.api.billing import router as billing_router
|
||||
from app.api.database import router as database_router
|
||||
from app.api.diagnostic import router as diagnostic_router
|
||||
@@ -43,6 +44,7 @@ router = APIRouter()
|
||||
# Include all the routers
|
||||
router.include_router(admin_users_router)
|
||||
router.include_router(user_router)
|
||||
router.include_router(backup_router)
|
||||
router.include_router(files_router)
|
||||
router.include_router(process_router)
|
||||
router.include_router(diagnostic_router)
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Backup and restore API endpoints for DocuElevate.
|
||||
|
||||
Provides REST endpoints for:
|
||||
- Listing existing backups
|
||||
- Triggering a manual backup
|
||||
- Downloading a backup archive
|
||||
- Restoring from an uploaded backup file
|
||||
- Deleting a backup record
|
||||
- Running retention cleanup
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import BackupRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/admin/backup", tags=["backup"])
|
||||
|
||||
|
||||
def _require_admin(request: Request) -> dict:
|
||||
"""Ensure the caller is an admin. Raises 403 otherwise."""
|
||||
user = request.session.get("user")
|
||||
if not user or not user.get("is_admin"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
# Annotated shorthand so FastAPI can resolve and tests can override it.
|
||||
AdminUser = Annotated[dict, Depends(_require_admin)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_backups(
|
||||
_admin: AdminUser,
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
"""Return all backup records, newest first."""
|
||||
records = db.query(BackupRecord).order_by(BackupRecord.created_at.desc()).all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"filename": r.filename,
|
||||
"backup_type": r.backup_type,
|
||||
"size_bytes": r.size_bytes,
|
||||
"checksum": r.checksum,
|
||||
"status": r.status,
|
||||
"local_path": r.local_path,
|
||||
"remote_destination": r.remote_destination,
|
||||
"remote_path": r.remote_path,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"local_available": bool(r.local_path and os.path.exists(r.local_path)),
|
||||
}
|
||||
for r in records
|
||||
]
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def trigger_backup(
|
||||
_admin: AdminUser,
|
||||
backup_type: str = "hourly",
|
||||
) -> dict:
|
||||
"""Trigger a manual backup immediately.
|
||||
|
||||
Query parameter ``backup_type`` accepts ``hourly``, ``daily``, or
|
||||
``weekly`` (default: ``hourly``).
|
||||
"""
|
||||
if backup_type not in ("hourly", "daily", "weekly"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid backup_type")
|
||||
|
||||
from app.tasks.backup_tasks import create_backup
|
||||
|
||||
task = create_backup.delay(backup_type=backup_type)
|
||||
return {"task_id": task.id, "status": "queued", "backup_type": backup_type}
|
||||
|
||||
|
||||
@router.get("/{backup_id}/download")
|
||||
async def download_backup(
|
||||
backup_id: int,
|
||||
_admin: AdminUser,
|
||||
db: Session = Depends(get_db),
|
||||
) -> FileResponse:
|
||||
"""Stream the backup archive to the client."""
|
||||
rec = db.get(BackupRecord, backup_id)
|
||||
if rec is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup not found")
|
||||
if not rec.local_path or not os.path.exists(rec.local_path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Local archive file is not available (may have been pruned)",
|
||||
)
|
||||
return FileResponse(
|
||||
path=rec.local_path,
|
||||
filename=rec.filename,
|
||||
media_type="application/gzip",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/restore")
|
||||
async def restore_backup(
|
||||
_admin: AdminUser,
|
||||
file: UploadFile,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Restore the database from an uploaded gzip-compressed SQL dump.
|
||||
|
||||
**Warning**: This overwrites the current database contents.
|
||||
Only SQLite databases are supported.
|
||||
|
||||
The uploaded file must be a ``.db.gz`` file produced by the DocuElevate
|
||||
backup task (a gzip-compressed SQLite ``.dump()`` SQL script).
|
||||
"""
|
||||
from app.tasks.backup_tasks import _db_path
|
||||
|
||||
db_path = _db_path()
|
||||
if db_path is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Restore is only supported for SQLite databases.",
|
||||
)
|
||||
|
||||
if not file.filename or not file.filename.endswith(".db.gz"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Uploaded file must be a .db.gz backup archive.",
|
||||
)
|
||||
|
||||
import gzip
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Write the upload to a temp file first so we can validate it
|
||||
with tempfile.NamedTemporaryFile(suffix=".db.gz", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
content = await file.read()
|
||||
tmp.write(content)
|
||||
|
||||
try:
|
||||
# Decompress and read SQL statements
|
||||
with gzip.open(str(tmp_path), "rt", encoding="utf-8") as gz:
|
||||
sql_script = gz.read()
|
||||
except Exception as exc:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to decompress backup file: {exc}",
|
||||
) from exc
|
||||
|
||||
# Create a fresh in-memory DB from the script to validate it
|
||||
try:
|
||||
mem_conn = sqlite3.connect(":memory:")
|
||||
mem_conn.executescript(sql_script)
|
||||
mem_conn.close()
|
||||
except sqlite3.Error as exc:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Backup file contains invalid SQL: {exc}",
|
||||
) from exc
|
||||
|
||||
# Close the application DB session before replacing the file
|
||||
db.close()
|
||||
|
||||
# Preserve the current DB before overwriting
|
||||
import shutil
|
||||
|
||||
bak = str(db_path) + ".pre_restore"
|
||||
try:
|
||||
shutil.copy2(str(db_path), bak)
|
||||
except OSError as exc:
|
||||
logger.warning(f"Could not create pre-restore backup at {bak}: {exc}")
|
||||
|
||||
try:
|
||||
# Write the restored database
|
||||
restore_conn = sqlite3.connect(str(db_path))
|
||||
restore_conn.executescript(sql_script)
|
||||
restore_conn.close()
|
||||
except sqlite3.Error as exc:
|
||||
# Attempt rollback
|
||||
try:
|
||||
if os.path.exists(bak):
|
||||
shutil.copy2(bak, str(db_path))
|
||||
except OSError as rollback_exc:
|
||||
logger.error(f"Rollback failed; database may be corrupted: {rollback_exc}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Restore failed: {exc}",
|
||||
) from exc
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
logger.info(f"Database restored from uploaded backup: {file.filename}")
|
||||
return {"status": "restored", "filename": file.filename}
|
||||
|
||||
|
||||
@router.delete("/{backup_id}")
|
||||
async def delete_backup(
|
||||
backup_id: int,
|
||||
_admin: AdminUser,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Delete a backup record (and local file if present)."""
|
||||
rec = db.get(BackupRecord, backup_id)
|
||||
if rec is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup not found")
|
||||
|
||||
if rec.local_path and os.path.exists(rec.local_path):
|
||||
try:
|
||||
os.remove(rec.local_path)
|
||||
except OSError as exc:
|
||||
logger.warning(f"Could not remove local backup file {rec.local_path}: {exc}")
|
||||
|
||||
db.delete(rec)
|
||||
db.commit()
|
||||
return {"status": "deleted", "id": backup_id}
|
||||
|
||||
|
||||
@router.post("/cleanup")
|
||||
async def run_cleanup(_admin: AdminUser) -> dict:
|
||||
"""Manually trigger the retention cleanup for all backup tiers."""
|
||||
from app.tasks.backup_tasks import cleanup_old_backups
|
||||
|
||||
task = cleanup_old_backups.delay()
|
||||
return {"task_id": task.id, "status": "queued"}
|
||||
@@ -8,6 +8,7 @@ from app import tasks # noqa: F401 - Imports app/tasks.py so Celery can registe
|
||||
# Import the shared Celery instance
|
||||
from app.celery_app import celery
|
||||
from app.config import settings
|
||||
from app.tasks.backup_tasks import cleanup_old_backups, create_backup # noqa: F401
|
||||
from app.tasks.check_credentials import check_credentials
|
||||
from app.tasks.compute_embedding import backfill_missing_embeddings, compute_document_embedding # noqa: F401
|
||||
from app.tasks.convert_to_pdf import convert_to_pdf # noqa: F401
|
||||
@@ -111,6 +112,40 @@ celery.conf.beat_schedule = {
|
||||
"schedule": crontab(hour="0", minute="5"), # 00:05 UTC daily
|
||||
"options": {"expires": 3600},
|
||||
},
|
||||
# ── Database backup tasks ──────────────────────────────────────────────
|
||||
# Hourly backup (kept for 4 days)
|
||||
"backup-hourly": (
|
||||
{
|
||||
"task": "app.tasks.backup_tasks.create_backup",
|
||||
"schedule": crontab(minute="0"), # top of every hour
|
||||
"kwargs": {"backup_type": "hourly"},
|
||||
"options": {"expires": 3300},
|
||||
}
|
||||
if settings.backup_enabled
|
||||
else None
|
||||
),
|
||||
# Daily backup (kept for 3 weeks) – runs at 02:30 UTC
|
||||
"backup-daily": (
|
||||
{
|
||||
"task": "app.tasks.backup_tasks.create_backup",
|
||||
"schedule": crontab(hour="2", minute="30"),
|
||||
"kwargs": {"backup_type": "daily"},
|
||||
"options": {"expires": 3600},
|
||||
}
|
||||
if settings.backup_enabled
|
||||
else None
|
||||
),
|
||||
# Weekly backup (kept for 13 weeks) – runs every Sunday at 03:00 UTC
|
||||
"backup-weekly": (
|
||||
{
|
||||
"task": "app.tasks.backup_tasks.create_backup",
|
||||
"schedule": crontab(hour="3", minute="0", day_of_week="0"),
|
||||
"kwargs": {"backup_type": "weekly"},
|
||||
"options": {"expires": 3600},
|
||||
}
|
||||
if settings.backup_enabled
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
# Remove None entries from beat_schedule
|
||||
|
||||
@@ -423,6 +423,48 @@ class Settings(BaseSettings):
|
||||
description="Enable webhook delivery for document events",
|
||||
)
|
||||
|
||||
# ── Backup / restore settings ──────────────────────────────────────────────
|
||||
backup_enabled: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Enable automatic scheduled database backups. "
|
||||
"When enabled, hourly, daily, and weekly backups are created automatically. Default: True."
|
||||
),
|
||||
)
|
||||
backup_dir: Optional[str] = Field(
|
||||
default=None,
|
||||
description=("Directory where local backup archives are stored. Defaults to <workdir>/backups when not set."),
|
||||
)
|
||||
# Remote destination: one of s3, dropbox, google_drive, onedrive, nextcloud,
|
||||
# webdav, ftp, sftp, email, or empty/None for local-only.
|
||||
backup_remote_destination: Optional[str] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Storage provider to upload remote backup copies to. "
|
||||
"Accepted values: s3, dropbox, google_drive, onedrive, nextcloud, webdav, ftp, sftp, email. "
|
||||
"Leave empty to keep backups local only."
|
||||
),
|
||||
)
|
||||
backup_remote_folder: str = Field(
|
||||
default="backups",
|
||||
description=(
|
||||
"Sub-folder / key prefix used when uploading backup archives to the remote destination. Default: 'backups'."
|
||||
),
|
||||
)
|
||||
# Retention counts (number of snapshots to keep per tier)
|
||||
backup_retain_hourly: int = Field(
|
||||
default=96,
|
||||
description="Number of hourly backups to retain (default 96 = 4 days × 24 h).",
|
||||
)
|
||||
backup_retain_daily: int = Field(
|
||||
default=21,
|
||||
description="Number of daily backups to retain (default 21 = 3 weeks).",
|
||||
)
|
||||
backup_retain_weekly: int = Field(
|
||||
default=13,
|
||||
description="Number of weekly backups to retain (default 13 ≈ 3 months / 91 days).",
|
||||
)
|
||||
|
||||
# File upload size limits (for security - see SECURITY_AUDIT.md)
|
||||
max_upload_size: int = Field(
|
||||
default=1073741824, # 1GB in bytes (1024 * 1024 * 1024)
|
||||
|
||||
@@ -374,3 +374,47 @@ class PipelineStep(Base):
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class BackupRecord(Base):
|
||||
"""Tracks database backup files and their retention metadata.
|
||||
|
||||
Each row represents one backup archive (a gzipped SQLite dump).
|
||||
``backup_type`` classifies the backup for retention purposes:
|
||||
- ``hourly`` – kept for up to 4 days (96 snapshots)
|
||||
- ``daily`` – kept for up to 3 weeks (21 snapshots)
|
||||
- ``weekly`` – kept for up to 13 weeks (≈ 90 days)
|
||||
``local_path`` is the full filesystem path of the local copy (``None``
|
||||
once pruned). ``remote_destination`` and ``remote_path`` describe the
|
||||
remote copy when one has been uploaded to a storage provider or e-mailed.
|
||||
"""
|
||||
|
||||
__tablename__ = "backup_records"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Human-readable archive filename (e.g. backup_hourly_2026-03-07T12-00-00.db.gz)
|
||||
filename = Column(String(255), nullable=False, unique=True)
|
||||
|
||||
# Full path on the local filesystem (may be NULL for remote-only backups)
|
||||
local_path = Column(String(1024), nullable=True)
|
||||
|
||||
# Classification used by the retention policy
|
||||
backup_type = Column(String(20), nullable=False, index=True) # hourly | daily | weekly
|
||||
|
||||
# Archive size in bytes (0 if unknown)
|
||||
size_bytes = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Checksum of the archive for integrity verification (SHA-256 hex)
|
||||
checksum = Column(String(64), nullable=True)
|
||||
|
||||
# Whether the backup was successfully created
|
||||
status = Column(String(20), nullable=False, default="ok") # ok | failed
|
||||
|
||||
# Storage destination where a remote copy was uploaded (e.g. "s3", "dropbox", "email")
|
||||
remote_destination = Column(String(50), nullable=True)
|
||||
|
||||
# Path / key of the remote copy (bucket key, folder path, etc.)
|
||||
remote_path = Column(String(1024), nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
Backup and restore tasks for DocuElevate.
|
||||
|
||||
Retention strategy
|
||||
------------------
|
||||
- **hourly** backups – retained for 4 days (``backup_retain_hourly``, default 96)
|
||||
- **daily** backups – retained for 3 weeks (``backup_retain_daily``, default 21)
|
||||
- **weekly** backups – retained for 13 weeks (``backup_retain_weekly``, default 13)
|
||||
|
||||
Three separate Celery-beat entries call ``create_backup`` with the appropriate
|
||||
``backup_type`` argument:
|
||||
- every hour → ``create_backup("hourly")``
|
||||
- every day → ``create_backup("daily")``
|
||||
- every week → ``create_backup("weekly")``
|
||||
|
||||
After each backup is created ``_apply_retention`` prunes old local backups for
|
||||
that tier. Remote copies are pruned by ``_prune_remote_backups`` which mirrors
|
||||
the same retention limits.
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.celery_app import celery
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.models import BackupRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BACKUP_TYPE_RETAIN: dict[str, str] = {
|
||||
"hourly": "backup_retain_hourly",
|
||||
"daily": "backup_retain_daily",
|
||||
"weekly": "backup_retain_weekly",
|
||||
}
|
||||
|
||||
|
||||
def _backup_dir() -> Path:
|
||||
"""Return (and create) the local backup directory."""
|
||||
raw = getattr(settings, "backup_dir", None) or os.path.join(settings.workdir, "backups")
|
||||
path = Path(raw)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _db_path() -> Path | None:
|
||||
"""Return the SQLite database file path, or None for non-SQLite databases."""
|
||||
from sqlalchemy.engine.url import make_url
|
||||
|
||||
url = make_url(settings.database_url)
|
||||
if url.get_backend_name() != "sqlite":
|
||||
return None
|
||||
db = url.database
|
||||
if not db or db == ":memory:":
|
||||
return None
|
||||
return Path(db)
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
"""Return the SHA-256 hex digest of *path*."""
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _dump_sqlite(db_path: Path, dest: Path) -> None:
|
||||
"""Write a gzip-compressed SQL dump of *db_path* to *dest*."""
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
with gzip.open(str(dest), "wt", encoding="utf-8") as gz:
|
||||
for line in conn.iterdump():
|
||||
gz.write(line + "\n")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _apply_retention(backup_type: str, db: object) -> None:
|
||||
"""Delete local backups beyond the retention limit for *backup_type*.
|
||||
|
||||
Args:
|
||||
backup_type: One of ``hourly``, ``daily``, ``weekly``.
|
||||
db: Active SQLAlchemy session.
|
||||
"""
|
||||
retain_attr = _BACKUP_TYPE_RETAIN.get(backup_type, "backup_retain_hourly")
|
||||
retain = int(getattr(settings, retain_attr, 96))
|
||||
|
||||
# Query ALL records for this tier (with or without a local file) so that
|
||||
# remote-only and already-pruned records still count toward the retention window.
|
||||
records = (
|
||||
db.query(BackupRecord)
|
||||
.filter(BackupRecord.backup_type == backup_type)
|
||||
.order_by(BackupRecord.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
to_prune = records[retain:]
|
||||
for rec in to_prune:
|
||||
if rec.local_path and os.path.exists(rec.local_path):
|
||||
try:
|
||||
os.remove(rec.local_path)
|
||||
logger.info(f"Pruned local backup: {rec.local_path}")
|
||||
except OSError as exc:
|
||||
logger.warning(f"Failed to remove local backup {rec.local_path}: {exc}")
|
||||
rec.local_path = None
|
||||
# If no remote copy either, delete the record entirely
|
||||
if not rec.remote_path:
|
||||
db.delete(rec)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
def _prune_remote_backups(backup_type: str, db: object) -> None:
|
||||
"""Prune remote backup records beyond the retention limit.
|
||||
|
||||
The actual remote deletion is best-effort (logged but not fatal).
|
||||
|
||||
Args:
|
||||
backup_type: One of ``hourly``, ``daily``, ``weekly``.
|
||||
db: Active SQLAlchemy session.
|
||||
"""
|
||||
retain_attr = _BACKUP_TYPE_RETAIN.get(backup_type, "backup_retain_hourly")
|
||||
retain = int(getattr(settings, retain_attr, 96))
|
||||
|
||||
# Query ALL records for this tier so that already-pruned local records
|
||||
# still count toward the retention window.
|
||||
records = (
|
||||
db.query(BackupRecord)
|
||||
.filter(BackupRecord.backup_type == backup_type)
|
||||
.order_by(BackupRecord.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
to_prune = [r for r in records[retain:] if r.remote_path]
|
||||
for rec in to_prune:
|
||||
_delete_remote_copy(rec)
|
||||
rec.remote_path = None
|
||||
rec.remote_destination = None
|
||||
if not rec.local_path:
|
||||
db.delete(rec)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
def _delete_remote_copy(rec: BackupRecord) -> None: # noqa: C901
|
||||
"""Best-effort deletion of the remote copy described by *rec*."""
|
||||
dest = rec.remote_destination
|
||||
remote_path = rec.remote_path
|
||||
if not dest or not remote_path:
|
||||
return
|
||||
|
||||
try:
|
||||
if dest == "s3":
|
||||
import boto3
|
||||
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
region_name=settings.aws_region,
|
||||
aws_access_key_id=settings.aws_access_key_id,
|
||||
aws_secret_access_key=settings.aws_secret_access_key,
|
||||
)
|
||||
s3.delete_object(Bucket=settings.s3_bucket_name, Key=remote_path)
|
||||
logger.info(f"Deleted remote S3 backup: s3://{settings.s3_bucket_name}/{remote_path}")
|
||||
|
||||
elif dest == "dropbox":
|
||||
import dropbox as dbx_module
|
||||
|
||||
dbx = dbx_module.Dropbox(settings.dropbox_refresh_token)
|
||||
dbx.files_delete_v2(remote_path)
|
||||
logger.info(f"Deleted remote Dropbox backup: {remote_path}")
|
||||
|
||||
elif dest in ("ftp", "sftp", "nextcloud", "webdav", "google_drive", "onedrive", "email"):
|
||||
# For other providers best-effort is logged only – deletion not implemented yet.
|
||||
logger.debug(f"Remote deletion not implemented for destination '{dest}', skipping {remote_path}")
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to delete remote backup {remote_path} from {dest}: {exc}")
|
||||
|
||||
|
||||
def _upload_remote(archive_path: Path, filename: str) -> tuple[str, str] | None: # noqa: C901
|
||||
"""Upload *archive_path* to the configured remote destination.
|
||||
|
||||
Returns:
|
||||
``(destination, remote_path)`` on success, ``None`` on failure or when
|
||||
no remote destination is configured.
|
||||
"""
|
||||
dest = getattr(settings, "backup_remote_destination", None)
|
||||
if not dest:
|
||||
return None
|
||||
|
||||
remote_folder = getattr(settings, "backup_remote_folder", "backups") or "backups"
|
||||
remote_key = f"{remote_folder}/{filename}"
|
||||
|
||||
try:
|
||||
if dest == "s3":
|
||||
import boto3
|
||||
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
region_name=settings.aws_region,
|
||||
aws_access_key_id=settings.aws_access_key_id,
|
||||
aws_secret_access_key=settings.aws_secret_access_key,
|
||||
)
|
||||
with open(archive_path, "rb") as fh:
|
||||
s3.upload_fileobj(fh, settings.s3_bucket_name, remote_key)
|
||||
logger.info(f"Uploaded backup to S3: s3://{settings.s3_bucket_name}/{remote_key}")
|
||||
return (dest, remote_key)
|
||||
|
||||
elif dest == "dropbox":
|
||||
import dropbox as dbx_module
|
||||
|
||||
dbx = dbx_module.Dropbox(settings.dropbox_refresh_token)
|
||||
dropbox_path = f"/{remote_key}"
|
||||
with open(archive_path, "rb") as fh:
|
||||
dbx.files_upload(fh.read(), dropbox_path, mode=dbx_module.files.WriteMode("overwrite"))
|
||||
logger.info(f"Uploaded backup to Dropbox: {dropbox_path}")
|
||||
return (dest, dropbox_path)
|
||||
|
||||
elif dest == "email":
|
||||
_email_backup(archive_path, filename)
|
||||
return (dest, f"email:{filename}")
|
||||
|
||||
elif dest == "nextcloud":
|
||||
import requests
|
||||
|
||||
url = f"{settings.nextcloud_upload_url}/{remote_key}"
|
||||
with open(archive_path, "rb") as fh:
|
||||
resp = requests.put(
|
||||
url,
|
||||
data=fh,
|
||||
auth=(settings.nextcloud_username, settings.nextcloud_password),
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
logger.info(f"Uploaded backup to Nextcloud: {url}")
|
||||
return (dest, url)
|
||||
|
||||
elif dest == "webdav":
|
||||
import requests
|
||||
|
||||
url = f"{settings.webdav_url}/{remote_key}"
|
||||
with open(archive_path, "rb") as fh:
|
||||
resp = requests.put(
|
||||
url,
|
||||
data=fh,
|
||||
auth=(settings.webdav_username, settings.webdav_password),
|
||||
verify=settings.webdav_verify_ssl,
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
logger.info(f"Uploaded backup to WebDAV: {url}")
|
||||
return (dest, url)
|
||||
|
||||
else:
|
||||
logger.warning(f"Backup remote destination '{dest}' upload not implemented; keeping local only.")
|
||||
return None
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to upload backup to {dest}: {exc}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _email_backup(archive_path: Path, filename: str) -> None:
|
||||
"""Send *archive_path* as an e-mail attachment to the default recipient."""
|
||||
import smtplib
|
||||
from email.mime.application import MIMEApplication
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
recipient = settings.email_default_recipient
|
||||
if not recipient:
|
||||
raise ValueError("email_default_recipient is not configured")
|
||||
|
||||
msg = MIMEMultipart()
|
||||
msg["Subject"] = f"[DocuElevate] Database backup – {filename}"
|
||||
msg["From"] = settings.email_sender or settings.email_username or "docuelevate@localhost"
|
||||
msg["To"] = recipient
|
||||
|
||||
body = MIMEText(f"Automated database backup from DocuElevate.\n\nFile: {filename}\n", "plain")
|
||||
msg.attach(body)
|
||||
|
||||
with open(archive_path, "rb") as fh:
|
||||
part = MIMEApplication(fh.read(), Name=filename)
|
||||
part["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
msg.attach(part)
|
||||
|
||||
with smtplib.SMTP(settings.email_host, settings.email_port, timeout=60) as server:
|
||||
if settings.email_use_tls:
|
||||
server.starttls()
|
||||
if settings.email_username and settings.email_password:
|
||||
server.login(settings.email_username, settings.email_password)
|
||||
server.sendmail(msg["From"], [recipient], msg.as_string())
|
||||
|
||||
logger.info(f"Backup e-mailed to {recipient}: {filename}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public Celery tasks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@celery.task(name="app.tasks.backup_tasks.create_backup", bind=True)
|
||||
def create_backup(self, backup_type: str = "hourly") -> dict:
|
||||
"""Create a database backup archive and apply retention.
|
||||
|
||||
Args:
|
||||
backup_type: ``"hourly"``, ``"daily"``, or ``"weekly"``.
|
||||
|
||||
Returns:
|
||||
A dict with ``filename``, ``size_bytes``, and ``status``.
|
||||
"""
|
||||
if backup_type not in _BACKUP_TYPE_RETAIN:
|
||||
backup_type = "hourly"
|
||||
|
||||
if not getattr(settings, "backup_enabled", True):
|
||||
logger.debug("Backup is disabled; skipping create_backup task.")
|
||||
return {"status": "disabled"}
|
||||
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
|
||||
filename = f"backup_{backup_type}_{ts}.db.gz"
|
||||
archive_path = _backup_dir() / filename
|
||||
|
||||
db_path = _db_path()
|
||||
if db_path is None:
|
||||
logger.warning("Backup task skipped: non-SQLite databases are not supported for file-based backups.")
|
||||
return {"status": "unsupported_db"}
|
||||
|
||||
if not db_path.exists():
|
||||
logger.error(f"Database file not found: {db_path}")
|
||||
return {"status": "error", "detail": f"DB file missing: {db_path}"}
|
||||
|
||||
status = "ok"
|
||||
checksum: str | None = None
|
||||
size_bytes = 0
|
||||
remote_destination: str | None = None
|
||||
remote_path: str | None = None
|
||||
|
||||
try:
|
||||
_dump_sqlite(db_path, archive_path)
|
||||
size_bytes = archive_path.stat().st_size
|
||||
checksum = _sha256(archive_path)
|
||||
logger.info(f"Created {backup_type} backup: {archive_path} ({size_bytes:,} bytes)")
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to create backup archive {filename}: {exc}", exc_info=True)
|
||||
status = "failed"
|
||||
# Record the failure so it is visible in the dashboard
|
||||
with SessionLocal() as db:
|
||||
rec = BackupRecord(
|
||||
filename=filename,
|
||||
local_path=None,
|
||||
backup_type=backup_type,
|
||||
size_bytes=0,
|
||||
checksum=None,
|
||||
status="failed",
|
||||
)
|
||||
db.add(rec)
|
||||
db.commit()
|
||||
return {"status": "error", "detail": str(exc)}
|
||||
|
||||
# Optional remote upload
|
||||
result = _upload_remote(archive_path, filename)
|
||||
if result:
|
||||
remote_destination, remote_path = result
|
||||
|
||||
with SessionLocal() as db:
|
||||
rec = BackupRecord(
|
||||
filename=filename,
|
||||
local_path=str(archive_path),
|
||||
backup_type=backup_type,
|
||||
size_bytes=size_bytes,
|
||||
checksum=checksum,
|
||||
status=status,
|
||||
remote_destination=remote_destination,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
db.add(rec)
|
||||
db.commit()
|
||||
|
||||
# Apply retention policy for this tier
|
||||
_apply_retention(backup_type, db)
|
||||
if remote_destination:
|
||||
_prune_remote_backups(backup_type, db)
|
||||
|
||||
return {
|
||||
"filename": filename,
|
||||
"size_bytes": size_bytes,
|
||||
"status": status,
|
||||
"remote_destination": remote_destination,
|
||||
}
|
||||
|
||||
|
||||
@celery.task(name="app.tasks.backup_tasks.cleanup_old_backups")
|
||||
def cleanup_old_backups() -> dict:
|
||||
"""Manually trigger retention clean-up for all backup tiers.
|
||||
|
||||
This is also called automatically after each ``create_backup`` run.
|
||||
"""
|
||||
with SessionLocal() as db:
|
||||
for btype in ("hourly", "daily", "weekly"):
|
||||
_apply_retention(btype, db)
|
||||
_prune_remote_backups(btype, db)
|
||||
return {"status": "ok"}
|
||||
@@ -1300,6 +1300,72 @@ SETTING_METADATA = {
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# Backup / Restore
|
||||
"backup_enabled": {
|
||||
"category": "Backup",
|
||||
"description": ("Enable automatic scheduled database backups (hourly, daily, weekly). Default: True."),
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"backup_dir": {
|
||||
"category": "Backup",
|
||||
"description": (
|
||||
"Directory where local backup archives are stored. Defaults to <workdir>/backups when not set."
|
||||
),
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"backup_remote_destination": {
|
||||
"category": "Backup",
|
||||
"description": (
|
||||
"Storage provider for remote backup copies. "
|
||||
"Accepted values: s3, dropbox, google_drive, onedrive, nextcloud, webdav, ftp, sftp, email. "
|
||||
"Leave empty to keep backups local only."
|
||||
),
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
"options": ["", "s3", "dropbox", "google_drive", "onedrive", "nextcloud", "webdav", "ftp", "sftp", "email"],
|
||||
},
|
||||
"backup_remote_folder": {
|
||||
"category": "Backup",
|
||||
"description": (
|
||||
"Sub-folder / key prefix used when uploading backup archives to the remote destination. Default: 'backups'."
|
||||
),
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"backup_retain_hourly": {
|
||||
"category": "Backup",
|
||||
"description": "Number of hourly backups to retain (default 96 = 4 days × 24 h).",
|
||||
"type": "integer",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"backup_retain_daily": {
|
||||
"category": "Backup",
|
||||
"description": "Number of daily backups to retain (default 21 = 3 weeks).",
|
||||
"type": "integer",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"backup_retain_weekly": {
|
||||
"category": "Backup",
|
||||
"description": "Number of weekly backups to retain (default 13 ≈ 3 months / 91 days).",
|
||||
"type": "integer",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# UI / Appearance
|
||||
"ui_default_color_scheme": {
|
||||
"category": "UI",
|
||||
|
||||
@@ -5,6 +5,7 @@ Aggregated view routers for the application.
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.views.admin_users import router as admin_users_router
|
||||
from app.views.backup import router as backup_router
|
||||
from app.views.db_wizard import router as db_wizard_router
|
||||
from app.views.dropbox import router as dropbox_router
|
||||
from app.views.filemanager import router as filemanager_router
|
||||
@@ -30,6 +31,7 @@ router = APIRouter()
|
||||
router.include_router(wizard_router) # Wizard first (for /setup)
|
||||
router.include_router(db_wizard_router) # Database wizard
|
||||
router.include_router(admin_users_router) # Admin user management
|
||||
router.include_router(backup_router) # Backup dashboard
|
||||
router.include_router(general_router)
|
||||
router.include_router(status_router)
|
||||
router.include_router(onedrive_router)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Backup management dashboard view – admin only.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models import BackupRecord
|
||||
from app.views.base import APIRouter, get_db, require_login, templates
|
||||
from app.views.settings import require_admin_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/admin/backup")
|
||||
@require_login
|
||||
@require_admin_access
|
||||
async def backup_dashboard(request: Request, db: Session = Depends(get_db)):
|
||||
"""Backup management dashboard – admin only."""
|
||||
try:
|
||||
records = db.query(BackupRecord).order_by(BackupRecord.created_at.desc()).limit(500).all()
|
||||
|
||||
# Summarise counts per tier
|
||||
counts: dict[str, int] = {"hourly": 0, "daily": 0, "weekly": 0}
|
||||
for r in records:
|
||||
if r.backup_type in counts:
|
||||
counts[r.backup_type] += 1
|
||||
|
||||
# Compute total local size
|
||||
total_size = sum(r.size_bytes for r in records if r.local_path and os.path.exists(r.local_path))
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"backup.html",
|
||||
{
|
||||
"request": request,
|
||||
"records": records,
|
||||
"counts": counts,
|
||||
"total_size": total_size,
|
||||
"backup_enabled": getattr(settings, "backup_enabled", True),
|
||||
"backup_remote_destination": getattr(settings, "backup_remote_destination", None),
|
||||
"backup_retain_hourly": getattr(settings, "backup_retain_hourly", 96),
|
||||
"backup_retain_daily": getattr(settings, "backup_retain_daily", 21),
|
||||
"backup_retain_weekly": getattr(settings, "backup_retain_weekly", 13),
|
||||
"app_version": settings.version,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading backup dashboard: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to load backup dashboard",
|
||||
)
|
||||
Reference in New Issue
Block a user