# 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)