128 lines
5.2 KiB
Python
128 lines
5.2 KiB
Python
|
|
"""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"}
|