"""YakPanel - Backup plans API""" import os import tarfile from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from pydantic import BaseModel from croniter import croniter from app.core.database import get_db from app.core.config import get_runtime_config from app.core.notification import send_email from app.api.auth import get_current_user from app.models.user import User from app.models.site import Site from app.models.database import Database from app.models.backup_plan import BackupPlan from app.services.database_service import backup_mysql_database, backup_postgresql_database, backup_mongodb_database router = APIRouter(prefix="/backup", tags=["backup"]) class CreateBackupPlanRequest(BaseModel): name: str plan_type: str # site | database target_id: int schedule: str # cron, e.g. "0 2 * * *" enabled: bool = True @router.get("/plans") async def backup_plans_list( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List all backup plans""" result = await db.execute(select(BackupPlan).order_by(BackupPlan.id)) rows = result.scalars().all() return [ { "id": r.id, "name": r.name, "plan_type": r.plan_type, "target_id": r.target_id, "schedule": r.schedule, "enabled": r.enabled, } for r in rows ] @router.post("/plans") async def backup_plan_create( body: CreateBackupPlanRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Create a backup plan""" if body.plan_type not in ("site", "database"): raise HTTPException(status_code=400, detail="plan_type must be site or database") if not body.schedule or len(body.schedule) < 9: raise HTTPException(status_code=400, detail="Invalid cron schedule") try: croniter(body.schedule) except Exception: raise HTTPException(status_code=400, detail="Invalid cron expression") if body.plan_type == "site": r = await db.execute(select(Site).where(Site.id == body.target_id)) if not r.scalar_one_or_none(): raise HTTPException(status_code=404, detail="Site not found") else: r = await db.execute(select(Database).where(Database.id == body.target_id)) if not r.scalar_one_or_none(): raise HTTPException(status_code=404, detail="Database not found") plan = BackupPlan( name=body.name, plan_type=body.plan_type, target_id=body.target_id, schedule=body.schedule, enabled=body.enabled, ) db.add(plan) await db.commit() return {"status": True, "msg": "Backup plan created", "id": plan.id} @router.put("/plans/{plan_id}") async def backup_plan_update( plan_id: int, body: CreateBackupPlanRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Update a backup plan""" result = await db.execute(select(BackupPlan).where(BackupPlan.id == plan_id)) plan = result.scalar_one_or_none() if not plan: raise HTTPException(status_code=404, detail="Backup plan not found") if body.schedule: try: croniter(body.schedule) except Exception: raise HTTPException(status_code=400, detail="Invalid cron expression") plan.name = body.name plan.plan_type = body.plan_type plan.target_id = body.target_id plan.schedule = body.schedule plan.enabled = body.enabled await db.commit() return {"status": True, "msg": "Updated"} @router.delete("/plans/{plan_id}") async def backup_plan_delete( plan_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Delete a backup plan""" result = await db.execute(select(BackupPlan).where(BackupPlan.id == plan_id)) plan = result.scalar_one_or_none() if not plan: raise HTTPException(status_code=404, detail="Backup plan not found") await db.delete(plan) await db.commit() return {"status": True, "msg": "Deleted"} def _run_site_backup(site: Site) -> tuple[bool, str, str | None]: """Run site backup (sync, for use in run_scheduled). Returns (ok, msg, filename).""" cfg = get_runtime_config() backup_dir = cfg["backup_path"] os.makedirs(backup_dir, exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"{site.name}_{ts}.tar.gz" dest = os.path.join(backup_dir, filename) try: with tarfile.open(dest, "w:gz") as tf: tf.add(site.path, arcname=os.path.basename(site.path)) return True, "Backup created", filename except Exception as e: return False, str(e), None def _run_database_backup(dbo: Database) -> tuple[bool, str, str | None]: """Run database backup (sync). Returns (ok, msg, filename).""" cfg = get_runtime_config() backup_dir = os.path.join(cfg["backup_path"], "database") if dbo.db_type == "MySQL": return backup_mysql_database(dbo.name, backup_dir) if dbo.db_type == "PostgreSQL": return backup_postgresql_database(dbo.name, backup_dir) if dbo.db_type == "MongoDB": return backup_mongodb_database(dbo.name, backup_dir) return False, "Unsupported database type", None @router.post("/run-scheduled") async def backup_run_scheduled( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Run all due backup plans. Call this from cron (e.g. every hour) or manually.""" from datetime import datetime as dt now = dt.utcnow() result = await db.execute(select(BackupPlan).where(BackupPlan.enabled == True)) plans = result.scalars().all() results = [] for plan in plans: try: prev_run = croniter(plan.schedule, now).get_prev(dt) # Run if we're within 15 minutes after the scheduled time secs_since = (now - prev_run).total_seconds() if secs_since > 900 or secs_since < 0: # Not within 15 min window continue except Exception: continue if plan.plan_type == "site": r = await db.execute(select(Site).where(Site.id == plan.target_id)) site = r.scalar_one_or_none() if not site or not os.path.isdir(site.path): results.append({"plan": plan.name, "status": "skipped", "msg": "Site not found or path invalid"}) continue ok, msg, filename = _run_site_backup(site) if ok: send_email( subject=f"YakPanel - Scheduled backup: {plan.name}", body=f"Site backup completed: {filename}\nSite: {site.name}", ) else: r = await db.execute(select(Database).where(Database.id == plan.target_id)) dbo = r.scalar_one_or_none() if not dbo: results.append({"plan": plan.name, "status": "skipped", "msg": "Database not found"}) continue ok, msg, filename = _run_database_backup(dbo) if ok: send_email( subject=f"YakPanel - Scheduled backup: {plan.name}", body=f"Database backup completed: {filename}\nDatabase: {dbo.name}", ) results.append({"plan": plan.name, "status": "ok" if ok else "failed", "msg": msg}) return {"status": True, "results": results}