"""YakPanel - App Store / Software API""" import asyncio import os import shlex import subprocess from fastapi import APIRouter, Depends, HTTPException from app.core.utils import exec_shell_sync from app.api.auth import get_current_user from app.models.user import User router = APIRouter(prefix="/soft", tags=["soft"]) # Curated list of common server software (Debian/Ubuntu package names) SOFTWARE_LIST = [ {"id": "nginx", "name": "Nginx", "desc": "Web server", "pkg": "nginx"}, {"id": "mysql-server", "name": "MySQL Server", "desc": "Database server", "pkg": "mysql-server"}, {"id": "mariadb-server", "name": "MariaDB", "desc": "Database server", "pkg": "mariadb-server"}, {"id": "php", "name": "PHP", "desc": "PHP runtime", "pkg": "php"}, {"id": "php-fpm", "name": "PHP-FPM", "desc": "PHP FastCGI", "pkg": "php-fpm"}, {"id": "redis-server", "name": "Redis", "desc": "In-memory cache", "pkg": "redis-server"}, {"id": "postgresql", "name": "PostgreSQL", "desc": "Database server", "pkg": "postgresql"}, {"id": "mongodb", "name": "MongoDB", "desc": "NoSQL database", "pkg": "mongodb"}, {"id": "certbot", "name": "Certbot", "desc": "Let's Encrypt SSL", "pkg": "certbot"}, {"id": "docker", "name": "Docker", "desc": "Container runtime", "pkg": "docker.io"}, {"id": "nodejs", "name": "Node.js", "desc": "JavaScript runtime", "pkg": "nodejs"}, {"id": "npm", "name": "npm", "desc": "Node package manager", "pkg": "npm"}, {"id": "git", "name": "Git", "desc": "Version control", "pkg": "git"}, {"id": "python3", "name": "Python 3", "desc": "Python runtime", "pkg": "python3"}, ] def _apt_env() -> dict[str, str]: env = os.environ.copy() env.setdefault("DEBIAN_FRONTEND", "noninteractive") env.setdefault("APT_LISTCHANGES_FRONTEND", "none") return env def _run_apt_shell(script: str, timeout: int) -> None: """Run apt/dpkg shell snippet; raise HTTPException if command fails.""" result = subprocess.run( script, shell=True, capture_output=True, text=True, timeout=timeout, env=_apt_env(), ) if result.returncode == 0: return out = (result.stdout or "").strip() err = (result.stderr or "").strip() msg = err or out or f"Command failed (exit {result.returncode})" raise HTTPException(status_code=500, detail=msg[:4000]) def _check_installed(pkg: str) -> tuple[bool, str]: """Check if package is installed. Returns (installed, version_or_error).""" out, err = exec_shell_sync(f"dpkg -l {pkg} 2>/dev/null | grep ^ii", timeout=5) if out.strip(): # Parse version from dpkg output: ii pkg version ... parts = out.split() if len(parts) >= 3: return True, parts[2] return False, "" @router.get("/list") async def soft_list(current_user: User = Depends(get_current_user)): """List software with install status""" result = [] for s in SOFTWARE_LIST: installed, version = _check_installed(s["pkg"]) result.append({ **s, "installed": installed, "version": version if installed else "", }) return {"software": result} @router.post("/install/{pkg_id}") async def soft_install( pkg_id: str, current_user: User = Depends(get_current_user), ): """Install package via apt (requires root)""" pkg = next((s["pkg"] for s in SOFTWARE_LIST if s["id"] == pkg_id), None) if not pkg: raise HTTPException(status_code=404, detail="Package not found") quoted = shlex.quote(pkg) _run_apt_shell(f"apt-get update -qq && apt-get install -y {quoted}", timeout=600) return {"status": True, "msg": "Installed"} @router.post("/uninstall/{pkg_id}") async def soft_uninstall( pkg_id: str, current_user: User = Depends(get_current_user), ): """Uninstall package via apt""" pkg = next((s["pkg"] for s in SOFTWARE_LIST if s["id"] == pkg_id), None) if not pkg: raise HTTPException(status_code=404, detail="Package not found") quoted = shlex.quote(pkg) await asyncio.to_thread(_run_apt_shell, f"apt-get remove -y {quoted}", 180) return {"status": True, "msg": "Uninstalled"}