"""YakPanel - Site API""" import os import tarfile from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from pydantic import BaseModel from app.core.database import get_db from app.core.config import get_runtime_config from app.core.notification import send_email from app.core.utils import exec_shell_sync from app.api.auth import get_current_user from app.models.user import User from app.models.site import Site from app.models.redirect import SiteRedirect from app.services.site_service import create_site, list_sites, delete_site, get_site_with_domains, update_site, set_site_status, regenerate_site_vhost router = APIRouter(prefix="/site", tags=["site"]) class CreateSiteRequest(BaseModel): name: str path: str | None = None domains: list[str] project_type: str = "PHP" ps: str = "" php_version: str = "74" force_https: bool = False proxy_upstream: str = "" proxy_websocket: bool = False dir_auth_path: str = "" dir_auth_user_file: str = "" php_deny_execute: bool = False class UpdateSiteRequest(BaseModel): path: str | None = None domains: list[str] | None = None ps: str | None = None php_version: str | None = None force_https: bool | None = None proxy_upstream: str | None = None proxy_websocket: bool | None = None dir_auth_path: str | None = None dir_auth_user_file: str | None = None php_deny_execute: bool | None = None @router.get("/list") async def site_list( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List all sites""" return await list_sites(db) @router.post("/create") async def site_create( body: CreateSiteRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Create a new site""" cfg = get_runtime_config() path = body.path or os.path.join(cfg["www_root"], body.name) result = await create_site( db, name=body.name, path=path, domains=body.domains, project_type=body.project_type, ps=body.ps, php_version=body.php_version or "74", force_https=1 if body.force_https else 0, proxy_upstream=(body.proxy_upstream or "").strip(), proxy_websocket=1 if body.proxy_websocket else 0, dir_auth_path=(body.dir_auth_path or "").strip(), dir_auth_user_file=(body.dir_auth_user_file or "").strip(), php_deny_execute=1 if body.php_deny_execute else 0, ) if not result["status"]: raise HTTPException(status_code=400, detail=result["msg"]) return result class SiteBatchRequest(BaseModel): action: str ids: list[int] @router.post("/batch") async def site_batch( body: SiteBatchRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Bulk enable, disable, or delete sites by id.""" if body.action not in ("enable", "disable", "delete"): raise HTTPException(status_code=400, detail="action must be enable, disable, or delete") if not body.ids: raise HTTPException(status_code=400, detail="ids required") results: list[dict] = [] for sid in body.ids: if body.action == "delete": r = await delete_site(db, sid) elif body.action == "enable": r = await set_site_status(db, sid, 1) else: r = await set_site_status(db, sid, 0) results.append({ "id": sid, "ok": bool(r.get("status")), "msg": r.get("msg", ""), }) return {"results": results} @router.get("/{site_id}") async def site_get( site_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Get site with domains for editing""" data = await get_site_with_domains(db, site_id) if not data: raise HTTPException(status_code=404, detail="Site not found") return data @router.put("/{site_id}") async def site_update( site_id: int, body: UpdateSiteRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Update site domains, path, or note""" result = await update_site( db, site_id, path=body.path, domains=body.domains, ps=body.ps, php_version=body.php_version, force_https=None if body.force_https is None else (1 if body.force_https else 0), proxy_upstream=body.proxy_upstream, proxy_websocket=None if body.proxy_websocket is None else (1 if body.proxy_websocket else 0), dir_auth_path=body.dir_auth_path, dir_auth_user_file=body.dir_auth_user_file, php_deny_execute=None if body.php_deny_execute is None else (1 if body.php_deny_execute else 0), ) if not result["status"]: raise HTTPException(status_code=400, detail=result["msg"]) return result class SiteStatusRequest(BaseModel): status: int @router.post("/{site_id}/status") async def site_set_status( site_id: int, body: SiteStatusRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Enable (1) or disable (0) site""" if body.status not in (0, 1): raise HTTPException(status_code=400, detail="Status must be 0 or 1") result = await set_site_status(db, site_id, body.status) if not result["status"]: raise HTTPException(status_code=404, detail=result["msg"]) return result class AddRedirectRequest(BaseModel): source: str target: str code: int = 301 class GitCloneRequest(BaseModel): url: str branch: str = "main" @router.post("/{site_id}/git/clone") async def site_git_clone( site_id: int, body: GitCloneRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Clone Git repo into site path (git clone -b branch url .)""" result = await db.execute(select(Site).where(Site.id == site_id)) site = result.scalar_one_or_none() if not site: raise HTTPException(status_code=404, detail="Site not found") url = (body.url or "").strip() if not url or " " in url or ";" in url or "|" in url: raise HTTPException(status_code=400, detail="Invalid Git URL") path = site.path if not os.path.isdir(path): raise HTTPException(status_code=400, detail="Site path does not exist") branch = body.branch or "main" if os.path.isdir(os.path.join(path, ".git")): raise HTTPException(status_code=400, detail="Already a Git repo; use Pull instead") out, err = exec_shell_sync( f"cd {path} && git init && git remote add origin {url} && git fetch origin {branch} && git checkout -b {branch} origin/{branch}", timeout=120, ) if err and "error" in err.lower() and "fatal" in err.lower(): raise HTTPException(status_code=500, detail=err.strip() or out.strip()) return {"status": True, "msg": "Cloned"} @router.post("/{site_id}/git/pull") async def site_git_pull( site_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Git pull in site path""" result = await db.execute(select(Site).where(Site.id == site_id)) site = result.scalar_one_or_none() if not site: raise HTTPException(status_code=404, detail="Site not found") path = site.path if not os.path.isdir(os.path.join(path, ".git")): raise HTTPException(status_code=400, detail="Not a Git repository") out, err = exec_shell_sync(f"cd {path} && git pull", timeout=60) if err and "error" in err.lower() and "fatal" in err.lower(): raise HTTPException(status_code=500, detail=err.strip() or out.strip()) return {"status": True, "msg": "Pulled", "output": out} @router.get("/{site_id}/redirects") async def site_redirects_list( site_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List redirects for a site""" result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site_id).order_by(SiteRedirect.id)) rows = result.scalars().all() return [{"id": r.id, "source": r.source, "target": r.target, "code": r.code} for r in rows] @router.post("/{site_id}/redirects") async def site_redirect_add( site_id: int, body: AddRedirectRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Add redirect for a site""" result = await db.execute(select(Site).where(Site.id == site_id)) if not result.scalar_one_or_none(): raise HTTPException(status_code=404, detail="Site not found") if not body.source or not body.target: raise HTTPException(status_code=400, detail="Source and target required") if body.code not in (301, 302): raise HTTPException(status_code=400, detail="Code must be 301 or 302") r = SiteRedirect(site_id=site_id, source=body.source.strip(), target=body.target.strip(), code=body.code) db.add(r) await db.commit() regen = await regenerate_site_vhost(db, site_id) if not regen["status"]: pass # redirect saved, vhost may need manual reload return {"status": True, "msg": "Redirect added", "id": r.id} @router.delete("/{site_id}/redirects/{redirect_id}") async def site_redirect_delete( site_id: int, redirect_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Delete a redirect""" result = await db.execute(select(SiteRedirect).where(SiteRedirect.id == redirect_id, SiteRedirect.site_id == site_id)) r = result.scalar_one_or_none() if not r: raise HTTPException(status_code=404, detail="Redirect not found") await db.delete(r) await db.commit() await regenerate_site_vhost(db, site_id) return {"status": True, "msg": "Redirect deleted"} @router.delete("/{site_id}") async def site_delete( site_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Delete a site""" result = await delete_site(db, site_id) if not result["status"]: raise HTTPException(status_code=404, detail=result["msg"]) return result class RestoreRequest(BaseModel): filename: str @router.post("/{site_id}/backup") async def site_backup( site_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Create tar.gz backup of site directory""" result = await db.execute(select(Site).where(Site.id == site_id)) site = result.scalar_one_or_none() if not site: raise HTTPException(status_code=404, detail="Site not found") if not os.path.isdir(site.path): raise HTTPException(status_code=400, detail="Site path does not exist") 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)) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # Send notification if email configured send_email( subject=f"YakPanel - Site backup: {site.name}", body=f"Backup completed: {filename}\nSite: {site.name}\nPath: {site.path}", ) return {"status": True, "msg": "Backup created", "filename": filename} @router.get("/{site_id}/backups") async def site_backups_list( site_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List backups for a site""" result = await db.execute(select(Site).where(Site.id == site_id)) site = result.scalar_one_or_none() if not site: raise HTTPException(status_code=404, detail="Site not found") cfg = get_runtime_config() backup_dir = cfg["backup_path"] if not os.path.isdir(backup_dir): return {"backups": []} prefix = f"{site.name}_" backups = [] for f in os.listdir(backup_dir): if f.startswith(prefix) and f.endswith(".tar.gz"): p = os.path.join(backup_dir, f) backups.append({"filename": f, "size": os.path.getsize(p) if os.path.isfile(p) else 0}) backups.sort(key=lambda x: x["filename"], reverse=True) return {"backups": backups} @router.get("/{site_id}/backups/download") async def site_backup_download( site_id: int, file: str = Query(...), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Download backup file""" result = await db.execute(select(Site).where(Site.id == site_id)) site = result.scalar_one_or_none() if not site: raise HTTPException(status_code=404, detail="Site not found") if ".." in file or "/" in file or "\\" in file or not file.startswith(f"{site.name}_") or not file.endswith(".tar.gz"): raise HTTPException(status_code=400, detail="Invalid filename") cfg = get_runtime_config() path = os.path.join(cfg["backup_path"], file) if not os.path.isfile(path): raise HTTPException(status_code=404, detail="Backup not found") return FileResponse(path, filename=file) @router.post("/{site_id}/restore") async def site_restore( site_id: int, body: RestoreRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Restore site from backup""" result = await db.execute(select(Site).where(Site.id == site_id)) site = result.scalar_one_or_none() if not site: raise HTTPException(status_code=404, detail="Site not found") file = body.filename if ".." in file or "/" in file or "\\" in file or not file.startswith(f"{site.name}_") or not file.endswith(".tar.gz"): raise HTTPException(status_code=400, detail="Invalid filename") cfg = get_runtime_config() backup_path = os.path.join(cfg["backup_path"], file) if not os.path.isfile(backup_path): raise HTTPException(status_code=404, detail="Backup not found") parent = os.path.dirname(site.path) try: with tarfile.open(backup_path, "r:gz") as tf: tf.extractall(parent) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) return {"status": True, "msg": "Restored"}