Files
leocrm/app/routes/plugins.py
T

659 lines
22 KiB
Python
Raw Normal View History

2026-07-23 19:01:18 +02:00
"""Plugin routes — list, install, activate, deactivate, uninstall, manifest schema, config, upload, install-url."""
from __future__ import annotations
2026-07-23 19:01:18 +02:00
import importlib
import logging
import os
import re
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import Any
import httpx
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import require_permission
2026-07-23 19:01:18 +02:00
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest
from app.plugins.migration_runner import MigrationValidationError
from app.services.plugin_service import get_plugin_service
2026-07-23 19:01:18 +02:00
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/plugins", tags=["plugins"])
class PluginConfigUpdate(BaseModel):
"""Request body for updating plugin configuration."""
config: dict
2026-07-23 19:01:18 +02:00
class PluginUrlInstall(BaseModel):
"""Request body for installing a plugin from a URL."""
url: str
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
@router.get("")
async def list_plugins(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("plugins:read")),
):
"""List all plugins with their current status (discovered, installed, active, inactive)."""
service = get_plugin_service()
plugins = await service.list_plugins(db)
return {"plugins": plugins, "total": len(plugins)}
@router.get("/manifest")
async def get_manifest_schema(
current_user: dict = Depends(require_permission("plugins:read")),
):
"""Get the plugin manifest schema documentation."""
service = get_plugin_service()
return service.get_manifest_schema()
2026-07-23 19:01:18 +02:00
@router.get("/active-manifests")
async def get_active_manifests(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("plugins:read")),
):
"""Get UI manifests for all active plugins.
Returns menu_items, page_routes, detail_tabs, settings_pages, and
dashboard_widgets contributed by each active plugin. Used by the
frontend PluginRegistry to dynamically register routes, sidebar items,
settings pages, and detail tabs.
"""
service = get_plugin_service()
manifests = await service.get_active_manifests(db)
return {"plugins": manifests, "total": len(manifests)}
2026-07-23 19:01:18 +02:00
@router.get("/{name}/config")
async def get_plugin_config(
name: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("plugins:read")),
):
"""Get the configuration for a specific plugin.
Returns the plugin's config field as a JSON object.
"""
service = get_plugin_service()
try:
config = await service.get_plugin_config(db, name)
return {"name": name, "config": config}
except ValueError as exc:
raise HTTPException(
404, detail={"detail": str(exc), "code": "plugin_not_found"}
) from None
@router.patch("/{name}/config")
async def update_plugin_config(
name: str,
body: PluginConfigUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("plugins:configure")),
):
"""Update the configuration for a specific plugin.
Stores the config as a JSON string in the plugin's config field.
"""
import uuid as uuid_mod
service = get_plugin_service()
try:
result = await service.update_plugin_config(
db,
name,
config=body.config,
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
user_id=uuid_mod.UUID(current_user["user_id"]),
)
return result
except ValueError as exc:
raise HTTPException(
404, detail={"detail": str(exc), "code": "plugin_not_found"}
) from None
@router.post("/{name}/install")
async def install_plugin(
name: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("plugins:configure")),
):
"""Install a plugin by name. Runs migrations and creates DB record.
Idempotent: returns 200 if already installed.
"""
import uuid as uuid_mod
service = get_plugin_service()
try:
result = await service.install_plugin(
db,
name,
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
user_id=uuid_mod.UUID(current_user["user_id"]),
)
return result
except ValueError as exc:
if "not found" in str(exc).lower():
raise HTTPException(
404, detail={"detail": str(exc), "code": "plugin_not_found"}
) from None
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None
except MigrationValidationError as exc:
raise HTTPException(
422, detail={"detail": str(exc), "code": "migration_validation_error"}
) from None
@router.post("/{name}/activate")
async def activate_plugin(
name: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("plugins:configure")),
):
"""Activate a plugin by name. Registers event listeners and routes.
Idempotent: returns 200 if already active.
"""
import uuid as uuid_mod
service = get_plugin_service()
try:
result = await service.activate_plugin(
db,
name,
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
user_id=uuid_mod.UUID(current_user["user_id"]),
)
return result
except ValueError as exc:
if "not installed" in str(exc).lower():
raise HTTPException(
400, detail={"detail": str(exc), "code": "plugin_not_installed"}
) from None
if "not found" in str(exc).lower():
raise HTTPException(
404, detail={"detail": str(exc), "code": "plugin_not_found"}
) from None
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None
@router.post("/{name}/deactivate")
async def deactivate_plugin(
name: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("plugins:configure")),
):
"""Deactivate a plugin by name. Unregisters event listeners and routes.
Idempotent: returns 200 if already inactive.
"""
import uuid as uuid_mod
service = get_plugin_service()
try:
result = await service.deactivate_plugin(
db,
name,
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
user_id=uuid_mod.UUID(current_user["user_id"]),
)
return result
except ValueError as exc:
if "not found" in str(exc).lower():
raise HTTPException(
404, detail={"detail": str(exc), "code": "plugin_not_found"}
) from None
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None
@router.delete("/{name}")
async def uninstall_plugin(
name: str,
remove_data: bool = Query(False),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("plugins:configure")),
):
2026-07-23 19:01:18 +02:00
"""Uninstall a plugin by name. Deactivates, drops tables, removes DB record.
2026-07-23 19:01:18 +02:00
Idempotent: returns 200 if already uninstalled.
"""
import uuid as uuid_mod
service = get_plugin_service()
try:
result = await service.uninstall_plugin(
db,
name,
remove_data=remove_data,
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
user_id=uuid_mod.UUID(current_user["user_id"]),
)
return result
except ValueError as exc:
if "not found" in str(exc).lower():
raise HTTPException(
404, detail={"detail": str(exc), "code": "plugin_not_found"}
) from None
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None
2026-07-23 19:01:18 +02:00
# ── Plugin Upload / URL Install ──────────────────────────────────────────────
def _validate_manifest_name(name: str) -> str:
"""Validate plugin name is alphanumeric with underscores only."""
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", name):
raise ValueError(
f"Invalid plugin name '{name}': must start with a letter and contain only "
f"alphanumeric characters and underscores"
)
return name
def _check_dangerous_imports(source_code: str) -> list[str]:
"""Check plugin source for dangerous imports/patterns.
Returns a list of dangerous patterns found (empty if safe).
"""
dangerous_patterns = [
(r"\bos\.system\b", "os.system call"),
(r"\bsubprocess\.", "subprocess module"),
(r"\beval\s*\(", "eval() call"),
(r"\bexec\s*\(", "exec() call"),
(r"\b__import__\s*\(", "__import__() call"),
(r"\bcompile\s*\(", "compile() call"),
]
found: list[str] = []
for pattern, description in dangerous_patterns:
if re.search(pattern, source_code):
found.append(description)
return found
def _check_migration_sql(sql_content: str) -> list[str]:
"""Basic SQL validation for migration files.
Returns a list of issues found (empty if OK).
"""
issues: list[str] = []
# Check for basic SQL syntax issues
lines = sql_content.strip().split("\n")
for i, line in enumerate(lines, 1):
stripped = line.strip()
if not stripped or stripped.startswith("--"):
continue
# Check for unclosed parentheses
if stripped.count("(") != stripped.count(")"):
issues.append(f"Line {i}: unbalanced parentheses")
# Check for DROP TABLE (dangerous in migrations)
if re.search(r"\bDROP\s+TABLE\b", stripped, re.IGNORECASE):
issues.append(f"Line {i}: DROP TABLE is not allowed in plugin migrations")
return issues
def _find_plugin_class_in_module(module: Any) -> type[BasePlugin] | None:
"""Find a BasePlugin subclass in a module."""
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
isinstance(attr, type)
and issubclass(attr, BasePlugin)
and attr is not BasePlugin
):
return attr
return None
def _extract_plugin_from_zip(zip_path: str) -> tuple[Path, str, type[BasePlugin]]:
"""Extract a ZIP file and find the plugin class.
Returns (extract_dir, plugin_name, plugin_class).
"""
extract_dir = Path(tempfile.mkdtemp(prefix="plugin_upload_"))
try:
with zipfile.ZipFile(zip_path, "r") as zf:
# Validate ZIP
bad_files = [f for f in zf.namelist() if f.startswith("..") or f.startswith("/")]
if bad_files:
raise ValueError(f"ZIP contains files with unsafe paths: {bad_files}")
zf.extractall(extract_dir)
# Find plugin.py in the extracted contents
plugin_py_path: Path | None = None
for fpath in extract_dir.rglob("plugin.py"):
plugin_py_path = fpath
break
if plugin_py_path is None:
raise ValueError("ZIP does not contain a plugin.py file")
# Import the module dynamically
spec = importlib.util.spec_from_file_location(
"uploaded_plugin", plugin_py_path
)
if spec is None or spec.loader is None:
raise ValueError("Could not load plugin.py module")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Find BasePlugin subclass
plugin_class = _find_plugin_class_in_module(module)
if plugin_class is None:
raise ValueError(
"plugin.py does not contain a BasePlugin subclass"
)
plugin_instance = plugin_class()
plugin_name = plugin_instance.name
# Validate manifest
manifest = plugin_instance.manifest
if not manifest.name or not manifest.version or not manifest.display_name:
raise ValueError(
"Plugin manifest must include name, version, and display_name"
)
# Validate plugin name
_validate_manifest_name(manifest.name)
# Check for dangerous imports in plugin.py
source_code = plugin_py_path.read_text(encoding="utf-8")
dangerous = _check_dangerous_imports(source_code)
if dangerous:
raise ValueError(
f"Plugin contains dangerous patterns: {', '.join(dangerous)}"
)
# Check migration SQL files
migrations_dir = plugin_py_path.parent / "migrations"
if migrations_dir.exists():
for sql_file in sorted(migrations_dir.glob("*.sql")):
sql_content = sql_file.read_text(encoding="utf-8")
issues = _check_migration_sql(sql_content)
if issues:
raise ValueError(
f"Migration file {sql_file.name} has issues: {'; '.join(issues)}"
)
return extract_dir, plugin_name, plugin_class
except Exception:
# Clean up on failure
shutil.rmtree(extract_dir, ignore_errors=True)
raise
def _install_plugin_from_dir(
extract_dir: Path,
plugin_name: str,
plugin_class: type[BasePlugin],
) -> None:
"""Copy plugin directory to builtins and register it.
Copies the extracted plugin directory to app/plugins/builtins/{plugin_name}/.
"""
builtins_dir = Path(__file__).parent.parent / "plugins" / "builtins" / plugin_name
builtins_dir.mkdir(parents=True, exist_ok=True)
# Copy all files from extract_dir to builtins_dir
for item in extract_dir.iterdir():
dest = builtins_dir / item.name
if item.is_dir():
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(item, dest)
else:
shutil.copy2(item, dest)
# Register the plugin in the registry
registry = get_plugin_service().registry
instance = plugin_class()
registry.register_plugin(instance)
logger.info(
"Installed plugin '%s' from uploaded ZIP to %s",
plugin_name,
builtins_dir,
)
@router.post("/upload")
async def upload_plugin(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("plugins:configure")),
):
"""Upload and install a plugin from a ZIP file.
The ZIP must contain a plugin directory with a plugin.py that defines a BasePlugin subclass.
Validates the manifest, checks for conflicts, runs migrations, and installs the plugin.
"""
import uuid as uuid_mod
# Validate file is a ZIP
if not file.filename or not file.filename.endswith(".zip"):
raise HTTPException(400, detail={"detail": "File must be a .zip archive", "code": "invalid_file"})
# Check file size
contents = await file.read()
if len(contents) > MAX_UPLOAD_SIZE:
raise HTTPException(
413,
detail={
"detail": f"File too large. Maximum size is {MAX_UPLOAD_SIZE // (1024*1024)} MB",
"code": "file_too_large",
},
)
# Write to temp file
tmp_zip = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
try:
tmp_zip.write(contents)
tmp_zip.close()
# Extract and validate
extract_dir, plugin_name, plugin_class = _extract_plugin_from_zip(tmp_zip.name)
# Check for name conflicts with existing plugins
service = get_plugin_service()
existing_plugins = await service.list_plugins(db)
existing_names = {p["name"] for p in existing_plugins}
if plugin_name in existing_names:
# Check if version is higher
existing_plugin = next(
(p for p in existing_plugins if p["name"] == plugin_name), None
)
if existing_plugin:
raise HTTPException(
409,
detail={
"detail": f"Plugin '{plugin_name}' already exists (version {existing_plugin.get('version', 'unknown')}). "
f"Uninstall the existing plugin first or upload a higher version.",
"code": "plugin_exists",
},
)
# Install the plugin directory
_install_plugin_from_dir(extract_dir, plugin_name, plugin_class)
# Run migrations and install via service
result = await service.install_plugin(
db,
plugin_name,
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
user_id=uuid_mod.UUID(current_user["user_id"]),
)
# Log audit
from app.core.audit import log_audit
await log_audit(
db,
uuid_mod.UUID(current_user["tenant_id"]),
uuid_mod.UUID(current_user["user_id"]),
action="plugin.upload",
entity_type="plugin",
changes={"name": plugin_name, "version": result.get("version"), "method": "upload"},
)
return {
**result,
"message": f"Plugin '{plugin_name}' uploaded and installed successfully",
}
except ValueError as exc:
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_validation_error"}) from None
except MigrationValidationError as exc:
raise HTTPException(
422, detail={"detail": str(exc), "code": "migration_validation_error"}
) from None
except Exception as exc:
logger.exception("Failed to upload plugin")
raise HTTPException(
500, detail={"detail": f"Failed to install plugin: {str(exc)}", "code": "install_error"}
) from None
finally:
# Clean up temp files
try:
os.unlink(tmp_zip.name)
except Exception:
pass
try:
if "extract_dir" in dir():
shutil.rmtree(extract_dir, ignore_errors=True)
except Exception:
pass
@router.post("/install-url")
async def install_plugin_from_url(
body: PluginUrlInstall,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("plugins:configure")),
):
"""Install a plugin from a URL (downloads ZIP and installs)."""
import uuid as uuid_mod
if not body.url:
raise HTTPException(400, detail={"detail": "URL is required", "code": "missing_url"})
# Download ZIP from URL
tmp_zip = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.get(body.url, follow_redirects=True)
response.raise_for_status()
content = response.content
if len(content) > MAX_UPLOAD_SIZE:
raise HTTPException(
413,
detail={
"detail": f"Downloaded file too large. Maximum size is {MAX_UPLOAD_SIZE // (1024*1024)} MB",
"code": "file_too_large",
},
)
tmp_zip.write(content)
tmp_zip.close()
# Extract and validate
extract_dir, plugin_name, plugin_class = _extract_plugin_from_zip(tmp_zip.name)
# Check for name conflicts
service = get_plugin_service()
existing_plugins = await service.list_plugins(db)
existing_names = {p["name"] for p in existing_plugins}
if plugin_name in existing_names:
raise HTTPException(
409,
detail={
"detail": f"Plugin '{plugin_name}' already exists. Uninstall the existing plugin first.",
"code": "plugin_exists",
},
)
# Install the plugin directory
_install_plugin_from_dir(extract_dir, plugin_name, plugin_class)
# Run migrations and install via service
result = await service.install_plugin(
db,
plugin_name,
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
user_id=uuid_mod.UUID(current_user["user_id"]),
)
# Log audit
from app.core.audit import log_audit
await log_audit(
db,
uuid_mod.UUID(current_user["tenant_id"]),
uuid_mod.UUID(current_user["user_id"]),
action="plugin.install_url",
entity_type="plugin",
changes={"name": plugin_name, "version": result.get("version"), "url": body.url},
)
return {
**result,
"message": f"Plugin '{plugin_name}' downloaded and installed successfully",
}
except httpx.HTTPStatusError as exc:
raise HTTPException(
400,
detail={
"detail": f"Failed to download plugin from URL: HTTP {exc.response.status_code}",
"code": "download_error",
},
) from None
except httpx.RequestError as exc:
raise HTTPException(
400,
detail={
"detail": f"Failed to download plugin from URL: {str(exc)}",
"code": "download_error",
},
) from None
except ValueError as exc:
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_validation_error"}) from None
except MigrationValidationError as exc:
raise HTTPException(
422, detail={"detail": str(exc), "code": "migration_validation_error"}
) from None
except Exception as exc:
logger.exception("Failed to install plugin from URL")
raise HTTPException(
500, detail={"detail": f"Failed to install plugin: {str(exc)}", "code": "install_error"}
) from None
finally:
# Clean up temp files
try:
os.unlink(tmp_zip.name)
except Exception:
pass
try:
if "extract_dir" in dir():
shutil.rmtree(extract_dir, ignore_errors=True)
except Exception:
pass