"""YakPanel - Site service""" import os import re from datetime import datetime, timezone 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, primary domain, backup count, SSL summary.""" cfg = get_runtime_config() backup_dir = cfg.get("backup_path") or "" 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).order_by(Domain.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] hostnames = [d.name for d in domain_rows] primary = hostnames[0] if hostnames else "" php_ver = getattr(s, "php_version", None) or "74" 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(domain_rows), "addtime": s.addtime.isoformat() if s.addtime else None, "php_version": php_ver, "primary_domain": primary, "domains": domain_list, "backup_count": _backup_count(s.name, backup_dir), "ssl": _best_ssl_for_hostnames(hostnames), }) 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"}