Initial YakPanel commit

This commit is contained in:
Niranjan
2026-04-07 02:04:22 +05:30
commit 2826d3e7f3
5359 changed files with 1390724 additions and 0 deletions

View File

@@ -0,0 +1 @@
# YakPanel - Services

View File

@@ -0,0 +1,49 @@
"""YakPanel - Config service (panel settings)"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.config import Config
from app.core.config import get_settings
async def get_config_value(db: AsyncSession, key: str) -> str:
"""Get config value by key"""
result = await db.execute(select(Config).where(Config.key == key))
row = result.scalar_one_or_none()
return row.value if row else ""
async def set_config_value(db: AsyncSession, key: str, value: str) -> None:
"""Set config value"""
result = await db.execute(select(Config).where(Config.key == key))
row = result.scalar_one_or_none()
if row:
row.value = value
else:
db.add(Config(key=key, value=value))
await db.commit()
def get_webserver_type() -> str:
"""Get webserver type (nginx, apache, openlitespeed)"""
return get_settings().webserver_type
def get_setup_path() -> str:
"""Get server setup path"""
return get_settings().setup_path
def get_www_root() -> str:
"""Get www root path"""
return get_settings().www_root
def get_www_logs() -> str:
"""Get www logs path"""
return get_settings().www_logs
def get_vhost_path() -> str:
"""Get vhost config path"""
return get_settings().vhost_path

View File

@@ -0,0 +1,411 @@
"""YakPanel - Database service (MySQL creation via CLI; PostgreSQL/MongoDB/Redis panel records only)"""
import os
import shutil
import subprocess
from datetime import datetime
from app.core.config import get_runtime_config
def get_mysql_root() -> str | None:
"""Get MySQL root password from config (key: mysql_root)"""
cfg = get_runtime_config()
return cfg.get("mysql_root") or None
def _run_mysql(sql: str, root_pw: str) -> tuple[str, str]:
"""Run mysql with SQL. Uses MYSQL_PWD to avoid password in argv."""
env = os.environ.copy()
env["MYSQL_PWD"] = root_pw
try:
r = subprocess.run(
["mysql", "-u", "root", "-e", sql],
capture_output=True,
text=True,
timeout=10,
env=env,
)
return r.stdout or "", r.stderr or ""
except FileNotFoundError:
return "", "mysql command not found"
except subprocess.TimeoutExpired:
return "", "Timed out"
def create_mysql_database(db_name: str, username: str, password: str) -> tuple[bool, str]:
"""Create MySQL database and user via mysql CLI. Returns (success, message)."""
root_pw = get_mysql_root()
if not root_pw:
return False, "MySQL root password not configured. Set it in Settings."
for x in (db_name, username):
if not all(c.isalnum() or c == "_" for c in x):
return False, f"Invalid characters in name: {x}"
pw_esc = password.replace("\\", "\\\\").replace("'", "''")
steps = [
(f"CREATE DATABASE IF NOT EXISTS `{db_name}`;", "create database"),
(f"CREATE USER IF NOT EXISTS '{username}'@'localhost' IDENTIFIED BY '{pw_esc}';", "create user"),
(f"GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{username}'@'localhost';", "grant"),
("FLUSH PRIVILEGES;", "flush"),
]
for sql, step in steps:
out, err = _run_mysql(sql, root_pw)
if err and "error" in err.lower() and "already exists" not in err.lower():
return False, f"{step}: {err.strip() or out.strip()}"
return True, "Database created"
def drop_mysql_database(db_name: str, username: str) -> tuple[bool, str]:
"""Drop MySQL database and user."""
root_pw = get_mysql_root()
if not root_pw:
return False, "MySQL root password not configured"
for x in (db_name, username):
if not all(c.isalnum() or c == "_" for c in x):
return False, f"Invalid characters: {x}"
steps = [
(f"DROP DATABASE IF EXISTS `{db_name}`;", "drop database"),
(f"DROP USER IF EXISTS '{username}'@'localhost';", "drop user"),
("FLUSH PRIVILEGES;", "flush"),
]
for sql, step in steps:
out, err = _run_mysql(sql, root_pw)
if err and "error" in err.lower():
return False, f"{step}: {err.strip() or out.strip()}"
return True, "Database dropped"
def backup_mysql_database(db_name: str, backup_dir: str) -> tuple[bool, str, str | None]:
"""Create mysqldump backup. Returns (success, message, filename)."""
root_pw = get_mysql_root()
if not root_pw:
return False, "MySQL root password not configured", None
if not all(c.isalnum() or c == "_" for c in db_name):
return False, "Invalid database name", None
os.makedirs(backup_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{db_name}_{ts}.sql.gz"
dest = os.path.join(backup_dir, filename)
env = os.environ.copy()
env["MYSQL_PWD"] = root_pw
try:
r = subprocess.run(
f'mysqldump -u root {db_name} | gzip > "{dest}"',
shell=True,
env=env,
timeout=300,
capture_output=True,
text=True,
)
if r.returncode != 0 or not os.path.isfile(dest):
return False, r.stderr or r.stdout or "Backup failed", None
return True, "Backup created", filename
except subprocess.TimeoutExpired:
return False, "Timed out", None
except Exception as e:
return False, str(e), None
def restore_mysql_database(db_name: str, backup_path: str) -> tuple[bool, str]:
"""Restore MySQL database from .sql.gz backup."""
root_pw = get_mysql_root()
if not root_pw:
return False, "MySQL root password not configured"
if not all(c.isalnum() or c == "_" for c in db_name):
return False, "Invalid database name"
if not os.path.isfile(backup_path):
return False, "Backup file not found"
env = os.environ.copy()
env["MYSQL_PWD"] = root_pw
try:
r = subprocess.run(
f'gunzip -c "{backup_path}" | mysql -u root {db_name}',
shell=True,
env=env,
timeout=300,
capture_output=True,
text=True,
)
if r.returncode != 0:
return False, r.stderr or r.stdout or "Restore failed"
return True, "Restored"
except subprocess.TimeoutExpired:
return False, "Timed out"
except Exception as e:
return False, str(e)
def change_mysql_password(username: str, new_password: str) -> tuple[bool, str]:
"""Change MySQL user password."""
root_pw = get_mysql_root()
if not root_pw:
return False, "MySQL root password not configured"
if not all(c.isalnum() or c == "_" for c in username):
return False, "Invalid username"
pw_esc = new_password.replace("\\", "\\\\").replace("'", "''")
sql = f"ALTER USER '{username}'@'localhost' IDENTIFIED BY '{pw_esc}';"
out, err = _run_mysql(sql, root_pw)
if err and "error" in err.lower():
return False, err.strip() or out.strip()
return True, "Password updated"
def create_postgresql_database(db_name: str, username: str, password: str) -> tuple[bool, str]:
"""Create PostgreSQL database and user via psql (runs as postgres). Returns (success, message)."""
for x in (db_name, username):
if not all(c.isalnum() or c == "_" for c in x):
return False, f"Invalid characters in name: {x}"
pw_esc = password.replace("'", "''")
cmds = [
f"CREATE DATABASE {db_name};",
f"CREATE USER {username} WITH PASSWORD '{pw_esc}';",
f"GRANT ALL PRIVILEGES ON DATABASE {db_name} TO {username};",
f"GRANT ALL ON SCHEMA public TO {username};",
]
for sql in cmds:
try:
r = subprocess.run(
["sudo", "-u", "postgres", "psql", "-tAc", sql],
capture_output=True,
text=True,
timeout=10,
)
if r.returncode != 0:
err = (r.stderr or r.stdout or "Failed").strip()[:200]
if "already exists" in err.lower():
continue
return False, err
except FileNotFoundError:
return False, "PostgreSQL (psql) not found"
return True, "Database created"
def drop_postgresql_database(db_name: str, username: str) -> tuple[bool, str]:
"""Drop PostgreSQL database and user."""
for x in (db_name, username):
if not all(c.isalnum() or c == "_" for c in x):
return False, f"Invalid characters: {x}"
try:
r = subprocess.run(
["sudo", "-u", "postgres", "psql", "-tAc", f"DROP DATABASE IF EXISTS {db_name};"],
capture_output=True,
text=True,
timeout=10,
)
if r.returncode != 0:
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
r = subprocess.run(
["sudo", "-u", "postgres", "psql", "-tAc", f"DROP USER IF EXISTS {username};"],
capture_output=True,
text=True,
timeout=10,
)
if r.returncode != 0:
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
return True, "Database dropped"
except FileNotFoundError:
return False, "PostgreSQL (psql) not found"
def change_postgresql_password(username: str, new_password: str) -> tuple[bool, str]:
"""Change PostgreSQL user password."""
if not all(c.isalnum() or c == "_" for c in username):
return False, "Invalid username"
pw_esc = new_password.replace("'", "''")
try:
r = subprocess.run(
["sudo", "-u", "postgres", "psql", "-tAc", f"ALTER USER {username} WITH PASSWORD '{pw_esc}';"],
capture_output=True,
text=True,
timeout=10,
)
if r.returncode != 0:
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
return True, "Password updated"
except FileNotFoundError:
return False, "PostgreSQL (psql) not found"
def create_mongodb_database(db_name: str, username: str, password: str) -> tuple[bool, str]:
"""Create MongoDB database and user via mongosh."""
for x in (db_name, username):
if not all(c.isalnum() or c == "_" for c in x):
return False, f"Invalid characters in name: {x}"
pw_esc = password.replace("\\", "\\\\").replace("'", "\\'")
js = (
f"db = db.getSiblingDB('{db_name}'); "
f"db.createUser({{user: '{username}', pwd: '{pw_esc}', roles: [{{role: 'readWrite', db: '{db_name}'}}]}});"
)
try:
r = subprocess.run(
["mongosh", "--quiet", "--eval", js],
capture_output=True,
text=True,
timeout=15,
)
if r.returncode != 0:
err = (r.stderr or r.stdout or "Failed").strip()[:200]
if "already exists" in err.lower():
return True, "Database created"
return False, err
return True, "Database created"
except FileNotFoundError:
return False, "MongoDB (mongosh) not found"
def drop_mongodb_database(db_name: str, username: str) -> tuple[bool, str]:
"""Drop MongoDB database and user."""
for x in (db_name, username):
if not all(c.isalnum() or c == "_" for c in x):
return False, f"Invalid characters: {x}"
try:
js = f"db = db.getSiblingDB('{db_name}'); db.dropUser('{username}'); db.dropDatabase();"
r = subprocess.run(
["mongosh", "--quiet", "--eval", js],
capture_output=True,
text=True,
timeout=15,
)
if r.returncode != 0:
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
return True, "Database dropped"
except FileNotFoundError:
return False, "MongoDB (mongosh) not found"
def backup_postgresql_database(db_name: str, backup_dir: str) -> tuple[bool, str, str | None]:
"""Create PostgreSQL backup via pg_dump. Returns (success, message, filename)."""
if not all(c.isalnum() or c == "_" for c in db_name):
return False, "Invalid database name", None
os.makedirs(backup_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{db_name}_{ts}.sql.gz"
dest = os.path.join(backup_dir, filename)
try:
r = subprocess.run(
f'sudo -u postgres pg_dump {db_name} | gzip > "{dest}"',
shell=True,
timeout=300,
capture_output=True,
text=True,
)
if r.returncode != 0 or not os.path.isfile(dest):
return False, (r.stderr or r.stdout or "Backup failed").strip()[:200], None
return True, "Backup created", filename
except subprocess.TimeoutExpired:
return False, "Timed out", None
except Exception as e:
return False, str(e), None
def restore_postgresql_database(db_name: str, backup_path: str) -> tuple[bool, str]:
"""Restore PostgreSQL database from .sql.gz backup."""
if not all(c.isalnum() or c == "_" for c in db_name):
return False, "Invalid database name"
if not os.path.isfile(backup_path):
return False, "Backup file not found"
try:
r = subprocess.run(
f'gunzip -c "{backup_path}" | sudo -u postgres psql -d {db_name}',
shell=True,
timeout=300,
capture_output=True,
text=True,
)
if r.returncode != 0:
return False, (r.stderr or r.stdout or "Restore failed").strip()[:200]
return True, "Restored"
except subprocess.TimeoutExpired:
return False, "Timed out"
except Exception as e:
return False, str(e)
def backup_mongodb_database(db_name: str, backup_dir: str) -> tuple[bool, str, str | None]:
"""Create MongoDB backup via mongodump. Returns (success, message, filename)."""
if not all(c.isalnum() or c == "_" for c in db_name):
return False, "Invalid database name", None
os.makedirs(backup_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out_dir = os.path.join(backup_dir, f"{db_name}_{ts}")
try:
r = subprocess.run(
["mongodump", "--db", db_name, "--out", out_dir, "--gzip"],
capture_output=True,
text=True,
timeout=300,
)
if r.returncode != 0:
return False, (r.stderr or r.stdout or "Backup failed").strip()[:200], None
# Create tarball of the dump
archive = out_dir + ".tar.gz"
r2 = subprocess.run(
f'tar -czf "{archive}" -C "{backup_dir}" "{os.path.basename(out_dir)}"',
shell=True,
capture_output=True,
text=True,
timeout=120,
)
if os.path.isdir(out_dir):
shutil.rmtree(out_dir, ignore_errors=True)
if r2.returncode != 0 or not os.path.isfile(archive):
return False, "Archive failed", None
return True, "Backup created", os.path.basename(archive)
except subprocess.TimeoutExpired:
return False, "Timed out", None
except Exception as e:
return False, str(e), None
def restore_mongodb_database(db_name: str, backup_path: str) -> tuple[bool, str]:
"""Restore MongoDB database from .tar.gz mongodump backup."""
if not all(c.isalnum() or c == "_" for c in db_name):
return False, "Invalid database name"
if not os.path.isfile(backup_path):
return False, "Backup file not found"
import tempfile
tmp = tempfile.mkdtemp()
try:
r = subprocess.run(
["tar", "-xzf", backup_path, "-C", tmp],
capture_output=True,
text=True,
timeout=60,
)
if r.returncode != 0:
return False, (r.stderr or r.stdout or "Extract failed").strip()[:200]
# Find the extracted dir (mongodump creates db_name/ subdir)
extracted = os.path.join(tmp, os.listdir(tmp)[0]) if os.listdir(tmp) else None
if not extracted or not os.path.isdir(extracted):
return False, "Invalid backup format"
r2 = subprocess.run(
["mongorestore", "--db", db_name, "--gzip", "--drop", extracted],
capture_output=True,
text=True,
timeout=300,
)
if r2.returncode != 0:
return False, (r2.stderr or r2.stdout or "Restore failed").strip()[:200]
return True, "Restored"
except Exception as e:
return False, str(e)
finally:
shutil.rmtree(tmp, ignore_errors=True)
def change_mongodb_password(username: str, db_name: str, new_password: str) -> tuple[bool, str]:
"""Change MongoDB user password."""
if not all(c.isalnum() or c == "_" for c in username):
return False, "Invalid username"
pw_esc = new_password.replace("\\", "\\\\").replace("'", "\\'")
js = f"db = db.getSiblingDB('{db_name}'); db.changeUserPassword('{username}', '{pw_esc}');"
try:
r = subprocess.run(
["mongosh", "--quiet", "--eval", js],
capture_output=True,
text=True,
timeout=15,
)
if r.returncode != 0:
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
return True, "Password updated"
except FileNotFoundError:
return False, "MongoDB (mongosh) not found"

View File

@@ -0,0 +1,69 @@
"""YakPanel - FTP service (Pure-FTPd via pure-pw)"""
import os
import subprocess
from app.core.config import get_runtime_config
def _run_pure_pw(args: str, stdin: str | None = None) -> tuple[str, str]:
"""Run pure-pw command. Returns (stdout, stderr)."""
try:
r = subprocess.run(
f"pure-pw {args}",
shell=True,
input=stdin,
capture_output=True,
text=True,
timeout=10,
)
return r.stdout or "", r.stderr or ""
except FileNotFoundError:
return "", "pure-pw not found"
except subprocess.TimeoutExpired:
return "", "Timed out"
def create_ftp_user(name: str, password: str, path: str) -> tuple[bool, str]:
"""Create Pure-FTPd virtual user via pure-pw."""
if not all(c.isalnum() or c in "._-" for c in name):
return False, "Invalid username"
if ".." in path:
return False, "Invalid path"
path_abs = os.path.abspath(path)
cfg = get_runtime_config()
www_root = os.path.abspath(cfg["www_root"])
if not (path_abs == www_root or path_abs.startswith(www_root + os.sep)):
return False, "Path must be under www_root"
os.makedirs(path_abs, exist_ok=True)
# pure-pw useradd prompts for password twice; pipe it
stdin = f"{password}\n{password}\n"
out, err = _run_pure_pw(
f'useradd {name} -u www-data -d "{path_abs}" -m',
stdin=stdin,
)
if err and "error" in err.lower() and "already exists" not in err.lower():
return False, err.strip() or out.strip()
out2, err2 = _run_pure_pw("mkdb")
if err2 and "error" in err2.lower():
return False, err2.strip() or out2.strip()
return True, "FTP user created"
def delete_ftp_user(name: str) -> tuple[bool, str]:
"""Delete Pure-FTPd virtual user."""
if not all(c.isalnum() or c in "._-" for c in name):
return False, "Invalid username"
out, err = _run_pure_pw(f'userdel {name} -m')
if err and "error" in err.lower():
return False, err.strip() or out.strip()
return True, "FTP user deleted"
def update_ftp_password(name: str, new_password: str) -> tuple[bool, str]:
"""Change Pure-FTPd user password."""
if not all(c.isalnum() or c in "._-" for c in name):
return False, "Invalid username"
stdin = f"{new_password}\n{new_password}\n"
out, err = _run_pure_pw(f'passwd {name} -m', stdin=stdin)
if err and "error" in err.lower():
return False, err.strip() or out.strip()
return True, "Password updated"

View File

@@ -0,0 +1,338 @@
"""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"}