Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions docs/PLUGIN_AUTHOR_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,12 +336,20 @@ interface IncomingHttpRequest {
interface OutgoingHttpResponse {
status?: number; // default 200
headers?: Record<string, string>;
body?: string;
body?: string | Uint8Array;
}
```

Endpoints are public by default. Gate admin features with `req.authenticated`.

Response strings are sent as UTF-8. In JavaScript, return an object and set its
`body` to a string for text or a `Uint8Array` for arbitrary bytes. In Python,
you may return `bytes` or `bytearray` directly or use either as a dictionary
body. Set the appropriate `Content-Type` header for the client.

Byte response bodies require Owncast v0.3.1 or later. Older hosts do not
support them.

`req.user` is the chat user the request came from, if Owncast could identify
one. The host resolves it from the visitor's chat identity cookie, which is set
when they register or connect to chat, so it is available on normal viewer
Expand Down Expand Up @@ -632,7 +640,7 @@ The host enforces these caps per plugin. They're generous for normal use. Size p
| `on_filter` output | 1 MiB | the (modified) message a filter returns |
| HTTP request body delivered to your handler | 1 MB | inbound `onHttpRequest` body |
| HTTP response body | 10 MB | what `onHttpRequest` returns |
| `on_http_request` envelope output | 12 MiB | the full encoded response envelope |
| `on_http_request` envelope output | 16 MiB | the full encoded response envelope |
Comment thread
gabek marked this conversation as resolved.
| Pending timers | 64 | `owncast.timer.setTimeout`/`setInterval` outstanding at once |
| Timer delay | 100 ms to 24 h | clamped into this range |
| SSE connections | 64 | concurrent browser clients on your event stream |
Expand Down Expand Up @@ -662,7 +670,7 @@ my-plugin/

A request to `/plugins/my-plugin/` serves `public/index.html` automatically.

For dynamic endpoints (JSON APIs, webhooks, etc.) write an `onHttpRequest`. Path traversal is blocked, response headers are filtered through an allowlist (allowed: `Content-Type`, `Cache-Control`, `Set-Cookie`, `Location`, `ETag`, `Last-Modified`, `Vary`, `Link`, and CORS headers, with host-owned things like `Server`, CSP, and HSTS blocked), and body sizes are capped at 1 MB request / 10 MB response. Cookies you set default to a `Path` scoped to your plugin's namespace.
For dynamic endpoints (JSON APIs, webhooks, generated files, etc.) write an `onHttpRequest`. In JavaScript, return `{ status, headers, body }` and put either a string or `Uint8Array` in `body`. In Python, you may return a string/bytes value directly or return a response dictionary. Path traversal is blocked, response headers are filtered through an allowlist (allowed: `Content-Type`, `Cache-Control`, `Set-Cookie`, `Location`, `ETag`, `Last-Modified`, `Vary`, `Link`, and CORS headers, with host-owned things like `Server`, CSP, and HSTS blocked), and body sizes are capped at 1 MB request / 10 MB response. Cookies you set default to a `Path` scoped to your plugin's namespace.

## Realtime updates (Server-Sent Events)

Expand Down Expand Up @@ -1177,7 +1185,7 @@ Point your `test` script at that one entry (`node __tests__/index.test.js`) inst

- `event: "<type>"`, fire-and-forget notification dispatch
- `filter: "<type>"`, filter chain. Inline `expect: {action, payload?, reason?}` checks the FilterResult
- `http: { method, path, headers?, body?, user?, authenticated?, expect: {status, headers?, body?, bodyContains?} }`, sends an HTTP request through your plugin server
- `http: { method, path, headers?, body?, user?, authenticated?, expect: {status, headers?, body?, bodyContains?, bodyBase64?} }`, sends an HTTP request through your plugin server. Use `bodyBase64` to compare arbitrary response bytes.
- `tabContent: { slug, user?, expect: {body?, bodyContains?} }`, calls `onTabContent` directly and asserts on the returned HTML
- `pageContent: { slug, user?, expect: {body?, bodyContains?} }`, calls `onPageContent` directly and asserts on the returned HTML
- `pageStyles: { expect: {body?, bodyContains?} }`, calls `onPageStyles` directly and asserts on the returned CSS
Expand Down
11 changes: 10 additions & 1 deletion docs/WIRE_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,7 @@ type OutgoingHttpResponse = {
status?: number;
headers?: { [key: string]: string };
body?: string;
bodyBase64?: string;
};

type ContentRequest = {
Expand All @@ -826,7 +827,15 @@ type AuthCheckResult =

The host always supplies every non-optional `IncomingHttpRequest` key. It
omits `user` for anonymous viewers and admin-only authentication. A missing or
zero response status defaults to 200.
zero response status defaults to 200. `body` contains UTF-8 text.
`bodyBase64` contains arbitrary response bytes encoded as standard base64.
Responses must not contain both fields. The JavaScript SDK uses `bodyBase64`
internally when a response's `body` is a `Uint8Array`. The Python SDK uses it
when a handler returns `bytes` or `bytearray` directly or sets its dictionary
body to either type.

Byte response bodies require Owncast v0.3.1 or later. Older hosts do not
support `bodyBase64`.

### Stream and server data

Expand Down
14 changes: 14 additions & 0 deletions examples/js/all-permissions-test/__tests__/binary.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[
{
"name": "invalid UTF-8 is returned as raw HTTP bytes",
"events": [
{
"http": {
"method": "GET",
"path": "/binary-response",
"expect": { "status": 200, "bodyBase64": "/wCA" }
}
}
]
}
]
9 changes: 8 additions & 1 deletion examples/js/all-permissions-test/src/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,14 @@ module.exports = definePlugin({
onFediverseMention() {},
onFediverseReply() {},
// Requires http.serve.
onHttpRequest: () => ({ status: 204 }),
onHttpRequest: (req) =>
req.path === "/binary-response"
? {
status: 200,
headers: { "Content-Type": "application/octet-stream" },
body: new Uint8Array([0xff, 0x00, 0x80]),
}
: { status: 204 },
// Requires auth.gate.
onAuthCheck: () => authCheck.ok(),
// Require ui.modify.
Expand Down
6 changes: 3 additions & 3 deletions examples/js/safeguard-stress/src/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ function hugeString(bytes) {
// Sized just over each cap so the handler can serialize and return the
// payload within the per-call timeout, the tests want the *size* check to
// fire, not the timeout. (MaxFilterOutputBytes = 1 MiB, and we send 1.1 MiB.
// MaxHTTPHandlerOutputBytes = 12 MiB, and the HTTP test has a 5s call cap so
// 13 MiB is fine there.)
// MaxHTTPHandlerOutputBytes = 16 MiB, and the HTTP test has a 5s call cap so
// 17 MiB is fine there.)
const HUGE_FILTER_BODY = hugeString(1126400); // ~1.075 MiB, > 1 MiB cap
const HUGE_HTTP_BODY = hugeString(13 * 1024 * 1024);
const HUGE_HTTP_BODY = hugeString(17 * 1024 * 1024);

module.exports = definePlugin({
filterChatMessage(msg) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
[
{
"name": "invalid UTF-8 survives assets, filesystem, and upload as raw bytes",
"name": "invalid UTF-8 survives assets, filesystem, upload, and HTTP as raw bytes",
"events": [
{
"http": {
"method": "GET",
"path": "/binary-round-trip",
"expect": { "status": 200, "body": "/wCA" }
"expect": { "status": 200, "bodyBase64": "/wCA" }
}
}
],
Expand Down
2 changes: 1 addition & 1 deletion examples/python/all-permissions-test/src/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ def on_http_request(req):
owncast.fs.write("invalid-utf8.bin", data)
stored = owncast.fs.read("invalid-utf8.bin")
owncast.storage.upload("invalid-utf8.bin", stored)
return {"status": 200, "body": base64.b64encode(stored).decode("ascii")}
return {"status": 200, "body": stored}


# Requires auth.gate.
Expand Down
4 changes: 2 additions & 2 deletions examples/python/safeguard-stress/src/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ def huge_string(num_bytes):
# Sized just over each cap so the handler can serialize and return the
# payload within the per-call timeout. The tests want the *size* check to
# fire, not the timeout. (MaxFilterOutputBytes = 1 MiB, we send 1.1 MiB.
# MaxHTTPHandlerOutputBytes = 12 MiB, HTTP test has a 5s call cap so 13 MiB
# MaxHTTPHandlerOutputBytes = 16 MiB, HTTP test has a 5s call cap so 17 MiB
# is fine there.)
HUGE_FILTER_BODY = huge_string(1126400) # ~1.075 MiB, > 1 MiB cap
HUGE_HTTP_BODY = huge_string(13 * 1024 * 1024)
HUGE_HTTP_BODY = huge_string(17 * 1024 * 1024)


@plugin.filter_chat_message
Expand Down
10 changes: 10 additions & 0 deletions host-runtime/plugin/testing/runner.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package testing

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
Expand Down Expand Up @@ -346,6 +347,15 @@ func runHTTPStep(server *plugin.Server, pluginName string, h *HTTPStep) error {
if h.Expect.BodyContains != "" && !strings.Contains(rec.Body.String(), h.Expect.BodyContains) {
return fmt.Errorf("http body does not contain %q\n body: %q", h.Expect.BodyContains, rec.Body.String())
}
if h.Expect.BodyBase64 != nil {
want, err := decodeScenarioBase64(*h.Expect.BodyBase64)
if err != nil {
return fmt.Errorf("http bodyBase64 is invalid: %w", err)
}
if !bytes.Equal(want, rec.Body.Bytes()) {
return fmt.Errorf("http bodyBase64: want %q got %q", *h.Expect.BodyBase64, base64.StdEncoding.EncodeToString(rec.Body.Bytes()))
}
}
return nil
}

Expand Down
1 change: 1 addition & 0 deletions host-runtime/plugin/testing/scenario.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ type HTTPExpect struct {
Headers map[string]string `json:"headers,omitempty"`
Body string `json:"body,omitempty"`
BodyContains string `json:"bodyContains,omitempty"`
BodyBase64 *string `json:"bodyBase64,omitempty"`
}

// ContentStep invokes the plugin's on_tab_content or on_page_content export.
Expand Down
3 changes: 2 additions & 1 deletion sdks/js/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,8 @@ export interface IncomingHttpRequest {
export interface OutgoingHttpResponse {
status?: number;
headers?: Record<string, string>;
body?: string;
/** Text is sent as UTF-8. Uint8Array preserves arbitrary response bytes. */
body?: string | Uint8Array;
}

/** Request context passed to `onTabContent` and `onPageContent` handlers. */
Expand Down
41 changes: 39 additions & 2 deletions sdks/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,38 @@ function dispatchFilter(envelope) {
return filter.pass();
}

function encodeBase64(bytes) {
const alphabet =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const chunks = [];
let chunk = "";
let i = 0;
for (; i + 2 < bytes.length; i += 3) {
const value = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
chunk +=
alphabet[(value >> 18) & 63] +
alphabet[(value >> 12) & 63] +
alphabet[(value >> 6) & 63] +
alphabet[value & 63];
if (chunk.length >= 16384) {
chunks.push(chunk);
chunk = "";
}
}
if (i < bytes.length) {
const value =
(bytes[i] << 16) |
(i + 1 < bytes.length ? bytes[i + 1] << 8 : 0);
chunk +=
alphabet[(value >> 18) & 63] +
alphabet[(value >> 12) & 63] +
(i + 1 < bytes.length ? alphabet[(value >> 6) & 63] : "=") +
"=";
}
chunks.push(chunk);
return chunks.join("");
}

// dispatchHttp routes incoming HTTP requests to the user's onHttpRequest
// handler. Returns a default 404 if the plugin doesn't define one.
function dispatchHttp(request) {
Expand All @@ -346,11 +378,16 @@ function dispatchHttp(request) {
}
const out = registered.onHttpRequest(request);
if (!out) return { status: 200, headers: {}, body: "" };
return {
const response = {
status: out.status || 200,
headers: out.headers || {},
body: out.body == null ? "" : String(out.body),
};
if (out.body instanceof Uint8Array) {
response.bodyBase64 = encodeBase64(out.body);
} else {
response.body = out.body == null ? "" : String(out.body);
}
return response;
}


Expand Down
2 changes: 1 addition & 1 deletion sdks/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def fallback(req):
- `@plugin.get/post/put/delete/patch(path)` and `@plugin.route(path, methods=[...])` for method-specific routes, and `@plugin.on_http_request(path)` for any method.
- Paths are exact and **plugin-relative** (e.g. `/api/messages`), excluding the query string. Read query params from `req.query`.
- A request whose path matches a route but not its method gets an automatic **405**. An unmatched path falls through to the bare catch-all, else **404**.
- A handler returns a `dict` (`{status, body, headers}`), a `str` (→ 200), or `None` (→ 204).
- A handler returns a `dict` (`{status, body, headers}`), a `str`, `bytes`, or `bytearray` (→ 200), or `None` (→ 204). A dictionary body may also be `bytes` or `bytearray`.

### The `owncast` host API

Expand Down
12 changes: 11 additions & 1 deletion sdks/python/owncast_plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def greet(msg):
except ImportError: # pragma: no cover - dev machine, not wasm
extism = None

import base64
import json

__all__ = ["plugin", "owncast", "filter", "auth_check", "CommandContext"]
Expand Down Expand Up @@ -884,9 +885,18 @@ def _dispatch_filter(envelope):

def _http_response(resp):
if isinstance(resp, dict):
return resp
out = dict(resp)
Comment thread
gabek marked this conversation as resolved.
body = out.get("body")
if isinstance(body, (bytes, bytearray)):
del out["body"]
out["bodyBase64"] = base64.b64encode(bytes(body)).decode("ascii")
elif "body" in out:
out.pop("bodyBase64", None)
return out
if resp is None:
return {"status": 204}
if isinstance(resp, (bytes, bytearray)):
return {"status": 200, "bodyBase64": base64.b64encode(bytes(resp)).decode("ascii")}
return {"status": 200, "body": str(resp)}


Expand Down
Loading