339 lines
14 KiB
Python
339 lines
14 KiB
Python
"""YakPanel - Site service"""
|
|
import os
|
|
import re
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
|
|
from app.models.site import Site, Domain
|
|
from app.models.redirect import SiteRedirect
|
|
from app.core.config import get_runtime_config
|
|
from app.core.utils import path_safe_check, write_file, read_file, exec_shell_sync
|
|
|
|
|
|
DOMAIN_REGEX = re.compile(r"^([\w\-\*]{1,100}\.){1,8}([\w\-]{1,24}|[\w\-]{1,24}\.[\w\-]{1,24})$")
|
|
|
|
|
|
def _render_vhost(
|
|
template: str,
|
|
server_names: str,
|
|
root_path: str,
|
|
logs_path: str,
|
|
site_name: str,
|
|
php_version: str,
|
|
force_https: int,
|
|
redirects: list[tuple[str, str, int]] | None = None,
|
|
) -> str:
|
|
"""Render nginx vhost template. redirects: [(source, target, code), ...]"""
|
|
force_block = "return 301 https://$host$request_uri;" if force_https else ""
|
|
redirect_lines = []
|
|
for src, tgt, code in (redirects or []):
|
|
if src and tgt:
|
|
redirect_lines.append(f" location = {src} {{ return {code} {tgt}; }}")
|
|
redirect_block = "\n".join(redirect_lines) if redirect_lines else ""
|
|
content = template.replace("{SERVER_NAMES}", server_names)
|
|
content = content.replace("{ROOT_PATH}", root_path)
|
|
content = content.replace("{LOGS_PATH}", logs_path)
|
|
content = content.replace("{SITE_NAME}", site_name)
|
|
content = content.replace("{PHP_VERSION}", php_version or "74")
|
|
content = content.replace("{FORCE_HTTPS_BLOCK}", force_block)
|
|
content = content.replace("{REDIRECTS_BLOCK}", redirect_block)
|
|
return content
|
|
|
|
|
|
async def domain_format(domains: list[str]) -> str | None:
|
|
"""Validate domain format. Returns first invalid domain or None."""
|
|
for d in domains:
|
|
if not DOMAIN_REGEX.match(d):
|
|
return d
|
|
return None
|
|
|
|
|
|
async def domain_exists(db: AsyncSession, domains: list[str], exclude_site_id: int | None = None) -> str | None:
|
|
"""Check if domain already exists. Returns first existing domain or None."""
|
|
for d in domains:
|
|
parts = d.split(":")
|
|
name, port = parts[0], parts[1] if len(parts) > 1 else "80"
|
|
q = select(Domain).where(Domain.name == name, Domain.port == port)
|
|
if exclude_site_id is not None:
|
|
q = q.where(Domain.pid != exclude_site_id)
|
|
result = await db.execute(q)
|
|
if result.scalar_one_or_none():
|
|
return d
|
|
return None
|
|
|
|
|
|
async def create_site(
|
|
db: AsyncSession,
|
|
name: str,
|
|
path: str,
|
|
domains: list[str],
|
|
project_type: str = "PHP",
|
|
ps: str = "",
|
|
php_version: str = "74",
|
|
force_https: int = 0,
|
|
) -> dict:
|
|
"""Create a new site with vhost config."""
|
|
if not path_safe_check(name) or not path_safe_check(path):
|
|
return {"status": False, "msg": "Invalid site name or path"}
|
|
|
|
invalid = await domain_format(domains)
|
|
if invalid:
|
|
return {"status": False, "msg": f"Invalid domain format: {invalid}"}
|
|
|
|
existing = await domain_exists(db, domains)
|
|
if existing:
|
|
return {"status": False, "msg": f"Domain already exists: {existing}"}
|
|
|
|
cfg = get_runtime_config()
|
|
setup_path = cfg["setup_path"]
|
|
www_root = cfg["www_root"]
|
|
www_logs = cfg["www_logs"]
|
|
vhost_path = os.path.join(setup_path, "panel", "vhost", "nginx")
|
|
|
|
site_path = os.path.join(www_root, name)
|
|
if not os.path.exists(site_path):
|
|
os.makedirs(site_path, 0o755)
|
|
|
|
site = Site(name=name, path=site_path, ps=ps, project_type=project_type, php_version=php_version or "74", force_https=force_https or 0)
|
|
db.add(site)
|
|
await db.flush()
|
|
|
|
for d in domains:
|
|
parts = d.split(":")
|
|
domain_name, port = parts[0], parts[1] if len(parts) > 1 else "80"
|
|
db.add(Domain(pid=site.id, name=domain_name, port=port))
|
|
|
|
await db.flush()
|
|
|
|
# Generate Nginx vhost
|
|
conf_path = os.path.join(vhost_path, f"{name}.conf")
|
|
panel_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
template_path = os.path.join(panel_root, "webserver", "templates", "nginx_site.conf")
|
|
|
|
if os.path.exists(template_path):
|
|
template = read_file(template_path) or ""
|
|
server_names = " ".join(d.split(":")[0] for d in domains)
|
|
content = _render_vhost(template, server_names, site_path, www_logs, name, php_version or "74", force_https or 0, [])
|
|
write_file(conf_path, content)
|
|
|
|
# Reload Nginx if available
|
|
nginx_bin = os.path.join(setup_path, "nginx", "sbin", "nginx")
|
|
if os.path.exists(nginx_bin):
|
|
exec_shell_sync(f"{nginx_bin} -t && {nginx_bin} -s reload")
|
|
|
|
await db.commit()
|
|
return {"status": True, "msg": "Site created", "id": site.id}
|
|
|
|
|
|
async def list_sites(db: AsyncSession) -> list[dict]:
|
|
"""List all sites with domain count."""
|
|
result = await db.execute(select(Site).order_by(Site.id))
|
|
sites = result.scalars().all()
|
|
out = []
|
|
for s in sites:
|
|
domain_result = await db.execute(select(Domain).where(Domain.pid == s.id))
|
|
domains = domain_result.scalars().all()
|
|
out.append({
|
|
"id": s.id,
|
|
"name": s.name,
|
|
"path": s.path,
|
|
"status": s.status,
|
|
"ps": s.ps,
|
|
"project_type": s.project_type,
|
|
"domain_count": len(domains),
|
|
"addtime": s.addtime.isoformat() if s.addtime else None,
|
|
})
|
|
return out
|
|
|
|
|
|
async def delete_site(db: AsyncSession, site_id: int) -> dict:
|
|
"""Delete a site and its vhost config."""
|
|
result = await db.execute(select(Site).where(Site.id == site_id))
|
|
site = result.scalar_one_or_none()
|
|
if not site:
|
|
return {"status": False, "msg": "Site not found"}
|
|
|
|
await db.execute(Domain.__table__.delete().where(Domain.pid == site_id))
|
|
await db.execute(SiteRedirect.__table__.delete().where(SiteRedirect.site_id == site_id))
|
|
await db.delete(site)
|
|
|
|
cfg = get_runtime_config()
|
|
conf_path = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx", f"{site.name}.conf")
|
|
if os.path.exists(conf_path):
|
|
os.remove(conf_path)
|
|
|
|
nginx_bin = os.path.join(cfg["setup_path"], "nginx", "sbin", "nginx")
|
|
if os.path.exists(nginx_bin):
|
|
exec_shell_sync(f"{nginx_bin} -s reload")
|
|
|
|
await db.commit()
|
|
return {"status": True, "msg": "Site deleted"}
|
|
|
|
|
|
async def get_site_count(db: AsyncSession) -> int:
|
|
"""Get total site count."""
|
|
from sqlalchemy import func
|
|
result = await db.execute(select(func.count()).select_from(Site))
|
|
return result.scalar() or 0
|
|
|
|
|
|
async def get_site_with_domains(db: AsyncSession, site_id: int) -> dict | None:
|
|
"""Get site with domain list for editing."""
|
|
result = await db.execute(select(Site).where(Site.id == site_id))
|
|
site = result.scalar_one_or_none()
|
|
if not site:
|
|
return None
|
|
domain_result = await db.execute(select(Domain).where(Domain.pid == site.id))
|
|
domains = domain_result.scalars().all()
|
|
domain_list = [f"{d.name}:{d.port}" if d.port != "80" else d.name for d in domains]
|
|
return {
|
|
"id": site.id,
|
|
"name": site.name,
|
|
"path": site.path,
|
|
"status": site.status,
|
|
"ps": site.ps,
|
|
"project_type": site.project_type,
|
|
"php_version": getattr(site, "php_version", None) or "74",
|
|
"force_https": getattr(site, "force_https", 0) or 0,
|
|
"domains": domain_list,
|
|
}
|
|
|
|
|
|
async def update_site(
|
|
db: AsyncSession,
|
|
site_id: int,
|
|
path: str | None = None,
|
|
domains: list[str] | None = None,
|
|
ps: str | None = None,
|
|
php_version: str | None = None,
|
|
force_https: int | None = None,
|
|
) -> dict:
|
|
"""Update site domains, path, or note."""
|
|
result = await db.execute(select(Site).where(Site.id == site_id))
|
|
site = result.scalar_one_or_none()
|
|
if not site:
|
|
return {"status": False, "msg": "Site not found"}
|
|
|
|
if domains is not None:
|
|
invalid = await domain_format(domains)
|
|
if invalid:
|
|
return {"status": False, "msg": f"Invalid domain format: {invalid}"}
|
|
existing = await domain_exists(db, domains, exclude_site_id=site_id)
|
|
if existing:
|
|
return {"status": False, "msg": f"Domain already exists: {existing}"}
|
|
await db.execute(Domain.__table__.delete().where(Domain.pid == site_id))
|
|
for d in domains:
|
|
parts = d.split(":")
|
|
domain_name, port = parts[0], parts[1] if len(parts) > 1 else "80"
|
|
db.add(Domain(pid=site.id, name=domain_name, port=port))
|
|
|
|
if path is not None and path_safe_check(path):
|
|
site.path = path
|
|
|
|
if ps is not None:
|
|
site.ps = ps
|
|
if php_version is not None:
|
|
site.php_version = php_version or "74"
|
|
if force_https is not None:
|
|
site.force_https = 1 if force_https else 0
|
|
|
|
await db.flush()
|
|
|
|
# Regenerate Nginx vhost if domains, php_version, or force_https changed
|
|
if domains is not None or php_version is not None or force_https is not None:
|
|
cfg = get_runtime_config()
|
|
vhost_path = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx")
|
|
conf_path = os.path.join(vhost_path, f"{site.name}.conf")
|
|
panel_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
template_path = os.path.join(panel_root, "webserver", "templates", "nginx_site.conf")
|
|
if os.path.exists(template_path):
|
|
template = read_file(template_path) or ""
|
|
domain_result = await db.execute(select(Domain).where(Domain.pid == site.id))
|
|
domain_rows = domain_result.scalars().all()
|
|
domain_list = [f"{d.name}:{d.port}" if d.port != "80" else d.name for d in domain_rows]
|
|
server_names = " ".join(d.split(":")[0] for d in domain_list) if domain_list else site.name
|
|
php_ver = getattr(site, "php_version", None) or "74"
|
|
fhttps = getattr(site, "force_https", 0) or 0
|
|
redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id))
|
|
redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()]
|
|
content = _render_vhost(template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects)
|
|
write_file(conf_path, content)
|
|
nginx_bin = os.path.join(cfg["setup_path"], "nginx", "sbin", "nginx")
|
|
if os.path.exists(nginx_bin):
|
|
exec_shell_sync(f"{nginx_bin} -t && {nginx_bin} -s reload")
|
|
|
|
await db.commit()
|
|
return {"status": True, "msg": "Site updated"}
|
|
|
|
|
|
def _vhost_path(site_name: str) -> tuple[str, str]:
|
|
"""Return (conf_path, disabled_path) for site vhost."""
|
|
cfg = get_runtime_config()
|
|
vhost_dir = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx")
|
|
disabled_dir = os.path.join(vhost_dir, "disabled")
|
|
return (
|
|
os.path.join(vhost_dir, f"{site_name}.conf"),
|
|
os.path.join(disabled_dir, f"{site_name}.conf"),
|
|
)
|
|
|
|
|
|
async def set_site_status(db: AsyncSession, site_id: int, status: int) -> dict:
|
|
"""Enable (1) or disable (0) site by moving vhost config."""
|
|
result = await db.execute(select(Site).where(Site.id == site_id))
|
|
site = result.scalar_one_or_none()
|
|
if not site:
|
|
return {"status": False, "msg": "Site not found"}
|
|
|
|
conf_path, disabled_path = _vhost_path(site.name)
|
|
disabled_dir = os.path.dirname(disabled_path)
|
|
|
|
if status == 1: # enable
|
|
if os.path.isfile(disabled_path):
|
|
os.makedirs(os.path.dirname(conf_path), exist_ok=True)
|
|
os.rename(disabled_path, conf_path)
|
|
else: # disable
|
|
if os.path.isfile(conf_path):
|
|
os.makedirs(disabled_dir, exist_ok=True)
|
|
os.rename(conf_path, disabled_path)
|
|
|
|
site.status = status
|
|
await db.commit()
|
|
|
|
nginx_bin = os.path.join(get_runtime_config()["setup_path"], "nginx", "sbin", "nginx")
|
|
if os.path.exists(nginx_bin):
|
|
exec_shell_sync(f"{nginx_bin} -t && {nginx_bin} -s reload")
|
|
|
|
return {"status": True, "msg": "Site " + ("enabled" if status == 1 else "disabled")}
|
|
|
|
|
|
async def regenerate_site_vhost(db: AsyncSession, site_id: int) -> dict:
|
|
"""Regenerate nginx vhost for a site (e.g. after redirect changes)."""
|
|
result = await db.execute(select(Site).where(Site.id == site_id))
|
|
site = result.scalar_one_or_none()
|
|
if not site:
|
|
return {"status": False, "msg": "Site not found"}
|
|
cfg = get_runtime_config()
|
|
vhost_path = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx")
|
|
conf_path = os.path.join(vhost_path, f"{site.name}.conf")
|
|
if site.status != 1:
|
|
return {"status": True, "msg": "Site disabled, vhost not active"}
|
|
panel_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
template_path = os.path.join(panel_root, "webserver", "templates", "nginx_site.conf")
|
|
if not os.path.exists(template_path):
|
|
return {"status": False, "msg": "Template not found"}
|
|
template = read_file(template_path) or ""
|
|
domain_result = await db.execute(select(Domain).where(Domain.pid == site.id))
|
|
domain_rows = domain_result.scalars().all()
|
|
domain_list = [f"{d.name}:{d.port}" if d.port != "80" else d.name for d in domain_rows]
|
|
server_names = " ".join(d.split(":")[0] for d in domain_list) if domain_list else site.name
|
|
php_ver = getattr(site, "php_version", None) or "74"
|
|
fhttps = getattr(site, "force_https", 0) or 0
|
|
redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id))
|
|
redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()]
|
|
content = _render_vhost(template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects)
|
|
write_file(conf_path, content)
|
|
nginx_bin = os.path.join(cfg["setup_path"], "nginx", "sbin", "nginx")
|
|
if os.path.exists(nginx_bin):
|
|
exec_shell_sync(f"{nginx_bin} -t && {nginx_bin} -s reload")
|
|
return {"status": True, "msg": "Vhost regenerated"}
|