mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-22 23:02:23 +02:00
Squash Odysseus development history
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# Companion bridge
|
||||
|
||||
A thin, additive layer so a LAN client (e.g. a phone) can discover what an
|
||||
Odysseus server offers and pair to it, without duplicating any LLM logic.
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/companion/ping` | session or token | cheap, auth-validated health check |
|
||||
| GET | `/api/companion/info` | session or token | server identity + capability flags |
|
||||
| GET | `/api/companion/models` | session or token | the **caller's own** model endpoints |
|
||||
| GET | `/api/companion/pair` | **admin cookie** | pairing page (a form; never mints) |
|
||||
| POST | `/api/companion/pair` | **admin cookie** | mint a one-time pairing token (`?format=json` for an in-app screen) |
|
||||
|
||||
`/models` scopes to the caller's real owner plus legacy null-owner shared rows
|
||||
(same rule as `owner_filter`) and never returns API-key material.
|
||||
|
||||
## Pairing CSRF posture
|
||||
|
||||
Minting happens **only on POST**. The session cookie is `SameSite=Lax`
|
||||
(`routes/auth_routes.py`), so a browser will not send it on a cross-site POST —
|
||||
the same protection `POST /api/tokens` relies on. A `GET` would be unsafe (Lax
|
||||
cookies ride top-level GET navigations), so `GET /pair` only renders a form.
|
||||
Minting invalidates the auth middleware's token cache, so a freshly minted token
|
||||
works on the next request without a restart.
|
||||
|
||||
The pairing/scoping rules live in small, tested units (`token_owner`,
|
||||
`owner_can_see`, `mint_pairing_token`, `pairing.*`) — see
|
||||
`tests/test_companion_readonly.py` and `tests/test_companion_pairing.py`.
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Odysseus companion bridge — additive LAN endpoints.
|
||||
|
||||
Read endpoints (/api/companion/ping, /info, owner-scoped /models) so a LAN
|
||||
client can discover what a server offers, plus admin-only pairing
|
||||
(/api/companion/pair) that mints a one-time chat-scoped token on POST. No new LLM
|
||||
logic; auth is enforced by the existing AuthMiddleware. See companion/README.md.
|
||||
"""
|
||||
|
||||
from companion.routes import setup_companion_routes
|
||||
|
||||
__all__ = ["setup_companion_routes"]
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Shared pairing helpers for the companion bridge.
|
||||
|
||||
Token minting + LAN discovery + QR rendering, kept here as small, importable
|
||||
units so the route layer stays thin and the logic is directly testable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import socket
|
||||
import uuid
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import bcrypt
|
||||
|
||||
from src.constants import AUTH_FILE
|
||||
|
||||
PAIRING_VERSION = 1
|
||||
COMPANION_SCOPE = "chat"
|
||||
|
||||
|
||||
_COMPANION_IPV4_NETWORKS = tuple(
|
||||
ipaddress.ip_network(cidr)
|
||||
for cidr in (
|
||||
"10.0.0.0/8",
|
||||
"100.64.0.0/10",
|
||||
"127.0.0.0/8",
|
||||
"169.254.0.0/16",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
)
|
||||
)
|
||||
_DNS_LABEL_RE = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\Z")
|
||||
|
||||
|
||||
def _valid_companion_client_host(host: str) -> bool:
|
||||
"""Match the host forms supported by the current v1 Expo client."""
|
||||
if not host or len(host) > 253 or not host.isascii() or "%" in host:
|
||||
return False
|
||||
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
labels = host.split(".")
|
||||
if any(not _DNS_LABEL_RE.fullmatch(label) for label in labels):
|
||||
return False
|
||||
if any(label.startswith("xn--") for label in labels):
|
||||
return False
|
||||
# WHATWG URL parsers treat a decimal or ``0x`` single-label hostname
|
||||
# as an IPv4 number even though Python's strict ``ipaddress`` parser
|
||||
# rejects that spelling. The v1 client interpolates this host back
|
||||
# into a URL, so accepting e.g. ``134744072`` would make the phone send
|
||||
# its bearer token to public 8.8.8.8. Keep DNS labels unambiguous.
|
||||
if len(labels) == 1 and (
|
||||
labels[0].isdigit()
|
||||
or re.fullmatch(r"0x[0-9a-f]*", labels[0]) is not None
|
||||
):
|
||||
return False
|
||||
return len(labels) == 1 or (len(labels) >= 2 and labels[-1] == "local")
|
||||
|
||||
return isinstance(address, ipaddress.IPv4Address) and any(
|
||||
address in network for network in _COMPANION_IPV4_NETWORKS
|
||||
)
|
||||
|
||||
|
||||
def parse_companion_base_url(value: str) -> tuple[str, int]:
|
||||
"""Validate a v1 companion address and return its legacy (host, port).
|
||||
|
||||
The deployed client understands only HTTP plus a LAN-style host and port.
|
||||
Reject anything outside that exact contract instead of advertising a URL
|
||||
the client would reject, downgrade, or interpret differently.
|
||||
"""
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
|
||||
if not value.isascii():
|
||||
raise ValueError("COMPANION_BASE_URL must contain only ASCII characters")
|
||||
if any(
|
||||
ord(char) <= 32 or ord(char) == 127 or char in {"\\", "%"}
|
||||
for char in value
|
||||
):
|
||||
raise ValueError(
|
||||
"COMPANION_BASE_URL contains a forbidden character"
|
||||
)
|
||||
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise ValueError("COMPANION_BASE_URL must be a valid HTTP LAN origin") from exc
|
||||
|
||||
host = parsed.hostname
|
||||
if parsed.scheme.lower() != "http" or not parsed.netloc or not host:
|
||||
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise ValueError("COMPANION_BASE_URL must not contain credentials")
|
||||
if parsed.path or parsed.query or parsed.fragment:
|
||||
raise ValueError("COMPANION_BASE_URL must not contain a path, query, or fragment")
|
||||
if port is not None and not 1 <= port <= 65535:
|
||||
raise ValueError("COMPANION_BASE_URL port must be between 1 and 65535")
|
||||
if not _valid_companion_client_host(host):
|
||||
raise ValueError("COMPANION_BASE_URL host is not supported by companion v1")
|
||||
|
||||
netloc = f"{host}:{port}" if port is not None else host
|
||||
origin = f"http://{netloc}"
|
||||
if value != origin:
|
||||
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
|
||||
return host, port or 80
|
||||
|
||||
|
||||
def configured_companion_origin() -> tuple[str, int] | None:
|
||||
"""Return the validated operator-configured v1 address, if any."""
|
||||
value = os.environ.get("COMPANION_BASE_URL")
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return parse_companion_base_url(value)
|
||||
|
||||
|
||||
def default_port() -> int:
|
||||
"""Best guess at the port the server is reachable on. Callers that know the
|
||||
real request port should pass it explicitly."""
|
||||
try:
|
||||
return int(os.environ.get("APP_PORT", "7000"))
|
||||
except ValueError:
|
||||
return 7000
|
||||
|
||||
|
||||
def lan_ip_candidates() -> list[str]:
|
||||
"""Likely LAN IPv4 addresses for this host, best candidate first.
|
||||
|
||||
The UDP-connect trick reveals the egress interface the OS would use to reach
|
||||
the default gateway -- i.e. the address a phone on the same Wi-Fi should
|
||||
target. No packets are actually sent. Loopback is dropped.
|
||||
"""
|
||||
candidates: list[str] = []
|
||||
|
||||
def _add(ip):
|
||||
if ip and ip not in candidates and not ip.startswith("127."):
|
||||
candidates.append(ip)
|
||||
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(("8.8.8.8", 80))
|
||||
_add(s.getsockname()[0])
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
try:
|
||||
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||||
_add(info[4][0])
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def find_admin_user() -> str | None:
|
||||
"""Resolve an admin username from data/auth.json (schema uses is_admin),
|
||||
falling back to the first user."""
|
||||
auth_path = AUTH_FILE
|
||||
try:
|
||||
with open(auth_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
users = data.get("users") or {}
|
||||
if not isinstance(users, dict):
|
||||
return None
|
||||
for uname, udata in users.items():
|
||||
if isinstance(udata, dict) and udata.get("is_admin") is True:
|
||||
return uname
|
||||
return next(iter(users), None)
|
||||
|
||||
|
||||
def mint_token(owner: str, name: str = "companion") -> tuple[str, str]:
|
||||
"""Create a chat-scoped API token row and return (token_id, raw_token).
|
||||
|
||||
The raw token is returned ONCE -- only its bcrypt hash + an 8-char prefix
|
||||
are persisted. Mirrors routes/api_token_routes.py so cookie- and
|
||||
companion-minted tokens are indistinguishable to the auth middleware.
|
||||
"""
|
||||
from core.database import get_db_session, ApiToken
|
||||
|
||||
raw_token = "ody_" + secrets.token_urlsafe(32)
|
||||
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
|
||||
token_id = str(uuid.uuid4())[:8]
|
||||
|
||||
with get_db_session() as db:
|
||||
db.add(ApiToken(
|
||||
id=token_id,
|
||||
owner=owner,
|
||||
name=name,
|
||||
token_hash=token_hash,
|
||||
token_prefix=raw_token[:8],
|
||||
scopes=COMPANION_SCOPE,
|
||||
is_active=True,
|
||||
))
|
||||
return token_id, raw_token
|
||||
|
||||
|
||||
def pairing_payload(host: str, port: int, token: str) -> dict:
|
||||
"""The exact JSON a client scans / accepts. Keep keys stable."""
|
||||
return {"v": PAIRING_VERSION, "host": host, "port": port, "token": token}
|
||||
|
||||
|
||||
def pairing_qr_png_data_uri(payload: dict) -> str | None:
|
||||
"""Render the pairing payload as a QR `data:` URI for an <img>. Returns None
|
||||
if the optional qrcode dep is unavailable."""
|
||||
try:
|
||||
import base64
|
||||
import io
|
||||
|
||||
import qrcode
|
||||
|
||||
img = qrcode.make(json.dumps(payload, separators=(",", ":")))
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Companion bridge — /api/companion/*.
|
||||
|
||||
A thin, additive layer so a LAN client (e.g. a phone) can discover what a server
|
||||
offers and pair to it, without duplicating any LLM logic.
|
||||
|
||||
Auth is enforced globally by AuthMiddleware (app.py), so reaching a handler here
|
||||
means the caller is authenticated by either a cookie session or a Bearer `ody_`
|
||||
API token. Ping/info accept either credential type, models requires a chat-
|
||||
scoped API token for bearer callers, and the pairing endpoints are admin-cookie
|
||||
only.
|
||||
|
||||
Pairing CSRF posture: minting happens ONLY on POST. The session cookie is
|
||||
SameSite=Lax (routes/auth_routes.py), which a browser does not send on a
|
||||
cross-site POST, so an admin's cookie can't be used by a malicious page to mint
|
||||
a token -- the same protection the existing POST /api/tokens relies on. Minting
|
||||
on a GET would be unsafe (Lax cookies ride top-level GET navigations), so GET
|
||||
/pair only renders a form.
|
||||
"""
|
||||
|
||||
import html
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from core.middleware import require_admin
|
||||
from src.auth_helpers import _auth_disabled, get_current_user
|
||||
|
||||
from companion import pairing as _pairing
|
||||
|
||||
|
||||
def token_owner(request: Request) -> str | None:
|
||||
"""The real owner to attribute a request to, for read-scoping.
|
||||
|
||||
Cookie sessions resolve to the logged-in username via get_current_user.
|
||||
Bearer-token callers come through as the sandboxed pseudo-user "api"; their
|
||||
real owner is stamped on request.state.api_token_owner by the auth
|
||||
middleware. Returns None when no owner can be resolved.
|
||||
"""
|
||||
if getattr(request.state, "api_token", False):
|
||||
return getattr(request.state, "api_token_owner", None)
|
||||
return get_current_user(request)
|
||||
|
||||
|
||||
def owner_can_see(row_owner, owner) -> bool:
|
||||
"""Owner-scope rule for read endpoints.
|
||||
|
||||
A caller sees a row when it is their own, or when it is a legacy null-owner
|
||||
("shared") row. A caller must NEVER see another owner's row. Mirrors the
|
||||
`owner_filter` rule used elsewhere, expressed as a pure predicate so it can
|
||||
be tested directly and used as a defensive in-Python check alongside the
|
||||
SQL filter.
|
||||
"""
|
||||
return row_owner is None or row_owner == owner
|
||||
|
||||
|
||||
def require_models_scope(request: Request) -> None:
|
||||
"""Require the companion chat scope for bearer-token model inventory."""
|
||||
if not getattr(request.state, "api_token", False):
|
||||
return
|
||||
scopes = getattr(request.state, "api_token_scopes", None) or []
|
||||
if isinstance(scopes, str):
|
||||
scopes = [scope.strip() for scope in scopes.split(",")]
|
||||
scope_set = {str(scope).strip() for scope in scopes if str(scope).strip()}
|
||||
if _pairing.COMPANION_SCOPE not in scope_set:
|
||||
raise HTTPException(403, "API token requires chat scope")
|
||||
|
||||
|
||||
def mint_pairing_token(owner: str, invalidate=None) -> tuple[str, str]:
|
||||
"""Mint a pairing token AND invalidate the auth middleware's in-memory token
|
||||
cache, so the new token is accepted on the very next request without a server
|
||||
restart. Returns (token_id, raw_token); the raw token is shown once.
|
||||
|
||||
`invalidate` is the app's request.app.state.invalidate_token_cache callable
|
||||
(passed in so this stays a pure, testable unit).
|
||||
"""
|
||||
token_id, raw_token = _pairing.mint_token(owner)
|
||||
if callable(invalidate):
|
||||
invalidate()
|
||||
return token_id, raw_token
|
||||
|
||||
|
||||
def setup_companion_routes() -> APIRouter:
|
||||
router = APIRouter(prefix="/api/companion", tags=["companion"])
|
||||
|
||||
@router.get("/ping")
|
||||
def ping(request: Request):
|
||||
"""Cheap, auth-validated health check. A 200 with ok=true confirms the
|
||||
host/port and credential are valid; middleware returns 401 otherwise."""
|
||||
from core.constants import APP_VERSION
|
||||
return {
|
||||
"ok": True,
|
||||
"name": "odysseus",
|
||||
"version": APP_VERSION,
|
||||
"auth": "token" if getattr(request.state, "api_token", False) else "session",
|
||||
}
|
||||
|
||||
@router.get("/info")
|
||||
def info(request: Request):
|
||||
"""Server identity + coarse capability flags. `owner` is the caller's own
|
||||
identity (the token's owner for bearer callers)."""
|
||||
from core.constants import APP_VERSION
|
||||
return {
|
||||
"name": "odysseus",
|
||||
"version": APP_VERSION,
|
||||
"owner": token_owner(request),
|
||||
"capabilities": {"chat": True, "streaming": True},
|
||||
}
|
||||
|
||||
@router.get("/models")
|
||||
def models(request: Request):
|
||||
"""LLM model endpoints the CALLER can use.
|
||||
|
||||
The stock /api/models route scopes to get_current_user, which for a
|
||||
bearer token is the sandboxed pseudo-user "api" (owns nothing). Here we
|
||||
scope to the token's real owner instead, plus legacy null-owner shared
|
||||
rows -- the same rule as owner_filter. Explicit auth-disabled mode keeps
|
||||
the stock route's single-user all-endpoints view. Read-only; never
|
||||
returns api_key material.
|
||||
"""
|
||||
require_models_scope(request)
|
||||
import json as _json
|
||||
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from src.endpoint_resolver import build_chat_url
|
||||
|
||||
owner = token_owner(request)
|
||||
single_user_mode = (
|
||||
owner is None
|
||||
and not getattr(request.state, "api_token", False)
|
||||
and _auth_disabled()
|
||||
)
|
||||
out = []
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.is_enabled == True, # noqa: E712
|
||||
(ModelEndpoint.model_type == "llm") | (ModelEndpoint.model_type == None), # noqa: E711
|
||||
)
|
||||
if owner:
|
||||
q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711
|
||||
for ep in q.all():
|
||||
if not single_user_mode and not owner_can_see(ep.owner, owner):
|
||||
continue
|
||||
try:
|
||||
model_ids = _json.loads(ep.cached_models) if ep.cached_models else []
|
||||
except (ValueError, TypeError):
|
||||
model_ids = []
|
||||
try:
|
||||
hidden = set(_json.loads(ep.hidden_models)) if ep.hidden_models else set()
|
||||
except (ValueError, TypeError):
|
||||
hidden = set()
|
||||
model_ids = [m for m in model_ids if m not in hidden]
|
||||
try:
|
||||
chat_url = build_chat_url(ep.base_url)
|
||||
except Exception:
|
||||
chat_url = ep.base_url
|
||||
out.append({
|
||||
"endpoint_id": ep.id,
|
||||
"name": ep.name,
|
||||
"endpoint_url": chat_url,
|
||||
"models": model_ids,
|
||||
"supports_tools": ep.supports_tools,
|
||||
})
|
||||
finally:
|
||||
db.close()
|
||||
return {"endpoints": out}
|
||||
|
||||
@router.get("/pair")
|
||||
def pair_page(request: Request):
|
||||
"""Admin-only pairing page. Renders a form that POSTs to mint a code.
|
||||
|
||||
A GET never mints a credential: SameSite=Lax session cookies ride
|
||||
top-level GET navigations, so minting on GET would be triggerable by a
|
||||
link or <img> (CSRF). The actual mint is the POST handler below.
|
||||
"""
|
||||
require_admin(request)
|
||||
page = """<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Pair a device</title>
|
||||
<style>
|
||||
body{font-family:-apple-system,system-ui,sans-serif;max-width:520px;margin:48px auto;padding:0 20px;color:#e8e8e8;background:#16161a}
|
||||
.card{background:#1f1f25;border:1px solid #2c2c35;border-radius:14px;padding:28px;text-align:center}
|
||||
button{background:#7c9cff;color:#0e0e12;border:none;border-radius:10px;padding:12px 20px;font-size:15px;font-weight:600;cursor:pointer}
|
||||
</style></head>
|
||||
<body><div class="card">
|
||||
<h2>Pair a device</h2>
|
||||
<p>Generate a one-time pairing code (a chat-scoped API token) for a LAN client.</p>
|
||||
<form method="POST" action="/api/companion/pair">
|
||||
<button type="submit">Generate pairing code</button>
|
||||
</form>
|
||||
<p style="color:#8a8a96;font-size:12px;margin-top:18px">Admin only. Each code mints a new token, shown once. Manage or revoke under Settings → API tokens.</p>
|
||||
</div></body></html>"""
|
||||
return HTMLResponse(page)
|
||||
|
||||
@router.post("/pair")
|
||||
def pair_create(request: Request):
|
||||
"""Mint a pairing code. Admin-cookie only; CSRF-safe because the
|
||||
SameSite=Lax session cookie is not sent on a cross-site POST (same
|
||||
protection as POST /api/tokens). Minting invalidates the token cache so
|
||||
the code works immediately, no restart. `?format=json` returns the
|
||||
payload for an in-app pairing screen."""
|
||||
require_admin(request)
|
||||
try:
|
||||
configured_origin = _pairing.configured_companion_origin()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(500, str(exc)) from None
|
||||
owner = get_current_user(request)
|
||||
invalidate = getattr(request.app.state, "invalidate_token_cache", None)
|
||||
token_id, raw_token = mint_pairing_token(owner, invalidate)
|
||||
|
||||
if configured_origin:
|
||||
host, port = configured_origin
|
||||
hosts = [host]
|
||||
else:
|
||||
hosts = _pairing.lan_ip_candidates()
|
||||
host = hosts[0] if hosts else "127.0.0.1"
|
||||
port = request.url.port or _pairing.default_port()
|
||||
payload = _pairing.pairing_payload(host, port, raw_token)
|
||||
qr = _pairing.pairing_qr_png_data_uri(payload)
|
||||
qr_ok = bool(qr and qr.startswith("data:image/png;base64,"))
|
||||
|
||||
if (request.query_params.get("format") or "").lower() == "json":
|
||||
response = {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"token": raw_token,
|
||||
"token_id": token_id,
|
||||
"hosts": hosts,
|
||||
"payload": payload,
|
||||
"qr": qr if qr_ok else None,
|
||||
}
|
||||
return response
|
||||
|
||||
import json as _json
|
||||
payload_json = _json.dumps(payload, separators=(",", ":"))
|
||||
# Only ever emit a known PNG data-URI into the src; every other value is
|
||||
# html.escaped.
|
||||
qr_block = (
|
||||
f'<img src="{html.escape(qr)}" alt="Pairing QR" width="260" height="260">'
|
||||
if qr_ok else "<p><em>QR rendering unavailable -- enter the details manually.</em></p>"
|
||||
)
|
||||
page = f"""<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Pairing code</title>
|
||||
<style>
|
||||
body{{font-family:-apple-system,system-ui,sans-serif;max-width:520px;margin:40px auto;padding:0 20px;color:#e8e8e8;background:#16161a}}
|
||||
.card{{background:#1f1f25;border:1px solid #2c2c35;border-radius:14px;padding:24px;text-align:center}}
|
||||
code{{background:#0e0e12;padding:2px 6px;border-radius:6px;word-break:break-all}}
|
||||
.row{{text-align:left;margin:10px 0;font-size:14px;color:#bdbdc7}}
|
||||
.warn{{color:#e0a85e;font-size:13px;margin-top:18px}}
|
||||
</style></head>
|
||||
<body><div class="card">
|
||||
<h2>Pairing code</h2>
|
||||
{qr_block}
|
||||
<div class="row"><strong>Host:</strong> <code>{html.escape(host)}</code></div>
|
||||
<div class="row"><strong>Port:</strong> <code>{html.escape(str(port))}</code></div>
|
||||
<div class="row"><strong>Token:</strong> <code>{html.escape(raw_token)}</code></div>
|
||||
<div class="row"><strong>Payload:</strong> <code>{html.escape(payload_json)}</code></div>
|
||||
<p class="warn">Shown once. This grants chat access to your Odysseus; revoke it
|
||||
in Settings → API tokens (id <code>{html.escape(token_id)}</code>). The
|
||||
device must be on the same network, and the server must bind to your LAN.</p>
|
||||
</div></body></html>"""
|
||||
return HTMLResponse(page)
|
||||
|
||||
return router
|
||||
Reference in New Issue
Block a user