365 lines
12 KiB
Python
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"}
|