Added browser sessions and protected route support.
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

This commit is contained in:
2026-05-04 21:01:57 -04:00
parent f22d73857b
commit 5443aa86ce
22 changed files with 2057 additions and 57 deletions
+1 -1
Submodule docs updated: d8cc18d242...d7f18ba962
+2
View File
@@ -36,6 +36,7 @@ from owlbot.owncast_http import OwncastError
from owlbot.registries.commands import ModuleCommands from owlbot.registries.commands import ModuleCommands
from owlbot.registries.events import ModuleEvents from owlbot.registries.events import ModuleEvents
from owlbot.registries.routes import ModuleRoutes from owlbot.registries.routes import ModuleRoutes
from owlbot.sessions import BrowserSession
# Command system (module-facing). # Command system (module-facing).
from .commands import ( from .commands import (
@@ -113,6 +114,7 @@ from .storage import ModuleStorage, StorageError
from .templates import ModuleTemplates from .templates import ModuleTemplates
__all__ = [ __all__ = [
"BrowserSession",
"ChatEvent", "ChatEvent",
"CommandContext", "CommandContext",
"CommandEvent", "CommandEvent",
+13
View File
@@ -28,11 +28,14 @@ from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable
from aiohttp import web from aiohttp import web
from owlbot.registries.commands import ModuleCommands from owlbot.registries.commands import ModuleCommands
from owlbot.registries.events import ModuleEvents from owlbot.registries.events import ModuleEvents
from owlbot.registries.routes import ModuleRoutes from owlbot.registries.routes import ModuleRoutes
from owlbot.sessions import BrowserSession
from .commands import CommandEvent from .commands import CommandEvent
from .config import ModuleConfig from .config import ModuleConfig
@@ -219,6 +222,9 @@ class CommandContext:
# Shared services for this module. # Shared services for this module.
module: ModuleContext module: ModuleContext
# Command-invocation-scoped protected-route URL builder.
_session_url_for: Callable[[str], str] = field(repr=False)
@property @property
def command(self) -> str: def command(self) -> str:
"""The command name that was invoked (canonical name, not alias).""" """The command name that was invoked (canonical name, not alias)."""
@@ -304,6 +310,10 @@ class CommandContext:
"""Optional client for the Owncast Admin API (None if admin is not enabled).""" """Optional client for the Owncast Admin API (None if admin is not enabled)."""
return self.module.admin_client return self.module.admin_client
def session_url_for(self, path: str) -> str:
"""Build a connect URL for a module route for this user."""
return self._session_url_for(path)
@dataclass(slots=True) @dataclass(slots=True)
class RouteContext: class RouteContext:
@@ -323,6 +333,9 @@ class RouteContext:
# Empty dict for plain routes, populated for routes with {name} patterns. # Empty dict for plain routes, populated for routes with {name} patterns.
match_info: dict[str, str] = field(default_factory=dict) match_info: dict[str, str] = field(default_factory=dict)
# Resolved Owlbot session for the request, if any.
session: BrowserSession | None = None
@property @property
def module_name(self) -> str: def module_name(self) -> str:
"""Module name.""" """Module name."""
+15 -1
View File
@@ -39,6 +39,9 @@ class RouteMark(TypedDict):
path: str path: str
methods: list[str] | None methods: list[str] | None
streaming: bool streaming: bool
requires_session: bool
requires_authenticated: bool
requires_moderator: bool
# Handler type: receives RouteContext, returns a response or dict (auto-JSON). # Handler type: receives RouteContext, returns a response or dict (auto-JSON).
@@ -57,6 +60,9 @@ class RouteInfo:
handler: RouteHandler handler: RouteHandler
module_name: str module_name: str
streaming: bool = False streaming: bool = False
requires_session: bool = False
requires_authenticated: bool = False
requires_moderator: bool = False
def on_route( def on_route(
@@ -64,6 +70,9 @@ def on_route(
*, *,
methods: list[str] | None = None, methods: list[str] | None = None,
streaming: bool = False, streaming: bool = False,
requires_session: bool = False,
requires_authenticated: bool = False,
requires_moderator: bool = False,
) -> Callable[[RouteHandler], RouteHandler]: ) -> Callable[[RouteHandler], RouteHandler]:
"""Register an HTTP route handler. """Register an HTTP route handler.
@@ -81,7 +90,12 @@ def on_route(
# methods=None is resolved to ["GET"] by RouteRegistry.register(). # methods=None is resolved to ["GET"] by RouteRegistry.register().
# Framework decorator marker; private to module authors. # Framework decorator marker; private to module authors.
func._owlbot_route = RouteMark( # type: ignore[attr-defined] # noqa: SLF001 func._owlbot_route = RouteMark( # type: ignore[attr-defined] # noqa: SLF001
path=path, methods=methods, streaming=streaming path=path,
methods=methods,
streaming=streaming,
requires_session=requires_session,
requires_authenticated=requires_authenticated,
requires_moderator=requires_moderator,
) )
return func return func
+10
View File
@@ -27,6 +27,7 @@ from .api.http_client import HttpClient
from .http_server import HttpServer from .http_server import HttpServer
from .module_loader import ModuleLoader from .module_loader import ModuleLoader
from .owncast_http import OwncastError from .owncast_http import OwncastError
from .sessions import SessionManager
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@@ -75,17 +76,21 @@ class Owlbot:
# Shared HTTP client (owns the aiohttp session lifecycle). # Shared HTTP client (owns the aiohttp session lifecycle).
self.http_client = HttpClient() self.http_client = HttpClient()
self.session_manager = SessionManager()
# Module loader (owns API clients, dispatchers, and module contexts). # Module loader (owns API clients, dispatchers, and module contexts).
self.module_loader = ModuleLoader( self.module_loader = ModuleLoader(
self.modules_dir, self.modules_dir,
self.config, self.config,
self.http_client, self.http_client,
self.session_manager,
) )
self.http_server = HttpServer( self.http_server = HttpServer(
config=self.config, config=self.config,
event_dispatch=self.module_loader.event_dispatcher.dispatch, event_dispatch=self.module_loader.event_dispatcher.dispatch,
route_dispatcher=self.module_loader.route_dispatcher, route_dispatcher=self.module_loader.route_dispatcher,
session_manager=self.session_manager,
) )
logger.debug("Owlbot initialization complete.") logger.debug("Owlbot initialization complete.")
@@ -118,6 +123,7 @@ class Owlbot:
# Auth is handled per-request by each Owncast client. # Auth is handled per-request by each Owncast client.
# Framework lifecycle; private to module authors. # Framework lifecycle; private to module authors.
await self.http_client._start() # noqa: SLF001 await self.http_client._start() # noqa: SLF001
self.session_manager.start()
if self.skip_api_check: if self.skip_api_check:
logger.info("Skipping startup connection tests.") logger.info("Skipping startup connection tests.")
@@ -140,6 +146,9 @@ class Owlbot:
f"Failed to start web server on " f"Failed to start web server on "
f"{self.config.host}:{self.config.port}: {e}" f"{self.config.host}:{self.config.port}: {e}"
) from e ) from e
except Exception:
await self.stop()
raise
async def stop(self) -> None: async def stop(self) -> None:
"""Gracefully shut down the bot, unloading all modules. """Gracefully shut down the bot, unloading all modules.
@@ -160,6 +169,7 @@ class Owlbot:
# Close the shared HTTP client (safe to call if startup failed early). # Close the shared HTTP client (safe to call if startup failed early).
# Framework lifecycle; private to module authors. # Framework lifecycle; private to module authors.
await self.http_client._close() # noqa: SLF001 await self.http_client._close() # noqa: SLF001
await self.session_manager.close()
logger.info("Owlbot shutdown complete.") logger.info("Owlbot shutdown complete.")
+52 -4
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
from http.cookies import SimpleCookie
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -27,22 +28,61 @@ from aiohttp import web
from . import __version__ from . import __version__
from .api.event_types import Event, EventType, parse_event 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: if TYPE_CHECKING:
from .api.config import Config from .api.config import Config
from .registries.routes import RouteDispatcher from .registries.routes import RouteDispatcher
from .sessions import SessionManager
logger = logging.getLogger("owlbot.web") logger = logging.getLogger("owlbot.web")
async def _on_response_prepare( def _session_cookie_delete_header() -> str:
request: web.Request, response: web.StreamResponse 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: ) -> None:
"""Set the Server header on all responses before headers are sent.""" """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__}" response.headers["Server"] = f"Owlbot/{__version__}"
if request.path.startswith("/owlbot/static/"): if request.path.startswith("/owlbot/static/"):
response.headers["Cache-Control"] = "max-age=86400" 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). # Callback type for webhook dispatch (injected from Owlbot).
type WebhookCallback = Callable[[EventType, Event], Coroutine[Any, Any, None]] type WebhookCallback = Callable[[EventType, Event], Coroutine[Any, Any, None]]
@@ -60,6 +100,7 @@ class HttpServer:
config: Config, config: Config,
event_dispatch: WebhookCallback, event_dispatch: WebhookCallback,
route_dispatcher: RouteDispatcher, route_dispatcher: RouteDispatcher,
session_manager: SessionManager,
) -> None: ) -> None:
"""Initialize the web server. """Initialize the web server.
@@ -78,7 +119,7 @@ class HttpServer:
self._runner: web.AppRunner | None = None self._runner: web.AppRunner | None = None
self.app = web.Application() self.app = web.Application()
self.app.on_response_prepare.append(_on_response_prepare) register_response_prepare_hook(self.app, session_manager=session_manager)
self.app.on_shutdown.append(self._on_shutdown) self.app.on_shutdown.append(self._on_shutdown)
self.app.router.add_post(self.config.webhook_path, self._handle_webhook) self.app.router.add_post(self.config.webhook_path, self._handle_webhook)
@@ -87,6 +128,13 @@ class HttpServer:
if static_dir.is_dir(): if static_dir.is_dir():
self.app.router.add_static("/owlbot/static", static_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. # Catch-all routes for dynamic module route dispatch.
# These single aiohttp routes handle all requests under /owlbot/ and # These single aiohttp routes handle all requests under /owlbot/ and
# dispatch them to the appropriate handler via RouteDispatcher lookup # dispatch them to the appropriate handler via RouteDispatcher lookup
+9 -1
View File
@@ -39,10 +39,11 @@ if TYPE_CHECKING:
from .api.http_client import HttpClient from .api.http_client import HttpClient
from .api.lifecycle import LifecycleHandler from .api.lifecycle import LifecycleHandler
from .sessions import SessionManager
logger = logging.getLogger("owlbot.modules") logger = logging.getLogger("owlbot.modules")
RESERVED_MODULE_NAMES: frozenset[str] = frozenset({"static"}) RESERVED_MODULE_NAMES: frozenset[str] = frozenset({"connect", "static"})
class ModuleLoadError(Exception): class ModuleLoadError(Exception):
@@ -57,6 +58,7 @@ class ModuleLoader:
modules_dir: str | Path, modules_dir: str | Path,
config: Config, config: Config,
http_client: HttpClient, http_client: HttpClient,
session_manager: SessionManager,
) -> None: ) -> None:
"""Initialize the module loader. """Initialize the module loader.
@@ -67,6 +69,7 @@ class ModuleLoader:
self.modules_dir = Path(modules_dir).resolve() self.modules_dir = Path(modules_dir).resolve()
self.config = config self.config = config
self.http_client = http_client self.http_client = http_client
self.session_manager = session_manager
self._module_contexts: dict[str, ModuleContext] = {} self._module_contexts: dict[str, ModuleContext] = {}
self.loaded_modules: set[str] = set() self.loaded_modules: set[str] = set()
@@ -92,15 +95,20 @@ class ModuleLoader:
handler_timeout=config.handler_timeout, handler_timeout=config.handler_timeout,
loaded_modules=self.loaded_modules, loaded_modules=self.loaded_modules,
command_prefix=config.command_prefix, command_prefix=config.command_prefix,
session_manager=self.session_manager,
public_base_url=config.public_base_url,
) )
self.event_dispatcher = EventDispatcher( self.event_dispatcher = EventDispatcher(
command_dispatch=self.command_dispatcher.dispatch, command_dispatch=self.command_dispatcher.dispatch,
get_module_context=self._require_module_context, get_module_context=self._require_module_context,
handler_timeout=config.handler_timeout, handler_timeout=config.handler_timeout,
session_manager=self.session_manager,
) )
self.route_dispatcher = RouteDispatcher( self.route_dispatcher = RouteDispatcher(
get_module_context=self._require_module_context, get_module_context=self._require_module_context,
handler_timeout=config.handler_timeout, handler_timeout=config.handler_timeout,
session_manager=self.session_manager,
command_prefix=config.command_prefix,
) )
self._core_template_dir = Path(__file__).resolve().parent / "templates" self._core_template_dir = Path(__file__).resolve().parent / "templates"
+78 -8
View File
@@ -22,9 +22,11 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import math import math
import posixpath
import time import time
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from urllib.parse import unquote, urlsplit
from owlbot._version import __version__ from owlbot._version import __version__
from owlbot.api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark from owlbot.api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark
@@ -35,7 +37,9 @@ if TYPE_CHECKING:
from types import ModuleType from types import ModuleType
from owlbot.api.context import ModuleContext from owlbot.api.context import ModuleContext
from owlbot.api.event_types import User
from owlbot.api.owncast_client import OwncastClient from owlbot.api.owncast_client import OwncastClient
from owlbot.sessions import SessionManager
type BuiltinCommandHandler = Callable[[ChatEvent, "OwncastClient"], Awaitable[None]] type BuiltinCommandHandler = Callable[[ChatEvent, "OwncastClient"], Awaitable[None]]
@@ -253,6 +257,8 @@ class CommandDispatcher:
owncast_client: OwncastClient, owncast_client: OwncastClient,
handler_timeout: float, handler_timeout: float,
loaded_modules: set[str], loaded_modules: set[str],
session_manager: SessionManager,
public_base_url: str,
command_prefix: str = "!", command_prefix: str = "!",
) -> None: ) -> None:
"""Initialize the command dispatcher. """Initialize the command dispatcher.
@@ -264,6 +270,8 @@ class CommandDispatcher:
:param owncast_client: Owncast API client for sending error messages. :param owncast_client: Owncast API client for sending error messages.
:param handler_timeout: Timeout for command handlers in seconds. :param handler_timeout: Timeout for command handlers in seconds.
:param loaded_modules: Reference to the set of currently loaded module names. :param loaded_modules: Reference to the set of currently loaded module names.
:param session_manager: Session manager used for browser connect links.
:param public_base_url: Public base URL for generated connect links.
:param command_prefix: Prefix character for commands (e.g., "!"). :param command_prefix: Prefix character for commands (e.g., "!").
""" """
self._command_registry = CommandRegistry(command_prefix) self._command_registry = CommandRegistry(command_prefix)
@@ -271,6 +279,8 @@ class CommandDispatcher:
self._owncast_client = owncast_client self._owncast_client = owncast_client
self._handler_timeout = handler_timeout self._handler_timeout = handler_timeout
self._loaded_modules = loaded_modules self._loaded_modules = loaded_modules
self._session_manager = session_manager
self._public_base_url = public_base_url.rstrip("/")
# Maps canonical command name -> monotonic timestamp of last invocation. # Maps canonical command name -> monotonic timestamp of last invocation.
self._cooldown_tracker: dict[str, float] = {} self._cooldown_tracker: dict[str, float] = {}
# Maps canonical command name -> built-in handler callable. # Maps canonical command name -> built-in handler callable.
@@ -289,6 +299,7 @@ class CommandDispatcher:
handler: BuiltinCommandHandler, handler: BuiltinCommandHandler,
*, *,
aliases: list[str] | tuple[str, ...] | None = None, aliases: list[str] | tuple[str, ...] | None = None,
cooldown: int = 60,
) -> None: ) -> None:
"""Register a built-in command handler. """Register a built-in command handler.
@@ -298,16 +309,17 @@ class CommandDispatcher:
:param name: Primary command name (case-insensitive). :param name: Primary command name (case-insensitive).
:param handler: Async function with signature (ChatEvent, OwncastClient). :param handler: Async function with signature (ChatEvent, OwncastClient).
:param aliases: Optional list of alternative names. :param aliases: Optional list of alternative names.
:param cooldown: Minimum seconds between invocations (0 to disable).
""" """
name_lower = name.lower() name_lower = name.lower()
self._builtin_handlers[name_lower] = handler
self._command_registry.register( self._command_registry.register(
name=name, name=name,
handler=handler, # type: ignore[arg-type] handler=handler, # type: ignore[arg-type]
aliases=aliases, aliases=aliases,
module_name="__builtin__", module_name="__builtin__",
cooldown=60, cooldown=cooldown,
) )
self._builtin_handlers[name_lower] = handler
logger.debug("Registered built-in command '%s'.", name_lower) logger.debug("Registered built-in command '%s'.", name_lower)
def register( def register(
@@ -359,6 +371,7 @@ class CommandDispatcher:
result = self._command_registry.unregister(name) result = self._command_registry.unregister(name)
if result and info is not None: if result and info is not None:
self._cooldown_tracker.pop(info.name, None) self._cooldown_tracker.pop(info.name, None)
self._builtin_handlers.pop(info.name, None)
return result return result
def get(self, trigger: str) -> CommandInfo | None: def get(self, trigger: str) -> CommandInfo | None:
@@ -415,6 +428,7 @@ class CommandDispatcher:
module_commands = self.get_by_module(module_name) module_commands = self.get_by_module(module_name)
for cmd_name in module_commands: for cmd_name in module_commands:
self._cooldown_tracker.pop(cmd_name, None) self._cooldown_tracker.pop(cmd_name, None)
self._builtin_handlers.pop(cmd_name, None)
return self._command_registry.unregister_by_module(module_name) return self._command_registry.unregister_by_module(module_name)
async def dispatch(self, event: ChatEvent) -> None: async def dispatch(self, event: ChatEvent) -> None:
@@ -539,6 +553,10 @@ class CommandDispatcher:
command_event=cmd_event, command_event=cmd_event,
event_context=event_ctx, event_context=event_ctx,
module=module_ctx, module=module_ctx,
_session_url_for=self._make_session_url_for(
command_info.module_name,
user,
),
) )
try: try:
@@ -567,22 +585,74 @@ class CommandDispatcher:
def _register_builtin_commands(self) -> None: def _register_builtin_commands(self) -> None:
"""Register all built-in commands.""" """Register all built-in commands."""
loaded_modules = self._loaded_modules self.register_builtin("about", self._builtin_about)
self.register_builtin("connect", self._builtin_connect, cooldown=0)
async def about_with_modules( async def _builtin_about(
event: ChatEvent, # noqa: ARG001 # required by builtin handler signature self,
_event: ChatEvent,
owncast_client: OwncastClient, owncast_client: OwncastClient,
) -> None: ) -> None:
module_count = len(loaded_modules) """Send the bot version and loaded-module summary."""
module_count = len(self._loaded_modules)
module_list = ( module_list = (
", ".join(sorted(loaded_modules)) if loaded_modules else "none" ", ".join(sorted(self._loaded_modules)) if self._loaded_modules else "none"
) )
await owncast_client.send_message( await owncast_client.send_message(
f"Owlbot v{__version__} - A logal.dev project | " f"Owlbot v{__version__} - A logal.dev project | "
f"Modules ({module_count}): {module_list}" f"Modules ({module_count}): {module_list}"
) )
self.register_builtin("about", about_with_modules) async def _builtin_connect(
self,
event: ChatEvent,
owncast_client: OwncastClient,
) -> None:
"""Send the invoking client a one-time Owlbot connect token."""
session_manager = self._session_manager
public_base_url = self._public_base_url
await owncast_client.set_message_visibility([event.message_id], visible=False)
token = session_manager.issue_connect_token(user=event.user)
session_url = f"{public_base_url}/owlbot/connect/{token}"
await owncast_client.send_system_message_to_client(
event.client_id,
(
f'<a href="{session_url}">'
"<u>Click here to connect your Owncast session with Owlbot.</u>"
"</a>"
),
unsanitized=True,
)
def _make_session_url_for(
self,
module_name: str,
user: User,
) -> Callable[[str], str]:
"""Build the command-scoped session URL helper."""
session_manager = self._session_manager
public_base_url = self._public_base_url
def session_url_for(path: str) -> str:
if not path.startswith("/"):
path = "/" + path
destination_path = f"/owlbot/{module_name}{path}"
module_root = f"/owlbot/{module_name}"
decoded_path = unquote(urlsplit(destination_path).path)
normalized_path = posixpath.normpath(decoded_path)
if normalized_path != module_root and not normalized_path.startswith(
f"{module_root}/"
):
msg = "session destination must stay within the module namespace"
raise ValueError(msg)
token = session_manager.issue_connect_token(destination_path, user=user)
return f"{public_base_url}/owlbot/connect/{token}"
return session_url_for
class ModuleCommands: class ModuleCommands:
+13 -1
View File
@@ -27,7 +27,12 @@ from collections import defaultdict
from typing import TYPE_CHECKING, NamedTuple, cast from typing import TYPE_CHECKING, NamedTuple, cast
from owlbot.api.context import EventContext, PropagationState from owlbot.api.context import EventContext, PropagationState
from owlbot.api.event_types import ChatEvent, Event, EventType, log_event from owlbot.api.event_types import (
ChatEvent,
Event,
EventType,
log_event,
)
from owlbot.api.events import EventHandler, EventMark, Priority from owlbot.api.events import EventHandler, EventMark, Priority
@@ -46,6 +51,7 @@ if TYPE_CHECKING:
from types import ModuleType from types import ModuleType
from owlbot.api.context import ModuleContext from owlbot.api.context import ModuleContext
from owlbot.sessions import SessionManager
logger = logging.getLogger("owlbot.events") logger = logging.getLogger("owlbot.events")
@@ -210,6 +216,7 @@ class EventDispatcher:
command_dispatch: Callable[[ChatEvent], Awaitable[None]], command_dispatch: Callable[[ChatEvent], Awaitable[None]],
get_module_context: Callable[[str], ModuleContext], get_module_context: Callable[[str], ModuleContext],
handler_timeout: float, handler_timeout: float,
session_manager: SessionManager,
) -> None: ) -> None:
"""Initialize the event dispatcher. """Initialize the event dispatcher.
@@ -225,6 +232,7 @@ class EventDispatcher:
self._command_dispatch = command_dispatch self._command_dispatch = command_dispatch
self._get_module_context = get_module_context self._get_module_context = get_module_context
self._handler_timeout = handler_timeout self._handler_timeout = handler_timeout
self._session_manager = session_manager
def register( def register(
self, self,
@@ -321,6 +329,10 @@ class EventDispatcher:
:param event_type: The type of event to dispatch. :param event_type: The type of event to dispatch.
:param event: The parsed event instance. :param event: The parsed event instance.
""" """
user = getattr(event, "user", None)
if user is not None:
self._session_manager.refresh_user(user)
log_event(event_type, event) log_event(event_type, event)
handler_entries = self._handler_registry.get(event_type) handler_entries = self._handler_registry.get(event_type)
+94 -2
View File
@@ -32,12 +32,15 @@ from aiohttp.web import DynamicResource
from owlbot.api.context import RouteContext from owlbot.api.context import RouteContext
from owlbot.api.routes import RouteHandler, RouteInfo, RouteMark from owlbot.api.routes import RouteHandler, RouteInfo, RouteMark
from owlbot.sessions import SESSION_COOKIE_NAME
from owlbot.web.sessions import connect_guidance_response
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
from types import ModuleType from types import ModuleType
from owlbot.api.context import ModuleContext from owlbot.api.context import ModuleContext
from owlbot.sessions import BrowserSession, SessionManager
logger = logging.getLogger("owlbot.web") logger = logging.getLogger("owlbot.web")
@@ -94,6 +97,9 @@ class RouteRegistry:
methods: list[str] | None = None, methods: list[str] | None = None,
module_name: str, module_name: str,
streaming: bool = False, streaming: bool = False,
requires_session: bool = False,
requires_authenticated: bool = False,
requires_moderator: bool = False,
) -> RouteInfo: ) -> RouteInfo:
"""Register a route handler. """Register a route handler.
@@ -134,6 +140,9 @@ class RouteRegistry:
handler=handler, handler=handler,
module_name=module_name, module_name=module_name,
streaming=streaming, streaming=streaming,
requires_session=requires_session,
requires_authenticated=requires_authenticated,
requires_moderator=requires_moderator,
) )
if group is None: if group is None:
@@ -357,6 +366,9 @@ class RouteRegistry:
methods=mark["methods"], methods=mark["methods"],
module_name=module_name, module_name=module_name,
streaming=mark["streaming"], streaming=mark["streaming"],
requires_session=mark["requires_session"],
requires_authenticated=mark["requires_authenticated"],
requires_moderator=mark["requires_moderator"],
) )
@@ -371,6 +383,8 @@ class RouteDispatcher:
self, self,
get_module_context: Callable[[str], ModuleContext], get_module_context: Callable[[str], ModuleContext],
handler_timeout: float, handler_timeout: float,
session_manager: SessionManager,
command_prefix: str = "!",
) -> None: ) -> None:
"""Initialize the route dispatcher. """Initialize the route dispatcher.
@@ -383,6 +397,8 @@ class RouteDispatcher:
self._route_registry = RouteRegistry() self._route_registry = RouteRegistry()
self._get_module_context = get_module_context self._get_module_context = get_module_context
self._handler_timeout = handler_timeout self._handler_timeout = handler_timeout
self._session_manager = session_manager
self._command_prefix = command_prefix
self._streaming_tasks: set[asyncio.Task[Any]] = set() self._streaming_tasks: set[asyncio.Task[Any]] = set()
self._handler_tasks: set[asyncio.Task[Any]] = set() self._handler_tasks: set[asyncio.Task[Any]] = set()
@@ -394,6 +410,9 @@ class RouteDispatcher:
methods: list[str] | None = None, methods: list[str] | None = None,
module_name: str, module_name: str,
streaming: bool = False, streaming: bool = False,
requires_session: bool = False,
requires_authenticated: bool = False,
requires_moderator: bool = False,
) -> RouteInfo: ) -> RouteInfo:
"""Register a route handler. """Register a route handler.
@@ -413,6 +432,9 @@ class RouteDispatcher:
methods=methods, methods=methods,
module_name=module_name, module_name=module_name,
streaming=streaming, streaming=streaming,
requires_session=requires_session,
requires_authenticated=requires_authenticated,
requires_moderator=requires_moderator,
) )
def unregister(self, full_path: str, *, method: str | None = None) -> bool: def unregister(self, full_path: str, *, method: str | None = None) -> bool:
@@ -520,6 +542,8 @@ class RouteDispatcher:
""" """
module_name = request.match_info["module_name"] module_name = request.match_info["module_name"]
path = request.match_info.get("path", "") path = request.match_info.get("path", "")
session_id = request.cookies.get(SESSION_COOKIE_NAME)
session = self._session_manager.get_session(session_id)
relative_path = f"/{path}" if path else "/" relative_path = f"/{path}" if path else "/"
@@ -545,13 +569,20 @@ class RouteDispatcher:
return web.Response(status=405, headers={"Allow": allowed}) return web.Response(status=405, headers={"Allow": allowed})
route_info, match_info = result route_info, match_info = result
return await self._handle_module_route(request, route_info, match_info) return await self._handle_module_route(
request,
route_info,
match_info,
session=session,
)
async def _handle_module_route( async def _handle_module_route(
self, self,
request: web.Request, request: web.Request,
route_info: RouteInfo, route_info: RouteInfo,
match_info: dict[str, str] | None = None, match_info: dict[str, str] | None = None,
*,
session: BrowserSession | None,
) -> web.StreamResponse: ) -> web.StreamResponse:
"""Handle an HTTP request to a module-registered route. """Handle an HTTP request to a module-registered route.
@@ -569,8 +600,16 @@ class RouteDispatcher:
request=request, request=request,
module=module_ctx, module=module_ctx,
match_info=match_info if match_info is not None else {}, match_info=match_info if match_info is not None else {},
session=session,
) )
guard_response = self._guard_response(
route_info,
session=session,
)
if guard_response is not None:
return guard_response
logger.debug( logger.debug(
"Calling route handler: %s from module: %s", "Calling route handler: %s from module: %s",
route_info.full_path, route_info.full_path,
@@ -640,6 +679,50 @@ class RouteDispatcher:
self._streaming_tasks.discard(task) self._streaming_tasks.discard(task)
self._handler_tasks.discard(task) self._handler_tasks.discard(task)
def _guard_response(
self,
route_info: RouteInfo,
*,
session: BrowserSession | None,
) -> web.Response | None:
requires_session = (
route_info.requires_session
or route_info.requires_authenticated
or route_info.requires_moderator
)
if requires_session and session is None:
return connect_guidance_response(
status=401,
command_prefix=self._command_prefix,
)
if session is None:
return None
if route_info.requires_authenticated and not session.is_authenticated:
return connect_guidance_response(
status=403,
command_prefix=self._command_prefix,
title="Authentication required",
message="You must be authenticated in Owncast to access this page.",
command_message=(
"If you believe this is in error, try using "
f"{self._command_prefix}connect in chat to reconnect your "
"Owncast account."
),
)
if route_info.requires_moderator and not session.is_moderator:
return connect_guidance_response(
status=403,
command_prefix=self._command_prefix,
title="Moderator access required",
message="Only moderators can access this page.",
command_message=(
"If you believe this is in error, try using "
f"{self._command_prefix}connect in chat to reconnect your "
"Owncast account."
),
)
return None
class ModuleRoutes: class ModuleRoutes:
"""Module-scoped wrapper around RouteDispatcher. """Module-scoped wrapper around RouteDispatcher.
@@ -650,7 +733,10 @@ class ModuleRoutes:
""" """
def __init__( def __init__(
self, dispatcher: RouteDispatcher, module_name: str, public_base_url: str self,
dispatcher: RouteDispatcher,
module_name: str,
public_base_url: str,
) -> None: ) -> None:
"""Initialize the module-scoped routes wrapper. """Initialize the module-scoped routes wrapper.
@@ -682,6 +768,9 @@ class ModuleRoutes:
*, *,
methods: list[str] | None = None, methods: list[str] | None = None,
streaming: bool = False, streaming: bool = False,
requires_session: bool = False,
requires_authenticated: bool = False,
requires_moderator: bool = False,
) -> RouteInfo: ) -> RouteInfo:
"""Register a route handler for this module. """Register a route handler for this module.
@@ -700,6 +789,9 @@ class ModuleRoutes:
methods=methods, methods=methods,
module_name=self._module_name, module_name=self._module_name,
streaming=streaming, streaming=streaming,
requires_session=requires_session,
requires_authenticated=requires_authenticated,
requires_moderator=requires_moderator,
) )
def unregister(self, path: str, *, method: str | None = None) -> bool: def unregister(self, path: str, *, method: str | None = None) -> bool:
+293
View File
@@ -0,0 +1,293 @@
# 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.
"""In-memory management for one-time connect tokens and browser sessions."""
from __future__ import annotations
import asyncio
import contextlib
import secrets
from dataclasses import dataclass, field, replace
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
from urllib.parse import unquote, urlsplit
if TYPE_CHECKING:
from owlbot.api.event_types import User
SESSION_COOKIE_NAME = "owlbot_session"
DEFAULT_CONNECT_DESTINATION_PATH = "/owlbot/connect"
DEFAULT_CONNECT_TOKEN_TTL = timedelta(minutes=5)
DEFAULT_SESSION_TTL = timedelta(hours=1)
_CLEANUP_INTERVAL_SECONDS = 60.0
def _utc_now() -> datetime:
return datetime.now(UTC)
@dataclass(frozen=True, slots=True)
class ConnectToken:
"""A one-time connect token issued to a specific user."""
token: str = field(repr=False)
destination_path: str
user: User
issued_at: datetime
expires_at: datetime
@dataclass(frozen=True, slots=True)
class BrowserSession:
"""A browser session associated with an Owncast user snapshot."""
session_id: str = field(repr=False)
user: User
created_at: datetime
expires_at: datetime
@property
def is_authenticated(self) -> bool:
"""Whether the session user is authenticated with Owncast."""
return self.user.is_authenticated
@property
def is_moderator(self) -> bool:
"""Whether the session user currently has moderator privileges."""
return self.user.is_moderator
class ConnectTokenRedemptionError(Exception):
"""Raised when a connect token cannot be redeemed into a session."""
class SessionManager:
"""Issue connect tokens and manage expiring browser sessions."""
def __init__(
self,
*,
connect_token_ttl: timedelta = DEFAULT_CONNECT_TOKEN_TTL,
session_ttl: timedelta = DEFAULT_SESSION_TTL,
) -> None:
"""Initialize the in-memory session state manager."""
self._connect_token_ttl = connect_token_ttl
self._session_ttl = session_ttl
self._connect_tokens_by_token: dict[str, ConnectToken] = {}
self._connect_token_by_user_id: dict[str, str] = {}
self._sessions_by_id: dict[str, BrowserSession] = {}
self._session_ids_by_user_id: dict[str, set[str]] = {}
self._cleanup_task: asyncio.Task[None] | None = None
def issue_connect_token(
self,
destination_path: str = DEFAULT_CONNECT_DESTINATION_PATH,
*,
user: User,
) -> str:
"""Create a one-time token that redeems into an Owlbot session."""
parts = urlsplit(destination_path)
if parts.scheme or parts.netloc or not parts.path.startswith("/owlbot/"):
msg = "session destination must stay within /owlbot"
raise ValueError(msg)
decoded_path = unquote(parts.path)
if "\\" in decoded_path:
msg = "session destination must use URL path separators"
raise ValueError(msg)
if any(segment in {".", ".."} for segment in decoded_path.split("/")):
msg = "session destination must not contain dot segments"
raise ValueError(msg)
now = _utc_now()
token = secrets.token_urlsafe(32)
# Token strings are the primary key for pending connections. Collisions
# are extraordinarily unlikely, but a duplicate would overwrite another
# user's pending token in the canonical token index.
while token in self._connect_tokens_by_token:
token = secrets.token_urlsafe(32)
self._store_connect_token(
ConnectToken(
token=token,
destination_path=destination_path,
user=user,
issued_at=now,
expires_at=now + self._connect_token_ttl,
)
)
return token
def get_connect_token(self, token: str) -> ConnectToken | None:
"""Return the tracked connect token, if it still exists."""
return self._connect_tokens_by_token.get(token)
def redeem_connect_token(
self,
token: str,
*,
replacing_session_id: str | None = None,
) -> BrowserSession:
"""Consume a connect token and return the resulting browser session."""
connect_token = self._pop_connect_token(token)
if connect_token is None:
raise ConnectTokenRedemptionError("invalid connect token")
if connect_token.expires_at <= _utc_now():
raise ConnectTokenRedemptionError("connect token expired")
return self.create_session(
connect_token.user,
replacing_session_id=replacing_session_id,
)
def create_session(
self,
user: User,
*,
replacing_session_id: str | None = None,
) -> BrowserSession:
"""Create a new browser session, optionally replacing an older one."""
if replacing_session_id is not None:
self.invalidate_session(replacing_session_id)
now = _utc_now()
session_id = secrets.token_urlsafe(32)
# Session IDs are the primary key for browser sessions. Collisions are
# extraordinarily unlikely, but a duplicate would overwrite another
# browser's active session in the canonical session index.
while session_id in self._sessions_by_id:
session_id = secrets.token_urlsafe(32)
session = BrowserSession(
session_id=session_id,
user=user,
created_at=now,
expires_at=now + self._session_ttl,
)
self._store_session(session)
return session
def get_session(self, session_id: str | None) -> BrowserSession | None:
"""Return a live session by id, evicting it first if expired."""
if not session_id:
return None
session = self._sessions_by_id.get(session_id)
if session is None:
return None
if session.expires_at <= _utc_now():
self.invalidate_session(session_id)
return None
return session
def invalidate_session(self, session_id: str | None) -> None:
"""Remove a session from all in-memory indexes."""
if session_id is None:
return
session = self._sessions_by_id.pop(session_id, None)
if session is None:
return
session_ids = self._session_ids_by_user_id.get(session.user.id)
if session_ids is None:
return
session_ids.discard(session_id)
if not session_ids:
self._session_ids_by_user_id.pop(session.user.id, None)
def refresh_user(self, user: User) -> None:
"""Update active sessions and pending connect tokens for the user id."""
connect_token_id = self._connect_token_by_user_id.get(user.id)
connect_token = (
self._connect_tokens_by_token.get(connect_token_id)
if connect_token_id is not None
else None
)
if connect_token is not None:
self._connect_tokens_by_token[connect_token.token] = replace(
connect_token,
user=user,
)
for session_id in list(self._session_ids_by_user_id.get(user.id, set())):
session = self._sessions_by_id.get(session_id)
if session is not None:
self._sessions_by_id[session_id] = replace(session, user=user)
def clear_expired(self) -> None:
"""Purge expired connect-token and browser-session state."""
now = _utc_now()
expired_tokens = [
token
for token, connect_token in self._connect_tokens_by_token.items()
if connect_token.expires_at <= now
]
for token in expired_tokens:
self._pop_connect_token(token)
expired_session_ids = [
session_id
for session_id, session in self._sessions_by_id.items()
if session.expires_at <= now
]
for session_id in expired_session_ids:
self.invalidate_session(session_id)
def start(self) -> None:
"""Start the background cleanup task if it is not already running."""
if self._cleanup_task is None:
self._cleanup_task = asyncio.create_task(
self._cleanup_loop(),
name="Session Manager - Cleanup loop",
)
async def close(self) -> None:
"""Cancel and await the background cleanup task, if present."""
if self._cleanup_task is None:
return
self._cleanup_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._cleanup_task
self._cleanup_task = None
async def _cleanup_loop(self) -> None:
while True:
await asyncio.sleep(_CLEANUP_INTERVAL_SECONDS)
self.clear_expired()
def _store_connect_token(self, connect_token: ConnectToken) -> None:
"""Store a connect token, replacing any pending token for its user."""
previous_token = self._connect_token_by_user_id.get(connect_token.user.id)
if previous_token is not None:
self._connect_tokens_by_token.pop(previous_token, None)
self._connect_tokens_by_token[connect_token.token] = connect_token
self._connect_token_by_user_id[connect_token.user.id] = connect_token.token
def _pop_connect_token(self, token: str) -> ConnectToken | None:
"""Remove a connect token from both token indexes."""
connect_token = self._connect_tokens_by_token.pop(token, None)
if connect_token is None:
return None
if self._connect_token_by_user_id.get(connect_token.user.id) == token:
self._connect_token_by_user_id.pop(connect_token.user.id, None)
return connect_token
def _store_session(self, session: BrowserSession) -> None:
"""Store a browser session in all session indexes."""
self._sessions_by_id[session.session_id] = session
self._session_ids_by_user_id.setdefault(session.user.id, set()).add(
session.session_id
)
+15
View File
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<h1 class="mb-3">{{ title }}</h1>
<div class="alert alert-warning" role="alert">
<p class="mb-0">{{ body_message }}</p>
{% if command_message is none %}
<p class="mb-0">Run <code>{{ command_prefix }}connect</code> in chat and try again.</p>
{% else %}
<p class="mb-0">{{ command_message }}</p>
{% endif %}
</div>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}Owlbot Session{% endblock %}
{% block content %}
<h1 class="mb-3">Owlbot Session</h1>
{% if session is none %}
<div class="alert alert-secondary mb-0" role="status">
<p class="mb-0">There is no active Owlbot session for this browser.</p>
<p class="mb-0">Run <code>{{ command_prefix }}connect</code> in chat and try again.</p>
</div>
{% else %}
<div class="alert alert-success mb-0" role="status">
<p class="mb-0">This browser has an active Owlbot session for <strong>{{ session.user.display_name }}</strong> (<code>{{ session.user.id }}</code>).</p>
<p class="mb-0">This Owlbot session expires at <code>{{ session.expires_at.isoformat() }}</code>.</p>
</div>
{% endif %}
{% endblock %}
+33 -1
View File
@@ -45,10 +45,13 @@ from owlbot.api.context import ModuleContext
from owlbot.api.http_client import HttpClient from owlbot.api.http_client import HttpClient
from owlbot.api.storage import ModuleStorage from owlbot.api.storage import ModuleStorage
from owlbot.api.templates import ModuleTemplates from owlbot.api.templates import ModuleTemplates
from owlbot.http_server import register_response_prepare_hook
from owlbot.module_loader import ModuleLoader from owlbot.module_loader import ModuleLoader
from owlbot.registries.commands import CommandDispatcher, ModuleCommands from owlbot.registries.commands import CommandDispatcher, ModuleCommands
from owlbot.registries.events import EventDispatcher, ModuleEvents from owlbot.registries.events import EventDispatcher, ModuleEvents
from owlbot.registries.routes import ModuleRoutes, RouteDispatcher from owlbot.registries.routes import ModuleRoutes, RouteDispatcher
from owlbot.sessions import SessionManager
from owlbot.web.sessions import register_browser_session_routes
from .stubs import RecordingOwncastAdminClient, RecordingOwncastClient from .stubs import RecordingOwncastAdminClient, RecordingOwncastClient
@@ -279,10 +282,18 @@ def _loaded_modules() -> set[str]:
return set() return set()
@pytest.fixture
def session_manager() -> SessionManager:
"""Return a shared session manager for the test bot instance."""
return SessionManager()
@pytest.fixture @pytest.fixture
def command_dispatcher( def command_dispatcher(
_context_registry: dict[str, ModuleContext], _context_registry: dict[str, ModuleContext],
_loaded_modules: set[str], _loaded_modules: set[str],
session_manager: SessionManager,
config: Config,
owncast_client: RecordingOwncastClient, owncast_client: RecordingOwncastClient,
handler_timeout: float, handler_timeout: float,
command_prefix: str, command_prefix: str,
@@ -294,12 +305,15 @@ def command_dispatcher(
handler_timeout=handler_timeout, handler_timeout=handler_timeout,
loaded_modules=_loaded_modules, loaded_modules=_loaded_modules,
command_prefix=command_prefix, command_prefix=command_prefix,
session_manager=session_manager,
public_base_url=config.public_base_url,
) )
@pytest.fixture @pytest.fixture
def event_dispatcher( def event_dispatcher(
_context_registry: dict[str, ModuleContext], _context_registry: dict[str, ModuleContext],
session_manager: SessionManager,
command_dispatcher: CommandDispatcher, command_dispatcher: CommandDispatcher,
handler_timeout: float, handler_timeout: float,
) -> EventDispatcher: ) -> EventDispatcher:
@@ -308,6 +322,7 @@ def event_dispatcher(
command_dispatch=command_dispatcher.dispatch, command_dispatch=command_dispatcher.dispatch,
get_module_context=_make_context_lookup(_context_registry), get_module_context=_make_context_lookup(_context_registry),
handler_timeout=handler_timeout, handler_timeout=handler_timeout,
session_manager=session_manager,
) )
@@ -315,11 +330,15 @@ def event_dispatcher(
def route_dispatcher( def route_dispatcher(
_context_registry: dict[str, ModuleContext], _context_registry: dict[str, ModuleContext],
handler_timeout: float, handler_timeout: float,
session_manager: SessionManager,
command_prefix: str,
) -> RouteDispatcher: ) -> RouteDispatcher:
"""Build a RouteDispatcher that resolves contexts via ``_context_registry``.""" """Build a RouteDispatcher that resolves contexts via ``_context_registry``."""
return RouteDispatcher( return RouteDispatcher(
get_module_context=_make_context_lookup(_context_registry), get_module_context=_make_context_lookup(_context_registry),
handler_timeout=handler_timeout, handler_timeout=handler_timeout,
session_manager=session_manager,
command_prefix=command_prefix,
) )
@@ -336,6 +355,7 @@ def module_context(
module_config: ModuleConfig, module_config: ModuleConfig,
templates: ModuleTemplates, templates: ModuleTemplates,
http: HttpClient, http: HttpClient,
session_manager: SessionManager,
request: pytest.FixtureRequest, request: pytest.FixtureRequest,
*, *,
admin_client_enabled: bool, admin_client_enabled: bool,
@@ -359,7 +379,9 @@ def module_context(
commands=ModuleCommands(command_dispatcher, module_name), commands=ModuleCommands(command_dispatcher, module_name),
events=ModuleEvents(event_dispatcher, module_name), events=ModuleEvents(event_dispatcher, module_name),
routes=ModuleRoutes( routes=ModuleRoutes(
route_dispatcher, module_name, module_config.public_base_url route_dispatcher,
module_name,
module_config.public_base_url,
), ),
http=http, http=http,
templates=templates, templates=templates,
@@ -373,10 +395,20 @@ def module_context(
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def route_client( async def route_client(
route_dispatcher: RouteDispatcher, route_dispatcher: RouteDispatcher,
session_manager: SessionManager,
command_prefix: str,
config: Config,
aiohttp_client: Any, aiohttp_client: Any,
) -> TestClient[Any, Any]: ) -> TestClient[Any, Any]:
"""Return an aiohttp test client wired to the RouteDispatcher.""" """Return an aiohttp test client wired to the RouteDispatcher."""
app = web.Application() app = web.Application()
register_response_prepare_hook(app, session_manager=session_manager)
register_browser_session_routes(
app,
session_manager=session_manager,
command_prefix=command_prefix,
cookie_secure=config.public_base_url.startswith("https://"),
)
app.router.add_route( app.router.add_route(
"*", "/owlbot/{module_name}/{path:.*}", route_dispatcher.dispatch "*", "/owlbot/{module_name}/{path:.*}", route_dispatcher.dispatch
) )
+15
View File
@@ -0,0 +1,15 @@
# 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.
"""Web helpers and server infrastructure for Owlbot."""
+140
View File
@@ -0,0 +1,140 @@
# 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.
"""Web helpers for Owlbot browser session flows."""
from __future__ import annotations
from http import HTTPStatus
from pathlib import Path
import jinja2
from aiohttp import web
from owlbot import __version__
from owlbot.sessions import (
SESSION_COOKIE_NAME,
BrowserSession,
ConnectTokenRedemptionError,
SessionManager,
)
_TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates"
_TEMPLATE_ENV = jinja2.Environment(
loader=jinja2.FileSystemLoader(_TEMPLATE_DIR),
autoescape=True,
)
_TEMPLATE_ENV.globals["owlbot_version"] = __version__
def _render_template(template_name: str, **context: object) -> str:
template = _TEMPLATE_ENV.get_template(template_name)
return template.render(**context)
def connect_guidance_response(
*,
status: int,
command_prefix: str = "!",
title: str = "Connect to Owlbot",
message: str | None = None,
command_message: str | None = None,
) -> web.Response:
"""Return a simple connect guidance page for protected routes."""
body_message = message or "Run this command in chat, then try again."
return web.Response(
status=status,
text=_render_template(
"connect_error.html",
title=title,
body_message=body_message,
command_message=command_message,
command_prefix=command_prefix,
),
content_type="text/html",
)
def browser_session_status_response(
session: BrowserSession | None,
*,
command_prefix: str,
) -> web.Response:
"""Return the browser session status page."""
return web.Response(
text=_render_template(
"session_status.html",
session=session,
command_prefix=command_prefix,
),
content_type="text/html",
)
def register_browser_session_routes(
app: web.Application,
*,
session_manager: SessionManager,
command_prefix: str,
cookie_secure: bool = False,
) -> None:
"""Register the shared browser-connection endpoints on the app."""
async def redeem(request: web.Request) -> web.Response:
token = request.match_info["token"]
replacing_session_id = request.cookies.get(SESSION_COOKIE_NAME)
connect_token = session_manager.get_connect_token(token)
if connect_token is None:
return connect_guidance_response(
status=HTTPStatus.FORBIDDEN,
command_prefix=command_prefix,
title="Invalid or expired link",
message="This connection link is invalid or has expired.",
)
try:
session = session_manager.redeem_connect_token(
token,
replacing_session_id=replacing_session_id,
)
except ConnectTokenRedemptionError:
return connect_guidance_response(
status=HTTPStatus.FORBIDDEN,
command_prefix=command_prefix,
title="Invalid or expired link",
message="This connection link is invalid or has expired.",
)
response = web.HTTPFound(location=connect_token.destination_path)
response.set_cookie(
SESSION_COOKIE_NAME,
session.session_id,
httponly=True,
max_age=int((session.expires_at - session.created_at).total_seconds()),
samesite="Lax",
secure=cookie_secure,
path="/owlbot",
)
raise response
async def status(request: web.Request) -> web.Response:
session_id = request.cookies.get(SESSION_COOKIE_NAME)
session = session_manager.get_session(session_id)
return browser_session_status_response(
session,
command_prefix=command_prefix,
)
app.router.add_get("/owlbot/connect", status)
app.router.add_get("/owlbot/connect/{token}", redeem)
+277
View File
@@ -37,9 +37,12 @@ from owlbot.registries.commands import (
CommandRegistry, CommandRegistry,
ModuleCommands, ModuleCommands,
) )
from owlbot.sessions import SessionManager
from owlbot.testing import RecordingOwncastClient, make_chat_event, make_user from owlbot.testing import RecordingOwncastClient, make_chat_event, make_user
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable
from owlbot.api.context import ModuleContext from owlbot.api.context import ModuleContext
from owlbot.api.event_types import ChatEvent from owlbot.api.event_types import ChatEvent
from owlbot.api.storage import ModuleStorage from owlbot.api.storage import ModuleStorage
@@ -444,15 +447,27 @@ class TestCommandDispatcherDispatch:
owncast_client: RecordingOwncastClient | None = None, owncast_client: RecordingOwncastClient | None = None,
handler_timeout: float = 5.0, handler_timeout: float = 5.0,
command_prefix: str = "!", command_prefix: str = "!",
session_manager: SessionManager | None = None,
public_base_url: str | None = None,
) -> tuple[CommandDispatcher, ModuleContext, RecordingOwncastClient]: ) -> tuple[CommandDispatcher, ModuleContext, RecordingOwncastClient]:
"""Build a CommandDispatcher wired to the given ModuleContext.""" """Build a CommandDispatcher wired to the given ModuleContext."""
stub = owncast_client or RecordingOwncastClient() stub = owncast_client or RecordingOwncastClient()
resolved_session_manager = (
session_manager if session_manager is not None else SessionManager()
)
resolved_public_base_url = (
public_base_url
if public_base_url is not None
else module_context.config.public_base_url
)
dispatcher = CommandDispatcher( dispatcher = CommandDispatcher(
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
owncast_client=stub, owncast_client=stub,
handler_timeout=handler_timeout, handler_timeout=handler_timeout,
loaded_modules=set(), loaded_modules=set(),
command_prefix=command_prefix, command_prefix=command_prefix,
session_manager=resolved_session_manager,
public_base_url=resolved_public_base_url,
) )
return dispatcher, module_context, stub return dispatcher, module_context, stub
@@ -862,6 +877,250 @@ class TestCommandDispatcherDispatch:
for r in caplog.records for r in caplog.records
) )
async def test_builtin_connect_sends_private_connect_link(
self, module_context: ModuleContext
) -> None:
"""!connect hides the chat line and whispers a one-time link."""
stub = RecordingOwncastClient()
session_manager = SessionManager()
dispatcher = CommandDispatcher(
get_module_context=lambda _name: module_context,
owncast_client=stub,
handler_timeout=5.0,
loaded_modules=set(),
session_manager=session_manager,
public_base_url=module_context.config.public_base_url,
)
user = make_user(id="user-123", display_name="Alice")
await dispatcher.dispatch(
make_chat_event(client_id=42, raw_body="!connect", user=user)
)
assert len(stub.calls) == 2
assert stub.calls[0].name == "set_message_visibility"
assert stub.calls[0].kwargs == {
"message_ids": ["test-msg-id"],
"visible": False,
}
assert stub.calls[1].name == "send_system_message_to_client"
assert stub.calls[1].kwargs["client_id"] == 42
assert stub.calls[1].kwargs["unsanitized"] is True
body = stub.calls[1].kwargs["body"]
url = body.split('href="', 1)[1].split('"', 1)[0]
token = url.rsplit("/", 1)[-1]
connect_token = session_manager.get_connect_token(token)
assert body == (
f'<a href="{url}">'
"<u>Click here to connect your Owncast session with Owlbot.</u>"
"</a>"
)
assert connect_token is not None
assert connect_token.destination_path == "/owlbot/connect"
assert connect_token.user.id == user.id
async def test_builtin_connect_can_be_reissued_immediately(
self, module_context: ModuleContext
) -> None:
"""!connect is not throttled by the generic built-in cooldown."""
stub = RecordingOwncastClient()
session_manager = SessionManager()
dispatcher = CommandDispatcher(
get_module_context=lambda _name: module_context,
owncast_client=stub,
handler_timeout=5.0,
loaded_modules=set(),
session_manager=session_manager,
public_base_url=module_context.config.public_base_url,
)
user = make_user(id="user-123", display_name="Alice")
await dispatcher.dispatch(
make_chat_event(client_id=42, raw_body="!connect", user=user)
)
await dispatcher.dispatch(
make_chat_event(client_id=42, raw_body="!connect", user=user)
)
assert len(stub.calls) == 4
assert [call.name for call in stub.calls] == [
"set_message_visibility",
"send_system_message_to_client",
"set_message_visibility",
"send_system_message_to_client",
]
assert all(
call.kwargs == {"message_ids": ["test-msg-id"], "visible": False}
for call in stub.calls[::2]
)
tokens: list[str] = []
for call in stub.calls[1::2]:
body = call.kwargs["body"]
url = body.split('href="', 1)[1].split('"', 1)[0]
tokens.append(url.rsplit("/", 1)[-1])
assert body == (
f'<a href="{url}">'
"<u>Click here to connect your Owncast session with Owlbot.</u>"
"</a>"
)
assert tokens[0] != tokens[1]
assert session_manager.get_connect_token(tokens[0]) is None
assert session_manager.get_connect_token(tokens[1]) is not None
async def test_command_context_session_url_for_uses_command_user(
self, module_context: ModuleContext
) -> None:
"""Dispatched command contexts can issue user-bound session URLs."""
session_manager = SessionManager()
captured_url = ""
user = make_user(id="user-123", is_authenticated=True)
async def handler(ctx: CommandContext) -> None:
nonlocal captured_url
captured_url = ctx.session_url_for("/panel")
dispatcher, _, _ = self._make_dispatcher(
module_context,
session_manager=session_manager,
public_base_url="https://example.com",
)
dispatcher.register(
"panel",
handler,
module_name=module_context.module_name,
)
await dispatcher.dispatch(make_chat_event(raw_body="!panel", user=user))
token = captured_url.rsplit("/", 1)[-1]
connect_token = session_manager.get_connect_token(token)
assert captured_url.startswith("https://example.com/owlbot/connect/")
assert connect_token is not None
assert connect_token.destination_path == "/owlbot/test_module/panel"
assert connect_token.user.id == user.id
def test_command_session_url_for_accepts_module_relative_path(
self, module_context: ModuleContext
) -> None:
"""Command session URLs add the module namespace to relative paths."""
session_manager = SessionManager()
dispatcher, _, _ = self._make_dispatcher(
module_context,
session_manager=session_manager,
public_base_url="https://example.com",
)
session_url_for = dispatcher._make_session_url_for(
module_context.module_name,
make_user(id="alice"),
)
url = session_url_for("panel")
token = url.rsplit("/", 1)[-1]
connect_token = session_manager.get_connect_token(token)
assert connect_token is not None
assert connect_token.destination_path == "/owlbot/test_module/panel"
@pytest.mark.parametrize(
"path",
[
pytest.param("../panel", id="relative-parent"),
pytest.param("/../panel", id="absolute-parent"),
pytest.param("/../../admin", id="multi-parent"),
pytest.param("nested/../../other", id="nested-parent"),
pytest.param("/%2e%2e/panel", id="encoded-parent"),
],
)
def test_command_session_url_for_rejects_paths_outside_module_namespace(
self, module_context: ModuleContext, path: str
) -> None:
"""Module-provided session paths cannot escape their namespace."""
session_manager = SessionManager()
dispatcher, _, _ = self._make_dispatcher(
module_context,
session_manager=session_manager,
public_base_url="https://example.com",
)
session_url_for = dispatcher._make_session_url_for(
module_context.module_name,
make_user(id="alice"),
)
with pytest.raises(ValueError, match="module namespace"):
session_url_for(path)
async def test_unregistered_builtin_does_not_shadow_later_module_command(
self, module_context: ModuleContext
) -> None:
"""Removing a built-in clears its fast-path handler entry too."""
builtin_calls: list[str] = []
module_calls: list[str] = []
async def builtin_handler(event: ChatEvent, owncast_client: Any) -> None:
builtin_calls.append("builtin")
async def module_handler(ctx: CommandContext) -> None:
module_calls.append(ctx.command)
dispatcher, _, _ = self._make_dispatcher(module_context)
dispatcher.register_builtin("test", builtin_handler)
assert dispatcher.unregister("test") is True
dispatcher.register("test", module_handler, module_name="mod_a")
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!test"))
assert builtin_calls == []
assert module_calls == ["test"]
async def test_failed_builtin_registration_does_not_poison_later_dispatch(
self, module_context: ModuleContext
) -> None:
"""A built-in conflict leaves no stale fast-path handler behind."""
builtin_calls: list[str] = []
module_calls: list[str] = []
async def module_handler(ctx: CommandContext) -> None:
module_calls.append(ctx.command)
async def builtin_handler(event: ChatEvent, owncast_client: Any) -> None:
builtin_calls.append("builtin")
dispatcher, _, _ = self._make_dispatcher(module_context)
dispatcher.register("test", module_handler, module_name="mod_a")
with pytest.raises(ValueError, match="conflicts with existing command 'test'"):
dispatcher.register_builtin("test", builtin_handler)
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!test"))
assert builtin_calls == []
assert module_calls == ["test"]
async def test_bulk_unregister_builtins_clears_fast_path_handler_map(
self, module_context: ModuleContext
) -> None:
"""Bulk-unregistering built-ins removes their fast-path handlers too."""
builtin_calls: list[str] = []
module_calls: list[str] = []
async def builtin_handler(event: ChatEvent, owncast_client: Any) -> None:
builtin_calls.append("builtin")
async def module_handler(ctx: CommandContext) -> None:
module_calls.append(ctx.command)
dispatcher, _, _ = self._make_dispatcher(module_context)
dispatcher.register_builtin("test", builtin_handler)
assert dispatcher.unregister_by_module("__builtin__") >= 1
dispatcher.register("test", module_handler, module_name="mod_a")
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!test"))
assert builtin_calls == []
assert module_calls == ["test"]
async def test_command_event_fields(self, module_context: ModuleContext) -> None: async def test_command_event_fields(self, module_context: ModuleContext) -> None:
"""Dispatched CommandContext carries correct CommandEvent fields.""" """Dispatched CommandContext carries correct CommandEvent fields."""
captured: list[CommandContext] = [] captured: list[CommandContext] = []
@@ -901,6 +1160,8 @@ class TestModuleCommands:
owncast_client=stub, owncast_client=stub,
handler_timeout=5.0, handler_timeout=5.0,
loaded_modules=set(), loaded_modules=set(),
session_manager=SessionManager(),
public_base_url=module_context.config.public_base_url,
) )
mod_cmds = {name: ModuleCommands(dispatcher, name) for name in module_names} mod_cmds = {name: ModuleCommands(dispatcher, name) for name in module_names}
return dispatcher, mod_cmds return dispatcher, mod_cmds
@@ -1031,6 +1292,8 @@ class TestCommandContext:
def _make_command_context( def _make_command_context(
self, self,
module_ctx: ModuleContext, module_ctx: ModuleContext,
*,
session_url_for: Callable[[str], str] | None = None,
) -> tuple[CommandContext, CommandEvent, EventContext[ChatEvent]]: ) -> tuple[CommandContext, CommandEvent, EventContext[ChatEvent]]:
"""Build a CommandContext wrapping the given ModuleContext.""" """Build a CommandContext wrapping the given ModuleContext."""
event = make_chat_event(client_id=42, raw_body="!greet world") event = make_chat_event(client_id=42, raw_body="!greet world")
@@ -1048,6 +1311,11 @@ class TestCommandContext:
command_event=cmd_event, command_event=cmd_event,
event_context=event_ctx, event_context=event_ctx,
module=module_ctx, module=module_ctx,
_session_url_for=(
session_url_for
if session_url_for is not None
else lambda path: f"https://example.com{path}"
),
) )
return cmd_ctx, cmd_event, event_ctx return cmd_ctx, cmd_event, event_ctx
@@ -1109,3 +1377,12 @@ class TestCommandContext:
"""ctx.event_context is the EventContext passed at construction.""" """ctx.event_context is the EventContext passed at construction."""
cmd_ctx, _, event_ctx = self._make_command_context(module_context) cmd_ctx, _, event_ctx = self._make_command_context(module_context)
assert cmd_ctx.event_context is event_ctx assert cmd_ctx.event_context is event_ctx
def test_session_url_for(self, module_context: ModuleContext) -> None:
"""ctx.session_url_for() delegates to the command-scoped URL builder."""
cmd_ctx, _, _ = self._make_command_context(
module_context,
session_url_for=lambda path: f"https://example.com{path}",
)
assert cmd_ctx.session_url_for("/panel") == "https://example.com/panel"
+118 -23
View File
@@ -41,7 +41,13 @@ from owlbot.registries.events import (
HandlerEntry, HandlerEntry,
ModuleEvents, ModuleEvents,
) )
from owlbot.testing import make_chat_event, make_user_joined_event from owlbot.sessions import SessionManager
from owlbot.testing import (
make_chat_event,
make_name_changed_event,
make_user,
make_user_joined_event,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from owlbot.api.context import ModuleContext from owlbot.api.context import ModuleContext
@@ -53,6 +59,22 @@ def _unused_get_module_context(name: str) -> ModuleContext:
raise AssertionError("get_module_context should not be called") raise AssertionError("get_module_context should not be called")
def _make_dispatcher(
command_dispatch: Any,
get_module_context: Any,
*,
handler_timeout: float,
session_manager: SessionManager | None = None,
) -> EventDispatcher:
"""Build an EventDispatcher with a default session manager for tests."""
return EventDispatcher(
command_dispatch=command_dispatch,
get_module_context=get_module_context,
handler_timeout=handler_timeout,
session_manager=session_manager or SessionManager(),
)
class TestOnEventDecorator: class TestOnEventDecorator:
"""Tests the @on_event decorator from owlbot.api.events.""" """Tests the @on_event decorator from owlbot.api.events."""
@@ -396,7 +418,7 @@ class TestEventDispatcherDispatch:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
handler_timeout=5.0, handler_timeout=5.0,
@@ -423,7 +445,7 @@ class TestEventDispatcherDispatch:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
handler_timeout=5.0, handler_timeout=5.0,
@@ -449,7 +471,7 @@ class TestEventDispatcherDispatch:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
handler_timeout=5.0, handler_timeout=5.0,
@@ -467,7 +489,7 @@ class TestEventDispatcherDispatch:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
handler_timeout=5.0, handler_timeout=5.0,
@@ -489,7 +511,7 @@ class TestEventDispatcherDispatch:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
handler_timeout=5.0, handler_timeout=5.0,
@@ -517,7 +539,7 @@ class TestEventDispatcherDispatch:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
handler_timeout=5.0, handler_timeout=5.0,
@@ -541,7 +563,7 @@ class TestEventDispatcherDispatch:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
handler_timeout=5.0, handler_timeout=5.0,
@@ -570,7 +592,7 @@ class TestEventDispatcherDispatch:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
handler_timeout=5.0, handler_timeout=5.0,
@@ -603,7 +625,7 @@ class TestEventDispatcherStorage:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
handler_timeout=5.0, handler_timeout=5.0,
@@ -634,7 +656,7 @@ class TestEventDispatcherStorage:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
handler_timeout=5.0, handler_timeout=5.0,
@@ -664,7 +686,7 @@ class TestEventDispatcherStorage:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
handler_timeout=5.0, handler_timeout=5.0,
@@ -685,7 +707,7 @@ class TestEventDispatcherStorage:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
handler_timeout=0.05, handler_timeout=0.05,
@@ -713,7 +735,7 @@ class TestEventDispatcherStorage:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
handler_timeout=0.05, handler_timeout=0.05,
@@ -741,7 +763,7 @@ class TestEventDispatcherStorage:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
handler_timeout=0.05, handler_timeout=0.05,
@@ -786,7 +808,7 @@ class TestCommandDispatchPhase:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
command_calls.append(event) command_calls.append(event)
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
handler_timeout=5.0, handler_timeout=5.0,
@@ -809,7 +831,7 @@ class TestCommandDispatchPhase:
async def stopper(ctx: EventContext[Any]) -> None: async def stopper(ctx: EventContext[Any]) -> None:
ctx.stop_propagation("spam") ctx.stop_propagation("spam")
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
handler_timeout=5.0, handler_timeout=5.0,
@@ -830,7 +852,7 @@ class TestCommandDispatchPhase:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
raise RuntimeError("fail") raise RuntimeError("fail")
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
handler_timeout=5.0, handler_timeout=5.0,
@@ -839,6 +861,79 @@ class TestCommandDispatchPhase:
await dispatcher.dispatch(EventType.CHAT, make_chat_event()) await dispatcher.dispatch(EventType.CHAT, make_chat_event())
assert any("Command dispatch failed." in r.message for r in caplog.records) assert any("Command dispatch failed." in r.message for r in caplog.records)
async def test_user_event_refreshes_matching_sessions(
self, module_context: ModuleContext
) -> None:
"""User-bearing events refresh any existing Owlbot sessions for that user."""
session_manager = SessionManager()
original_user = make_user(id="user-123", display_name="Original")
session = session_manager.create_session(original_user)
refreshed_user = make_user(
id="user-123",
display_name="UpdatedName",
is_authenticated=True,
)
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=5.0,
session_manager=session_manager,
)
await dispatcher.dispatch(
EventType.USER_JOINED,
make_user_joined_event(
user=refreshed_user, client_id=1, event_id="evt-001"
),
)
refreshed_session = session_manager.get_session(session.session_id)
assert refreshed_session is not None
assert refreshed_session.user.display_name == "UpdatedName"
assert refreshed_session.user.is_authenticated is True
async def test_name_change_event_refreshes_sessions_with_new_display_name(
self, module_context: ModuleContext
) -> None:
"""NAME_CHANGE refreshes sessions from the webhook user snapshot."""
session_manager = SessionManager()
original_user = make_user(id="user-123", display_name="Original")
session = session_manager.create_session(original_user)
renamed_user = make_user(
id="user-123",
display_name="UpdatedName",
is_authenticated=True,
)
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=5.0,
session_manager=session_manager,
)
await dispatcher.dispatch(
EventType.NAME_CHANGE,
make_name_changed_event(
user=renamed_user,
new_name="UpdatedName",
client_id=1,
event_id="evt-002",
),
)
refreshed_session = session_manager.get_session(session.session_id)
assert refreshed_session is not None
assert refreshed_session.user.display_name == "UpdatedName"
assert refreshed_session.user.is_authenticated is True
class TestModuleEvents: class TestModuleEvents:
"""Tests ModuleEvents from owlbot.registries.events.""" """Tests ModuleEvents from owlbot.registries.events."""
@@ -849,7 +944,7 @@ class TestModuleEvents:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=_unused_get_module_context, get_module_context=_unused_get_module_context,
handler_timeout=5.0, handler_timeout=5.0,
@@ -868,7 +963,7 @@ class TestModuleEvents:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=_unused_get_module_context, get_module_context=_unused_get_module_context,
handler_timeout=5.0, handler_timeout=5.0,
@@ -890,7 +985,7 @@ class TestModuleEvents:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=_unused_get_module_context, get_module_context=_unused_get_module_context,
handler_timeout=5.0, handler_timeout=5.0,
@@ -914,7 +1009,7 @@ class TestModuleEvents:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=_unused_get_module_context, get_module_context=_unused_get_module_context,
handler_timeout=5.0, handler_timeout=5.0,
@@ -936,7 +1031,7 @@ class TestModuleEvents:
async def command_dispatch(event: ChatEvent) -> None: async def command_dispatch(event: ChatEvent) -> None:
pass pass
dispatcher = EventDispatcher( dispatcher = _make_dispatcher(
command_dispatch=command_dispatch, command_dispatch=command_dispatch,
get_module_context=_unused_get_module_context, get_module_context=_unused_get_module_context,
handler_timeout=5.0, handler_timeout=5.0,
+79
View File
@@ -0,0 +1,79 @@
# 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.
"""Focused regression tests for Task 5 module-loader auth wiring."""
from pathlib import Path
from ruamel.yaml import YAML
from owlbot.api.config import Config
from owlbot.api.http_client import HttpClient
from owlbot.module_loader import RESERVED_MODULE_NAMES, ModuleLoader
from owlbot.sessions import SessionManager
def _write_config(tmp_path: Path, *, command_prefix: str = "!") -> Path:
path = tmp_path / "config.yaml"
yaml = YAML(typ="safe")
with path.open("w") as f:
yaml.dump(
{
"owncast": {"url": "http://localhost:8080"},
"owlbot": {
"public_base_url": "http://localhost:8081",
"command_prefix": command_prefix,
},
},
f,
)
return path
def test_connect_is_reserved_module_name() -> None:
"""The built-in connect route namespace cannot be claimed by modules."""
assert "connect" in RESERVED_MODULE_NAMES
def test_module_loader_threads_one_shared_session_manager(tmp_path: Path) -> None:
"""ModuleLoader reuses the same session manager across its dispatch stack."""
config = Config(_write_config(tmp_path))
session_manager = SessionManager()
loader = ModuleLoader(
tmp_path / "modules",
config,
HttpClient(),
session_manager,
)
assert loader.session_manager is session_manager
assert loader.command_dispatcher._session_manager is session_manager
assert loader.event_dispatcher._session_manager is session_manager
assert loader.route_dispatcher._session_manager is session_manager
def test_module_loader_threads_configured_command_prefix_to_route_dispatcher(
tmp_path: Path,
) -> None:
"""ModuleLoader passes the configured command prefix to RouteDispatcher."""
config = Config(_write_config(tmp_path, command_prefix="/"))
session_manager = SessionManager()
loader = ModuleLoader(
tmp_path / "modules",
config,
HttpClient(),
session_manager,
)
assert loader.route_dispatcher._command_prefix == "/"
+393 -3
View File
@@ -35,6 +35,8 @@ from aiohttp.test_utils import make_mocked_request
from owlbot.api.context import RouteContext from owlbot.api.context import RouteContext
from owlbot.api.routes import RouteInfo, RouteMark, on_route from owlbot.api.routes import RouteInfo, RouteMark, on_route
from owlbot.registries.routes import ModuleRoutes, RouteDispatcher, RouteRegistry from owlbot.registries.routes import ModuleRoutes, RouteDispatcher, RouteRegistry
from owlbot.sessions import SessionManager
from owlbot.testing import make_user
if TYPE_CHECKING: if TYPE_CHECKING:
from owlbot.api.context import ModuleContext from owlbot.api.context import ModuleContext
@@ -43,11 +45,17 @@ if TYPE_CHECKING:
def _make_dispatcher( def _make_dispatcher(
module_context: ModuleContext, module_context: ModuleContext,
handler_timeout: float = 0.1, handler_timeout: float = 0.1,
*,
session_manager: SessionManager | None = None,
command_prefix: str = "!",
) -> RouteDispatcher: ) -> RouteDispatcher:
"""Build a RouteDispatcher wired to the given ModuleContext.""" """Build a RouteDispatcher wired to the given ModuleContext."""
session_manager = session_manager or SessionManager()
return RouteDispatcher( return RouteDispatcher(
get_module_context=lambda _name: module_context, get_module_context=lambda _name: module_context,
handler_timeout=handler_timeout, handler_timeout=handler_timeout,
session_manager=session_manager,
command_prefix=command_prefix,
) )
@@ -105,6 +113,23 @@ class TestOnRouteDecorator:
assert mark["methods"] == expected_methods assert mark["methods"] == expected_methods
assert mark["streaming"] is expected_streaming assert mark["streaming"] is expected_streaming
def test_sets_auth_requirements(self) -> None:
"""@on_route stores the route auth flags on the mark."""
@on_route(
"/secure",
requires_session=True,
requires_authenticated=True,
requires_moderator=True,
)
async def handler(ctx: Any) -> None:
pass
mark: RouteMark = handler._owlbot_route # type: ignore[attr-defined]
assert mark["requires_session"] is True
assert mark["requires_authenticated"] is True
assert mark["requires_moderator"] is True
def test_returns_function_unchanged(self) -> None: def test_returns_function_unchanged(self) -> None:
"""@on_route returns the original function.""" """@on_route returns the original function."""
@@ -164,6 +189,35 @@ class TestRouteInfo:
assert info.handler is _noop assert info.handler is _noop
assert info.module_name == "mod" assert info.module_name == "mod"
def test_auth_flags_default_false(self) -> None:
"""RouteInfo auth flags default to False."""
info = RouteInfo(
path="/x",
full_path="/owlbot/mod/x",
methods=frozenset({"GET"}),
handler=_noop,
module_name="mod",
)
assert info.requires_session is False
assert info.requires_authenticated is False
assert info.requires_moderator is False
def test_auth_flags_accessible(self) -> None:
"""RouteInfo stores route auth metadata."""
info = RouteInfo(
path="/x",
full_path="/owlbot/mod/x",
methods=frozenset({"GET"}),
handler=_noop,
module_name="mod",
requires_session=True,
requires_authenticated=True,
requires_moderator=True,
)
assert info.requires_session is True
assert info.requires_authenticated is True
assert info.requires_moderator is True
class TestRouteRegistry: class TestRouteRegistry:
"""Tests RouteRegistry from owlbot.registries.routes.""" """Tests RouteRegistry from owlbot.registries.routes."""
@@ -231,6 +285,21 @@ class TestRouteRegistry:
info = reg.register("/sse", _noop, module_name="mod", streaming=True) info = reg.register("/sse", _noop, module_name="mod", streaming=True)
assert info.streaming is True assert info.streaming is True
def test_register_auth_flags(self) -> None:
"""register() stores route auth metadata on RouteInfo."""
reg = RouteRegistry()
info = reg.register(
"/secure",
_noop,
module_name="mod",
requires_session=True,
requires_authenticated=True,
requires_moderator=True,
)
assert info.requires_session is True
assert info.requires_authenticated is True
assert info.requires_moderator is True
def test_get_no_method_returns_list(self) -> None: def test_get_no_method_returns_list(self) -> None:
"""get() without method returns list of all handlers at path.""" """get() without method returns list of all handlers at path."""
reg = RouteRegistry() reg = RouteRegistry()
@@ -495,6 +564,281 @@ class TestRouteRegistry:
class TestRouteDispatcherDispatch: class TestRouteDispatcherDispatch:
"""Tests RouteDispatcher.dispatch() from owlbot.registries.routes.""" """Tests RouteDispatcher.dispatch() from owlbot.registries.routes."""
async def test_requires_session_without_cookie_returns_401_with_guidance(
self, module_context: ModuleContext
) -> None:
"""A session-protected route returns guidance when no cookie is present."""
async def handler(ctx: RouteContext) -> web.Response:
return web.Response(text="ok")
dispatcher = _make_dispatcher(module_context, session_manager=SessionManager())
dispatcher.register(
"/secure",
handler,
module_name="mod",
requires_session=True,
)
request = make_mocked_request("GET", "/owlbot/mod/secure")
request.match_info["module_name"] = "mod"
request.match_info["path"] = "secure"
response = await dispatcher.dispatch(request)
assert response.status == HTTPStatus.UNAUTHORIZED
assert isinstance(response, web.Response)
assert response.text is not None
assert "!connect" in response.text
async def test_requires_authenticated_rejects_unauthenticated_session(
self, module_context: ModuleContext
) -> None:
"""An unauthenticated linked session is rejected before the handler runs."""
auth_manager = SessionManager()
session = auth_manager.create_session(make_user(id="guest"))
handler_called = False
async def handler(ctx: RouteContext) -> web.Response:
nonlocal handler_called
handler_called = True
return web.Response(text="ok")
dispatcher = _make_dispatcher(module_context, session_manager=auth_manager)
dispatcher.register(
"/secure",
handler,
module_name="mod",
requires_authenticated=True,
)
request = make_mocked_request(
"GET",
"/owlbot/mod/secure",
headers={"Cookie": f"owlbot_session={session.session_id}"},
)
request.match_info["module_name"] = "mod"
request.match_info["path"] = "secure"
response = await dispatcher.dispatch(request)
assert response.status == HTTPStatus.FORBIDDEN
assert handler_called is False
assert isinstance(response, web.Response)
assert response.text is not None
assert (
"You must be authenticated in Owncast to access this page." in response.text
)
assert (
"If you believe this is in error, try using !connect in chat "
"to reconnect your Owncast account." in response.text
)
async def test_requires_authenticated_guidance_uses_configured_command_prefix(
self, module_context: ModuleContext
) -> None:
"""Protected-route guidance uses the configured connect command prefix."""
auth_manager = SessionManager()
session = auth_manager.create_session(make_user(id="guest"))
dispatcher = _make_dispatcher(
module_context,
session_manager=auth_manager,
command_prefix="/",
)
dispatcher.register(
"/secure",
_noop,
module_name="mod",
requires_authenticated=True,
)
request = make_mocked_request(
"GET",
"/owlbot/mod/secure",
headers={"Cookie": f"owlbot_session={session.session_id}"},
)
request.match_info["module_name"] = "mod"
request.match_info["path"] = "secure"
response = await dispatcher.dispatch(request)
assert response.status == HTTPStatus.FORBIDDEN
assert isinstance(response, web.Response)
assert response.text is not None
assert "/connect in chat" in response.text
assert "!connect in chat" not in response.text
async def test_requires_session_with_stale_cookie_returns_guidance(
self, module_context: ModuleContext
) -> None:
"""A stale session cookie is treated as missing for a protected route."""
dispatcher = _make_dispatcher(module_context, session_manager=SessionManager())
dispatcher.register(
"/secure",
_noop,
module_name="mod",
requires_session=True,
)
request = make_mocked_request(
"GET",
"/owlbot/mod/secure",
headers={"Cookie": "owlbot_session=stale-session"},
)
request.match_info["module_name"] = "mod"
request.match_info["path"] = "secure"
response = await dispatcher.dispatch(request)
assert response.status == HTTPStatus.UNAUTHORIZED
assert isinstance(response, web.Response)
assert response.text is not None
assert "!connect" in response.text
async def test_requires_authenticated_with_stale_cookie_returns_guidance(
self, module_context: ModuleContext
) -> None:
"""A stale session cookie is treated as missing for stricter routes."""
dispatcher = _make_dispatcher(module_context, session_manager=SessionManager())
dispatcher.register(
"/secure",
_noop,
module_name="mod",
requires_authenticated=True,
)
request = make_mocked_request(
"GET",
"/owlbot/mod/secure",
headers={"Cookie": "owlbot_session=stale-session"},
)
request.match_info["module_name"] = "mod"
request.match_info["path"] = "secure"
response = await dispatcher.dispatch(request)
assert response.status == HTTPStatus.UNAUTHORIZED
assert isinstance(response, web.Response)
assert response.text is not None
assert "!connect" in response.text
async def test_valid_session_populates_route_context_session(
self, module_context: ModuleContext
) -> None:
"""Resolved sessions are exposed on RouteContext for handlers."""
auth_manager = SessionManager()
session = auth_manager.create_session(
make_user(id="alice", is_authenticated=True)
)
captured_session = None
async def handler(ctx: RouteContext) -> web.Response:
nonlocal captured_session
captured_session = ctx.session
return web.Response(text="ok")
dispatcher = _make_dispatcher(module_context, session_manager=auth_manager)
dispatcher.register(
"/secure",
handler,
module_name="mod",
requires_session=True,
)
request = make_mocked_request(
"GET",
"/owlbot/mod/secure",
headers={"Cookie": f"owlbot_session={session.session_id}"},
)
request.match_info["module_name"] = "mod"
request.match_info["path"] = "secure"
response = await dispatcher.dispatch(request)
assert response.status == HTTPStatus.OK
assert captured_session is session
async def test_requires_moderator_rejects_non_moderator_session(
self, module_context: ModuleContext
) -> None:
"""A non-moderator session is rejected before the handler runs."""
auth_manager = SessionManager()
session = auth_manager.create_session(
make_user(id="alice", is_authenticated=True, is_moderator=False)
)
handler_called = False
async def handler(ctx: RouteContext) -> web.Response:
nonlocal handler_called
handler_called = True
return web.Response(text="ok")
dispatcher = _make_dispatcher(module_context, session_manager=auth_manager)
dispatcher.register(
"/secure",
handler,
module_name="mod",
requires_moderator=True,
)
request = make_mocked_request(
"GET",
"/owlbot/mod/secure",
headers={"Cookie": f"owlbot_session={session.session_id}"},
)
request.match_info["module_name"] = "mod"
request.match_info["path"] = "secure"
response = await dispatcher.dispatch(request)
assert response.status == HTTPStatus.FORBIDDEN
assert handler_called is False
assert isinstance(response, web.Response)
assert response.text is not None
assert "Only moderators can access this page." in response.text
assert (
"If you believe this is in error, try using !connect in chat "
"to reconnect your Owncast account." in response.text
)
async def test_requires_moderator_allows_moderator_session(
self, module_context: ModuleContext
) -> None:
"""A moderator session reaches the protected handler."""
auth_manager = SessionManager()
session = auth_manager.create_session(
make_user(id="alice", is_authenticated=True, is_moderator=True)
)
handler_called = False
async def handler(ctx: RouteContext) -> web.Response:
nonlocal handler_called
handler_called = True
assert ctx.session is session
return web.Response(text="ok")
dispatcher = _make_dispatcher(module_context, session_manager=auth_manager)
dispatcher.register(
"/secure",
handler,
module_name="mod",
requires_moderator=True,
)
request = make_mocked_request(
"GET",
"/owlbot/mod/secure",
headers={"Cookie": f"owlbot_session={session.session_id}"},
)
request.match_info["module_name"] = "mod"
request.match_info["path"] = "secure"
response = await dispatcher.dispatch(request)
assert response.status == HTTPStatus.OK
assert handler_called is True
async def test_dispatch_dict_response(self, module_context: ModuleContext) -> None: async def test_dispatch_dict_response(self, module_context: ModuleContext) -> None:
"""Handler returning dict produces JSON 200 response.""" """Handler returning dict produces JSON 200 response."""
@@ -986,6 +1330,21 @@ class TestRouteDispatcherDelegation:
dispatcher.register_from_module(mod, "fake") dispatcher.register_from_module(mod, "fake")
assert len(dispatcher.get_by_module("fake")) == 1 assert len(dispatcher.get_by_module("fake")) == 1
def test_register_auth_flags(self, module_context: ModuleContext) -> None:
"""RouteDispatcher.register() passes auth flags through."""
dispatcher = _make_dispatcher(module_context)
info = dispatcher.register(
"/secure",
_noop,
module_name="mod",
requires_session=True,
requires_authenticated=True,
requires_moderator=True,
)
assert info.requires_session is True
assert info.requires_authenticated is True
assert info.requires_moderator is True
def test_unregister_by_module(self, module_context: ModuleContext) -> None: def test_unregister_by_module(self, module_context: ModuleContext) -> None:
"""RouteDispatcher.unregister_by_module() delegates to registry.""" """RouteDispatcher.unregister_by_module() delegates to registry."""
dispatcher = _make_dispatcher(module_context) dispatcher = _make_dispatcher(module_context)
@@ -999,12 +1358,21 @@ class TestModuleRoutes:
"""Tests ModuleRoutes from owlbot.registries.routes.""" """Tests ModuleRoutes from owlbot.registries.routes."""
def _make_mod_routes( def _make_mod_routes(
self, module_context: ModuleContext, *module_names: str self,
module_context: ModuleContext,
*module_names: str,
) -> tuple[RouteDispatcher, dict[str, ModuleRoutes]]: ) -> tuple[RouteDispatcher, dict[str, ModuleRoutes]]:
"""Build a dispatcher and ModuleRoutes wrappers for given modules.""" """Build a dispatcher and ModuleRoutes wrappers for given modules."""
dispatcher = _make_dispatcher(module_context, handler_timeout=5.0) dispatcher = _make_dispatcher(
module_context,
handler_timeout=5.0,
)
mod_routes = { mod_routes = {
name: ModuleRoutes(dispatcher, name, "https://example.com") name: ModuleRoutes(
dispatcher,
name,
"https://example.com",
)
for name in module_names for name in module_names
} }
return dispatcher, mod_routes return dispatcher, mod_routes
@@ -1030,6 +1398,20 @@ class TestModuleRoutes:
info = mod_routes["mod_a"].register("/data", _noop, methods=["GET", "POST"]) info = mod_routes["mod_a"].register("/data", _noop, methods=["GET", "POST"])
assert info.methods == frozenset({"GET", "POST"}) assert info.methods == frozenset({"GET", "POST"})
def test_register_auth_flags(self, module_context: ModuleContext) -> None:
"""ModuleRoutes.register() passes auth flags through."""
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
info = mod_routes["mod_a"].register(
"/secure",
_noop,
requires_session=True,
requires_authenticated=True,
requires_moderator=True,
)
assert info.requires_session is True
assert info.requires_authenticated is True
assert info.requires_moderator is True
def test_unregister_by_path(self, module_context: ModuleContext) -> None: def test_unregister_by_path(self, module_context: ModuleContext) -> None:
"""ModuleRoutes.unregister() removes all handlers at path.""" """ModuleRoutes.unregister() removes all handlers at path."""
_, mod_routes = self._make_mod_routes(module_context, "mod_a") _, mod_routes = self._make_mod_routes(module_context, "mod_a")
@@ -1186,3 +1568,11 @@ class TestRouteContext:
) )
ctx = RouteContext(request=request, module=module_context) ctx = RouteContext(request=request, module=module_context)
assert ctx.match_info == {} assert ctx.match_info == {}
def test_session_defaults_none(self, module_context: ModuleContext) -> None:
"""RouteContext.session defaults to None."""
request = make_mocked_request(
"GET", f"/owlbot/{module_context.module_name}/test"
)
ctx = RouteContext(request=request, module=module_context)
assert ctx.session is None
+288
View File
@@ -0,0 +1,288 @@
# 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.
"""Regression tests for connect token and session management."""
from __future__ import annotations
from datetime import timedelta
import pytest
from owlbot.sessions import (
DEFAULT_CONNECT_DESTINATION_PATH,
ConnectTokenRedemptionError,
SessionManager,
)
from owlbot.testing import make_user
class TestSessionManager:
"""Focused tests for the in-memory session manager."""
def test_issue_connect_token_tracks_internal_destination(self) -> None:
"""Issued tokens retain the internal Owlbot destination path."""
manager = SessionManager()
token = manager.issue_connect_token(
"/owlbot/demo/panel",
user=make_user(id="alice", is_authenticated=True),
)
connect_token = manager.get_connect_token(token)
assert connect_token is not None
assert connect_token.destination_path == "/owlbot/demo/panel"
def test_issue_connect_token_defaults_to_connect_status_destination(self) -> None:
"""Issued tokens default to the built-in connect status page."""
manager = SessionManager()
token = manager.issue_connect_token(
user=make_user(id="alice", is_authenticated=True),
)
connect_token = manager.get_connect_token(token)
assert connect_token is not None
assert connect_token.destination_path == DEFAULT_CONNECT_DESTINATION_PATH
def test_issue_connect_token_replaces_pending_token_for_same_user(self) -> None:
"""Issuing a new token invalidates older pending tokens for that user."""
manager = SessionManager()
first_alice_token = manager.issue_connect_token(
"/owlbot/demo/first",
user=make_user(id="alice", is_authenticated=True),
)
bob_token = manager.issue_connect_token(
"/owlbot/demo/bob",
user=make_user(id="bob", is_authenticated=True),
)
second_alice_token = manager.issue_connect_token(
"/owlbot/demo/second",
user=make_user(id="alice", is_authenticated=True),
)
assert manager.get_connect_token(first_alice_token) is None
assert manager.get_connect_token(bob_token) is not None
connect_token = manager.get_connect_token(second_alice_token)
assert connect_token is not None
assert connect_token.destination_path == "/owlbot/demo/second"
assert first_alice_token not in manager._connect_tokens_by_token
assert manager._connect_token_by_user_id == {
"alice": second_alice_token,
"bob": bob_token,
}
@pytest.mark.parametrize(
"destination_path",
[
pytest.param("/outside/demo/panel", id="outside-owlbot"),
pytest.param("https://example.com/owlbot/demo/panel", id="absolute-url"),
pytest.param("/owlbot", id="missing-trailing-path"),
pytest.param("//example.com/owlbot/demo/panel", id="protocol-relative-url"),
],
)
def test_issue_connect_token_rejects_non_owlbot_destination(
self, destination_path: str
) -> None:
"""Connect tokens only allow redirects within Owlbot's own routes."""
manager = SessionManager()
with pytest.raises(ValueError, match="within /owlbot"):
manager.issue_connect_token(
destination_path,
user=make_user(id="alice", is_authenticated=True),
)
@pytest.mark.parametrize(
"destination_path",
[
pytest.param("/owlbot/demo/../admin", id="parent-segment"),
pytest.param("/owlbot/demo/./panel", id="current-segment"),
pytest.param("/owlbot/demo/%2e%2e/admin", id="encoded-parent-segment"),
pytest.param("/owlbot/demo/%2E/panel", id="encoded-current-segment"),
],
)
def test_issue_connect_token_rejects_dot_segment_destination(
self, destination_path: str
) -> None:
"""Connect-token redirects reject browser-resolved dot segments."""
manager = SessionManager()
with pytest.raises(ValueError, match="dot segments"):
manager.issue_connect_token(
destination_path,
user=make_user(id="alice", is_authenticated=True),
)
@pytest.mark.parametrize(
"destination_path",
[
pytest.param(r"/owlbot/demo\..\admin", id="backslash"),
pytest.param("/owlbot/demo/%5cadmin", id="encoded-backslash"),
],
)
def test_issue_connect_token_rejects_backslash_destination(
self, destination_path: str
) -> None:
"""Connect-token redirects reject browser-normalized backslashes."""
manager = SessionManager()
with pytest.raises(ValueError, match="URL path separators"):
manager.issue_connect_token(
destination_path,
user=make_user(id="alice", is_authenticated=True),
)
def test_redeem_connect_token_replaces_existing_session(self) -> None:
"""Redeeming a connect token can replace an existing browser session."""
manager = SessionManager()
existing = manager.create_session(make_user(id="guest"))
token = manager.issue_connect_token(
"/owlbot/demo/panel",
user=make_user(id="alice", is_authenticated=True),
)
redeemed = manager.redeem_connect_token(
token,
replacing_session_id=existing.session_id,
)
assert redeemed.user.id == "alice"
assert redeemed.session_id != token
assert manager.get_session(existing.session_id) is None
assert manager.get_session(redeemed.session_id) is redeemed
def test_redeem_connect_token_rejects_invalid_token(self) -> None:
"""Unknown connect tokens are rejected."""
manager = SessionManager()
with pytest.raises(ConnectTokenRedemptionError, match="invalid connect token"):
manager.redeem_connect_token("not-a-real-token")
def test_redeem_connect_token_rejects_expired_token(self) -> None:
"""Expired connect tokens are rejected and evicted."""
manager = SessionManager(connect_token_ttl=timedelta(seconds=0))
token = manager.issue_connect_token(
"/owlbot/demo/panel",
user=make_user(id="alice", is_authenticated=True),
)
with pytest.raises(ConnectTokenRedemptionError, match="connect token expired"):
manager.redeem_connect_token(token)
assert manager.get_connect_token(token) is None
assert token not in manager._connect_tokens_by_token
assert "alice" not in manager._connect_token_by_user_id
def test_redeem_connect_token_deletes_token_when_claimed(self) -> None:
"""Redeemed connect tokens are evicted immediately."""
manager = SessionManager()
token = manager.issue_connect_token(
"/owlbot/demo/panel",
user=make_user(id="alice", is_authenticated=True),
)
manager.redeem_connect_token(token)
assert manager.get_connect_token(token) is None
assert token not in manager._connect_tokens_by_token
assert "alice" not in manager._connect_token_by_user_id
with pytest.raises(ConnectTokenRedemptionError, match="invalid connect token"):
manager.redeem_connect_token(token)
def test_refresh_user_updates_all_sessions_for_matching_user(self) -> None:
"""Refreshing a user snapshot updates all active sessions for that user."""
manager = SessionManager()
first = manager.create_session(make_user(id="alice", display_name="Alice"))
second = manager.create_session(make_user(id="alice", display_name="Alice"))
updated_user = make_user(
id="alice",
display_name="Alice Renamed",
is_authenticated=True,
is_moderator=True,
)
manager.refresh_user(updated_user)
refreshed_first = manager.get_session(first.session_id)
refreshed_second = manager.get_session(second.session_id)
assert refreshed_first is not None
assert refreshed_second is not None
assert refreshed_first.user.display_name == "Alice Renamed"
assert refreshed_second.user.display_name == "Alice Renamed"
assert refreshed_first.is_authenticated is True
assert refreshed_second.is_moderator is True
def test_refresh_user_updates_pending_tokens_before_redemption(self) -> None:
"""Refreshing a user snapshot updates unredeemed connect tokens too."""
manager = SessionManager()
token = manager.issue_connect_token(
"/owlbot/demo/panel",
user=make_user(id="alice", display_name="Alice"),
)
updated_user = make_user(
id="alice",
display_name="Alice Renamed",
is_authenticated=True,
is_moderator=True,
)
manager.refresh_user(updated_user)
session = manager.redeem_connect_token(token)
assert session.user.display_name == "Alice Renamed"
assert session.is_authenticated is True
assert session.is_moderator is True
def test_get_session_drops_expired_session(self) -> None:
"""Expired sessions are removed on read."""
manager = SessionManager(session_ttl=timedelta(seconds=0))
session = manager.create_session(make_user(id="alice"))
assert manager.get_session(session.session_id) is None
def test_clear_expired_removes_expired_tokens_and_sessions(self) -> None:
"""Periodic cleanup removes expired tokens and sessions."""
manager = SessionManager(
connect_token_ttl=timedelta(seconds=0),
session_ttl=timedelta(seconds=0),
)
token = manager.issue_connect_token(
"/owlbot/demo/panel",
user=make_user(id="alice", is_authenticated=True),
)
session = manager.create_session(make_user(id="alice"))
manager.clear_expired()
assert manager.get_connect_token(token) is None
assert token not in manager._connect_tokens_by_token
assert "alice" not in manager._connect_token_by_user_id
assert manager.get_session(session.session_id) is None
async def test_start_and_close_cancel_cleanup_task_cleanly(self) -> None:
"""Background cleanup can be started and shut down cleanly."""
manager = SessionManager()
manager.start()
try:
assert manager._cleanup_task is not None
assert manager._cleanup_task.done() is False
assert manager._cleanup_task.get_name() == "Session Manager - Cleanup loop"
finally:
await manager.close()
assert manager._cleanup_task is None
+90 -1
View File
@@ -30,6 +30,7 @@ from owlbot.api.storage import ModuleStorage
from owlbot.api.templates import ModuleTemplates from owlbot.api.templates import ModuleTemplates
from owlbot.builtin_modules import quotes as quotes_pkg from owlbot.builtin_modules import quotes as quotes_pkg
from owlbot.registries.events import EventDispatcher from owlbot.registries.events import EventDispatcher
from owlbot.sessions import DEFAULT_SESSION_TTL, SESSION_COOKIE_NAME, SessionManager
from owlbot.testing import ( from owlbot.testing import (
make_chat_event, make_chat_event,
make_user, make_user,
@@ -47,7 +48,12 @@ if TYPE_CHECKING:
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
from aioresponses import aioresponses from aioresponses import aioresponses
from owlbot.api.context import CommandContext, EventContext, ModuleContext from owlbot.api.context import (
CommandContext,
EventContext,
ModuleContext,
RouteContext,
)
from owlbot.api.event_types import UserJoinedEvent from owlbot.api.event_types import UserJoinedEvent
from owlbot.registries.commands import CommandDispatcher from owlbot.registries.commands import CommandDispatcher
from owlbot.registries.routes import RouteDispatcher from owlbot.registries.routes import RouteDispatcher
@@ -618,3 +624,86 @@ class TestRouteClient:
resp = await route_client.get("/owlbot/test_module/noop") resp = await route_client.get("/owlbot/test_module/noop")
assert resp.status == 204 assert resp.status == 204
async def test_connect_token_redemption_sets_browser_session(
self,
module_context: ModuleContext,
command_dispatcher: CommandDispatcher,
route_client: TestClient[Any, Any],
session_manager: SessionManager,
) -> None:
"""Redeeming a connect token persists the session cookie."""
session_urls: list[str] = []
async def handler(ctx: RouteContext) -> dict[str, str | bool]:
assert ctx.session is not None
return {
"user_id": ctx.session.user.id,
"authenticated": ctx.session.is_authenticated,
}
async def link_command(ctx: CommandContext) -> None:
session_urls.append(ctx.session_url_for("/secure"))
module_context.routes.register(
"/secure",
handler,
methods=["GET"],
requires_authenticated=True,
)
module_context.commands.register("secure", link_command)
await command_dispatcher.dispatch(
make_chat_event(
raw_body="!secure",
user=make_user(id="alice", is_authenticated=True),
)
)
session_url = session_urls[0]
redeem = await route_client.get(
session_url.removeprefix("http://localhost:8081"),
allow_redirects=False,
)
assert redeem.status == 302
session_cookie = redeem.cookies[SESSION_COOKIE_NAME]
assert session_cookie["max-age"] == str(
int(DEFAULT_SESSION_TTL.total_seconds())
)
assert session_cookie["path"] == "/owlbot"
assert session_cookie["httponly"] is True
assert session_cookie["samesite"] == "Lax"
resp = await route_client.get("/owlbot/test_module/secure")
assert resp.status == 200
assert await resp.json() == {"user_id": "alice", "authenticated": True}
session_manager.invalidate_session(session_cookie.value)
stale = await route_client.get("/owlbot/test_module/secure")
assert stale.status == 401
cleared_cookie = stale.cookies[SESSION_COOKIE_NAME]
assert cleared_cookie["path"] == "/owlbot"
assert cleared_cookie["max-age"] == "0"
browser_cookies = route_client.session.cookie_jar.filter_cookies(
route_client.make_url("/owlbot/test_module/secure")
)
assert SESSION_COOKIE_NAME not in browser_cookies
async def test_invalid_connect_token_returns_guidance_page(
self,
route_client: TestClient[Any, Any],
) -> None:
"""Invalid connect tokens return the shared guidance page with a 403 status."""
resp = await route_client.get(
"/owlbot/connect/not-a-real-token",
allow_redirects=False,
)
assert resp.status == 403
text = await resp.text()
assert "!connect" in text
assert "Powered by Owlbot v" in text