Documented browser sessions and protected routes.
@@ -7,6 +7,7 @@ A quick listing of all commands included with Owlbot.
|
||||
| Command | Aliases | Description |
|
||||
|---------|---------|-------------|
|
||||
| `!about` | | Display bot version and project link. |
|
||||
| `!connect` | | Send a private link to connect your browser to your Owncast chat user for protected Owlbot pages. |
|
||||
|
||||
## Clips
|
||||
|
||||
|
||||
@@ -67,6 +67,24 @@ Two permission gates are available:
|
||||
|
||||
When a permission check fails, a private system message is sent to the user explaining why. The command handler is not called. No error is raised; other viewers see nothing happen.
|
||||
|
||||
## Links to Protected Routes
|
||||
|
||||
If a command sends a user to one of your module's protected routes, use `ctx.session_url_for(path)` instead of `ctx.routes.url_for(path)`. The generated URL is tied to the user who ran the command and connects their browser before redirecting to the route.
|
||||
|
||||
```python
|
||||
from owlbot.api import CommandContext, on_command
|
||||
|
||||
@on_command("settings", requires_moderator=True)
|
||||
async def settings(ctx: CommandContext) -> None:
|
||||
url = ctx.session_url_for("/settings")
|
||||
await ctx.owncast_client.send_system_message_to_client(
|
||||
ctx.chat_event.client_id,
|
||||
f"Open settings: {url}",
|
||||
)
|
||||
```
|
||||
|
||||
Use plain `ctx.routes.url_for(path)` for public pages. See [Protected Routes](Modules-Routes#protected-routes) for route-level session and permission checks.
|
||||
|
||||
## Cooldowns
|
||||
|
||||
Cooldowns are global per-command, not per-user. Setting `cooldown=10` means the command can only fire once every 10 seconds regardless of who invokes it. This uses `time.monotonic()` internally so it's not affected by clock changes.
|
||||
|
||||
+22
-2
@@ -145,6 +145,12 @@ async def greet(ctx: CommandContext) -> None:
|
||||
| `chat_event` | `ChatEvent` | The original chat event. |
|
||||
| `user` | `User` | The user who invoked the command (shortcut to `chat_event.user`). |
|
||||
|
||||
### Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `session_url_for(path)` | Build a one-time connect URL for one of this module's routes, tied to the command user. Available in command handlers; use this when sending links to protected routes. |
|
||||
|
||||
All `ModuleContext` services are available directly on `ctx` (see [Proxied Properties](#proxied-properties) below).
|
||||
|
||||
## RouteContext
|
||||
@@ -166,10 +172,24 @@ async def stats_page(ctx: RouteContext) -> dict:
|
||||
|-------|------|-------------|
|
||||
| `request` | `aiohttp.web.Request` | The aiohttp request object. Access query params, body, headers, path info, etc. |
|
||||
| `match_info` | `dict[str, str]` | Captured path parameters from `{name}` patterns. Empty dict for plain routes. |
|
||||
| `session` | `BrowserSession \| None` | Connected browser session for this request, if one was provided. Protected routes can use this to read the linked Owncast user. |
|
||||
| `module` | `ModuleContext` | The full module context with all services. |
|
||||
|
||||
All `ModuleContext` services are available directly on `ctx` (see [Proxied Properties](#proxied-properties) below).
|
||||
|
||||
### BrowserSession
|
||||
|
||||
`ctx.session` is set when the browser has an active Owlbot session. Routes that use `requires_session=True`, `requires_authenticated=True`, or `requires_moderator=True` only run after the required session check passes, so `ctx.session` is available inside those handlers.
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `user` | `User` | Owncast user linked to this browser session. |
|
||||
| `expires_at` | `datetime` | When the session expires. |
|
||||
| `is_authenticated` | `bool` | Whether the linked user is authenticated. |
|
||||
| `is_moderator` | `bool` | Whether the linked user has the MODERATOR scope. |
|
||||
|
||||
For public routes, `ctx.session` may be `None`. Check it before reading session fields unless the route is protected. See [Protected Routes](Modules-Routes#protected-routes) for the route flags and session caveats.
|
||||
|
||||
## Proxied Properties
|
||||
|
||||
`EventContext`, `CommandContext`, and `RouteContext` all proxy the services from `ModuleContext` directly onto `ctx`. This means `ctx.owncast_client` and `ctx.module.owncast_client` return the same object. The full set of proxied properties:
|
||||
@@ -179,7 +199,7 @@ All `ModuleContext` services are available directly on `ctx` (see [Proxied Prope
|
||||
The only differences between the three context types are the handler-specific fields:
|
||||
|
||||
- **EventContext** adds `.event` (the triggering event) and propagation control.
|
||||
- **CommandContext** adds `.command_event` (parsed command data) and convenience properties for command/args/user.
|
||||
- **RouteContext** adds `.request` (the HTTP request).
|
||||
- **CommandContext** adds `.command_event` (parsed command data), convenience properties for command/args/user, and `.session_url_for()`.
|
||||
- **RouteContext** adds `.request` (the HTTP request) and `.session` (the connected browser session, if any).
|
||||
|
||||
Utility functions that need to work across handler types can accept `ModuleContext` directly, since all three contexts expose it via `.module`.
|
||||
|
||||
+63
-1
@@ -21,6 +21,9 @@ async def stats(ctx: RouteContext) -> dict:
|
||||
| `path` | `str` | *(required)* | URL path relative to the module's namespace. |
|
||||
| `methods` | `list[str] \| None` | `["GET"]` | HTTP methods to accept. |
|
||||
| `streaming` | `bool` | `False` | If `True`, the handler runs without a timeout. Use for long-lived connections like SSE streams. The handler is responsible for its own keepalives, idle detection, and cleanup. |
|
||||
| `requires_session` | `bool` | `False` | If `True`, the request must come from a browser connected to an Owncast user. |
|
||||
| `requires_authenticated` | `bool` | `False` | If `True`, the connected Owncast user must be authenticated. Implies `requires_session`. |
|
||||
| `requires_moderator` | `bool` | `False` | If `True`, the connected Owncast user must have the MODERATOR scope. Implies `requires_session`. |
|
||||
|
||||
A route that accepts multiple methods:
|
||||
|
||||
@@ -193,6 +196,62 @@ url = ctx.routes.url_for("/list")
|
||||
|
||||
This uses the `public_base_url` from config. If not set, it falls back to `owncast.url`. Set `public_base_url` if Owlbot is hosted on a different domain than your Owncast instance.
|
||||
|
||||
## Protected Routes
|
||||
|
||||
Routes that need to know which chat user is visiting can require an Owlbot browser session. A browser session is created when a viewer runs the built-in `!connect` command in chat and opens the private link, or when your module sends them a session URL with `ctx.session_url_for()` from a command.
|
||||
|
||||
`requires_session=True` only requires a linked browser session. Use `requires_authenticated=True` when the page should only be available to logged-in Owncast users. Use `requires_moderator=True` when it should only be available to moderators.
|
||||
|
||||
```python
|
||||
from aiohttp import web
|
||||
from markupsafe import escape
|
||||
from owlbot.api import RouteContext, on_route
|
||||
|
||||
@on_route("/panel", requires_moderator=True)
|
||||
async def moderator_panel(ctx: RouteContext) -> web.Response:
|
||||
assert ctx.session is not None
|
||||
# Display names are user-controlled. Escape them directly or render
|
||||
# through Jinja templates so autoescape handles it.
|
||||
name = escape(ctx.session.user.display_name)
|
||||
return web.Response(
|
||||
text=f"<h1>Moderator panel for {name}</h1>",
|
||||
content_type="text/html",
|
||||
)
|
||||
```
|
||||
|
||||
If a request does not have a usable session, Owlbot returns a connection page explaining that the viewer should run `!connect` in chat. The handler is not called. If a session exists but fails the authentication or moderator check, Owlbot returns a permission page instead.
|
||||
|
||||
When a protected route runs, `ctx.session` contains the linked Owncast user snapshot:
|
||||
|
||||
| Property | Description |
|
||||
|----------|-------------|
|
||||
| `ctx.session.user` | The Owncast `User` tied to this browser session. |
|
||||
| `ctx.session.is_authenticated` | Whether that user was authenticated in the latest user snapshot Owlbot has seen. |
|
||||
| `ctx.session.is_moderator` | Whether that user had the MODERATOR scope in the latest user snapshot Owlbot has seen. |
|
||||
| `ctx.session.expires_at` | When the browser session expires. |
|
||||
|
||||
### Sending Session URLs
|
||||
|
||||
Call `ctx.session_url_for(path)` from a command handler when you need to link to a protected page. Use it instead of `ctx.routes.url_for(path)` so Owlbot can link the browser session to the user who invoked the command. It accepts the same module-relative paths as `url_for()`, but returns a one-time `/owlbot/connect/<token>` URL for that user. When the user opens that link, Owlbot creates their browser session and redirects them to the route path you provided.
|
||||
|
||||
For example:
|
||||
|
||||
```python
|
||||
from owlbot.api import CommandContext, on_command
|
||||
|
||||
@on_command("panel", requires_moderator=True)
|
||||
async def send_panel_link(ctx: CommandContext) -> None:
|
||||
url = ctx.session_url_for("/panel")
|
||||
await ctx.owncast_client.send_system_message_to_client(
|
||||
ctx.chat_event.client_id,
|
||||
f"Open your moderator panel: {url}",
|
||||
)
|
||||
```
|
||||
|
||||
The path is module-relative (`"/panel"` or `"panel"`), not a full `/owlbot/...` path.
|
||||
|
||||
> **Warning:** Owncast does not send webhook events when a user's authentication or moderator status changes. Owlbot updates session user data when it sees a later event for that user, and sessions expire after one hour by default. This means permission changes may take up to the session lifetime, or the next user event, to affect already-connected browsers.
|
||||
|
||||
## Templates
|
||||
|
||||
Every module has access to a Jinja2 template environment via `ctx.templates`. Templates are loaded from the module's `templates/` directory first, then from Owlbot's core templates (which includes a Bootstrap `base.html`). Autoescape is enabled by default.
|
||||
@@ -301,7 +360,7 @@ async def setup(ctx: ModuleContext) -> None:
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `ctx.routes.register(path, handler, methods=..., streaming=...)` | Register a route. Accepts the same parameters as `@on_route`. Returns `RouteInfo`. |
|
||||
| `ctx.routes.register(path, handler, methods=..., streaming=..., requires_session=..., requires_authenticated=..., requires_moderator=...)` | Register a route. Accepts the same parameters as `@on_route`. Returns `RouteInfo`. |
|
||||
| `ctx.routes.unregister(path)` | Remove all handlers at the path. Returns `True` if found. |
|
||||
| `ctx.routes.unregister(path, method=...)` | Remove only the handler covering that method. Returns `True` if found. |
|
||||
| `ctx.routes.get(path)` | Get all handlers at the path. Returns `list[RouteInfo]`. |
|
||||
@@ -321,3 +380,6 @@ async def setup(ctx: ModuleContext) -> None:
|
||||
| `handler` | `RouteHandler` | The handler function. |
|
||||
| `module_name` | `str` | Module that owns this route. |
|
||||
| `streaming` | `bool` | Whether the handler bypasses the timeout. |
|
||||
| `requires_session` | `bool` | Whether the route requires a connected browser session. |
|
||||
| `requires_authenticated` | `bool` | Whether the route requires an authenticated Owncast user. |
|
||||
| `requires_moderator` | `bool` | Whether the route requires the MODERATOR scope. |
|
||||
|
||||
@@ -230,6 +230,56 @@ async def test_status_route(
|
||||
|
||||
Route URLs follow the `/owlbot/<module_name>/<path>` pattern used in production. Setting `module_pkg` also wires a real `ModuleTemplates` into `module_context.templates`, so route handlers that call `ctx.templates.render(...)` work without any extra setup.
|
||||
|
||||
### Testing Protected Routes
|
||||
|
||||
The `route_client` fixture includes Owlbot's connect routes and keeps cookies between requests, so protected route tests can use the same flow as a browser. Dispatch a command for a test user, redeem the generated session URL with `route_client`, then request the protected route:
|
||||
|
||||
```python
|
||||
from aiohttp.test_utils import TestClient
|
||||
from owlbot.api import CommandContext, EventType, ModuleContext, RouteContext
|
||||
from owlbot.testing import make_chat_event, make_user
|
||||
|
||||
|
||||
async def test_protected_route(
|
||||
event_dispatcher,
|
||||
module_context: ModuleContext,
|
||||
route_client: TestClient,
|
||||
) -> None:
|
||||
session_urls: list[str] = []
|
||||
|
||||
async def handler(ctx: RouteContext) -> dict[str, str]:
|
||||
assert ctx.session is not None
|
||||
return {"user_id": ctx.session.user.id}
|
||||
|
||||
async def send_link(ctx: CommandContext) -> None:
|
||||
session_urls.append(ctx.session_url_for("/secure"))
|
||||
|
||||
module_context.routes.register(
|
||||
"/secure",
|
||||
handler,
|
||||
requires_authenticated=True,
|
||||
)
|
||||
module_context.commands.register("secure", send_link)
|
||||
|
||||
event = make_chat_event(
|
||||
raw_body="!secure",
|
||||
user=make_user(id="alice", is_authenticated=True),
|
||||
)
|
||||
await event_dispatcher.dispatch(EventType.CHAT, event)
|
||||
|
||||
session_url = session_urls[0]
|
||||
path = session_url.removeprefix(module_context.config.public_base_url)
|
||||
|
||||
redeem = await route_client.get(path, allow_redirects=False)
|
||||
assert redeem.status == 302
|
||||
|
||||
resp = await route_client.get("/owlbot/test_module/secure")
|
||||
assert resp.status == 200
|
||||
assert await resp.json() == {"user_id": "alice"}
|
||||
```
|
||||
|
||||
Use `make_user(is_authenticated=True)` or `make_user(is_moderator=True)` to build users that pass the matching route guards. If you only need to assert that a protected page rejects an unconnected browser, request the route directly without redeeming a session URL first.
|
||||
|
||||
### Using Storage
|
||||
|
||||
The `storage` fixture provides a real `ModuleStorage` backed by an in-memory SQLite database. It behaves identically to production storage but does not write to disk, so each test starts with a clean database. A module that creates its schema at startup:
|
||||
|
||||
+3
-1
@@ -141,6 +141,8 @@ async def status_page(ctx: RouteContext) -> web.Response:
|
||||
|
||||
Returning a `dict` automatically serializes to JSON. A `web.Response` gives full control over the output. A `web.FileResponse` can serve static files directly from disk. See [HTTP Routes](Modules-Routes) for templates, URL building, and more.
|
||||
|
||||
Routes can also be protected so only connected, authenticated, or moderator Owncast users can access them. See [Protected Routes](Modules-Routes#protected-routes) for the browser session flow.
|
||||
|
||||
## Context
|
||||
|
||||
Every handler receives a context object (`ctx`) as its only argument. The context is the gateway to all of Owlbot's services: sending messages, querying the database, reading config, making HTTP requests, and more.
|
||||
@@ -252,4 +254,4 @@ For more complex HTML, use `ctx.templates.render()` with Jinja2 templates from a
|
||||
|
||||
## Testing
|
||||
|
||||
Owlbot includes a pytest plugin with fixtures and helpers for testing modules without a running Owncast server. Tests run against the real dispatch pipeline with in-memory storage and recording stubs. See [Testing](Modules-Testing) for setup, available fixtures, and examples.
|
||||
Owlbot includes a pytest plugin with fixtures and helpers for testing modules without a running Owncast server. Tests run against the real dispatch pipeline with in-memory storage and recording stubs. See [Testing](Modules-Testing) for setup, available fixtures, and examples.
|
||||
|
||||
Reference in New Issue
Block a user