ArchiveBox/archivebox/opencode/views.py

141 lines
6.2 KiB
Python

"""Django adapter for the optional plugin; never import its runtime at startup."""
import logging
from io import BytesIO
from asgiref.sync import sync_to_async
from django.contrib.auth.middleware import AuthenticationMiddleware
from django.contrib.auth.views import redirect_to_login
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.exceptions import PermissionDenied
from django.core.handlers.asgi import ASGIRequest
from django.db import close_old_connections
from django.http import Http404, HttpResponse, HttpResponseForbidden, StreamingHttpResponse
from django.template import engines
from django.urls import Resolver404, resolve
from django.views.decorators.csrf import csrf_exempt
from archivebox.config import CONSTANTS
from archivebox.config.common import get_request_config
from archivebox.core.middleware import ReverseProxyAuthMiddleware
from archivebox.core.routes_util import build_admin_url, get_admin_host, get_api_base_url, get_base_url, host_matches
from archivebox.plugins.discovery import get_plugin_template
_LOGGER = logging.getLogger(__name__)
def _runtime_settings(request, config):
from abx_plugins.plugins.opencode import runtime
settings = runtime._settings(config, CONSTANTS.DATA_DIR)
route_config = request.__dict__.get("archivebox_config")
settings.update(
archivebox_base_url=get_base_url(request=request, config=route_config).rstrip("/"),
archivebox_admin_url=build_admin_url("/admin/", request=request, config=route_config).rstrip("/"),
archivebox_api_url=f"{get_api_base_url(request=request, config=route_config).rstrip('/')}/api/",
)
return runtime, settings
def _dispatch(request, path=None):
try:
# Middleware already merged server defaults and live Machine overrides.
# Re-resolving every extractor's config here stalls the UI request burst.
config = get_request_config(request).model_dump(mode="json")
if not config.get("OPENCODE_ENABLED", False):
raise Http404
if not request.user.is_authenticated:
return redirect_to_login(request.get_full_path(), login_url="/admin/login/")
if not request.user.is_active or not request.user.is_superuser:
return HttpResponseForbidden("Agent access requires a superuser account.")
runtime, settings = _runtime_settings(request, config)
if path is None:
from archivebox.core.admin_site import archivebox_admin
context = {**archivebox_admin.each_context(request), **runtime.agent_context(settings)}
source = get_plugin_template("opencode", "agent", fallback=False)
if source is None:
raise RuntimeError("Agent template unavailable")
return HttpResponse(engines["django"].from_string(source).render(context, request))
if not runtime._origin_allowed(request.method, request.get_host(), request.headers):
return HttpResponseForbidden("Cross-origin agent requests are blocked.")
status, headers, body = runtime.proxy(
settings,
request.method,
path,
tuple((key, value) for key, values in request.GET.lists() for value in values),
request.headers,
request.body,
)
response_type = HttpResponse if isinstance(body, bytes) else StreamingHttpResponse
response = response_type(body, status=status, headers=headers)
response.xframe_options_exempt = True
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Content-Security-Policy"] = "frame-ancestors 'self'"
return response
except Http404:
raise
except Exception:
# Optional-service boundary, including imports and template rendering.
_LOGGER.exception("Optional AI service failed")
return HttpResponse("AI service unavailable. See server logs.", status=503, content_type="text/plain")
def agent_view(request):
return _dispatch(request)
@csrf_exempt
def opencode_proxy_view(request, path=None):
return _dispatch(request, path=path or "")
def _websocket_context(scope):
close_old_connections()
try:
match = resolve(scope["path"])
if match.url_name != "opencode-proxy":
raise PermissionDenied
request = ASGIRequest(
{**scope, "type": "http", "method": "GET", "scheme": "https" if scope.get("scheme") == "wss" else "http"},
BytesIO(),
)
route_config = get_request_config(request, resolve_plugins=False)
request.archivebox_config = route_config
if not route_config.CONTROL_PLANE_ENABLED:
raise PermissionDenied
if (
route_config.USES_SUBDOMAIN_ROUTING
and route_config.BASE_URL
and not host_matches(request.get_host(), get_admin_host(config=route_config, request=request))
):
raise PermissionDenied
SessionMiddleware(_dispatch).process_request(request)
AuthenticationMiddleware(_dispatch).process_request(request)
ReverseProxyAuthMiddleware(_dispatch).process_request(request)
config = route_config.model_dump(mode="json")
if not config.get("OPENCODE_ENABLED") or not request.user.is_active or not request.user.is_superuser:
raise PermissionDenied
runtime, settings = _runtime_settings(request, config)
if not request.headers.get("Origin") or not runtime._origin_allowed("POST", request.get_host(), request.headers):
raise PermissionDenied
return runtime, settings, match.kwargs.get("path", "")
finally:
close_old_connections()
async def websocket_view(scope, receive, send):
if (await receive())["type"] != "websocket.connect":
return
try:
runtime, settings, path = await sync_to_async(_websocket_context)(scope)
await runtime.websocket_proxy(settings, path, scope.get("query_string", b""), scope.get("subprotocols", []), receive, send)
except (PermissionDenied, Resolver404, Http404):
await send({"type": "websocket.close", "code": 1008})
except Exception:
_LOGGER.exception("Optional AI WebSocket failed")
await send({"type": "websocket.close", "code": 1011})