00466a9801
Introduces a manifest contract so feature containers self-describe their settings (JSON Schema + access rules). Backend and frontend gain generic plugin proxy and dynamic Extensions UI with zero feature-specific code. Doc-service is the first plugin consumer: exposes /plugin/manifest and /plugin/settings, adds a watchdog-based file watcher that auto-ingests PDFs from a mounted directory, maps subfolders to categories, supports AI-suggested folder/filename (user-confirmed), and enforces a no-remove policy. Access is gated by is_superuser or doc-service-admin group. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
126 lines
4.3 KiB
Python
126 lines
4.3 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 _REGISTRY, get_cached_manifest, 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 _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)
|