"""YakPanel - Plugin / Extensions API""" import json import re from urllib.parse import urlparse from urllib.request import urlopen, Request from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from pydantic import BaseModel from app.core.database import get_db from app.api.auth import get_current_user from app.models.user import User from app.models.plugin import CustomPlugin router = APIRouter(prefix="/plugin", tags=["plugin"]) # Built-in extensions (features) - always enabled BUILTIN_PLUGINS = [ {"id": "backup", "name": "Backup", "version": "1.0", "desc": "Site and database backup/restore", "enabled": True, "builtin": True}, {"id": "ssl", "name": "SSL/ACME", "version": "1.0", "desc": "Let's Encrypt certificates", "enabled": True, "builtin": True}, {"id": "docker", "name": "Docker", "version": "1.0", "desc": "Container management", "enabled": True, "builtin": True}, {"id": "node", "name": "Node.js", "version": "1.0", "desc": "PM2 process manager", "enabled": True, "builtin": True}, {"id": "services", "name": "Services", "version": "1.0", "desc": "System service control", "enabled": True, "builtin": True}, {"id": "logs", "name": "Logs", "version": "1.0", "desc": "Log file viewer", "enabled": True, "builtin": True}, {"id": "terminal", "name": "Terminal", "version": "1.0", "desc": "Web terminal", "enabled": True, "builtin": True}, {"id": "monitor", "name": "Monitor", "version": "1.0", "desc": "System monitoring", "enabled": True, "builtin": True}, ] @router.get("/list") async def plugin_list( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List built-in + custom plugins""" result = await db.execute(select(CustomPlugin).order_by(CustomPlugin.id)) custom = result.scalars().all() builtin_ids = {p["id"] for p in BUILTIN_PLUGINS} plugins = list(BUILTIN_PLUGINS) for c in custom: plugins.append({ "id": c.plugin_id, "name": c.name, "version": c.version, "desc": c.desc, "enabled": c.enabled, "builtin": False, "db_id": c.id, }) return {"plugins": plugins} class AddPluginRequest(BaseModel): url: str @router.post("/add-from-url") async def plugin_add_from_url( body: AddPluginRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Add a third-party plugin from a JSON manifest URL""" url = (body.url or "").strip() if not url: raise HTTPException(status_code=400, detail="URL required") parsed = urlparse(url) if parsed.scheme not in ("http", "https"): raise HTTPException(status_code=400, detail="Only http/https URLs allowed") if not parsed.netloc or parsed.netloc.startswith("127.") or parsed.netloc == "localhost": raise HTTPException(status_code=400, detail="Invalid URL") try: req = Request(url, headers={"User-Agent": "YakPanel/1.0"}) with urlopen(req, timeout=10) as r: data = r.read(64 * 1024).decode("utf-8", errors="replace") except Exception as e: raise HTTPException(status_code=400, detail=f"Failed to fetch: {str(e)[:100]}") try: manifest = json.loads(data) except json.JSONDecodeError as e: raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}") pid = (manifest.get("id") or "").strip() name = (manifest.get("name") or "").strip() if not pid or not name: raise HTTPException(status_code=400, detail="Manifest must have 'id' and 'name'") if not re.match(r"^[a-z0-9_-]+$", pid): raise HTTPException(status_code=400, detail="Plugin id must be alphanumeric, underscore, hyphen only") version = (manifest.get("version") or "1.0").strip()[:32] desc = (manifest.get("desc") or "").strip()[:512] # Check builtin conflict if any(p["id"] == pid for p in BUILTIN_PLUGINS): raise HTTPException(status_code=400, detail="Plugin id conflicts with built-in") # Check existing custom r = await db.execute(select(CustomPlugin).where(CustomPlugin.plugin_id == pid)) if r.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Plugin already installed") cp = CustomPlugin( plugin_id=pid, name=name, version=version, desc=desc, source_url=url[:512], enabled=True, ) db.add(cp) await db.commit() return {"status": True, "msg": "Plugin added", "id": cp.plugin_id} @router.delete("/{plugin_id}") async def plugin_delete( plugin_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Remove a custom plugin (built-in plugins cannot be removed)""" if any(p["id"] == plugin_id for p in BUILTIN_PLUGINS): raise HTTPException(status_code=400, detail="Cannot remove built-in plugin") result = await db.execute(select(CustomPlugin).where(CustomPlugin.plugin_id == plugin_id)) cp = result.scalar_one_or_none() if not cp: raise HTTPException(status_code=404, detail="Plugin not found") await db.delete(cp) await db.commit() return {"status": True, "msg": "Plugin removed"}