Initial YakPanel commit
This commit is contained in:
364
YakPanel-server/backend/app/api/site.py
Normal file
364
YakPanel-server/backend/app/api/site.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""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"}
|
||||
Reference in New Issue
Block a user