Files
yakpanel-core/YakPanel-server/backend/app/api/site.py
2026-04-07 02:04:22 +05:30

365 lines
12 KiB
Python

"""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
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
@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,
)
if not result["status"]:
raise HTTPException(status_code=400, detail=result["msg"])
return result
@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),
)
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"}