Initial YakPanel commit
This commit is contained in:
127
YakPanel-server/backend/app/api/plugin.py
Normal file
127
YakPanel-server/backend/app/api/plugin.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""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"}
|
||||
Reference in New Issue
Block a user