18a638bc3a
- Fix: list_plugins imported _REGISTRY as a direct reference to the empty list that existed at import time; register_services() replaces _REGISTRY with a new list so the imported reference was always []. Added get_registry() helper so callers access the live list via the module namespace. GET /api/plugins now correctly returns accessible plugins for the current user. - Fix: switch watchdog from InotifyObserver to PollingObserver. Inotify events from the macOS host are not forwarded through the Docker bind mount, so new files were only detected via the startup scan. PollingObserver (1s default interval) works reliably on all platforms including macOS+Docker bind mounts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
126 lines
4.4 KiB
Python
126 lines
4.4 KiB
Python
"""
|
|
Generic plugin proxy.
|
|
|
|
Feature containers advertise themselves via GET /plugin/manifest. The backend
|
|
health-poller caches those manifests. This router exposes them to the browser
|
|
through auth-gated endpoints so the frontend never needs to know about specific
|
|
features.
|
|
|
|
Routes:
|
|
GET /api/plugins → list accessible plugins for current user
|
|
GET /api/plugins/{id}/manifest → cached manifest (404 if not accessible)
|
|
GET /api/plugins/{id}/settings → proxy to feature /plugin/settings
|
|
PATCH /api/plugins/{id}/settings → proxy to feature /plugin/settings
|
|
"""
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import Response
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.deps import check_plugin_access, get_current_user
|
|
from app.models.user import User
|
|
from app.services.service_health import get_cached_manifest, get_registry, get_service_url
|
|
|
|
router = APIRouter()
|
|
|
|
_HOP_BY_HOP = frozenset([
|
|
"connection",
|
|
"keep-alive",
|
|
"proxy-authenticate",
|
|
"proxy-authorization",
|
|
"te",
|
|
"trailers",
|
|
"transfer-encoding",
|
|
"upgrade",
|
|
"host",
|
|
"accept-encoding",
|
|
])
|
|
_STRIP_RESPONSE = frozenset([*_HOP_BY_HOP, "content-length", "content-type"])
|
|
|
|
|
|
async def _proxy(plugin_id: str, method: str, path: str, body: bytes | None,
|
|
content_type: str | None = None) -> Response:
|
|
"""Forward a request to the feature container's plugin endpoint."""
|
|
url = get_service_url(plugin_id)
|
|
if url is None:
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
|
|
headers: dict[str, str] = {}
|
|
if content_type:
|
|
headers["content-type"] = content_type
|
|
|
|
try:
|
|
async with httpx.AsyncClient(base_url=url, timeout=30.0) as client:
|
|
resp = await client.request(method, path, content=body, headers=headers)
|
|
except httpx.RequestError as exc:
|
|
raise HTTPException(status_code=502, detail=f"Plugin service unreachable: {exc}")
|
|
|
|
resp_headers = {k: v for k, v in resp.headers.items() if k.lower() not in _STRIP_RESPONSE}
|
|
return Response(
|
|
content=resp.content,
|
|
status_code=resp.status_code,
|
|
headers=resp_headers,
|
|
media_type=resp.headers.get("content-type", "application/json"),
|
|
)
|
|
|
|
|
|
@router.get("")
|
|
async def list_plugins(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> list[dict]:
|
|
"""Return the list of plugins the current user may access."""
|
|
accessible = []
|
|
for svc in get_registry():
|
|
manifest = get_cached_manifest(svc.id)
|
|
if manifest is None:
|
|
continue
|
|
if await check_plugin_access(svc.id, current_user, db):
|
|
accessible.append({
|
|
"id": manifest["id"],
|
|
"name": manifest["name"],
|
|
"icon": manifest.get("icon", "package"),
|
|
"version": manifest.get("version", ""),
|
|
})
|
|
return accessible
|
|
|
|
|
|
@router.get("/{plugin_id}/manifest")
|
|
async def get_plugin_manifest(
|
|
plugin_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> dict:
|
|
if not await check_plugin_access(plugin_id, current_user, db):
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
manifest = get_cached_manifest(plugin_id)
|
|
if manifest is None:
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
return manifest
|
|
|
|
|
|
@router.get("/{plugin_id}/settings")
|
|
async def get_plugin_settings(
|
|
plugin_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Response:
|
|
if not await check_plugin_access(plugin_id, current_user, db):
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
return await _proxy(plugin_id, "GET", "/plugin/settings", None)
|
|
|
|
|
|
@router.patch("/{plugin_id}/settings")
|
|
async def update_plugin_settings(
|
|
plugin_id: str,
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Response:
|
|
if not await check_plugin_access(plugin_id, current_user, db):
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
body = await request.body()
|
|
content_type = request.headers.get("content-type", "application/json")
|
|
return await _proxy(plugin_id, "PATCH", "/plugin/settings", body, content_type)
|