"""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"}