Files
Owlbot/owlbot/http_server.py
T
LogalDeveloper 5443aa86ce
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 2m48s
CI / Tests (Python 3.13) (push) Successful in 2m49s
CI / Tests (Python 3.14) (push) Successful in 2m43s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
Added browser sessions and protected route support.
2026-05-04 21:01:57 -04:00

230 lines
8.1 KiB
Python

# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""HTTP server for Owlbot."""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable, Coroutine
from http.cookies import SimpleCookie
from pathlib import Path
from typing import TYPE_CHECKING, Any
import orjson
from aiohttp import web
from . import __version__
from .api.event_types import Event, EventType, parse_event
from .sessions import SESSION_COOKIE_NAME
from .web.sessions import register_browser_session_routes
if TYPE_CHECKING:
from .api.config import Config
from .registries.routes import RouteDispatcher
from .sessions import SessionManager
logger = logging.getLogger("owlbot.web")
def _session_cookie_delete_header() -> str:
cookie = SimpleCookie()
cookie[SESSION_COOKIE_NAME] = ""
morsel = cookie[SESSION_COOKIE_NAME]
morsel["expires"] = "Thu, 01 Jan 1970 00:00:00 GMT"
morsel["max-age"] = "0"
morsel["path"] = "/owlbot"
return morsel.OutputString()
_SESSION_COOKIE_DELETE_HEADER = _session_cookie_delete_header()
def _is_owlbot_path(path: str) -> bool:
return path == "/owlbot" or path.startswith("/owlbot/")
def register_response_prepare_hook(
app: web.Application,
*,
session_manager: SessionManager,
) -> None:
"""Register the shared response-prepare hook for an Owlbot HTTP app."""
async def on_response_prepare(
request: web.Request,
response: web.StreamResponse,
) -> None:
"""Set common headers and clear stale session cookies before headers send."""
response.headers["Server"] = f"Owlbot/{__version__}"
if request.path.startswith("/owlbot/static/"):
response.headers["Cache-Control"] = "max-age=86400"
session_id = request.cookies.get(SESSION_COOKIE_NAME)
if (
session_id is not None
and _is_owlbot_path(request.path)
and SESSION_COOKIE_NAME not in response.cookies
and session_manager.get_session(session_id) is None
):
response.headers.add("Set-Cookie", _SESSION_COOKIE_DELETE_HEADER)
app.on_response_prepare.append(on_response_prepare)
# Callback type for webhook dispatch (injected from Owlbot).
type WebhookCallback = Callable[[EventType, Event], Coroutine[Any, Any, None]]
class HttpServer:
"""HTTP server for Owlbot.
Manages the aiohttp Application, built-in routes (webhook),
and module-registered routes via the RouteDispatcher.
"""
def __init__(
self,
config: Config,
event_dispatch: WebhookCallback,
route_dispatcher: RouteDispatcher,
session_manager: SessionManager,
) -> None:
"""Initialize the web server.
:param config: Bot configuration.
:param event_dispatch: Async callback to dispatch webhook events.
:param route_dispatcher: Dispatcher for module HTTP routes.
"""
self.config = config
self._event_dispatch = event_dispatch
self._route_dispatcher = route_dispatcher
# Pending webhook dispatch tasks (tracked so drain() can await them).
self._pending_tasks: set[asyncio.Task[None]] = set()
# Runner is created by start() and cleaned up by stop().
self._runner: web.AppRunner | None = None
self.app = web.Application()
register_response_prepare_hook(self.app, session_manager=session_manager)
self.app.on_shutdown.append(self._on_shutdown)
self.app.router.add_post(self.config.webhook_path, self._handle_webhook)
# Static assets served from the owlbot package.
static_dir = Path(__file__).resolve().parent / "static"
if static_dir.is_dir():
self.app.router.add_static("/owlbot/static", static_dir)
register_browser_session_routes(
self.app,
session_manager=session_manager,
command_prefix=self.config.command_prefix,
cookie_secure=self.config.public_base_url.startswith("https://"),
)
# Catch-all routes for dynamic module route dispatch.
# These single aiohttp routes handle all requests under /owlbot/ and
# dispatch them to the appropriate handler via RouteDispatcher lookup
# at request time, enabling modules to register and unregister routes
# dynamically without restarting the server.
self.app.router.add_route(
"*", "/owlbot/{module_name}/{path:.*}", self._handle_catch_all
)
self.app.router.add_route("*", "/owlbot/{module_name}", self._handle_catch_all)
logger.debug("HttpServer initialized.")
async def start(self, host: str, port: int) -> None:
"""Start the HTTP server.
:param host: Address to bind to.
:param port: Port to bind to.
"""
self._runner = web.AppRunner(self.app)
await self._runner.setup()
site = web.TCPSite(self._runner, host, port)
logger.debug("Binding web server to %s:%s...", host, port)
try:
await site.start()
except OSError:
logger.exception("Failed to bind web server to %s:%s.", host, port)
raise
logger.info("Web server started at: http://%s:%s", host, port)
async def stop(self) -> None:
"""Stop the HTTP server."""
if self._runner:
await self._runner.cleanup()
self._runner = None
logger.info("Web server stopped.")
async def drain(self) -> None:
"""Wait for all pending webhook dispatch tasks to complete."""
if self._pending_tasks:
logger.info(
"Waiting for %d pending event(s) to complete...",
len(self._pending_tasks),
)
await asyncio.gather(*self._pending_tasks)
logger.debug("All pending events drained.")
async def _on_shutdown(self, _app: web.Application) -> None:
"""Drain active route handlers so connections close promptly."""
await self._route_dispatcher.drain_handlers()
async def _handle_webhook(self, request: web.Request) -> web.Response:
"""Handle incoming webhook requests from Owncast."""
try:
data = await request.json(loads=orjson.loads)
except ValueError as e:
# Invalid JSON received. This shouldn't happen
# with legitimate Owncast webhooks.
logger.warning("Failed to parse webhook JSON: %s", e)
return web.Response(status=400)
event_type = data.get("type", "unknown")
logger.debug("Received webhook: %s", event_type)
result = parse_event(data)
if result is None:
# Owncast may have added a new webhook we don't handle yet.
logger.warning(
"Owncast sent unrecognized event type: %r. Payload: %s",
event_type,
data,
)
return web.Response(status=400)
parsed_type, event = result
# Dispatch the event to handlers in a background task so
# we can respond immediately. This avoids the possibility
# of slow handlers blocking Owncast's webhook delivery.
task: asyncio.Task[None] = asyncio.create_task(
self._event_dispatch(parsed_type, event),
name=f"Event dispatch - {parsed_type.value}",
)
self._pending_tasks.add(task)
task.add_done_callback(self._pending_tasks.discard)
return web.Response(status=202)
async def _handle_catch_all(self, request: web.Request) -> web.StreamResponse:
"""Delegate module route requests to the RouteDispatcher."""
return await self._route_dispatcher.dispatch(request)