fix(overlay): authenticate the OBS overlay with a token when login is enabled (#2613)

The /overlay/{id} route renders without a login, but everything it draws is
auth-gated: printer status and name (PRINTERS_READ), one setting (SETTINGS_READ),
and the camera stream (a camera-stream token). A signed-in browser rides its JWT
from local storage; OBS is a fresh browser with no session, so the overlay stayed
blank whenever authentication was enabled. Cloudflare/remote access was never the
cause -- an incognito window fails identically.

Give the overlay a self-contained kiosk-token mode, mirroring the Cam Wall:

- New `overlay` long-lived-token scope, kept separate from `camwall`: the overlay
  names the printed file on screen, which a Cam Wall token is trusted never to
  expose, so folding it in would silently widen every existing wall token.
- New token-authed GET /printers/{id}/overlay-status returning exactly the fields
  the overlay draws and nothing else; added to the auth-middleware allowlist so it
  reaches its own RequireOverlayTokenIfAuthEnabled gate.
- StreamOverlayPage reads ?token= and, in that mode, authenticates its status and
  camera calls with the token and skips the WebSocket (the 2s poll is the feed).
  The logged-in path is unchanged.
- Token-mint UI (Settings > API Keys) offers the scope with a ready-made
  /overlay/{id}?token= URL copied once on creation.
This commit is contained in:
maziggy
2026-07-20 13:04:35 +02:00
parent 64f9d04c80
commit 258db95483
27 changed files with 651 additions and 49 deletions
+30 -1
View File
@@ -294,7 +294,7 @@ export interface SystemHealthResult {
// 'camera_stream' reaches the video endpoints only. 'camwall' additionally
// reaches the read-only Cam Wall feed, which names the printers (#2531), so it
// is a separate scope rather than a widening of tokens already in the wild.
export type LongLivedTokenScope = 'camera_stream' | 'camwall';
export type LongLivedTokenScope = 'camera_stream' | 'camwall' | 'overlay';
export interface LongLivedCameraToken {
id: number;
@@ -324,6 +324,26 @@ export interface CamWallPrinter {
hms_errors: HMSError[];
}
// Streaming-overlay feed (#2613). The subset of print state the /overlay page
// draws for one printer, served behind an `overlay`-scoped token so OBS embeds
// with no login session can read it. Unlike CamWallPrinter this names the file
// being printed (the overlay shows the part on screen).
export interface OverlayStatus {
id: number;
name: string;
camera_rotation: number;
connected: boolean;
state: string | null;
current_print: string | null;
gcode_file: string | null;
progress: number | null;
remaining_time: number | null;
layer_num: number | null;
total_layers: number | null;
stg_cur_name: string | null;
time_format: 'system' | '12h' | '24h';
}
// Printer types
export interface Printer {
id: number;
@@ -5748,6 +5768,15 @@ export const api = {
request<CamWallPrinter[]>(
token ? `/camwall/printers?token=${encodeURIComponent(token)}` : '/camwall/printers',
),
// Token-authenticated streaming-overlay feed (#2613). OBS (or any embed with
// no login session) loads /overlay/{id}?token=... and this backs it. `token`
// is omitted only when auth is disabled, where the backend gate is a no-op.
getOverlayStatus: (printerId: number, token?: string) =>
request<OverlayStatus>(
token
? `/printers/${printerId}/overlay-status?token=${encodeURIComponent(token)}`
: `/printers/${printerId}/overlay-status`,
),
getCameraStreamUrl: (printerId: number, fps = 10) =>
withStreamToken(`${API_BASE}/printers/${printerId}/camera/stream?fps=${fps}`),
getCameraSnapshotUrl: (printerId: number) =>